@txco/web-abi 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ // The chassis's side of the contract: the request envelope a runner hands
2
+ // the bridge, and the delta the bridge answers with.
3
+
4
+ /** The parts of the chassis's request envelope the bridge reads. */
5
+ export interface TxcEnvelope {
6
+ _txc?: {
7
+ client?: { ip?: string };
8
+ web?: {
9
+ req?: {
10
+ method?: string;
11
+ host?: string;
12
+ url?: { full?: string; path?: string; query?: { raw?: string } };
13
+ /** Header name → values, one per line. */
14
+ headers?: Record<string, string[] | string>;
15
+ /** The body, base64; absent when empty. */
16
+ body?: string;
17
+ };
18
+ };
19
+ };
20
+ [k: string]: unknown;
21
+ }
22
+
23
+ /** The response half: what an op writes to answer an HTTP request. */
24
+ export interface TxcWebRes {
25
+ status: number;
26
+ /** Lower-case header name → values. */
27
+ headers: Record<string, string[]>;
28
+ /** The body, base64; absent for HEAD, 204 and 304. */
29
+ body?: string;
30
+ }
31
+
32
+ /**
33
+ * The bridge's answer: a delta the chassis merges into the run, never the
34
+ * envelope echoed back (op answers merge by appending arrays, so an echo
35
+ * would duplicate every header).
36
+ */
37
+ export interface TxcDelta {
38
+ _txc: { web: { res: TxcWebRes }; halt: true };
39
+ }
40
+
41
+ /** What a server entry gets beside the Request. Deliberately minimal. */
42
+ export interface TxcContext {
43
+ readonly client: { readonly ip: string };
44
+ }
45
+
46
+ /** The server/ entry's default export. */
47
+ export interface FetchHandler {
48
+ fetch(request: Request, ctx: TxcContext): Response | Promise<Response>;
49
+ }
package/src/harness.ts ADDED
@@ -0,0 +1,149 @@
1
+ // The harness: load a build's server/ entry and check it honours the
2
+ // contract, before any runner exists; and serve a build locally behind the
3
+ // same bridge a runner will use.
4
+
5
+ import { createServer, type IncomingMessage, type Server } from "node:http";
6
+ import { readFile, stat } from "node:fs/promises";
7
+ import { extname, join, normalize, sep } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import { dispatch } from "./bridge.js";
11
+ import type { FetchHandler, TxcEnvelope } from "./envelope.js";
12
+ import { readManifest } from "./manifest.js";
13
+
14
+ /** Imports a build's server entry and checks its default export. */
15
+ export async function loadServer(abiDir: string): Promise<FetchHandler> {
16
+ const m = await readManifest(abiDir);
17
+ if (!m.server) throw new Error("the manifest names no server.entry: this is a static build");
18
+ const mod = await import(pathToFileURL(join(abiDir, m.server.entry)).href);
19
+ const h = mod.default as FetchHandler | undefined;
20
+ if (!h || typeof h.fetch !== "function") {
21
+ throw new Error(`${m.server.entry}: the default export must be { fetch(request, ctx) }`);
22
+ }
23
+ return h;
24
+ }
25
+
26
+ /** Builds an envelope the way the chassis's web head does, for a request. */
27
+ export function envelopeFor(method: string, path: string, init: { headers?: Record<string, string[]>; body?: Uint8Array; host?: string; ip?: string } = {}): TxcEnvelope {
28
+ const host = init.host ?? "localhost";
29
+ const q = path.indexOf("?");
30
+ return {
31
+ _txc: {
32
+ client: { ip: init.ip ?? "127.0.0.1" },
33
+ web: {
34
+ req: {
35
+ method,
36
+ host,
37
+ url: { full: `http://${host}${path}`, path: q < 0 ? path : path.slice(0, q), query: { raw: q < 0 ? "" : path.slice(q + 1) } },
38
+ headers: init.headers ?? {},
39
+ body: init.body && init.body.byteLength > 0 ? Buffer.from(init.body).toString("base64") : undefined,
40
+ },
41
+ },
42
+ },
43
+ };
44
+ }
45
+
46
+ export interface CheckResult {
47
+ name: string;
48
+ ok: boolean;
49
+ detail?: string;
50
+ }
51
+
52
+ /**
53
+ * Drives a server entry with the requests every handler must answer, through
54
+ * the bridge, and checks each answer is a valid delta: a status in
55
+ * 100–599, headers as arrays, no echo of the request envelope, a body only
56
+ * where one is allowed.
57
+ */
58
+ export async function checkServer(abiDir: string): Promise<CheckResult[]> {
59
+ const results: CheckResult[] = [];
60
+ let handler: FetchHandler;
61
+ try {
62
+ handler = await loadServer(abiDir);
63
+ results.push({ name: "load", ok: true });
64
+ } catch (e) {
65
+ return [{ name: "load", ok: false, detail: (e as Error).message }];
66
+ }
67
+ const probes: Array<[string, TxcEnvelope, string]> = [
68
+ ["GET /", envelopeFor("GET", "/", { headers: { accept: ["text/html"] } }), "GET"],
69
+ ["GET unknown page", envelopeFor("GET", "/txco-web-abi-check-unknown", { headers: { accept: ["text/html"] } }), "GET"],
70
+ ["POST unknown", envelopeFor("POST", "/txco-web-abi-check-unknown", { headers: { "content-type": ["application/json"] }, body: new TextEncoder().encode("{}") }), "POST"],
71
+ ["HEAD /", envelopeFor("HEAD", "/"), "HEAD"],
72
+ ];
73
+ for (const [name, env, method] of probes) {
74
+ const delta = await dispatch(handler, env);
75
+ const res = delta?._txc?.web?.res;
76
+ const problems: string[] = [];
77
+ if (!res || !Number.isInteger(res.status) || res.status < 100 || res.status > 599) problems.push("no valid status");
78
+ if (res && Object.values(res.headers).some((v) => !Array.isArray(v))) problems.push("headers must be arrays");
79
+ if ((delta as unknown as { _txc: { web: { req?: unknown } } })._txc.web.req !== undefined) problems.push("the answer echoes the request");
80
+ if (method === "HEAD" && res?.body) problems.push("a HEAD answer carries a body");
81
+ if (res?.status === 500 && (method === "HEAD" || (res.body && Buffer.from(res.body, "base64").toString() === "internal error\n"))) {
82
+ problems.push("the handler threw or returned no Response (a 500 from the bridge)");
83
+ }
84
+ try {
85
+ JSON.stringify(delta);
86
+ } catch {
87
+ problems.push("the delta isn't serialisable");
88
+ }
89
+ results.push({ name, ok: problems.length === 0, detail: problems.join("; ") || `→ ${res?.status}` });
90
+ }
91
+ return results;
92
+ }
93
+
94
+ const TYPES: Record<string, string> = {
95
+ ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript",
96
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
97
+ ".webp": "image/webp", ".ico": "image/x-icon", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2",
98
+ };
99
+
100
+ async function readBody(req: IncomingMessage): Promise<Uint8Array> {
101
+ const chunks: Buffer[] = [];
102
+ for await (const c of req) chunks.push(c as Buffer);
103
+ return Buffer.concat(chunks);
104
+ }
105
+
106
+ /**
107
+ * Serves a build locally: public/ files first (as the chassis's static
108
+ * serving answers first), everything else through the bridge to server/.
109
+ * For local development; not a chassis.
110
+ */
111
+ export async function serve(abiDir: string, opts: { port?: number; host?: string } = {}): Promise<Server> {
112
+ const handler = await loadServer(abiDir);
113
+ const pub = join(abiDir, "public");
114
+ const server = createServer(async (req, res) => {
115
+ const method = (req.method ?? "GET").toUpperCase();
116
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
117
+ if (method === "GET" || method === "HEAD") {
118
+ const segs = path.split("/").filter(Boolean);
119
+ if (!segs.some((s) => s.startsWith("."))) {
120
+ const file = normalize(join(pub, ...segs, path.endsWith("/") ? "index.html" : ""));
121
+ if (file.startsWith(pub + sep) || file === pub) {
122
+ try {
123
+ if ((await stat(file)).isFile()) {
124
+ const bytes = await readFile(file);
125
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
126
+ res.end(method === "HEAD" ? undefined : bytes);
127
+ return;
128
+ }
129
+ } catch {
130
+ // not a file: the server answers
131
+ }
132
+ }
133
+ }
134
+ }
135
+ const headers: Record<string, string[]> = {};
136
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
137
+ (headers[req.rawHeaders[i].toLowerCase()] ??= []).push(req.rawHeaders[i + 1]);
138
+ }
139
+ const delta = await dispatch(handler, envelopeFor(method, req.url ?? "/", {
140
+ headers, body: await readBody(req), host: req.headers.host, ip: req.socket.remoteAddress ?? "",
141
+ }));
142
+ const out = delta._txc.web.res;
143
+ for (const [name, values] of Object.entries(out.headers)) res.setHeader(name, values);
144
+ res.writeHead(out.status);
145
+ res.end(out.body ? Buffer.from(out.body, "base64") : undefined);
146
+ });
147
+ await new Promise<void>((resolve) => server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", resolve));
148
+ return server;
149
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./manifest.js";
2
+ export * from "./envelope.js";
3
+ export * from "./bridge.js";
4
+ export * from "./harness.js";
@@ -0,0 +1,127 @@
1
+ // The Web ABI manifest (txco-web.json): what a build is, never its routing.
2
+ // This validator mirrors txco-web.schema.json plus the rules a schema can't
3
+ // express, and is held to the same fixture corpus as the Go validator
4
+ // (chassis/webabi), so the two agree.
5
+
6
+ import { readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ /** The manifest's file name at the root of an ABI directory. */
10
+ export const MANIFEST_NAME = "txco-web.json";
11
+ /** The manifest's abi value this kit reads. */
12
+ export const ABI_VERSION = 1;
13
+ /** Producers' ops live in the band starting here, after every author op. */
14
+ export const PRODUCER_SCOPE = 900000;
15
+
16
+ export interface Manifest {
17
+ abi: 1;
18
+ server?: { entry: string };
19
+ immutable?: string[];
20
+ [extension: `x-${string}`]: unknown;
21
+ }
22
+
23
+ export interface Problem {
24
+ /** A JSON pointer into the manifest ("" is the root). */
25
+ pointer: string;
26
+ message: string;
27
+ }
28
+
29
+ export type ValidationResult = { ok: true; manifest: Manifest } | { ok: false; errors: Problem[] };
30
+
31
+ const ENTRY_RE = /^server\/[^/\\][^\\]*\.m?js$/;
32
+ const PREFIX_RE = /^[^/\\][^\\]*\/$/;
33
+
34
+ function cleanPath(p: string): boolean {
35
+ if (p === "" || p.startsWith("/") || p.endsWith("/")) return false;
36
+ return p.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
37
+ }
38
+
39
+ /** Validates a parsed manifest. */
40
+ export function validateManifest(doc: unknown): ValidationResult {
41
+ const errors: Problem[] = [];
42
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
43
+ return { ok: false, errors: [{ pointer: "", message: "the manifest must be a JSON object" }] };
44
+ }
45
+ const m = doc as Record<string, unknown>;
46
+ for (const key of Object.keys(m)) {
47
+ if (!["$schema", "abi", "server", "immutable"].includes(key) && !key.startsWith("x-")) {
48
+ errors.push({ pointer: "", message: `unknown property "${key}" (a manifest describes the build, never its routing; extensions start with x-)` });
49
+ }
50
+ }
51
+ if (!("abi" in m)) {
52
+ errors.push({ pointer: "", message: 'missing required property "abi"' });
53
+ } else if (m.abi !== ABI_VERSION) {
54
+ errors.push({ pointer: "/abi", message: `abi must be ${ABI_VERSION}` });
55
+ }
56
+ if ("$schema" in m && typeof m.$schema !== "string") {
57
+ errors.push({ pointer: "/$schema", message: "must be a string" });
58
+ }
59
+ if ("server" in m) {
60
+ const s = m.server;
61
+ if (typeof s !== "object" || s === null || Array.isArray(s)) {
62
+ errors.push({ pointer: "/server", message: "must be an object" });
63
+ } else {
64
+ const so = s as Record<string, unknown>;
65
+ for (const key of Object.keys(so)) {
66
+ if (key !== "entry") errors.push({ pointer: "/server", message: `unknown property "${key}"` });
67
+ }
68
+ if (typeof so.entry !== "string") {
69
+ errors.push({ pointer: "/server", message: 'missing required property "entry"' });
70
+ } else if (!ENTRY_RE.test(so.entry) || so.entry.length > 512) {
71
+ errors.push({ pointer: "/server/entry", message: "must be a .js or .mjs module under server/" });
72
+ } else if (!cleanPath(so.entry)) {
73
+ errors.push({ pointer: "/server/entry", message: "the entry must be a clean path under server/" });
74
+ }
75
+ }
76
+ }
77
+ if ("immutable" in m) {
78
+ const im = m.immutable;
79
+ if (!Array.isArray(im)) {
80
+ errors.push({ pointer: "/immutable", message: "must be an array of prefixes" });
81
+ } else {
82
+ if (new Set(im).size !== im.length) {
83
+ errors.push({ pointer: "/immutable", message: "items must be unique" });
84
+ }
85
+ im.forEach((p, i) => {
86
+ const ptr = `/immutable/${i}`;
87
+ if (typeof p !== "string" || p.length < 2 || p.length > 512 || !PREFIX_RE.test(p)) {
88
+ errors.push({ pointer: ptr, message: "a prefix is a relative path ending in '/'" });
89
+ } else if (!cleanPath(p.replace(/\/$/, ""))) {
90
+ errors.push({ pointer: ptr, message: "a prefix must be a clean path, with no '.' or '..' segment" });
91
+ } else if (p.split("/")[0] === "_txco") {
92
+ errors.push({ pointer: ptr, message: "_txco/ is reserved for the installer" });
93
+ }
94
+ });
95
+ }
96
+ }
97
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, manifest: m as unknown as Manifest };
98
+ }
99
+
100
+ /** Reads and validates <dir>/txco-web.json. Throws on an unreadable or invalid manifest. */
101
+ export async function readManifest(dir: string): Promise<Manifest> {
102
+ const raw = await readFile(join(dir, MANIFEST_NAME), "utf8");
103
+ let doc: unknown;
104
+ try {
105
+ doc = JSON.parse(raw);
106
+ } catch (e) {
107
+ throw new Error(`${MANIFEST_NAME}: not valid JSON: ${(e as Error).message}`);
108
+ }
109
+ const r = validateManifest(doc);
110
+ if (!r.ok) {
111
+ throw new Error(`${MANIFEST_NAME}: ` + r.errors.map((p) => `${p.pointer || "/"}: ${p.message}`).join("; "));
112
+ }
113
+ return r.manifest;
114
+ }
115
+
116
+ /**
117
+ * The private root of a public/ path: cut at the end of its first "_"
118
+ * segment ("" when it has none). The installer writes one public marker per
119
+ * root, so `_app/x.js` → `_app`, `assets/_Dk3.js` → itself.
120
+ */
121
+ export function publicRoot(rel: string): string {
122
+ const segs = rel.split("/");
123
+ for (let i = 0; i < segs.length; i++) {
124
+ if (segs[i].startsWith("_")) return segs.slice(0, i + 1).join("/");
125
+ }
126
+ return "";
127
+ }
@@ -0,0 +1,269 @@
1
+ // What every Web ABI producer writes beside public/: the ops that answer a
2
+ // request no file did, the manifest, and the guard that runs before a build
3
+ // overwrites its output dir. The producers (a framework adapter, a Nitro
4
+ // preset, a Vite plugin) share it, so their ops stay the same rules
5
+ // (chassis/webabi checks that they agree).
6
+ import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+
9
+ import { MANIFEST_NAME, PRODUCER_SCOPE, validateManifest } from "./manifest.js";
10
+
11
+ /** Where the catch-all sits: after the navigation op, since ops in one scope run concurrently. */
12
+ export const catchAllScope = (scope: number): number => scope + 900;
13
+
14
+ /**
15
+ * A navigation: an HTTP GET or HEAD of a path whose last segment has no
16
+ * extension. Everything else (an asset miss, a POST) is the catch-all's.
17
+ */
18
+ export const NAV_GUARD =
19
+ '@src == "http" && (@web.req.method == "GET" || @web.req.method == "HEAD")\n' +
20
+ " && @web.req.url.path !~ /(?i)\\.[a-z0-9]+$/";
21
+
22
+ /** The page a 404 op serves when the build has no 404.html. */
23
+ export const BUILTIN_404 =
24
+ '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head>' +
25
+ "<body><h1>404 Not Found</h1></body></html>\n";
26
+
27
+ /** An HTML page from public/, embedded in an op. */
28
+ export interface Page {
29
+ /** Its file name in public/, for the comments. */
30
+ name: string;
31
+ html: string;
32
+ }
33
+
34
+ /**
35
+ * How a page navigation nothing else answered is handled:
36
+ *
37
+ * - "spa": the app routes in the browser: 200, with the app shell;
38
+ * - "routes": the same, but only for a path the framework's route table
39
+ * knows (`routes`); any other navigation gets the shell with 404;
40
+ * - "404": every page is a file in public/, so a path that reached the ops
41
+ * has no page: 404, with the 404 page;
42
+ * - "none": no navigation op, only the catch-all.
43
+ */
44
+ export type NavMode = "spa" | "routes" | "404" | "none";
45
+
46
+ export interface RenderOptions {
47
+ mode: NavMode;
48
+ /** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
49
+ scope?: number;
50
+ /** The generated-file header's producer name. */
51
+ producer: string;
52
+ /**
53
+ * The shell ("spa" and "routes": required), the 404 page ("404": null
54
+ * writes a built-in one), or null ("none").
55
+ */
56
+ page: Page | null;
57
+ /**
58
+ * For "routes": an RE2 regex source, anchored, matching exactly the app's
59
+ * page paths, with "/" escaped for txcl's /…/ literal.
60
+ */
61
+ routes?: string;
62
+ /** The producer's own comment lines, added to the navigation op's header. */
63
+ notes?: readonly string[];
64
+ }
65
+
66
+ /**
67
+ * Renders the build's ops, as path (relative to ops/) → file text: one
68
+ * navigation op for the mode, and the catch-all, a plain 404 for every other
69
+ * HTTP request, so every request reaching the end of the stack is answered.
70
+ */
71
+ export function renderOps(o: RenderOptions): Record<string, string> {
72
+ const scope = o.scope ?? PRODUCER_SCOPE;
73
+ const notes = (o.notes ?? []).map((l) => (l ? `# ${l}` : "#")).join("\n");
74
+ const extra = notes ? `${notes}\n` : "";
75
+ const out: Record<string, string> = {};
76
+ if (o.mode === "routes") {
77
+ if (o.page === null) throw new Error("renderOps: a routes build needs its shell");
78
+ if (!o.routes) throw new Error("renderOps: a routes build needs its route matcher");
79
+ out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback: known routes (generated; do not edit by hand).
80
+ #
81
+ # Serves the app shell (${o.page.name}) with 200 for a navigation to a known
82
+ # page route that no file or earlier op answered. Paired with spa-404.txcl.
83
+ ${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
84
+ WHEN ${NAV_GUARD}
85
+ && @web.req.url.path =~ /${o.routes}/
86
+ ${emitPage(200, o.page.html)}`;
87
+ out[`${scope}/spa-404.txcl`] = `# ${o.producer} — SPA 404 (generated; do not edit by hand).
88
+ #
89
+ # A navigation matching no known page route is a real miss: 404, with the
90
+ # shell as the body so the client renders its error page.
91
+ WHEN ${NAV_GUARD}
92
+ && @web.req.url.path !~ /${o.routes}/
93
+ ${emitPage(404, o.page.html)}`;
94
+ } else if (o.mode === "spa") {
95
+ if (o.page === null) throw new Error("renderOps: an spa build needs its shell");
96
+ out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback (generated; do not edit by hand).
97
+ #
98
+ # Serves the app shell (${o.page.name}) with 200 for any page navigation
99
+ # nothing else answered: the app routes in the browser, so unknown pages get
100
+ # 200 too and the client renders its not-found view.
101
+ ${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
102
+ WHEN ${NAV_GUARD}
103
+ ${emitPage(200, o.page.html)}`;
104
+ } else if (o.mode === "404") {
105
+ const what = o.page
106
+ ? `Serves ${o.page.name} with 404`
107
+ : "Serves a plain 404 page (the build has no 404.html)";
108
+ out[`${scope}/page-404.txcl`] = `# ${o.producer} — 404 page (generated; do not edit by hand).
109
+ #
110
+ # ${what} for any page navigation nothing else answered:
111
+ # no file in public/ and no op of the stack had a page for it.
112
+ ${extra}# Regenerated on every build — the page embeds content-hashed asset URLs.
113
+ WHEN ${NAV_GUARD}
114
+ ${emitPage(404, o.page ? o.page.html : BUILTIN_404)}`;
115
+ }
116
+ out[`${catchAllScope(scope)}/not-found.txcl`] = `# ${o.producer} — not found (generated; do not edit by hand).
117
+ #
118
+ # Every other HTTP request that reached the end of the stack: an asset that
119
+ # doesn't exist, a method nothing handles. Without it the request would get
120
+ # the envelope back as JSON. It sits after the navigation ops on purpose: ops
121
+ # in one scope run concurrently.
122
+ WHEN @src == "http"
123
+ EMIT @web.res.status = 404,
124
+ @web.res.headers.content-type.0 = "text/plain; charset=utf-8",
125
+ @web.res.body = b64"404 not found\\n",
126
+ @halt = true
127
+ `;
128
+ return out;
129
+ }
130
+
131
+ /** The EMIT tail for a page answer: the page as a readable b64"…" literal, and halt. */
132
+ export function emitPage(status: number, html: string): string {
133
+ // The lexer base64-encodes a b64"…" literal at parse time, so the file stays
134
+ // readable HTML. Only backslash and double-quote need escaping.
135
+ const escaped = html.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
136
+ return ` EMIT @web.res.status = ${status},
137
+ @web.res.headers.content-type.0 = "text/html; charset=utf-8",
138
+ @web.res.headers.cache-control.0 = "no-cache",
139
+ @web.res.body = b64"${escaped}",
140
+ @halt = true
141
+ `;
142
+ }
143
+
144
+ /** What may already be in an output dir: a previous build, and nothing else. */
145
+ export const ABI_ENTRIES: ReadonlySet<string> = new Set([MANIFEST_NAME, "public", "server", "ops", ".gitignore", ".DS_Store"]);
146
+
147
+ export interface OutDirCheck {
148
+ /** The output dir the build is about to wipe and rewrite. */
149
+ dir: string;
150
+ /** Paths the wipe must not reach: the project root, the sources, another build's output. */
151
+ protect?: readonly string[];
152
+ /** Paths the output dir must not sit inside (a bundler's own outDir, which it empties). */
153
+ notInside?: readonly string[];
154
+ /** The dir's current entries, or null when it doesn't exist. */
155
+ entries: readonly string[] | null;
156
+ /** Entries a producer's own previous build leaves, besides ABI_ENTRIES. */
157
+ allow?: readonly string[];
158
+ }
159
+
160
+ const within = (child: string, parent: string) => {
161
+ const rel = relative(parent, child);
162
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
163
+ };
164
+
165
+ /** Every reason a build mustn't wipe and rewrite the output dir. Empty when it may. */
166
+ export function checkOutDir(c: OutDirCheck): string[] {
167
+ const dir = resolve(c.dir);
168
+ const problems: string[] = [];
169
+ if (dir.split(sep).includes("OPS")) {
170
+ problems.push(
171
+ `the output dir ${dir} is inside OPS/. A Web ABI build lives outside the stack tree; txco.yaml binds it to a stack (stacks: <name>: abi: <dir>).`,
172
+ );
173
+ }
174
+ for (const p of c.protect ?? []) {
175
+ if (p && within(resolve(p), dir)) {
176
+ problems.push(`the output dir ${dir} holds ${resolve(p)}, and the build wipes the output dir. Point it at a directory of its own.`);
177
+ }
178
+ }
179
+ for (const p of c.notInside ?? []) {
180
+ if (p && within(dir, resolve(p))) {
181
+ problems.push(`the output dir ${dir} is inside ${resolve(p)}, another build's output. Point it at a directory of its own.`);
182
+ }
183
+ }
184
+ const allowed = new Set([...ABI_ENTRIES, ...(c.allow ?? [])]);
185
+ const foreign = (c.entries ?? []).filter((e) => !allowed.has(e));
186
+ if (foreign.length > 0) {
187
+ problems.push(
188
+ `the output dir ${dir} holds ${foreign.map((e) => `"${e}"`).join(", ")}, which isn't part of a Web ABI build, and the build wipes it. Point the output somewhere else, or empty it.`,
189
+ );
190
+ }
191
+ return problems;
192
+ }
193
+
194
+ /** Replaces <out>/ops/ with the given ops (path relative to ops/ → text). */
195
+ export async function writeOps(out: string, ops: Record<string, string>): Promise<void> {
196
+ await rm(join(out, "ops"), { recursive: true, force: true });
197
+ for (const [rel, text] of Object.entries(ops)) {
198
+ const file = join(out, "ops", rel);
199
+ await mkdir(dirname(file), { recursive: true });
200
+ await writeFile(file, text);
201
+ }
202
+ }
203
+
204
+ /** Validates the manifest, then writes <out>/txco-web.json. Throws on an invalid one. */
205
+ export async function writeManifest(out: string, manifest: Record<string, unknown>): Promise<void> {
206
+ const r = validateManifest(manifest);
207
+ if (!r.ok) {
208
+ throw new Error(`${MANIFEST_NAME}: ` + r.errors.map((p) => `${p.pointer || "/"}: ${p.message}`).join("; "));
209
+ }
210
+ await mkdir(out, { recursive: true });
211
+ await writeFile(join(out, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
212
+ }
213
+
214
+ /** Every file under root, as "/"-separated paths relative to it, sorted. */
215
+ export async function listFiles(root: string): Promise<string[]> {
216
+ const out: string[] = [];
217
+ for (const e of await readdir(root, { recursive: true, withFileTypes: true })) {
218
+ // parentPath is Node ≥ 20.12; earlier 20.x calls it path.
219
+ const parent = (e as { parentPath?: string; path?: string }).parentPath ?? (e as { path?: string }).path ?? root;
220
+ if (e.isFile()) out.push(relative(root, join(parent, e.name)).split(sep).join("/"));
221
+ }
222
+ return out.sort();
223
+ }
224
+
225
+ /**
226
+ * Whether a file name carries a content hash: Vite's `main-BxYz12Ab.js`,
227
+ * Nuxt's `entry.CdEf34Gh.css`. A heuristic, for warnings only.
228
+ */
229
+ export function looksHashed(name: string): boolean {
230
+ const stem = name.replace(/\.[^.]+$/, "");
231
+ const run = stem.match(/[A-Za-z0-9_-]{8,}/);
232
+ return run !== null && /[0-9A-Z]/.test(run[0]);
233
+ }
234
+
235
+ /**
236
+ * Where a build lands under public/, from its base path: "/app/" serves the
237
+ * site under /app/, so its files go under public/app/. A relative or root
238
+ * base serves it at the root. A full URL (a CDN) loads the assets from
239
+ * elsewhere: the files still go to the root, and the producer should warn.
240
+ */
241
+ export function publicPrefix(base: string): { prefix: string; external: boolean } {
242
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(base) || base.startsWith("//")) return { prefix: "", external: true };
243
+ const path = base.replace(/^\.?\/+|\/+$/g, "");
244
+ return { prefix: path && path !== "." ? `${path}/` : "", external: false };
245
+ }
246
+
247
+ /** Whether a "/"-separated relative path is one a Web ABI build never deploys: a dot segment. */
248
+ export function isDotPath(rel: string): boolean {
249
+ return rel.split("/").some((s) => s.startsWith("."));
250
+ }
251
+
252
+ /**
253
+ * Copies a framework's static output dir into <out>/public/<prefix>,
254
+ * leaving out every dot path (they never deploy). Returns the ones it left
255
+ * out, other than .vite/ (build metadata), for the producer to warn about.
256
+ */
257
+ export async function copyPublic(from: string, out: string, prefix = ""): Promise<{ skipped: string[] }> {
258
+ const skipped: string[] = [];
259
+ await cp(from, join(out, "public", prefix), {
260
+ recursive: true,
261
+ filter: (src) => {
262
+ const rel = relative(from, src).split(sep).join("/");
263
+ if (rel === "" || !isDotPath(rel)) return true;
264
+ if (rel !== ".vite" && !rel.startsWith(".vite/")) skipped.push(rel);
265
+ return false;
266
+ },
267
+ });
268
+ return { skipped };
269
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://thanks.computer/schemas/txco-web-1.json",
4
+ "title": "TxCo Web ABI manifest (txco-web.json)",
5
+ "description": "Describes a Web ABI directory: public/ (files), an optional server/ Fetch entry, and ops/ (.txcl). It describes the artifact, never its routing: web behaviour is ops.",
6
+ "type": "object",
7
+ "required": ["abi"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "abi": {
11
+ "description": "The ABI version.",
12
+ "const": 1
13
+ },
14
+ "server": {
15
+ "description": "The application server, a Fetch handler module: export default { fetch(request, ctx) }. Absent for a static build.",
16
+ "type": "object",
17
+ "required": ["entry"],
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "entry": {
21
+ "description": "The ES module, relative to the ABI directory and under server/.",
22
+ "type": "string",
23
+ "maxLength": 512,
24
+ "pattern": "^server/[^/\\\\][^\\\\]*\\.m?js$"
25
+ }
26
+ }
27
+ },
28
+ "immutable": {
29
+ "description": "public/ prefixes whose files carry a content hash in their name, served with a year-long immutable cache. Each ends in '/'.",
30
+ "type": "array",
31
+ "uniqueItems": true,
32
+ "items": {
33
+ "type": "string",
34
+ "minLength": 2,
35
+ "maxLength": 512,
36
+ "pattern": "^[^/\\\\][^\\\\]*/$"
37
+ }
38
+ }
39
+ },
40
+ "patternProperties": {
41
+ "^x-": true
42
+ },
43
+ "additionalProperties": false
44
+ }