serve
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
- tcmts.cli.codebase.Codebase
- tcmts.cli.style.bold
- tcmts.cli.style.dim
- tcmts.cli.style.green
- tcmts.cli.style.red
- tcmts.cli.style.yellow
- tcmts.cli.sync.ConflictError
- tcmts.cli.sync.Peer
- tcmts.cli.sync.ServeOptions
- tcmts.cli.sync.authorFor
- tcmts.cli.sync.callerOf
- tcmts.cli.sync.forward
- tcmts.cli.sync.isLoopback
- tcmts.cli.sync.leanRecord
- tcmts.cli.sync.proxyFor
- tcmts.cli.sync.refusal
- tcmts.cli.sync.serveSite
- tcmts.cli.sync.serverUsers
- tcmts.cli.sync.shippedApp
- tcmts.cli.sync.webUi
- tcmts.core.hash.shortHash
- tcmts.share.BASE
- tcmts.share.FORGE_ICON