@txco/web-abi 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # @txco/web-abi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The conformance kit for the [Thanks, Computer](https://www.thanks.computer) (TxCo) Web ABI: the contract a framework's build
4
+ produces so `txco` can deploy it.
5
+
6
+ ```
7
+ <out>/
8
+ txco-web.json the manifest: what the build is, never its routing
9
+ public/ files, installed as the stack's FILES/
10
+ server/ an optional Fetch handler: export default { fetch(request, ctx) }
11
+ ops/ ordinary .txcl, in scope directories (ops/900000/…)
12
+ ```
13
+
14
+ The kit has three parts:
15
+
16
+ - **The manifest schema** (`txco-web.schema.json`) and a validator. `txco`
17
+ embeds the same schema file, and both validators pass the same test corpus.
18
+ - **The envelope ↔ Fetch bridge**, which every runner uses. It turns the
19
+ chassis's request envelope into a Fetch `Request`, calls the handler, and
20
+ turns the `Response` into the delta the chassis merges.
21
+ - **A harness** that checks a build's `server/` before any runner exists, and
22
+ serves a build locally.
23
+
24
+ `txco web check <out>` checks the rest: the files, the ops, and every request
25
+ the build has to answer.
26
+
27
+ ## The manifest
28
+
29
+ ```json
30
+ {
31
+ "abi": 1,
32
+ "server": { "entry": "server/index.mjs" },
33
+ "immutable": ["_app/immutable/"]
34
+ }
35
+ ```
36
+
37
+ | Field | Meaning |
38
+ | -------------- | ------------------------------------------------------------------------------------- |
39
+ | `abi` | `1` |
40
+ | `server.entry` | The handler module, under `server/`. Leave it out for a static build. |
41
+ | `immutable` | `public/` prefixes whose file names carry a content hash. They are cached for a year. |
42
+
43
+ Keys starting with `x-` are free for producers. Any other key is an error.
44
+
45
+ ## The server contract
46
+
47
+ ```js
48
+ export default {
49
+ async fetch(request, ctx) {
50
+ // a Fetch Request; ctx = { client: { ip } }
51
+ return new Response("…");
52
+ },
53
+ };
54
+ ```
55
+
56
+ - **Buffered both ways.** An answer is capped at about 3 MiB of body.
57
+ - **Cookies.** Each `Set-Cookie` arrives on its own line.
58
+ - **Errors.** A handler that throws, or returns something other than a
59
+ `Response`, answers `500` without revealing why.
60
+
61
+ ## Use
62
+
63
+ ```sh
64
+ npx txco-web-abi validate <out> # the manifest
65
+ npx txco-web-abi check-server <out> [--json] # drive server/ through the bridge
66
+ npx txco-web-abi serve <out> [--port 8787] # public/ first, then server/
67
+ ```
68
+
69
+ ```js
70
+ import {
71
+ dispatch,
72
+ envelopeToRequest,
73
+ responseToDelta,
74
+ } from "@txco/web-abi/bridge";
75
+ import { validateManifest } from "@txco/web-abi/manifest";
76
+ ```
77
+
78
+ ## Develop
79
+
80
+ ```sh
81
+ npm install
82
+ npm test # tsc, then node --test (Node 20+)
83
+ ```
@@ -0,0 +1,25 @@
1
+ import type { FetchHandler, TxcContext, TxcDelta, TxcEnvelope } from "./envelope.js";
2
+ /** An op answer is capped at 4 MiB (--op-payload-max); the body is base64 inside it. */
3
+ export declare const DEFAULT_MAX_ANSWER_BYTES: number;
4
+ export interface RequestOptions {
5
+ /** Refuse a request body larger than this (bytes). Default: no limit. */
6
+ maxBodyBytes?: number;
7
+ }
8
+ /** Builds the Fetch Request an envelope describes. */
9
+ export declare function envelopeToRequest(env: TxcEnvelope, opts?: RequestOptions): Request;
10
+ /** The handler's context: only what the contract defines. */
11
+ export declare function contextFrom(env: TxcEnvelope): TxcContext;
12
+ export interface DeltaOptions {
13
+ /** The request's method: a HEAD answer carries no body. */
14
+ method?: string;
15
+ /** Refuse a body larger than this (bytes). */
16
+ maxAnswerBytes?: number;
17
+ }
18
+ /** Turns a Response into the chassis delta. */
19
+ export declare function responseToDelta(res: Response, opts?: DeltaOptions): Promise<TxcDelta>;
20
+ /**
21
+ * Runs one request through a handler, end to end. A handler that throws, or
22
+ * returns something other than a Response, answers 500; the error is never
23
+ * shown to the client.
24
+ */
25
+ export declare function dispatch(handler: FetchHandler, env: TxcEnvelope, opts?: RequestOptions & DeltaOptions): Promise<TxcDelta>;
package/dist/bridge.js ADDED
@@ -0,0 +1,103 @@
1
+ // The envelope ↔ Fetch bridge. A runner hands it the chassis's request
2
+ // envelope; it builds a Fetch Request, calls the server entry, and turns the
3
+ // Response into the delta the chassis merges. Every runner uses this one
4
+ // mapping, so a handler sees the same Request wherever it runs.
5
+ /** An op answer is capped at 4 MiB (--op-payload-max); the body is base64 inside it. */
6
+ export const DEFAULT_MAX_ANSWER_BYTES = 3 * 1024 * 1024;
7
+ // Hop-by-hop headers (RFC 9110 §7.6.1) and those the Request computes.
8
+ const DROPPED_HEADERS = new Set([
9
+ "connection", "keep-alive", "proxy-connection", "transfer-encoding", "te", "trailer",
10
+ "upgrade", "content-length", "host",
11
+ ]);
12
+ /** Builds the Fetch Request an envelope describes. */
13
+ export function envelopeToRequest(env, opts = {}) {
14
+ const req = env._txc?.web?.req ?? {};
15
+ const method = (req.method ?? "GET").toUpperCase();
16
+ let url = req.url?.full;
17
+ if (!url) {
18
+ const host = req.host ?? "localhost";
19
+ const raw = req.url?.query?.raw;
20
+ url = `http://${host}${req.url?.path ?? "/"}${raw ? "?" + raw : ""}`;
21
+ }
22
+ const headers = new Headers();
23
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
24
+ if (DROPPED_HEADERS.has(name.toLowerCase()))
25
+ continue;
26
+ for (const v of Array.isArray(value) ? value : [value])
27
+ headers.append(name, v);
28
+ }
29
+ let body;
30
+ if (req.body && method !== "GET" && method !== "HEAD") {
31
+ const bytes = Buffer.from(req.body, "base64");
32
+ if (opts.maxBodyBytes !== undefined && bytes.byteLength > opts.maxBodyBytes) {
33
+ throw new Error(`request body is ${bytes.byteLength} bytes, over ${opts.maxBodyBytes}`);
34
+ }
35
+ body = new ArrayBuffer(bytes.byteLength);
36
+ new Uint8Array(body).set(bytes);
37
+ }
38
+ return new Request(url, { method, headers, body });
39
+ }
40
+ /** The handler's context: only what the contract defines. */
41
+ export function contextFrom(env) {
42
+ return Object.freeze({ client: Object.freeze({ ip: env._txc?.client?.ip ?? "" }) });
43
+ }
44
+ /** Turns a Response into the chassis delta. */
45
+ export async function responseToDelta(res, opts = {}) {
46
+ const headers = {};
47
+ res.headers.forEach((value, name) => {
48
+ if (name === "set-cookie")
49
+ return; // each cookie on its own line, below
50
+ (headers[name] ??= []).push(value);
51
+ });
52
+ const cookies = res.headers.getSetCookie();
53
+ if (cookies.length > 0)
54
+ headers["set-cookie"] = cookies;
55
+ const delta = { _txc: { web: { res: { status: res.status, headers } }, halt: true } };
56
+ const noBody = (opts.method ?? "GET").toUpperCase() === "HEAD" || res.status === 204 || res.status === 304;
57
+ if (!noBody) {
58
+ const bytes = Buffer.from(await res.arrayBuffer());
59
+ const max = opts.maxAnswerBytes ?? DEFAULT_MAX_ANSWER_BYTES;
60
+ if (bytes.byteLength > max) {
61
+ throw new Error(`response body is ${bytes.byteLength} bytes, over the ${max}-byte answer limit`);
62
+ }
63
+ if (bytes.byteLength > 0)
64
+ delta._txc.web.res.body = bytes.toString("base64");
65
+ }
66
+ return delta;
67
+ }
68
+ function errorDelta(status, message, method) {
69
+ const res = { status, headers: { "content-type": ["text/plain; charset=utf-8"] } };
70
+ if (method.toUpperCase() !== "HEAD")
71
+ res.body = Buffer.from(message + "\n").toString("base64");
72
+ return { _txc: { web: { res }, halt: true } };
73
+ }
74
+ /**
75
+ * Runs one request through a handler, end to end. A handler that throws, or
76
+ * returns something other than a Response, answers 500; the error is never
77
+ * shown to the client.
78
+ */
79
+ export async function dispatch(handler, env, opts = {}) {
80
+ const method = env._txc?.web?.req?.method ?? "GET";
81
+ let request;
82
+ try {
83
+ request = envelopeToRequest(env, opts);
84
+ }
85
+ catch {
86
+ return errorDelta(413, "request body too large", method);
87
+ }
88
+ let res;
89
+ try {
90
+ res = await handler.fetch(request, contextFrom(env));
91
+ }
92
+ catch {
93
+ return errorDelta(500, "internal error", method);
94
+ }
95
+ if (!(res instanceof Response))
96
+ return errorDelta(500, "internal error", method);
97
+ try {
98
+ return await responseToDelta(res, { ...opts, method: request.method });
99
+ }
100
+ catch {
101
+ return errorDelta(502, "response too large", method);
102
+ }
103
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // txco-web-abi: validate a manifest, check a build's server/, or serve a
3
+ // build locally.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { checkServer, serve } from "./harness.js";
7
+ import { MANIFEST_NAME, validateManifest } from "./manifest.js";
8
+ const USAGE = `Usage: txco-web-abi <command> <abi-dir>
9
+
10
+ Commands:
11
+ validate <abi-dir> check txco-web.json against the Web ABI schema
12
+ check-server <abi-dir> [--json] drive the build's server/ entry through the bridge
13
+ serve <abi-dir> [--port N] serve public/ and server/ locally
14
+ `;
15
+ async function main(argv) {
16
+ const [cmd, dir, ...rest] = argv;
17
+ if (!cmd || !dir) {
18
+ process.stderr.write(USAGE);
19
+ return 2;
20
+ }
21
+ switch (cmd) {
22
+ case "validate": {
23
+ let doc;
24
+ try {
25
+ doc = JSON.parse(await readFile(join(dir, MANIFEST_NAME), "utf8"));
26
+ }
27
+ catch (e) {
28
+ process.stderr.write(`${MANIFEST_NAME}: ${e.message}\n`);
29
+ return 1;
30
+ }
31
+ const r = validateManifest(doc);
32
+ if (r.ok) {
33
+ process.stdout.write(`${MANIFEST_NAME}: ok\n`);
34
+ return 0;
35
+ }
36
+ for (const p of r.errors)
37
+ process.stdout.write(`${p.pointer || "/"}: ${p.message}\n`);
38
+ return 1;
39
+ }
40
+ case "check-server": {
41
+ const results = await checkServer(dir);
42
+ if (rest.includes("--json")) {
43
+ process.stdout.write(JSON.stringify({ ok: results.every((r) => r.ok), results }, null, 2) + "\n");
44
+ }
45
+ else {
46
+ for (const r of results)
47
+ process.stdout.write(`${r.ok ? "✓" : "✗"} ${r.name}${r.detail ? " " + r.detail : ""}\n`);
48
+ }
49
+ return results.every((r) => r.ok) ? 0 : 1;
50
+ }
51
+ case "serve": {
52
+ const i = rest.indexOf("--port");
53
+ const port = i >= 0 ? Number(rest[i + 1]) : 8787;
54
+ const server = await serve(dir, { port });
55
+ const addr = server.address();
56
+ process.stdout.write(`serving ${dir} on http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : port}\n`);
57
+ return await new Promise(() => { }); // until interrupted
58
+ }
59
+ default:
60
+ process.stderr.write(USAGE);
61
+ return 2;
62
+ }
63
+ }
64
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
65
+ process.stderr.write(`txco-web-abi: ${e.message}\n`);
66
+ process.exit(1);
67
+ });
@@ -0,0 +1,57 @@
1
+ /** The parts of the chassis's request envelope the bridge reads. */
2
+ export interface TxcEnvelope {
3
+ _txc?: {
4
+ client?: {
5
+ ip?: string;
6
+ };
7
+ web?: {
8
+ req?: {
9
+ method?: string;
10
+ host?: string;
11
+ url?: {
12
+ full?: string;
13
+ path?: string;
14
+ query?: {
15
+ raw?: string;
16
+ };
17
+ };
18
+ /** Header name → values, one per line. */
19
+ headers?: Record<string, string[] | string>;
20
+ /** The body, base64; absent when empty. */
21
+ body?: string;
22
+ };
23
+ };
24
+ };
25
+ [k: string]: unknown;
26
+ }
27
+ /** The response half: what an op writes to answer an HTTP request. */
28
+ export interface TxcWebRes {
29
+ status: number;
30
+ /** Lower-case header name → values. */
31
+ headers: Record<string, string[]>;
32
+ /** The body, base64; absent for HEAD, 204 and 304. */
33
+ body?: string;
34
+ }
35
+ /**
36
+ * The bridge's answer: a delta the chassis merges into the run, never the
37
+ * envelope echoed back (op answers merge by appending arrays, so an echo
38
+ * would duplicate every header).
39
+ */
40
+ export interface TxcDelta {
41
+ _txc: {
42
+ web: {
43
+ res: TxcWebRes;
44
+ };
45
+ halt: true;
46
+ };
47
+ }
48
+ /** What a server entry gets beside the Request. Deliberately minimal. */
49
+ export interface TxcContext {
50
+ readonly client: {
51
+ readonly ip: string;
52
+ };
53
+ }
54
+ /** The server/ entry's default export. */
55
+ export interface FetchHandler {
56
+ fetch(request: Request, ctx: TxcContext): Response | Promise<Response>;
57
+ }
@@ -0,0 +1,3 @@
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
+ export {};
@@ -0,0 +1,32 @@
1
+ import { type Server } from "node:http";
2
+ import type { FetchHandler, TxcEnvelope } from "./envelope.js";
3
+ /** Imports a build's server entry and checks its default export. */
4
+ export declare function loadServer(abiDir: string): Promise<FetchHandler>;
5
+ /** Builds an envelope the way the chassis's web head does, for a request. */
6
+ export declare function envelopeFor(method: string, path: string, init?: {
7
+ headers?: Record<string, string[]>;
8
+ body?: Uint8Array;
9
+ host?: string;
10
+ ip?: string;
11
+ }): TxcEnvelope;
12
+ export interface CheckResult {
13
+ name: string;
14
+ ok: boolean;
15
+ detail?: string;
16
+ }
17
+ /**
18
+ * Drives a server entry with the requests every handler must answer, through
19
+ * the bridge, and checks each answer is a valid delta: a status in
20
+ * 100–599, headers as arrays, no echo of the request envelope, a body only
21
+ * where one is allowed.
22
+ */
23
+ export declare function checkServer(abiDir: string): Promise<CheckResult[]>;
24
+ /**
25
+ * Serves a build locally: public/ files first (as the chassis's static
26
+ * serving answers first), everything else through the bridge to server/.
27
+ * For local development; not a chassis.
28
+ */
29
+ export declare function serve(abiDir: string, opts?: {
30
+ port?: number;
31
+ host?: string;
32
+ }): Promise<Server>;
@@ -0,0 +1,144 @@
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
+ import { createServer } from "node:http";
5
+ import { readFile, stat } from "node:fs/promises";
6
+ import { extname, join, normalize, sep } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { dispatch } from "./bridge.js";
9
+ import { readManifest } from "./manifest.js";
10
+ /** Imports a build's server entry and checks its default export. */
11
+ export async function loadServer(abiDir) {
12
+ const m = await readManifest(abiDir);
13
+ if (!m.server)
14
+ throw new Error("the manifest names no server.entry: this is a static build");
15
+ const mod = await import(pathToFileURL(join(abiDir, m.server.entry)).href);
16
+ const h = mod.default;
17
+ if (!h || typeof h.fetch !== "function") {
18
+ throw new Error(`${m.server.entry}: the default export must be { fetch(request, ctx) }`);
19
+ }
20
+ return h;
21
+ }
22
+ /** Builds an envelope the way the chassis's web head does, for a request. */
23
+ export function envelopeFor(method, path, init = {}) {
24
+ const host = init.host ?? "localhost";
25
+ const q = path.indexOf("?");
26
+ return {
27
+ _txc: {
28
+ client: { ip: init.ip ?? "127.0.0.1" },
29
+ web: {
30
+ req: {
31
+ method,
32
+ host,
33
+ url: { full: `http://${host}${path}`, path: q < 0 ? path : path.slice(0, q), query: { raw: q < 0 ? "" : path.slice(q + 1) } },
34
+ headers: init.headers ?? {},
35
+ body: init.body && init.body.byteLength > 0 ? Buffer.from(init.body).toString("base64") : undefined,
36
+ },
37
+ },
38
+ },
39
+ };
40
+ }
41
+ /**
42
+ * Drives a server entry with the requests every handler must answer, through
43
+ * the bridge, and checks each answer is a valid delta: a status in
44
+ * 100–599, headers as arrays, no echo of the request envelope, a body only
45
+ * where one is allowed.
46
+ */
47
+ export async function checkServer(abiDir) {
48
+ const results = [];
49
+ let handler;
50
+ try {
51
+ handler = await loadServer(abiDir);
52
+ results.push({ name: "load", ok: true });
53
+ }
54
+ catch (e) {
55
+ return [{ name: "load", ok: false, detail: e.message }];
56
+ }
57
+ const probes = [
58
+ ["GET /", envelopeFor("GET", "/", { headers: { accept: ["text/html"] } }), "GET"],
59
+ ["GET unknown page", envelopeFor("GET", "/txco-web-abi-check-unknown", { headers: { accept: ["text/html"] } }), "GET"],
60
+ ["POST unknown", envelopeFor("POST", "/txco-web-abi-check-unknown", { headers: { "content-type": ["application/json"] }, body: new TextEncoder().encode("{}") }), "POST"],
61
+ ["HEAD /", envelopeFor("HEAD", "/"), "HEAD"],
62
+ ];
63
+ for (const [name, env, method] of probes) {
64
+ const delta = await dispatch(handler, env);
65
+ const res = delta?._txc?.web?.res;
66
+ const problems = [];
67
+ if (!res || !Number.isInteger(res.status) || res.status < 100 || res.status > 599)
68
+ problems.push("no valid status");
69
+ if (res && Object.values(res.headers).some((v) => !Array.isArray(v)))
70
+ problems.push("headers must be arrays");
71
+ if (delta._txc.web.req !== undefined)
72
+ problems.push("the answer echoes the request");
73
+ if (method === "HEAD" && res?.body)
74
+ problems.push("a HEAD answer carries a body");
75
+ if (res?.status === 500 && (method === "HEAD" || (res.body && Buffer.from(res.body, "base64").toString() === "internal error\n"))) {
76
+ problems.push("the handler threw or returned no Response (a 500 from the bridge)");
77
+ }
78
+ try {
79
+ JSON.stringify(delta);
80
+ }
81
+ catch {
82
+ problems.push("the delta isn't serialisable");
83
+ }
84
+ results.push({ name, ok: problems.length === 0, detail: problems.join("; ") || `→ ${res?.status}` });
85
+ }
86
+ return results;
87
+ }
88
+ const TYPES = {
89
+ ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript",
90
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
91
+ ".webp": "image/webp", ".ico": "image/x-icon", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2",
92
+ };
93
+ async function readBody(req) {
94
+ const chunks = [];
95
+ for await (const c of req)
96
+ chunks.push(c);
97
+ return Buffer.concat(chunks);
98
+ }
99
+ /**
100
+ * Serves a build locally: public/ files first (as the chassis's static
101
+ * serving answers first), everything else through the bridge to server/.
102
+ * For local development; not a chassis.
103
+ */
104
+ export async function serve(abiDir, opts = {}) {
105
+ const handler = await loadServer(abiDir);
106
+ const pub = join(abiDir, "public");
107
+ const server = createServer(async (req, res) => {
108
+ const method = (req.method ?? "GET").toUpperCase();
109
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
110
+ if (method === "GET" || method === "HEAD") {
111
+ const segs = path.split("/").filter(Boolean);
112
+ if (!segs.some((s) => s.startsWith("."))) {
113
+ const file = normalize(join(pub, ...segs, path.endsWith("/") ? "index.html" : ""));
114
+ if (file.startsWith(pub + sep) || file === pub) {
115
+ try {
116
+ if ((await stat(file)).isFile()) {
117
+ const bytes = await readFile(file);
118
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
119
+ res.end(method === "HEAD" ? undefined : bytes);
120
+ return;
121
+ }
122
+ }
123
+ catch {
124
+ // not a file: the server answers
125
+ }
126
+ }
127
+ }
128
+ }
129
+ const headers = {};
130
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
131
+ (headers[req.rawHeaders[i].toLowerCase()] ??= []).push(req.rawHeaders[i + 1]);
132
+ }
133
+ const delta = await dispatch(handler, envelopeFor(method, req.url ?? "/", {
134
+ headers, body: await readBody(req), host: req.headers.host, ip: req.socket.remoteAddress ?? "",
135
+ }));
136
+ const out = delta._txc.web.res;
137
+ for (const [name, values] of Object.entries(out.headers))
138
+ res.setHeader(name, values);
139
+ res.writeHead(out.status);
140
+ res.end(out.body ? Buffer.from(out.body, "base64") : undefined);
141
+ });
142
+ await new Promise((resolve) => server.listen(opts.port ?? 0, opts.host ?? "127.0.0.1", resolve));
143
+ return server;
144
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./manifest.js";
2
+ export * from "./envelope.js";
3
+ export * from "./bridge.js";
4
+ export * from "./harness.js";
package/dist/index.js 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,36 @@
1
+ /** The manifest's file name at the root of an ABI directory. */
2
+ export declare const MANIFEST_NAME = "txco-web.json";
3
+ /** The manifest's abi value this kit reads. */
4
+ export declare const ABI_VERSION = 1;
5
+ /** Producers' ops live in the band starting here, after every author op. */
6
+ export declare const PRODUCER_SCOPE = 900000;
7
+ export interface Manifest {
8
+ abi: 1;
9
+ server?: {
10
+ entry: string;
11
+ };
12
+ immutable?: string[];
13
+ [extension: `x-${string}`]: unknown;
14
+ }
15
+ export interface Problem {
16
+ /** A JSON pointer into the manifest ("" is the root). */
17
+ pointer: string;
18
+ message: string;
19
+ }
20
+ export type ValidationResult = {
21
+ ok: true;
22
+ manifest: Manifest;
23
+ } | {
24
+ ok: false;
25
+ errors: Problem[];
26
+ };
27
+ /** Validates a parsed manifest. */
28
+ export declare function validateManifest(doc: unknown): ValidationResult;
29
+ /** Reads and validates <dir>/txco-web.json. Throws on an unreadable or invalid manifest. */
30
+ export declare function readManifest(dir: string): Promise<Manifest>;
31
+ /**
32
+ * The private root of a public/ path: cut at the end of its first "_"
33
+ * segment ("" when it has none). The installer writes one public marker per
34
+ * root, so `_app/x.js` → `_app`, `assets/_Dk3.js` → itself.
35
+ */
36
+ export declare function publicRoot(rel: string): string;
@@ -0,0 +1,116 @@
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
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ /** The manifest's file name at the root of an ABI directory. */
8
+ export const MANIFEST_NAME = "txco-web.json";
9
+ /** The manifest's abi value this kit reads. */
10
+ export const ABI_VERSION = 1;
11
+ /** Producers' ops live in the band starting here, after every author op. */
12
+ export const PRODUCER_SCOPE = 900000;
13
+ const ENTRY_RE = /^server\/[^/\\][^\\]*\.m?js$/;
14
+ const PREFIX_RE = /^[^/\\][^\\]*\/$/;
15
+ function cleanPath(p) {
16
+ if (p === "" || p.startsWith("/") || p.endsWith("/"))
17
+ return false;
18
+ return p.split("/").every((seg) => seg !== "" && !seg.startsWith("."));
19
+ }
20
+ /** Validates a parsed manifest. */
21
+ export function validateManifest(doc) {
22
+ const errors = [];
23
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
24
+ return { ok: false, errors: [{ pointer: "", message: "the manifest must be a JSON object" }] };
25
+ }
26
+ const m = doc;
27
+ for (const key of Object.keys(m)) {
28
+ if (!["$schema", "abi", "server", "immutable"].includes(key) && !key.startsWith("x-")) {
29
+ errors.push({ pointer: "", message: `unknown property "${key}" (a manifest describes the build, never its routing; extensions start with x-)` });
30
+ }
31
+ }
32
+ if (!("abi" in m)) {
33
+ errors.push({ pointer: "", message: 'missing required property "abi"' });
34
+ }
35
+ else if (m.abi !== ABI_VERSION) {
36
+ errors.push({ pointer: "/abi", message: `abi must be ${ABI_VERSION}` });
37
+ }
38
+ if ("$schema" in m && typeof m.$schema !== "string") {
39
+ errors.push({ pointer: "/$schema", message: "must be a string" });
40
+ }
41
+ if ("server" in m) {
42
+ const s = m.server;
43
+ if (typeof s !== "object" || s === null || Array.isArray(s)) {
44
+ errors.push({ pointer: "/server", message: "must be an object" });
45
+ }
46
+ else {
47
+ const so = s;
48
+ for (const key of Object.keys(so)) {
49
+ if (key !== "entry")
50
+ errors.push({ pointer: "/server", message: `unknown property "${key}"` });
51
+ }
52
+ if (typeof so.entry !== "string") {
53
+ errors.push({ pointer: "/server", message: 'missing required property "entry"' });
54
+ }
55
+ else if (!ENTRY_RE.test(so.entry) || so.entry.length > 512) {
56
+ errors.push({ pointer: "/server/entry", message: "must be a .js or .mjs module under server/" });
57
+ }
58
+ else if (!cleanPath(so.entry)) {
59
+ errors.push({ pointer: "/server/entry", message: "the entry must be a clean path under server/" });
60
+ }
61
+ }
62
+ }
63
+ if ("immutable" in m) {
64
+ const im = m.immutable;
65
+ if (!Array.isArray(im)) {
66
+ errors.push({ pointer: "/immutable", message: "must be an array of prefixes" });
67
+ }
68
+ else {
69
+ if (new Set(im).size !== im.length) {
70
+ errors.push({ pointer: "/immutable", message: "items must be unique" });
71
+ }
72
+ im.forEach((p, i) => {
73
+ const ptr = `/immutable/${i}`;
74
+ if (typeof p !== "string" || p.length < 2 || p.length > 512 || !PREFIX_RE.test(p)) {
75
+ errors.push({ pointer: ptr, message: "a prefix is a relative path ending in '/'" });
76
+ }
77
+ else if (!cleanPath(p.replace(/\/$/, ""))) {
78
+ errors.push({ pointer: ptr, message: "a prefix must be a clean path, with no '.' or '..' segment" });
79
+ }
80
+ else if (p.split("/")[0] === "_txco") {
81
+ errors.push({ pointer: ptr, message: "_txco/ is reserved for the installer" });
82
+ }
83
+ });
84
+ }
85
+ }
86
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, manifest: m };
87
+ }
88
+ /** Reads and validates <dir>/txco-web.json. Throws on an unreadable or invalid manifest. */
89
+ export async function readManifest(dir) {
90
+ const raw = await readFile(join(dir, MANIFEST_NAME), "utf8");
91
+ let doc;
92
+ try {
93
+ doc = JSON.parse(raw);
94
+ }
95
+ catch (e) {
96
+ throw new Error(`${MANIFEST_NAME}: not valid JSON: ${e.message}`);
97
+ }
98
+ const r = validateManifest(doc);
99
+ if (!r.ok) {
100
+ throw new Error(`${MANIFEST_NAME}: ` + r.errors.map((p) => `${p.pointer || "/"}: ${p.message}`).join("; "));
101
+ }
102
+ return r.manifest;
103
+ }
104
+ /**
105
+ * The private root of a public/ path: cut at the end of its first "_"
106
+ * segment ("" when it has none). The installer writes one public marker per
107
+ * root, so `_app/x.js` → `_app`, `assets/_Dk3.js` → itself.
108
+ */
109
+ export function publicRoot(rel) {
110
+ const segs = rel.split("/");
111
+ for (let i = 0; i < segs.length; i++) {
112
+ if (segs[i].startsWith("_"))
113
+ return segs.slice(0, i + 1).join("/");
114
+ }
115
+ return "";
116
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@txco/web-abi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The TxCo Web ABI conformance kit: the txco-web.json schema and validator, the envelope ↔ Fetch bridge every runner uses, and a harness that checks a build's server/ Fetch handler.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": ["txco", "thanks-computer", "web-abi", "fetch", "conformance"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/LoremLabs/thanks-computer.git",
12
+ "directory": "sdk/web-abi"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "files": ["dist", "src", "txco-web.schema.json", "README.md"],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./manifest": {
24
+ "types": "./dist/manifest.d.ts",
25
+ "default": "./dist/manifest.js"
26
+ },
27
+ "./bridge": {
28
+ "types": "./dist/bridge.d.ts",
29
+ "default": "./dist/bridge.js"
30
+ },
31
+ "./harness": {
32
+ "types": "./dist/harness.d.ts",
33
+ "default": "./dist/harness.js"
34
+ },
35
+ "./schema.json": "./txco-web.schema.json"
36
+ },
37
+ "bin": {
38
+ "txco-web-abi": "./dist/cli.js"
39
+ },
40
+ "scripts": {
41
+ "clean": "rm -rf dist",
42
+ "build": "tsc -p tsconfig.json",
43
+ "test": "npm run build && node --test test/*.test.mjs",
44
+ "prepublishOnly": "npm run clean && npm run build"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^20.0.0",
48
+ "typescript": "^5.4.0"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,115 @@
1
+ // The envelope ↔ Fetch bridge. A runner hands it the chassis's request
2
+ // envelope; it builds a Fetch Request, calls the server entry, and turns the
3
+ // Response into the delta the chassis merges. Every runner uses this one
4
+ // mapping, so a handler sees the same Request wherever it runs.
5
+
6
+ import type { FetchHandler, TxcContext, TxcDelta, TxcEnvelope } from "./envelope.js";
7
+
8
+ /** An op answer is capped at 4 MiB (--op-payload-max); the body is base64 inside it. */
9
+ export const DEFAULT_MAX_ANSWER_BYTES = 3 * 1024 * 1024;
10
+
11
+ // Hop-by-hop headers (RFC 9110 §7.6.1) and those the Request computes.
12
+ const DROPPED_HEADERS = new Set([
13
+ "connection", "keep-alive", "proxy-connection", "transfer-encoding", "te", "trailer",
14
+ "upgrade", "content-length", "host",
15
+ ]);
16
+
17
+ export interface RequestOptions {
18
+ /** Refuse a request body larger than this (bytes). Default: no limit. */
19
+ maxBodyBytes?: number;
20
+ }
21
+
22
+ /** Builds the Fetch Request an envelope describes. */
23
+ export function envelopeToRequest(env: TxcEnvelope, opts: RequestOptions = {}): Request {
24
+ const req = env._txc?.web?.req ?? {};
25
+ const method = (req.method ?? "GET").toUpperCase();
26
+ let url = req.url?.full;
27
+ if (!url) {
28
+ const host = req.host ?? "localhost";
29
+ const raw = req.url?.query?.raw;
30
+ url = `http://${host}${req.url?.path ?? "/"}${raw ? "?" + raw : ""}`;
31
+ }
32
+ const headers = new Headers();
33
+ for (const [name, value] of Object.entries(req.headers ?? {})) {
34
+ if (DROPPED_HEADERS.has(name.toLowerCase())) continue;
35
+ for (const v of Array.isArray(value) ? value : [value]) headers.append(name, v);
36
+ }
37
+ let body: ArrayBuffer | undefined;
38
+ if (req.body && method !== "GET" && method !== "HEAD") {
39
+ const bytes = Buffer.from(req.body, "base64");
40
+ if (opts.maxBodyBytes !== undefined && bytes.byteLength > opts.maxBodyBytes) {
41
+ throw new Error(`request body is ${bytes.byteLength} bytes, over ${opts.maxBodyBytes}`);
42
+ }
43
+ body = new ArrayBuffer(bytes.byteLength);
44
+ new Uint8Array(body).set(bytes);
45
+ }
46
+ return new Request(url, { method, headers, body });
47
+ }
48
+
49
+ /** The handler's context: only what the contract defines. */
50
+ export function contextFrom(env: TxcEnvelope): TxcContext {
51
+ return Object.freeze({ client: Object.freeze({ ip: env._txc?.client?.ip ?? "" }) });
52
+ }
53
+
54
+ export interface DeltaOptions {
55
+ /** The request's method: a HEAD answer carries no body. */
56
+ method?: string;
57
+ /** Refuse a body larger than this (bytes). */
58
+ maxAnswerBytes?: number;
59
+ }
60
+
61
+ /** Turns a Response into the chassis delta. */
62
+ export async function responseToDelta(res: Response, opts: DeltaOptions = {}): Promise<TxcDelta> {
63
+ const headers: Record<string, string[]> = {};
64
+ res.headers.forEach((value, name) => {
65
+ if (name === "set-cookie") return; // each cookie on its own line, below
66
+ (headers[name] ??= []).push(value);
67
+ });
68
+ const cookies = res.headers.getSetCookie();
69
+ if (cookies.length > 0) headers["set-cookie"] = cookies;
70
+
71
+ const delta: TxcDelta = { _txc: { web: { res: { status: res.status, headers } }, halt: true } };
72
+ const noBody = (opts.method ?? "GET").toUpperCase() === "HEAD" || res.status === 204 || res.status === 304;
73
+ if (!noBody) {
74
+ const bytes = Buffer.from(await res.arrayBuffer());
75
+ const max = opts.maxAnswerBytes ?? DEFAULT_MAX_ANSWER_BYTES;
76
+ if (bytes.byteLength > max) {
77
+ throw new Error(`response body is ${bytes.byteLength} bytes, over the ${max}-byte answer limit`);
78
+ }
79
+ if (bytes.byteLength > 0) delta._txc.web.res.body = bytes.toString("base64");
80
+ }
81
+ return delta;
82
+ }
83
+
84
+ function errorDelta(status: number, message: string, method: string): TxcDelta {
85
+ const res: TxcDelta["_txc"]["web"]["res"] = { status, headers: { "content-type": ["text/plain; charset=utf-8"] } };
86
+ if (method.toUpperCase() !== "HEAD") res.body = Buffer.from(message + "\n").toString("base64");
87
+ return { _txc: { web: { res }, halt: true } };
88
+ }
89
+
90
+ /**
91
+ * Runs one request through a handler, end to end. A handler that throws, or
92
+ * returns something other than a Response, answers 500; the error is never
93
+ * shown to the client.
94
+ */
95
+ export async function dispatch(handler: FetchHandler, env: TxcEnvelope, opts: RequestOptions & DeltaOptions = {}): Promise<TxcDelta> {
96
+ const method = env._txc?.web?.req?.method ?? "GET";
97
+ let request: Request;
98
+ try {
99
+ request = envelopeToRequest(env, opts);
100
+ } catch {
101
+ return errorDelta(413, "request body too large", method);
102
+ }
103
+ let res: unknown;
104
+ try {
105
+ res = await handler.fetch(request, contextFrom(env));
106
+ } catch {
107
+ return errorDelta(500, "internal error", method);
108
+ }
109
+ if (!(res instanceof Response)) return errorDelta(500, "internal error", method);
110
+ try {
111
+ return await responseToDelta(res, { ...opts, method: request.method });
112
+ } catch {
113
+ return errorDelta(502, "response too large", method);
114
+ }
115
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // txco-web-abi: validate a manifest, check a build's server/, or serve a
3
+ // build locally.
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ import { checkServer, serve } from "./harness.js";
9
+ import { MANIFEST_NAME, validateManifest } from "./manifest.js";
10
+
11
+ const USAGE = `Usage: txco-web-abi <command> <abi-dir>
12
+
13
+ Commands:
14
+ validate <abi-dir> check txco-web.json against the Web ABI schema
15
+ check-server <abi-dir> [--json] drive the build's server/ entry through the bridge
16
+ serve <abi-dir> [--port N] serve public/ and server/ locally
17
+ `;
18
+
19
+ async function main(argv: string[]): Promise<number> {
20
+ const [cmd, dir, ...rest] = argv;
21
+ if (!cmd || !dir) {
22
+ process.stderr.write(USAGE);
23
+ return 2;
24
+ }
25
+ switch (cmd) {
26
+ case "validate": {
27
+ let doc: unknown;
28
+ try {
29
+ doc = JSON.parse(await readFile(join(dir, MANIFEST_NAME), "utf8"));
30
+ } catch (e) {
31
+ process.stderr.write(`${MANIFEST_NAME}: ${(e as Error).message}\n`);
32
+ return 1;
33
+ }
34
+ const r = validateManifest(doc);
35
+ if (r.ok) {
36
+ process.stdout.write(`${MANIFEST_NAME}: ok\n`);
37
+ return 0;
38
+ }
39
+ for (const p of r.errors) process.stdout.write(`${p.pointer || "/"}: ${p.message}\n`);
40
+ return 1;
41
+ }
42
+ case "check-server": {
43
+ const results = await checkServer(dir);
44
+ if (rest.includes("--json")) {
45
+ process.stdout.write(JSON.stringify({ ok: results.every((r) => r.ok), results }, null, 2) + "\n");
46
+ } else {
47
+ for (const r of results) process.stdout.write(`${r.ok ? "✓" : "✗"} ${r.name}${r.detail ? " " + r.detail : ""}\n`);
48
+ }
49
+ return results.every((r) => r.ok) ? 0 : 1;
50
+ }
51
+ case "serve": {
52
+ const i = rest.indexOf("--port");
53
+ const port = i >= 0 ? Number(rest[i + 1]) : 8787;
54
+ const server = await serve(dir, { port });
55
+ const addr = server.address();
56
+ process.stdout.write(`serving ${dir} on http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : port}\n`);
57
+ return await new Promise<number>(() => {}); // until interrupted
58
+ }
59
+ default:
60
+ process.stderr.write(USAGE);
61
+ return 2;
62
+ }
63
+ }
64
+
65
+ main(process.argv.slice(2)).then(
66
+ (code) => process.exit(code),
67
+ (e) => {
68
+ process.stderr.write(`txco-web-abi: ${(e as Error).message}\n`);
69
+ process.exit(1);
70
+ },
71
+ );
@@ -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,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
+ }