renderPage
uses node:url
import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { decode, encode } from "#codebase/tcmts/cli/codec"; import { yellow } from "#codebase/tcmts/cli/style"; import { dataDir, errorReply, installSsr, isHttpError, lateCookies, renderComponent, serverApiHost, ssrRequests, } from "#codebase/tcmts/cli/web"; import type { ComponentPlan, PartialRender, RenderApp, RpcOptions, Shell, SsrRequest } from "#codebase/tcmts/cli/web"; import { exportId, shortHash } from "#codebase/tcmts/core/hash"; import { checkArgs } from "#codebase/tcmts/core/schema"; import { randomBytes } from "node:crypto"; import type http from "node:http"; import { pathToFileURL } from "node:url"; export async function renderPage( cb: Codebase, req: http.IncomingMessage, res: http.ServerResponse, template: string, render: RenderApp, components: ComponentPlan, opts: RpcOptions, partly?: { shell: Shell; resume: PartialRender["resume"] }, ): Promise<boolean> { const began = performance.now(); installSsr(); const host = await serverApiHost(cb)(); const data = dataDir(cb); // (a shell rendered ahead has the trees of what renders the same already: this request renders what's live) const trees: SsrRequest["trees"] = { ...partly?.shell.trees }; // cookies server components set: in the page's headers if they're set before those go out, else collected // by the browser afterwards let started = false; const early: string[] = []; const late: string[] = []; const keep = (cookies: string[]) => (started ? late : early).push(...cookies); // the page's request, one for every server component it renders: what React's `cache` keeps is kept for all of them; // aborted if the browser goes away (`cacheSignal`) const gone = new AbortController(); res.on("close", () => { if (!res.writableFinished) gone.abort(new Error("the client disconnected")); }); const pageCall = { method: "GET", url: req.url ?? "/", headers: req.headers, ip: req.socket.remoteAddress, signal: gone.signal, }; const page: SsrRequest = { // (the page's address as the browser has it: under the app's base) url: new URL( `${opts.base ?? ""}${req.url ?? "/"}`, `${opts.secure ? "https" : "http"}://${req.headers.host ?? "localhost"}`, ), cache: new Map(), trees, async render(hash, body, write) { const call = pageCall; if (!components.isComponent(hash)) throw new Error(`${shortHash(hash)} isn't a server component of this app`); const name = cb.displayRef(`#${hash}`); const rec = cb.store.getDef(hash)!; const args = decode(body) as unknown[]; const problem = rec.params && checkArgs(rec.params, !rec.params.params.length ? [] : args); if (problem) throw new TypeError(`${name}: ${problem}`); const fn = ( (await import(pathToFileURL(cb.modulePath(rec.componentHash)).href)) as Record< string, (...a: unknown[]) => unknown > )[exportId(hash)]!; const key = hash + body; trees[key] = ""; const describe = (e: unknown) => errorReply(e as Error, isHttpError(e), name, opts.production).body; // the page's request is the server component's request: its cookies and session let taken = 0; const take = (all: string[]) => { keep(all.slice(taken)); taken = all.length; }; const r = await host.withCall( call, { secure: opts.secure, dataDir: data, cookiePath: opts.base }, () => renderComponent( cb, fn, args[0], describe, (text) => { take(host.pendingCookies()); trees[key] += text; write(text); }, gone.signal, ), ); take(r.setCookies); if (!r.ok) throw r.error; }, }; const [head, tail] = template.split('<div id="app"></div>') as [string, string]; // the page's shell, sent at once, then what React left of it, rendered for this request; or the page, whole const run: RenderApp = partly ? async (write, onError) => { write(partly.shell.html); return partly.shell.postponed === null ? { done: Promise.resolve() } : partly.resume(JSON.parse(partly.shell.postponed) as unknown, write, onError); } : render; try { const { done } = await ssrRequests.run(page, () => run( (html) => { if (!started) { started = true; res.setHeader("content-type", "text/html"); // (how long its first part took to render: what's inside a <Suspense> boundary may come later) res.setHeader("server-timing", `render;dur=${(performance.now() - began).toFixed(1)}`); if (early.length) res.setHeader("set-cookie", early); // the app's own <title>, if it renders one, is the page's: the shell's (its name) goes res.write( `${/<title[\s>]/.test(html) ? head.replace(/<title>[^<]*<\/title>/, "") : head}<div id="app">`, ); } res.write(html); }, // a boundary that fails on the server renders in the browser instead (e) => console.log( yellow( ` server rendering: ${(e as Error)?.message ?? e} (that part renders in the browser instead)`, ), ), // what React finds the page will need (what it preloads: fonts, styles, `preload()`): said to the browser at // once, as Early Hints, while the rest renders (headers) => { // (in Node, React gives them as an object) const link = typeof headers.get === "function" ? headers.get("link") : (headers as unknown as Record<string, string | undefined>).Link; const hint = (res as { writeEarlyHints?: (h: Record<string, string>) => void }) .writeEarlyHints; if (link && !started && typeof hint === "function") hint.call(res, { link }); }, ), ); await done; } catch (e) { if (started) throw e; console.log( yellow( ` server rendering failed, so the page renders in the browser: ${(!opts.production && (e as Error)?.stack) || ((e as Error)?.message ?? e)}`, ), ); return false; } if (!started) res.write(`${head}<div id="app">`); // after the HTML, before the page's script: the trees it hydrates with, and the token for late cookies const payload = JSON.stringify(encode(trees, "the server component trees of the page")).replace( /</g, "\\u003c", ); let cookies = ""; if (late.length) { const token = randomBytes(18).toString("base64url"); lateCookies.set(token, { cookies: late, expires: Date.now() + 60_000 }); cookies = ` var __TCM_COOKIES__ = ${JSON.stringify(token)};`; } res.end(`</div><script>var __TCM_TREES__ = ${payload};${cookies}</script>${tail}`); return true; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.codec.decode
- tcmts.cli.codec.encode
- tcmts.cli.style.yellow
- tcmts.cli.web.ComponentPlan
- tcmts.cli.web.PartialRender
- tcmts.cli.web.RenderApp
- tcmts.cli.web.RpcOptions
- tcmts.cli.web.Shell
- tcmts.cli.web.SsrRequest
- tcmts.cli.web.dataDir
- tcmts.cli.web.errorReply
- tcmts.cli.web.installSsr
- tcmts.cli.web.isHttpError
- tcmts.cli.web.lateCookies
- tcmts.cli.web.renderComponent
- tcmts.cli.web.serverApiHost
- tcmts.cli.web.ssrRequests
- tcmts.core.hash.exportId
- tcmts.core.hash.shortHash
- tcmts.core.schema.checkArgs