start

tcmts.cli.web.start · #r6ezjnur · term · runs on the server

uses node:path

Serve the app in production: the build its name points to now, plus every earlier build, so open tabs keep working across deploys. When the name moves, the new version is built (if needed) and becomes current.

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { encode } from "#codebase/tcmts/cli/codec";
import { useFlight } from "#codebase/tcmts/cli/flight";
import { bold, dim, green, red, yellow } from "#codebase/tcmts/cli/style";
import {
  appsDir, assetsDir, build as build_1, buildWorkDir, httpHandler, isPageRequest, lateCookieHandler, leaseBuilds,
  listBuilds, normalBase, pageTreesOf, pageUnder, planApp, prerenderPage, printRoutes, pruneBuilds, readBuildFile,
  renderedPage, renderPage, reportPlan, rpcHandler, serveAsset, startBackground, watchCodebase,
} from "#codebase/tcmts/cli/web";
import type { ComponentPlan, PartialRender, RenderApp, Shell, StartOptions } from "#codebase/tcmts/cli/web";
import { shortHash } from "#codebase/tcmts/core/hash";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import path from "node:path";
import { pathToFileURL } from "node:url";

export async function start(
  cb: Codebase,
  appName: string,
  opts: StartOptions,
): Promise<{ url: string; close: () => Promise<void> }> {
  // React's production builds, Flight's included: a development one sends the server's stacks and logs to the browser
  process.env.NODE_ENV ??= "production";
  // the build it serves, held from pruning (in any process) while it does
  const lease = leaseBuilds(buildWorkDir(cb));
  let current = await build_1(cb, appName, { lease });
  // served under a path of a site (`--base /examples/notes`): requests outside it aren't the app's, and the page says
  // where it is (server rendering here sees the same, so links it renders match the browser's)
  const base = normalBase(opts.base);
  if (base) (globalThis as { __TCM_BASE__?: string }).__TCM_BASE__ = base;
  const options = { production: true, secure: !!opts.tls, ...(base ? { base } : {}) };
  // every retained build's endpoints stay callable: old clients call the hashes they were built against.
  // Re-read whenever the set of builds changes (a deploy here, or `tcm apps prune` in another shell).
  let endpoints = new Set<string>();
  let components = new Set<string>();
  let clientRefs = new Set<string>();
  let serverRefs = new Set<string>();
  let chunks = new Map<string, string[]>();
  let packageImports = new Map<string, string[]>();
  let cacheable = new Set<string>();
  // pages' shells, rendered ahead, by build and address
  const shells = new Map<string, Promise<Shell | undefined>>();
  // each build's page's code, as Early Hints say it
  const preloads = new Map<string, string[]>();
  let buildsSeen = "";
  const loadEndpoints = () => {
    const builds = listBuilds(cb);
    const key = builds.map((b) => b.hash).join(",");
    if (key === buildsSeen) return;
    buildsSeen = key;
    endpoints = new Set(builds.flatMap((b) => b.endpoints));
    components = new Set(builds.flatMap((b) => b.components ?? []));
    clientRefs = new Set(builds.flatMap((b) => b.clientRefs ?? []));
    serverRefs = new Set(builds.flatMap((b) => b.serverRefs ?? []));
    // (the newest build's, for a module two of them split)
    chunks = new Map(builds.flatMap((b) => Object.entries(b.chunks ?? {})));
    packageImports = new Map(builds.flatMap((b) => Object.entries(b.packageImports ?? {})));
    cacheable = new Set(builds.flatMap((b) => b.cacheable ?? []));
  };
  loadEndpoints();
  const rescan = setInterval(loadEndpoints, 1000);
  // server code loads as React's server environment has it, what server components send by reference as references
  useFlight(cb, {
    client: (h) => clientRefs.has(h),
    server: (h) => serverRefs.has(h),
    chunks: (c) => chunks.get(c) ?? [],
    packageImports: (c) => packageImports.get(c) ?? [],
  });
  const componentPlan: ComponentPlan = {
    isComponent: (h) => components.has(h),
    isClientRef: (h) => clientRefs.has(h),
    isCacheable: (h) => cacheable.has(h),
  };
  const rpc = rpcHandler(
    cb,
    (h) => endpoints.has(h),
    // any other hash is an old page's: a build since pruned, or one this server never had (served from another copy of
    // the codebase, as a forge's app is copied in afresh), so it's told to reload onto the current build
    () => true,
    options,
    // a hash's placement never changes, so one set across builds is right for each of them
    componentPlan,
  );

  const routes = opts.http
    ? httpHandler(cb, opts.http, options)
    : (_q: http.IncomingMessage, _s: http.ServerResponse, next: () => void) => next();
  const servePage = (res: http.ServerResponse, file: string) => {
    res.statusCode = 200;
    res.setHeader("content-type", "text/html");
    res.setHeader("cache-control", "no-cache");
    res.end(pageUnder(readBuildFile(file).toString("utf8"), base));
  };
  const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
    if (base) {
      const at = req.url ?? "/";
      // the base itself is its page: with the slash, so relative links resolve under it
      if (at === base || at.startsWith(`${base}?`)) {
        res.writeHead(308, { location: `${base}/${at.slice(base.length)}` });
        return res.end();
      }
      if (!at.startsWith(`${base}/`)) {
        res.statusCode = 404;
        return res.end("not found");
      }
      req.url = at.slice(base.length);
    }
    rpc(req, res, () =>
      lateCookieHandler(req, res, () =>
        routes(req, res, () => {
          const url = decodeURIComponent((req.url ?? "/").split("?")[0]!);
          if (url.startsWith("/_app/")) {
            // any build's assets, so pages loaded before a deploy can still fetch theirs (names are content-addressed);
            // nothing else under /_app (a build's server rendering stays on the server)
            const asset = /^\/_app\/assets\/([^/]+)$/.exec(url);
            const file = asset && path.join(assetsDir(cb), asset[1]!);
            if (
              !file ||
              /\.(br|gz)$/.test(file) ||
              !fs.existsSync(file) ||
              !fs.statSync(file).isFile()
            ) {
              res.statusCode = 404;
              return res.end("not found");
            }
            return serveAsset(req, res, file, base);
          }
          if (req.method !== "GET" && req.method !== "HEAD") {
            res.statusCode = 405;
            return res.end();
          }
          // a PWA's service worker: the current build's, looked for again on every load (so never cached)
          if (url === "/sw.js" && current.pwa) {
            res.statusCode = 200;
            res.setHeader("content-type", "text/javascript; charset=utf-8");
            res.setHeader("cache-control", "no-cache");
            return res.end(readBuildFile(path.join(appsDir(cb), current.hash, "sw.js")));
          }
          // what a page of the app shows (the trees its server components render), rendered before the browser goes to
          // it, when a link to it is pointed at or pressed: as its page would be, for this request (its cookies)
          if (url === "/__tcm/page") {
            const at = new URL(req.url ?? "/", "http://app").searchParams.get("path") ?? "/";
            const build = current;
            if (!build.ssr || !at.startsWith("/") || at.startsWith("//")) {
              res.statusCode = 404;
              return res.end();
            }
            void (async () => {
              try {
                const { render } = (await import(
                  pathToFileURL(path.join(appsDir(cb), build.hash, build.ssr!)).href
                )) as { render: RenderApp };
                const template = pageUnder(
                  readBuildFile(path.join(appsDir(cb), build.hash, "index.html")).toString("utf8"),
                  base,
                );
                const html = await renderedPage(
                  cb,
                  at,
                  req.headers,
                  template,
                  render,
                  componentPlan,
                  options,
                );
                res.writeHead(200, {
                  "content-type": "application/json",
                  "cache-control": "no-store",
                });
                res.end(pageTreesOf(html ?? "") ?? encode({}));
              } catch (e) {
                console.error(red(`  rendering ${at} ahead failed: ${(e as Error)?.stack ?? e}`));
                if (!res.headersSent) res.statusCode = 500;
                res.end();
              }
            })();
            return;
          }
          // everything else is the current build's page (client-side routes included), rendered on the server first
          // when the app is a component
          const page = path.join(appsDir(cb), current.hash, "index.html");
          const build = current;
          // the page's code, said to the browser at once (Early Hints) so it's on its way while the page renders
          if (isPageRequest(req)) {
            let hints = preloads.get(build.hash);
            if (!hints) {
              const html = readBuildFile(page).toString("utf8");
              hints = [
                ...html.matchAll(
                  /<script type="module"[^>]*\ssrc="([^"]+)"|<link rel="modulepreload"[^>]*\shref="([^"]+)"/g,
                ),
              ].map((m) => `<${base}${m[1] ?? m[2]}>; rel=modulepreload; crossorigin`);
              preloads.set(build.hash, hints);
            }
            if (hints.length) res.writeEarlyHints({ link: hints });
          }
          if (!build.ssr || !isPageRequest(req)) return servePage(res, page);
          void (async () => {
            try {
              const ssr = (await import(
                pathToFileURL(path.join(appsDir(cb), build.hash, build.ssr!)).href
              )) as { render: RenderApp } & Partial<PartialRender>;
              const { render } = ssr;
              // the page's shell, rendered once (per address, per build) and sent at once; what's live is rendered
              // for this request after it
              let partly: { shell: Shell; resume: PartialRender["resume"] } | undefined;
              if (build.shells && ssr.prerenderShell && ssr.resume) {
                const key = `${build.hash}\n${req.url}`;
                let made = shells.get(key);
                if (!made) {
                  const at = new URL(
                    `${base}${req.url ?? "/"}`,
                    `${opts.tls ? "https" : "http"}://${req.headers.host ?? "localhost"}`,
                  );
                  made = prerenderPage(cb, at, componentPlan, options, ssr as PartialRender).then(
                    (s) => {
                      console.log(
                        `  ${dim("→")} ${req.url} ${green("shell")} ${dim(s ? (s.postponed === null ? "the whole page renders the same: kept" : "kept; what's live renders for each request") : "none: something live is outside any <Suspense>")}`,
                      );
                      return s;
                    },
                    (e) => {
                      console.error(
                        red(
                          `  ${req.url}: its shell couldn't be rendered ahead: ${(e as Error)?.stack ?? e}`,
                        ),
                      );
                      return undefined;
                    },
                  );
                  shells.set(key, made);
                  if (shells.size > 1000) shells.delete(shells.keys().next().value!);
                }
                const shell = await made;
                if (shell) partly = { shell, resume: ssr.resume };
              }
              res.setHeader("cache-control", "no-cache");
              if (
                await renderPage(
                  cb,
                  req,
                  res,
                  pageUnder(readBuildFile(page).toString("utf8"), base),
                  render,
                  componentPlan,
                  options,
                  partly,
                )
              )
                return;
            } catch (e) {
              console.error(red(`  server rendering failed: ${(e as Error)?.stack ?? e}`));
              if (res.headersSent) return res.end();
            }
            servePage(res, page);
          })();
        }),
      ),
    );
  };
  const server = opts.tls ? https.createServer(opts.tls, handler) : http.createServer(handler);
  await new Promise<void>((r) => server.listen(opts.port, opts.host, r));
  const { port } = server.address() as { port: number };
  // (an IPv6 address in brackets, as a URL has it: `--host ::` listens on both)
  const host = opts.host.includes(":") ? `[${opts.host}]` : opts.host;
  const url = `${opts.tls ? "https" : "http"}://${host}:${port}${base}/`;
  console.log(`${bold("tcm start")} ${appName} ${dim(shortHash(current.hash))}\n  url      ${url}`);
  console.log(
    `  server   ${endpoints.size} callable function hash(es) across ${listBuilds(cb).length} build(s)`,
  );
  if (opts.http) printRoutes(cb, opts.http, url);
  const background = await startBackground(cb, appName);

  const stop = watchCodebase(cb, async () => {
    // recurring jobs follow names too, even when the app itself didn't change
    await background.refresh();
    const next = await build_1(cb, appName, { quiet: true, lease });
    if (next.hash === current.hash) return;
    const before = current.hash;
    current = next;
    lease.hold({ builds: [next.hash] });
    if (opts.keep !== undefined) pruneBuilds(cb, opts.keep, true);
    loadEndpoints();
    console.log(
      `${yellow("deployed")} ${appName} ${dim(`${shortHash(before)} → ${shortHash(next.hash)}`)}; earlier builds keep working`,
    );
    reportPlan(cb, appName, planApp(cb, appName).plan);
  });
  return {
    url,
    close: async () => {
      clearInterval(rescan);
      stop();
      background.stop();
      await new Promise((r) => server.close(r));
      lease.release();
    },
  };
}

uses

used by