rpcHandler

tcmts.cli.web.rpcHandler · #2h6hsqjo · 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 { 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

used by