rpcHandler
uses node:url
import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { decode, encode } from "#codebase/tcmts/cli/codec"; import { dim, green, red } from "#codebase/tcmts/cli/style"; import { dataDir, errorReply, isAsyncIterable, isHttpError, modulePathIn, renderComponent, serverApiHost, storeLookup, STREAMED, } from "#codebase/tcmts/cli/web"; import type { ComponentPlan, Lookup, Middleware, RpcOptions } from "#codebase/tcmts/cli/web"; import { exportId, shortHash } from "#codebase/tcmts/core/hash"; import { checkArgs } from "#codebase/tcmts/core/schema"; import { pathToFileURL } from "node:url"; export function rpcHandler( cb: Codebase, isEndpoint: (hash: string) => boolean, retired: (hash: string) => boolean = () => false, opts: RpcOptions = { production: false, secure: false }, components: ComponentPlan = { isComponent: () => false, isClientRef: () => false }, look: Lookup = storeLookup(cb), ): Middleware { const host = serverApiHost(cb); const data = dataDir(cb); // the trees of components that render the same for the same props, by their props: rendered once (the newest // thousand kept) const kept = new Map<string, string[]>(); const handle = async (...[req, res, next]: Parameters<Middleware>) => { const m = /^\/__tcm\/rpc\/([a-z2-7]{52})$/.exec((req.url ?? "").split("?")[0]!); if (!m) return next(); const started = performance.now(); const hash = m[1]!; const name = cb.displayRef(`#${hash}`); const send = (status: number, body: unknown) => { let text: string; try { text = encode(body, `result of ${name}`); } catch (e) { status = 500; text = encode({ name: "TypeError", message: (e as Error).message }); } res.statusCode = status; res.setHeader("content-type", "application/json"); res.setHeader("server-timing", `call;dur=${(performance.now() - started).toFixed(1)}`); res.end(text); const mark = status === 200 ? green(String(status)) : red(String(status)); console.log( ` ${dim("→")} ${name} ${mark} ${dim(`${Math.round(performance.now() - started)}ms`)}`, ); }; // a component that renders the same for the same props (`cacheableComponents`) is asked for with GET too, its props // in the URL, so a browser, or anything in between, keeps its tree for good. It reads no cookies: no CSRF to fear const cacheable = components.isComponent(hash) && !!components.isCacheable?.(hash); const gotten = req.method === "GET" && cacheable; // CSRF: server functions can read cookies, so only the app's own pages may call them. Cross-site requests // are refused, and requiring a JSON body rules out plain HTML form posts, which can't set that content type. const origin = req.headers.origin; if (!gotten) { if (origin && new URL(origin).host !== req.headers.host) return send(403, { name: "RemoteError", message: "cross-origin call refused" }); if (req.headers["sec-fetch-site"] === "cross-site") return send(403, { name: "RemoteError", message: "cross-site call refused" }); if (req.method !== "POST") return send(405, { name: "RemoteError", message: "use POST" }); if (!/^application\/json\b/.test(req.headers["content-type"] ?? "")) return send(415, { name: "RemoteError", message: "send arguments as application/json" }); } // (an endpoint of a kept build whose code the codebase no longer has, as the web UI's after tcm changes and its // codebase is copied in again: retired as well) if (!isEndpoint(hash) || !look.get(hash)) { // a page from a pruned build: 410 tells the browser runtime to reload itself onto the current build if (retired(hash) || isEndpoint(hash)) return send(410, { name: "OutdatedError", message: "this page's build is no longer served; reload for the current version", }); return send(403, { name: "RemoteError", message: `${shortHash(hash)} isn't a server function this app calls`, }); } let text = gotten ? (new URL(req.url ?? "/", "http://x").searchParams.get("p") ?? "") : ""; if (!gotten) for await (const chunk of req) { text += chunk; if (text.length > 16 * 1024 * 1024) return send(413, { name: "RemoteError", message: "arguments too large" }); } const forever = { "cache-control": "public, max-age=31536000, immutable" }; const had = cacheable ? kept.get(`${hash}\n${text}`) : undefined; if (had) { res.writeHead(200, { "content-type": "text/x-component", ...forever }); res.end(had.join("")); console.log( ` ${dim("→")} ${name} ${green("kept")} ${dim(`${Math.round(performance.now() - started)}ms`)}`, ); return; } let args: unknown; try { args = decode(text); } catch { return send(400, { name: "RemoteError", message: "malformed arguments" }); } if (!Array.isArray(args)) return send(400, { name: "RemoteError", message: "arguments must be a list" }); const rec = look.get(hash)!; // arguments come from the network: check them against the function's parameter types before calling it // a server component gets its props: none at all if it takes none const component = components.isComponent(hash); const problem = rec.params && checkArgs(rec.params, component && !rec.params.params.length ? [] : args); if (problem) return send(400, { name: "TypeError", message: `${name}: ${problem}` }); // a hash names immutable code, so Node's module cache can never serve a stale version of it const fn = ( (await import(pathToFileURL(modulePathIn(cb, look, rec.componentHash)).href)) as Record< string, (...a: unknown[]) => unknown > )[exportId(hash)]!; const abort = new AbortController(); res.on("close", () => { if (!res.writableFinished) abort.abort(new Error("the client disconnected")); }); const { withCall, pendingCookies } = await host(); const call = { method: req.method ?? "POST", url: req.url ?? "/", headers: req.headers, ip: req.socket.remoteAddress, signal: abort.signal, }; const r = await withCall( call, { secure: opts.secure, dataDir: data, cookiePath: opts.base }, async () => { if (component) { // its tree as Flight streams it: what's inside each <Suspense> boundary as it finishes const describe = (e: unknown) => errorReply(e as Error, isHttpError(e), name, opts.production).body; let sent = 0; const pieces: string[] = []; const { failed } = await renderComponent( cb, fn, (args as unknown[])[0], describe, (piece) => { if (cacheable) pieces.push(piece); if (!sent++) res.writeHead(200, { "content-type": "text/x-component", // (how long it took to start: its first piece, after what it awaits outside any <Suspense>) "server-timing": `render;dur=${(performance.now() - started).toFixed(1)}`, ...(cacheable ? forever : { "cache-control": "no-store" }), ...(pendingCookies().length ? { "set-cookie": pendingCookies() } : {}), }); if (!abort.signal.aborted) res.write(piece); }, abort.signal, ); res.end(); // (kept whole and without an error: an error might not come again) if (cacheable && !failed && !abort.signal.aborted) { kept.set(`${hash}\n${text}`, pieces); if (kept.size > 1000) kept.delete(kept.keys().next().value!); } console.log( ` ${dim("→")} ${name} ${green("rendered")} ${dim(`${Math.round(performance.now() - started)}ms`)}`, ); return STREAMED; } const out = await fn(...(args as unknown[])); if (!isAsyncIterable(out)) return out; // A stream: newline-delimited chunks, iterated inside the call so the request context lasts as long as it. // Cookies set before the first chunk go out with the headers; the client stopping ends the iteration. res.writeHead(200, { "content-type": "application/x-ndjson", "cache-control": "no-store", ...(pendingCookies().length ? { "set-cookie": pendingCookies() } : {}), }); let chunks = 0; try { for await (const v of out) { if (abort.signal.aborted) break; res.write(encode({ v }, `chunk ${chunks} of ${name}`) + "\n"); chunks++; } res.end(encode({ done: true }) + "\n"); } catch (e) { const err = errorReply(e as Error, isHttpError(e), name, opts.production); res.end(encode({ e: err.body }) + "\n"); } console.log( ` ${dim("→")} ${name} ${green("stream")} ${dim(`${chunks} chunk(s), ${Math.round(performance.now() - started)}ms${abort.signal.aborted ? ", stopped by the client" : ""}`)}`, ); return STREAMED; }, ); if (r.ok && r.value === STREAMED) return; if (r.setCookies.length) res.setHeader("set-cookie", r.setCookies); if (r.ok) return send(200, r.value); const err = errorReply( r.error as Error & { status?: number; retryAfter?: number }, isHttpError(r.error), name, opts.production, ); if (err.retryAfter !== undefined) res.setHeader("retry-after", String(err.retryAfter)); send(err.status, err.body); }; // (a call that fails in a way nothing above caught is that call's 500, not the server's end) return (req, res, next) => void handle(req, res, next).catch((e: Error) => { const err = errorReply(e, false, req.url ?? "a call", opts.production); if (res.headersSent) return res.end(); res.writeHead(err.status, { "content-type": "application/json" }); res.end(encode(err.body)); }); }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.codec.decode
- tcmts.cli.codec.encode
- tcmts.cli.style.dim
- tcmts.cli.style.green
- tcmts.cli.style.red
- tcmts.cli.web.ComponentPlan
- tcmts.cli.web.Lookup
- tcmts.cli.web.Middleware
- tcmts.cli.web.RpcOptions
- tcmts.cli.web.STREAMED
- tcmts.cli.web.dataDir
- tcmts.cli.web.errorReply
- tcmts.cli.web.isAsyncIterable
- tcmts.cli.web.isHttpError
- tcmts.cli.web.modulePathIn
- tcmts.cli.web.renderComponent
- tcmts.cli.web.serverApiHost
- tcmts.cli.web.storeLookup
- tcmts.core.hash.exportId
- tcmts.core.hash.shortHash
- tcmts.core.schema.checkArgs