renderPage

tcmts.cli.web.renderPage · #pbtndisg · term · runs on the server

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

used by