serve

tcmts.cli.sync.serve · #245fmn7f · term · runs on the server

uses node:zlib

Serve this codebase to other tcm instances. With the server's token, clients can store definitions, move branch names and — unless allowRun is false — run code here, so it listens on localhost by default. Contributors' tokens (tcm user add) push only their own branches and propose them; with public, anyone can pull and read contributions (refusal says who can do what).

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { bold, dim, green, red, yellow } from "#codebase/tcmts/cli/style";
import {
  authorFor, callerOf, ConflictError, forward, isLoopback, leanRecord, Peer, proxyFor, refusal, serverUsers,
  serveSite, shippedApp, webUi,
} from "#codebase/tcmts/cli/sync";
import type { ServeOptions } from "#codebase/tcmts/cli/sync";
import { shortHash } from "#codebase/tcmts/core/hash";
import { BASE, FORGE_ICON } from "#codebase/tcmts/share";
import { X509Certificate } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import path_1 from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";

export function serve(cb: Codebase, opts: ServeOptions, insecure = false): http.Server {
  if (!isLoopback(opts.host) && !opts.tls && !insecure) {
    throw new Error(
      `serving on ${opts.host} without TLS would send the token in clear text: add --tls (or --insecure on a network you trust)`,
    );
  }
  const peer = new Peer(cb, opts.allowRun);
  const ui = webUi(cb, { token: opts.token, isPublic: !!opts.public });
  const LIMIT = 64 * 1024 * 1024;
  // which of a proxied path's servers answered last, so it's asked first
  const answered = new Map<string, { at: number }>();

  const handler = async (req: http.IncomingMessage, res: http.ServerResponse) => {
    const reply = (status: number, body: unknown) => {
      const text = JSON.stringify(body);
      // (saying it takes compressed bodies, RFC 7694: a client sends them once it knows)
      // (and lean records: it derives IR and shapes itself)
      const headers = {
        "content-type": "application/json",
        "accept-encoding": "gzip",
        "x-tcm-records": "lean",
      };
      if (
        text.length > 16 * 1024 &&
        /\bgzip\b/.test(String(req.headers["accept-encoding"] ?? ""))
      ) {
        res.writeHead(status, { ...headers, "content-encoding": "gzip", vary: "accept-encoding" });
        return res.end(gzipSync(text));
      }
      res.writeHead(status, headers);
      res.end(text);
    };
    // the web UI (the forge): under /forge, beside a site's pages (which have every other path: a page /contributions,
    // as tcm's docs do), and the front page of a server with no site
    const pathname = (req.url ?? "/").split("?")[0]!;
    if (pathname === BASE || pathname.startsWith(`${BASE}/`)) return ui.handle(req, res);
    if (pathname === "/" && !opts.site && (req.method === "GET" || req.method === "HEAD")) {
      res.writeHead(302, { location: `${BASE}/` });
      return res.end();
    }
    // the icon browsers ask any page's server for: the web UI's, unless the site has its own
    if (
      pathname === "/favicon.ico" &&
      !(opts.site && fs.existsSync(path_1.join(opts.site, "favicon.ico"))) &&
      (req.method === "GET" || req.method === "HEAD")
    ) {
      res.writeHead(200, {
        "content-type": "image/svg+xml",
        "cache-control": "public, max-age=86400",
      });
      return res.end(decodeURIComponent(FORGE_ICON.slice(FORGE_ICON.indexOf(",") + 1)));
    }
    // apps served beside it, under paths of the site, to anyone: they're the server's to publish, like the site
    const app = opts.proxies && proxyFor(opts.proxies, req.url ?? "/");
    if (app) {
      if (!answered.has(app.path)) answered.set(app.path, { at: 0 });
      return forward(req, res, app.to, answered.get(app.path));
    }
    // the site, to anyone: it's what the server publishes (docs), not the codebase
    if (opts.site && (req.method === "GET" || req.method === "HEAD"))
      return serveSite(
        opts.site,
        req,
        res,
        opts.moved,
        (p) => !!opts.proxies && !!proxyFor(opts.proxies, p),
      );
    const caller = callerOf(serverUsers(cb), req.headers.authorization, opts.token, !!opts.public);
    if (!caller) return reply(401, { error: "missing or wrong token" });
    if (req.method !== "POST") return reply(405, { error: "use POST" });
    let size = 0;
    const chunks: Buffer[] = [];
    for await (const c of req) {
      size += (c as Buffer).length;
      if (size > LIMIT) return reply(413, { error: "request too large" });
      chunks.push(c as Buffer);
    }
    // a body can come compressed: it's held to the same limit unpacked, so a small one can't unpack into a huge one
    let raw = Buffer.concat(chunks);
    const encoding = req.headers["content-encoding"] ?? "identity";
    if (encoding === "gzip") {
      try {
        raw = gunzipSync(raw, { maxOutputLength: LIMIT });
      } catch (e) {
        return (e as NodeJS.ErrnoException).code === "ERR_BUFFER_TOO_LARGE"
          ? reply(413, { error: "request too large" })
          : reply(400, { error: "invalid gzip body" });
      }
    } else if (encoding !== "identity")
      return reply(415, { error: `unsupported content-encoding '${encoding}'` });
    let body: Record<string, any>;
    try {
      body = JSON.parse(raw.toString("utf8") || "{}");
    } catch {
      return reply(400, { error: "invalid JSON" });
    }
    const route = req.url?.replace(/^\//, "") ?? "";
    const refused = refusal(caller, route, body, (id) => peer.sourceOf(id));
    if (refused) return reply(caller.role === "reader" ? 401 : 403, { error: refused });
    try {
      switch (route) {
        case "whoami":
          return reply(200, caller);
        case "users":
          return reply(200, { users: peer.users() });
        case "add-user": {
          const token = peer.addUser(String(body.handle ?? ""));
          console.log(
            `${green("added")} user ${bold(`@${String(body.handle).replace(/^@/, "")}`)}`,
          );
          return reply(200, { token });
        }
        case "remove-user":
          return reply(200, { removed: peer.removeUser(String(body.handle ?? "")) });
        case "missing":
          return reply(200, { missing: peer.missing(body.components ?? []) });
        case "get":
          return reply(200, {
            records:
              req.headers["x-tcm-records"] === "lean"
                ? peer.get(body.hashes ?? []).map(leanRecord)
                : peer.get(body.hashes ?? []),
          });
        case "put": {
          const r = await peer.put(body.records ?? []);
          console.log(
            dim(
              `received ${r.stored} definition(s)${r.checkers > 1 ? `, checked by ${r.checkers} processes` : ""}`,
            ),
          );
          return reply(200, { stored: r.stored });
        }
        case "packages":
          return reply(200, { packages: peer.packages() });
        case "put-packages":
          peer.putPackages(body.packages ?? []);
          return reply(200, {});
        case "branch":
          return reply(200, { branch: peer.branch(String(body.name)) });
        case "set-branch":
          peer.setBranch(
            String(body.name),
            String(body.head),
            body.expect ?? null,
            String(body.command ?? "remote"),
            body.conflicts,
          );
          console.log(
            `${green("updated")} branch ${bold(String(body.name))} ${dim(`(${body.command})`)}`,
          );
          return reply(200, { ok: true });
        case "ancestors":
          return reply(200, { causals: peer.ancestors(String(body.head)) });
        case "missing-causals":
          return reply(200, { missing: peer.missingCausals(body.hashes ?? []) });
        case "get-causals":
          return reply(200, { causals: peer.getCausals(body.hashes ?? []) });
        case "put-causals":
          peer.putCausals(body.causals ?? []);
          return reply(200, {});
        case "missing-namespaces":
          return reply(200, { missing: peer.missingNamespaces(body.hashes ?? []) });
        case "get-namespaces":
          return reply(200, { nodes: peer.getNamespaces(body.hashes ?? []) });
        case "put-namespaces":
          peer.putNamespaces(body.nodes ?? []);
          return reply(200, {});
        case "releases":
          return reply(200, { releases: peer.releases() });
        case "result-keys":
          return reply(200, { keys: peer.resultKeys(body.hashes ?? []) });
        case "results":
          return reply(200, { results: peer.results(body.pairs ?? []) });
        case "put-results":
          peer.putResults(body.results ?? []);
          return reply(200, {});
        case "lint-keys":
          return reply(200, { keys: peer.lintKeys(body.hashes ?? []) });
        case "lints":
          return reply(200, { lints: peer.lints(body.pairs ?? []) });
        case "put-lints":
          peer.putLints(body.lints ?? []);
          return reply(200, {});
        case "put-releases":
          peer.putReleases(body.releases ?? []);
          for (const r of body.releases ?? [])
            console.log(`${green("released")} ${bold(`${r.package}@${r.version}`)}`);
          return reply(200, {});
        case "contributions":
          return reply(200, { contributions: peer.contributions(!!body.all) });
        case "contribution":
          return reply(200, peer.contribution(Number(body.id)));
        case "open-contribution": {
          const r = peer.openContribution({
            title: String(body.title ?? ""),
            description: String(body.description ?? ""),
            source: String(body.source),
            target: String(body.target),
            author: authorFor(caller, body.author),
          });
          console.log(
            `${green(r.created ? "opened" : "updated")} contribution #${r.id} ${dim(`(${body.source} → ${body.target})`)}`,
          );
          return reply(200, r);
        }
        case "comment":
          peer.comment(Number(body.id), {
            author: authorFor(caller, body.author),
            body: String(body.body ?? ""),
            about: body.about ? String(body.about) : null,
          });
          return reply(200, {});
        case "merge-contribution": {
          const r = peer.mergeContribution(Number(body.id), String(body.author ?? ""));
          console.log(
            `${green("merged")} contribution #${body.id} ${dim(`as ${shortHash(r.mergedAs)}`)}`,
          );
          return reply(200, r);
        }
        case "close-contribution":
        case "reopen-contribution":
          peer.closeContribution(
            Number(body.id),
            authorFor(caller, body.author),
            route === "reopen-contribution",
          );
          return reply(200, {});
        case "run": {
          const hash = String(body.hash);
          const args = Array.isArray(body.args) ? body.args.map(String) : [];
          res.writeHead(200, { "content-type": "application/x-ndjson" });
          const send = (msg: unknown) => res.write(JSON.stringify(msg) + "\n");
          console.log(dim(`running ${shortHash(hash)} ${args.join(" ")}`));
          try {
            send({ exit: await peer.run(hash, args, (o, d) => send({ o, d })) });
          } catch (e) {
            send({ error: (e as Error).message, exit: 1 });
          }
          return res.end();
        }
        default:
          return reply(404, { error: `unknown route '${route}'` });
      }
    } catch (e) {
      if (!res.headersSent)
        reply(e instanceof ConflictError ? 409 : 400, { error: (e as Error).message });
    }
  };
  const server = opts.tls
    ? https.createServer({ cert: opts.tls.cert, key: opts.tls.key }, handler)
    : http.createServer(handler);
  // idle connections stay open longer than a client takes between requests (re-hashing what it received), and
  // than a proxy in front of it expects (Railway's, say)
  server.on("close", () => ui.close());
  server.keepAliveTimeout = 65_000;
  server.headersTimeout = 66_000;
  const fingerprint = opts.tls && new X509Certificate(opts.tls.cert).fingerprint256;
  const scheme = opts.tls ? "https" : "http";
  server.listen(opts.port, opts.host, () => {
    const { port } = server.address() as { port: number };
    console.log(`${bold("tcm serve")} ${cb.lay.root}`);
    console.log(`  url    ${scheme}://${opts.host}:${port}`);
    if (fingerprint) console.log(`  cert   sha256 ${fingerprint}`);
    console.log(`  token  ${opts.showToken === false ? dim("(as given)") : opts.token}`);
    const users = serverUsers(cb).length;
    if (users) console.log(`  users  ${users} contributor(s) (\`tcm user\` lists them)`);
    if (opts.public)
      console.log(`  public ${yellow("yes: anyone can pull, and read contributions")}`);
    console.log(
      `  ui     ${scheme}://${opts.host}:${port}${BASE}/${shippedApp("share") ? "" : dim(" (not in this tcm: a tcm installed from its package has it)")}`,
    );
    if (opts.site)
      console.log(`  site   ${opts.site}${opts.moved ? dim(` (moved under ${opts.moved})`) : ""}`);
    for (const p of opts.proxies ?? []) console.log(`  app    ${p.path} → ${p.to.join(" | ")}`);
    console.log(
      `  run    ${opts.allowRun ? yellow("enabled: clients with the server's token can execute code here") : "disabled"}`,
    );
    if (!isLoopback(opts.host))
      console.log(
        opts.tls
          ? yellow("  listening beyond localhost")
          : red("  warning: listening beyond localhost without TLS"),
      );
    const pin = fingerprint ? ` --fingerprint ${fingerprint}` : "";
    console.log(
      dim(
        `\n  on another machine: tcm remote add <name> ${scheme}://<this-host>:${port} --token ${opts.showToken === false ? "<token>" : opts.token}${pin}`,
      ),
    );
    // (anyone may browse a public server: its web UI starts now, so a new tcm's first build is done before they ask)
    if (opts.public) ui.warm();
  });
  return server;
}

uses

used by