webUi
uses node:fs
The web UI tcm serve shows under /forge/: the app tcm ships (share), run by tcm start beside the server on
the codebase it serves, started when it's first asked for (or ahead: warm). Its codebase is copied into the served one's
.tcm/share/ (again when tcm's changes), where its builds are kept; requests under /forge/ go to it (a page asked
for while it starts says so, and tries again by itself). It's told the server's token and whether it's public, and
signs people in by the same tokens. A page it hasn't begun to answer in patience (30 seconds) gets a 503 saying
it's busy; one that has answered nothing for a minute while asked is stuck, and is stopped (it starts again when
next asked for), unless an action (a merge, say) is still being made. shown is another codebase's instead, under a
path of the server (tcm serve --forge): the codebase in dir, read by anyone and signed in to by no one, its app
kept in .tcm/share-<path>/. A dir that's a link is followed: when it comes to be another codebase, the UI starts
again on that one; a page asked for while there's none there says so.
import type { Codebase } from "#codebase/tcmts/cli/codebase"; import { migrateCopy } from "#codebase/tcmts/cli/migrate"; import { SHIPPED_APP } from "#codebase/tcmts/cli/pack"; import { dim, yellow } from "#codebase/tcmts/cli/style"; import { forward, freePort, shippedApp, uiOutput } from "#codebase/tcmts/cli/sync"; import { BASE } from "#codebase/tcmts/share"; import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import type http from "node:http"; import path from "node:path"; export function webUi( cb: Codebase, o: { token: string; isPublic: boolean; patience?: number; stuck?: number; shown?: { path: string; dir: string }; }, ) { const base = o.shown?.path ?? BASE; // the codebase shown, where it is now (a link followed), once it's there const shownNow = () => { try { const d = fs.realpathSync(o.shown!.dir); return fs.existsSync(path.join(d, ".tcm", "codebase.sqlite")) ? d : undefined; } catch { return undefined; } }; let showing: string | undefined; let child: ChildProcess | undefined; let port: Promise<number> | undefined; const answered: { at: number; when?: number } = { at: 0 }; const patience = o.patience ?? 30_000; const stuck = o.stuck ?? 60_000; // requests other than GET and HEAD it's making: actions, never cut short let acting = 0; const slow = () => { const c = child; if (!c || acting > 0 || answered.when === undefined || Date.now() - answered.when < stuck) return; console.log( yellow(` ui answered nothing for ${Math.round(stuck / 1000)}s: stopped, to start again`), ); // (its event loop held: only a kill stops it) c.kill("SIGKILL"); }; const start = (): Promise<number> | undefined => { const shipped = shippedApp("share"); if (!shipped) return undefined; const dir = path.join( cb.lay.tcmDir, o.shown ? `share${o.shown.path.replaceAll("/", "-")}` : "share", ); const from = path.join(shipped, "codebase.sqlite"); const to = path.join(dir, ".tcm", "codebase.sqlite"); const digest = createHash("sha256").update(fs.readFileSync(from)).digest("hex"); const mark = path.join(dir, ".tcm", "shipped"); if (!fs.existsSync(to) || !fs.existsSync(mark) || fs.readFileSync(mark, "utf8") !== digest) { fs.mkdirSync(path.dirname(to), { recursive: true }); for (const f of [to, `${to}-wal`, `${to}-shm`]) fs.rmSync(f, { force: true }); fs.copyFileSync(from, to); fs.writeFileSync(mark, digest); } // (an older tcm may have packed it, or copied it here) migrateCopy(to); return freePort().then((p) => { const env: NodeJS.ProcessEnv = { ...process.env, // (a link as it was given, so the codebase is called by it: /data/examples/effect, not where it leads) TCM_SHARE_CODEBASE: o.shown ? o.shown.dir : cb.lay.root, TCM_SHARE_TOKEN: o.token, TCM_SHARE_PUBLIC: o.isPublic || o.shown ? "1" : "0", // (it stops when this server does, however this one ends) TCM_STOP_WITH_PARENT: "1", }; delete env.TCM_CODEBASE; delete env.TCM_BRANCH; // (public with no token: read by anyone, signed in to by no one) if (o.shown) delete env.TCM_SHARE_TOKEN; // (one started for a codebase shown before, if it's still going) child?.kill(); const c = spawn( process.execPath, [process.argv[1]!, "start", SHIPPED_APP, "--port", String(p), "--base", base], { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] }, ); child = c; // (stuck is counted from its first answer: building it first can take a while) answered.when = undefined; const sayer = () => { const shown = uiOutput(); return (d: Buffer) => { for (const line of shown(d.toString("utf8"))) console.log(dim(` ui ${line}`)); }; }; c.stdout?.on("data", sayer()); c.stderr?.on("data", sayer()); c.on("exit", (code) => { if (child === c) ((child = undefined), (port = undefined)); if (code) console.log(yellow(` ui stopped (${code}); it starts again when it's next asked for`)); }); const stop = () => c.kill(); process.once("exit", stop); return p; }); }; return { handle(req: http.IncomingMessage, res: http.ServerResponse) { if (o.shown) { const now = shownNow(); if (!now) { res.writeHead(503, { "content-type": "text/plain; charset=utf-8", "retry-after": "60" }); return res.end( `There's no codebase to show here yet (${o.shown.path}/ shows ${o.shown.dir}): it may still be being made. Try again in a minute.`, ); } if (now !== showing) { child?.kill(); ((child = undefined), (port = undefined)); showing = now; } } port ??= start(); if (!port) { res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); return res.end("This tcm doesn't ship the web UI: a tcm installed from its package does."); } if (req.method !== "GET" && req.method !== "HEAD") { acting++; res.once("close", () => acting--); } void port.then( (p) => forward(req, res, `http://127.0.0.1:${p}`, answered, { patience, slow }), (e: Error) => { port = undefined; res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); res.end(`the web UI couldn't start: ${e.message}`); }, ); }, /** starts it now, before anyone asks for it: a public server's first reader doesn't wait for its first build */ warm() { const p = (port ??= start()); p?.catch(() => { if (port === p) port = undefined; }); }, close() { child?.kill(); }, }; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.migrate.migrateCopy
- tcmts.cli.pack.SHIPPED_APP
- tcmts.cli.style.dim
- tcmts.cli.style.yellow
- tcmts.cli.sync.forward
- tcmts.cli.sync.freePort
- tcmts.cli.sync.shippedApp
- tcmts.cli.sync.uiOutput
- tcmts.share.BASE