@txco/web-abi 0.1.0 → 0.2.1
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 +28 -1
- package/dist/producer.d.ts +108 -0
- package/dist/producer.js +214 -0
- package/package.json +6 -2
- package/src/producer.ts +279 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ produces so `txco` can deploy it.
|
|
|
11
11
|
ops/ ordinary .txcl, in scope directories (ops/900000/…)
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The kit has
|
|
14
|
+
The kit has four parts:
|
|
15
15
|
|
|
16
16
|
- **The manifest schema** (`txco-web.schema.json`) and a validator. `txco`
|
|
17
17
|
embeds the same schema file, and both validators pass the same test corpus.
|
|
@@ -20,6 +20,11 @@ The kit has three parts:
|
|
|
20
20
|
turns the `Response` into the delta the chassis merges.
|
|
21
21
|
- **A harness** that checks a build's `server/` before any runner exists, and
|
|
22
22
|
serves a build locally.
|
|
23
|
+
- **The producer helpers** (`@txco/web-abi/producer`) that a framework adapter,
|
|
24
|
+
preset or plugin writes a build with. They render the ops (a navigation op
|
|
25
|
+
and the catch-all), write and validate the manifest, and guard the output
|
|
26
|
+
directory before a build wipes it. Producers built on them answer requests
|
|
27
|
+
the same way.
|
|
23
28
|
|
|
24
29
|
`txco web check <out>` checks the rest: the files, the ops, and every request
|
|
25
30
|
the build has to answer.
|
|
@@ -75,9 +80,31 @@ import {
|
|
|
75
80
|
import { validateManifest } from "@txco/web-abi/manifest";
|
|
76
81
|
```
|
|
77
82
|
|
|
83
|
+
## Write a producer
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
import { checkOutDir, renderOps, writeManifest, writeOps } from "@txco/web-abi/producer";
|
|
87
|
+
|
|
88
|
+
// Before the build: refuse an output dir inside OPS/, one holding foreign
|
|
89
|
+
// files, or one holding the project.
|
|
90
|
+
const problems = checkOutDir({ dir: out, protect: [root, outDir], notInside: [outDir], entries });
|
|
91
|
+
|
|
92
|
+
// After it: public/ is in place. One navigation op (the shell with 200 for
|
|
93
|
+
// an app that routes in the browser, or the 404 page with 404), plus the
|
|
94
|
+
// catch-all at 900900.
|
|
95
|
+
await writeOps(out, renderOps({ mode: "spa", producer: "my-adapter", page: { name: "index.html", html } }));
|
|
96
|
+
await writeManifest(out, { abi: 1, immutable: ["assets/"], "x-producer": { name: "my-adapter" } });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`@txco/vite-plugin` is built this way.
|
|
100
|
+
|
|
78
101
|
## Develop
|
|
79
102
|
|
|
80
103
|
```sh
|
|
81
104
|
npm install
|
|
82
105
|
npm test # tsc, then node --test (Node 20+)
|
|
83
106
|
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Where the catch-all sits: after the navigation op, since ops in one scope run concurrently. */
|
|
2
|
+
export declare const catchAllScope: (scope: number) => number;
|
|
3
|
+
/** An HTTP GET or HEAD: what a navigation is made of. */
|
|
4
|
+
export declare const METHOD_GUARD = "@src == \"http\" && (@web.req.method == \"GET\" || @web.req.method == \"HEAD\")";
|
|
5
|
+
/**
|
|
6
|
+
* A navigation: an HTTP GET or HEAD of a path whose last segment has no
|
|
7
|
+
* extension. Everything else (an asset miss, a POST) is the catch-all's.
|
|
8
|
+
*
|
|
9
|
+
* A producer with a route table ("routes" mode) trusts the table instead for
|
|
10
|
+
* its 200: a path a page route knows is a page whatever dots it carries (a
|
|
11
|
+
* param may hold one — /p/mister.parade), so that op opens with METHOD_GUARD
|
|
12
|
+
* alone; its 404 op and every producer without a table keep this guard, so
|
|
13
|
+
* an asset miss is still the catch-all's plain 404.
|
|
14
|
+
*/
|
|
15
|
+
export declare const NAV_GUARD: string;
|
|
16
|
+
/** The page a 404 op serves when the build has no 404.html. */
|
|
17
|
+
export declare const BUILTIN_404: string;
|
|
18
|
+
/** An HTML page from public/, embedded in an op. */
|
|
19
|
+
export interface Page {
|
|
20
|
+
/** Its file name in public/, for the comments. */
|
|
21
|
+
name: string;
|
|
22
|
+
html: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How a page navigation nothing else answered is handled:
|
|
26
|
+
*
|
|
27
|
+
* - "spa": the app routes in the browser: 200, with the app shell;
|
|
28
|
+
* - "routes": the same, but only for a path the framework's route table
|
|
29
|
+
* knows (`routes`); any other navigation gets the shell with 404;
|
|
30
|
+
* - "404": every page is a file in public/, so a path that reached the ops
|
|
31
|
+
* has no page: 404, with the 404 page;
|
|
32
|
+
* - "none": no navigation op, only the catch-all.
|
|
33
|
+
*/
|
|
34
|
+
export type NavMode = "spa" | "routes" | "404" | "none";
|
|
35
|
+
export interface RenderOptions {
|
|
36
|
+
mode: NavMode;
|
|
37
|
+
/** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
|
|
38
|
+
scope?: number;
|
|
39
|
+
/** The generated-file header's producer name. */
|
|
40
|
+
producer: string;
|
|
41
|
+
/**
|
|
42
|
+
* The shell ("spa" and "routes": required), the 404 page ("404": null
|
|
43
|
+
* writes a built-in one), or null ("none").
|
|
44
|
+
*/
|
|
45
|
+
page: Page | null;
|
|
46
|
+
/**
|
|
47
|
+
* For "routes": an RE2 regex source, anchored, matching exactly the app's
|
|
48
|
+
* page paths, with "/" escaped for txcl's /…/ literal.
|
|
49
|
+
*/
|
|
50
|
+
routes?: string;
|
|
51
|
+
/** The producer's own comment lines, added to the navigation op's header. */
|
|
52
|
+
notes?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Renders the build's ops, as path (relative to ops/) → file text: one
|
|
56
|
+
* navigation op for the mode, and the catch-all, a plain 404 for every other
|
|
57
|
+
* HTTP request, so every request reaching the end of the stack is answered.
|
|
58
|
+
*/
|
|
59
|
+
export declare function renderOps(o: RenderOptions): Record<string, string>;
|
|
60
|
+
/** The EMIT tail for a page answer: the page as a readable b64"…" literal, and halt. */
|
|
61
|
+
export declare function emitPage(status: number, html: string): string;
|
|
62
|
+
/** What may already be in an output dir: a previous build, and nothing else. */
|
|
63
|
+
export declare const ABI_ENTRIES: ReadonlySet<string>;
|
|
64
|
+
export interface OutDirCheck {
|
|
65
|
+
/** The output dir the build is about to wipe and rewrite. */
|
|
66
|
+
dir: string;
|
|
67
|
+
/** Paths the wipe must not reach: the project root, the sources, another build's output. */
|
|
68
|
+
protect?: readonly string[];
|
|
69
|
+
/** Paths the output dir must not sit inside (a bundler's own outDir, which it empties). */
|
|
70
|
+
notInside?: readonly string[];
|
|
71
|
+
/** The dir's current entries, or null when it doesn't exist. */
|
|
72
|
+
entries: readonly string[] | null;
|
|
73
|
+
/** Entries a producer's own previous build leaves, besides ABI_ENTRIES. */
|
|
74
|
+
allow?: readonly string[];
|
|
75
|
+
}
|
|
76
|
+
/** Every reason a build mustn't wipe and rewrite the output dir. Empty when it may. */
|
|
77
|
+
export declare function checkOutDir(c: OutDirCheck): string[];
|
|
78
|
+
/** Replaces <out>/ops/ with the given ops (path relative to ops/ → text). */
|
|
79
|
+
export declare function writeOps(out: string, ops: Record<string, string>): Promise<void>;
|
|
80
|
+
/** Validates the manifest, then writes <out>/txco-web.json. Throws on an invalid one. */
|
|
81
|
+
export declare function writeManifest(out: string, manifest: Record<string, unknown>): Promise<void>;
|
|
82
|
+
/** Every file under root, as "/"-separated paths relative to it, sorted. */
|
|
83
|
+
export declare function listFiles(root: string): Promise<string[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a file name carries a content hash: Vite's `main-BxYz12Ab.js`,
|
|
86
|
+
* Nuxt's `entry.CdEf34Gh.css`. A heuristic, for warnings only.
|
|
87
|
+
*/
|
|
88
|
+
export declare function looksHashed(name: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Where a build lands under public/, from its base path: "/app/" serves the
|
|
91
|
+
* site under /app/, so its files go under public/app/. A relative or root
|
|
92
|
+
* base serves it at the root. A full URL (a CDN) loads the assets from
|
|
93
|
+
* elsewhere: the files still go to the root, and the producer should warn.
|
|
94
|
+
*/
|
|
95
|
+
export declare function publicPrefix(base: string): {
|
|
96
|
+
prefix: string;
|
|
97
|
+
external: boolean;
|
|
98
|
+
};
|
|
99
|
+
/** Whether a "/"-separated relative path is one a Web ABI build never deploys: a dot segment. */
|
|
100
|
+
export declare function isDotPath(rel: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Copies a framework's static output dir into <out>/public/<prefix>,
|
|
103
|
+
* leaving out every dot path (they never deploy). Returns the ones it left
|
|
104
|
+
* out, other than .vite/ (build metadata), for the producer to warn about.
|
|
105
|
+
*/
|
|
106
|
+
export declare function copyPublic(from: string, out: string, prefix?: string): Promise<{
|
|
107
|
+
skipped: string[];
|
|
108
|
+
}>;
|
package/dist/producer.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
import { MANIFEST_NAME, PRODUCER_SCOPE, validateManifest } from "./manifest.js";
|
|
9
|
+
/** Where the catch-all sits: after the navigation op, since ops in one scope run concurrently. */
|
|
10
|
+
export const catchAllScope = (scope) => scope + 900;
|
|
11
|
+
/** An HTTP GET or HEAD: what a navigation is made of. */
|
|
12
|
+
export const METHOD_GUARD = '@src == "http" && (@web.req.method == "GET" || @web.req.method == "HEAD")';
|
|
13
|
+
/**
|
|
14
|
+
* A navigation: an HTTP GET or HEAD of a path whose last segment has no
|
|
15
|
+
* extension. Everything else (an asset miss, a POST) is the catch-all's.
|
|
16
|
+
*
|
|
17
|
+
* A producer with a route table ("routes" mode) trusts the table instead for
|
|
18
|
+
* its 200: a path a page route knows is a page whatever dots it carries (a
|
|
19
|
+
* param may hold one — /p/mister.parade), so that op opens with METHOD_GUARD
|
|
20
|
+
* alone; its 404 op and every producer without a table keep this guard, so
|
|
21
|
+
* an asset miss is still the catch-all's plain 404.
|
|
22
|
+
*/
|
|
23
|
+
export const NAV_GUARD = METHOD_GUARD + "\n" + " && @web.req.url.path !~ /(?i)\\.[a-z0-9]+$/";
|
|
24
|
+
/** The page a 404 op serves when the build has no 404.html. */
|
|
25
|
+
export const BUILTIN_404 = '<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head>' +
|
|
26
|
+
"<body><h1>404 Not Found</h1></body></html>\n";
|
|
27
|
+
/**
|
|
28
|
+
* Renders the build's ops, as path (relative to ops/) → file text: one
|
|
29
|
+
* navigation op for the mode, and the catch-all, a plain 404 for every other
|
|
30
|
+
* HTTP request, so every request reaching the end of the stack is answered.
|
|
31
|
+
*/
|
|
32
|
+
export function renderOps(o) {
|
|
33
|
+
const scope = o.scope ?? PRODUCER_SCOPE;
|
|
34
|
+
const notes = (o.notes ?? []).map((l) => (l ? `# ${l}` : "#")).join("\n");
|
|
35
|
+
const extra = notes ? `${notes}\n` : "";
|
|
36
|
+
const out = {};
|
|
37
|
+
if (o.mode === "routes") {
|
|
38
|
+
if (o.page === null)
|
|
39
|
+
throw new Error("renderOps: a routes build needs its shell");
|
|
40
|
+
if (!o.routes)
|
|
41
|
+
throw new Error("renderOps: a routes build needs its route matcher");
|
|
42
|
+
out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback: known routes (generated; do not edit by hand).
|
|
43
|
+
#
|
|
44
|
+
# Serves the app shell (${o.page.name}) with 200 for a navigation to a known
|
|
45
|
+
# page route that no file or earlier op answered. Paired with spa-404.txcl.
|
|
46
|
+
# The route table decides what a page is: a path it knows is one whatever
|
|
47
|
+
# dots it carries (a param may hold one), so this op has no extension guard;
|
|
48
|
+
# spa-404 and the catch-all keep it, and an asset miss is still a plain 404.
|
|
49
|
+
${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
|
|
50
|
+
WHEN ${METHOD_GUARD}
|
|
51
|
+
&& @web.req.url.path =~ /${o.routes}/
|
|
52
|
+
${emitPage(200, o.page.html)}`;
|
|
53
|
+
out[`${scope}/spa-404.txcl`] = `# ${o.producer} — SPA 404 (generated; do not edit by hand).
|
|
54
|
+
#
|
|
55
|
+
# A navigation matching no known page route is a real miss: 404, with the
|
|
56
|
+
# shell as the body so the client renders its error page.
|
|
57
|
+
WHEN ${NAV_GUARD}
|
|
58
|
+
&& @web.req.url.path !~ /${o.routes}/
|
|
59
|
+
${emitPage(404, o.page.html)}`;
|
|
60
|
+
}
|
|
61
|
+
else if (o.mode === "spa") {
|
|
62
|
+
if (o.page === null)
|
|
63
|
+
throw new Error("renderOps: an spa build needs its shell");
|
|
64
|
+
out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback (generated; do not edit by hand).
|
|
65
|
+
#
|
|
66
|
+
# Serves the app shell (${o.page.name}) with 200 for any page navigation
|
|
67
|
+
# nothing else answered: the app routes in the browser, so unknown pages get
|
|
68
|
+
# 200 too and the client renders its not-found view.
|
|
69
|
+
${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
|
|
70
|
+
WHEN ${NAV_GUARD}
|
|
71
|
+
${emitPage(200, o.page.html)}`;
|
|
72
|
+
}
|
|
73
|
+
else if (o.mode === "404") {
|
|
74
|
+
const what = o.page
|
|
75
|
+
? `Serves ${o.page.name} with 404`
|
|
76
|
+
: "Serves a plain 404 page (the build has no 404.html)";
|
|
77
|
+
out[`${scope}/page-404.txcl`] = `# ${o.producer} — 404 page (generated; do not edit by hand).
|
|
78
|
+
#
|
|
79
|
+
# ${what} for any page navigation nothing else answered:
|
|
80
|
+
# no file in public/ and no op of the stack had a page for it.
|
|
81
|
+
${extra}# Regenerated on every build — the page embeds content-hashed asset URLs.
|
|
82
|
+
WHEN ${NAV_GUARD}
|
|
83
|
+
${emitPage(404, o.page ? o.page.html : BUILTIN_404)}`;
|
|
84
|
+
}
|
|
85
|
+
out[`${catchAllScope(scope)}/not-found.txcl`] = `# ${o.producer} — not found (generated; do not edit by hand).
|
|
86
|
+
#
|
|
87
|
+
# Every other HTTP request that reached the end of the stack: an asset that
|
|
88
|
+
# doesn't exist, a method nothing handles. Without it the request would get
|
|
89
|
+
# the envelope back as JSON. It sits after the navigation ops on purpose: ops
|
|
90
|
+
# in one scope run concurrently.
|
|
91
|
+
WHEN @src == "http"
|
|
92
|
+
EMIT @web.res.status = 404,
|
|
93
|
+
@web.res.headers.content-type.0 = "text/plain; charset=utf-8",
|
|
94
|
+
@web.res.body = b64"404 not found\\n",
|
|
95
|
+
@halt = true
|
|
96
|
+
`;
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** The EMIT tail for a page answer: the page as a readable b64"…" literal, and halt. */
|
|
100
|
+
export function emitPage(status, html) {
|
|
101
|
+
// The lexer base64-encodes a b64"…" literal at parse time, so the file stays
|
|
102
|
+
// readable HTML. Only backslash and double-quote need escaping.
|
|
103
|
+
const escaped = html.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
104
|
+
return ` EMIT @web.res.status = ${status},
|
|
105
|
+
@web.res.headers.content-type.0 = "text/html; charset=utf-8",
|
|
106
|
+
@web.res.headers.cache-control.0 = "no-cache",
|
|
107
|
+
@web.res.body = b64"${escaped}",
|
|
108
|
+
@halt = true
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
/** What may already be in an output dir: a previous build, and nothing else. */
|
|
112
|
+
export const ABI_ENTRIES = new Set([MANIFEST_NAME, "public", "server", "ops", ".gitignore", ".DS_Store"]);
|
|
113
|
+
const within = (child, parent) => {
|
|
114
|
+
const rel = relative(parent, child);
|
|
115
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
116
|
+
};
|
|
117
|
+
/** Every reason a build mustn't wipe and rewrite the output dir. Empty when it may. */
|
|
118
|
+
export function checkOutDir(c) {
|
|
119
|
+
const dir = resolve(c.dir);
|
|
120
|
+
const problems = [];
|
|
121
|
+
if (dir.split(sep).includes("OPS")) {
|
|
122
|
+
problems.push(`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>).`);
|
|
123
|
+
}
|
|
124
|
+
for (const p of c.protect ?? []) {
|
|
125
|
+
if (p && within(resolve(p), dir)) {
|
|
126
|
+
problems.push(`the output dir ${dir} holds ${resolve(p)}, and the build wipes the output dir. Point it at a directory of its own.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const p of c.notInside ?? []) {
|
|
130
|
+
if (p && within(dir, resolve(p))) {
|
|
131
|
+
problems.push(`the output dir ${dir} is inside ${resolve(p)}, another build's output. Point it at a directory of its own.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const allowed = new Set([...ABI_ENTRIES, ...(c.allow ?? [])]);
|
|
135
|
+
const foreign = (c.entries ?? []).filter((e) => !allowed.has(e));
|
|
136
|
+
if (foreign.length > 0) {
|
|
137
|
+
problems.push(`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.`);
|
|
138
|
+
}
|
|
139
|
+
return problems;
|
|
140
|
+
}
|
|
141
|
+
/** Replaces <out>/ops/ with the given ops (path relative to ops/ → text). */
|
|
142
|
+
export async function writeOps(out, ops) {
|
|
143
|
+
await rm(join(out, "ops"), { recursive: true, force: true });
|
|
144
|
+
for (const [rel, text] of Object.entries(ops)) {
|
|
145
|
+
const file = join(out, "ops", rel);
|
|
146
|
+
await mkdir(dirname(file), { recursive: true });
|
|
147
|
+
await writeFile(file, text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Validates the manifest, then writes <out>/txco-web.json. Throws on an invalid one. */
|
|
151
|
+
export async function writeManifest(out, manifest) {
|
|
152
|
+
const r = validateManifest(manifest);
|
|
153
|
+
if (!r.ok) {
|
|
154
|
+
throw new Error(`${MANIFEST_NAME}: ` + r.errors.map((p) => `${p.pointer || "/"}: ${p.message}`).join("; "));
|
|
155
|
+
}
|
|
156
|
+
await mkdir(out, { recursive: true });
|
|
157
|
+
await writeFile(join(out, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
|
|
158
|
+
}
|
|
159
|
+
/** Every file under root, as "/"-separated paths relative to it, sorted. */
|
|
160
|
+
export async function listFiles(root) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const e of await readdir(root, { recursive: true, withFileTypes: true })) {
|
|
163
|
+
// parentPath is Node ≥ 20.12; earlier 20.x calls it path.
|
|
164
|
+
const parent = e.parentPath ?? e.path ?? root;
|
|
165
|
+
if (e.isFile())
|
|
166
|
+
out.push(relative(root, join(parent, e.name)).split(sep).join("/"));
|
|
167
|
+
}
|
|
168
|
+
return out.sort();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a file name carries a content hash: Vite's `main-BxYz12Ab.js`,
|
|
172
|
+
* Nuxt's `entry.CdEf34Gh.css`. A heuristic, for warnings only.
|
|
173
|
+
*/
|
|
174
|
+
export function looksHashed(name) {
|
|
175
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
176
|
+
const run = stem.match(/[A-Za-z0-9_-]{8,}/);
|
|
177
|
+
return run !== null && /[0-9A-Z]/.test(run[0]);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Where a build lands under public/, from its base path: "/app/" serves the
|
|
181
|
+
* site under /app/, so its files go under public/app/. A relative or root
|
|
182
|
+
* base serves it at the root. A full URL (a CDN) loads the assets from
|
|
183
|
+
* elsewhere: the files still go to the root, and the producer should warn.
|
|
184
|
+
*/
|
|
185
|
+
export function publicPrefix(base) {
|
|
186
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(base) || base.startsWith("//"))
|
|
187
|
+
return { prefix: "", external: true };
|
|
188
|
+
const path = base.replace(/^\.?\/+|\/+$/g, "");
|
|
189
|
+
return { prefix: path && path !== "." ? `${path}/` : "", external: false };
|
|
190
|
+
}
|
|
191
|
+
/** Whether a "/"-separated relative path is one a Web ABI build never deploys: a dot segment. */
|
|
192
|
+
export function isDotPath(rel) {
|
|
193
|
+
return rel.split("/").some((s) => s.startsWith("."));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Copies a framework's static output dir into <out>/public/<prefix>,
|
|
197
|
+
* leaving out every dot path (they never deploy). Returns the ones it left
|
|
198
|
+
* out, other than .vite/ (build metadata), for the producer to warn about.
|
|
199
|
+
*/
|
|
200
|
+
export async function copyPublic(from, out, prefix = "") {
|
|
201
|
+
const skipped = [];
|
|
202
|
+
await cp(from, join(out, "public", prefix), {
|
|
203
|
+
recursive: true,
|
|
204
|
+
filter: (src) => {
|
|
205
|
+
const rel = relative(from, src).split(sep).join("/");
|
|
206
|
+
if (rel === "" || !isDotPath(rel))
|
|
207
|
+
return true;
|
|
208
|
+
if (rel !== ".vite" && !rel.startsWith(".vite/"))
|
|
209
|
+
skipped.push(rel);
|
|
210
|
+
return false;
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
return { skipped };
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@txco/web-abi",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "The TxCo Web ABI
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "The TxCo Web ABI kit: the txco-web.json schema and validator, the envelope ↔ Fetch bridge every runner uses, a harness that checks a build's server/ Fetch handler, and the ops and manifest writer producers share.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"types": "./dist/harness.d.ts",
|
|
33
33
|
"default": "./dist/harness.js"
|
|
34
34
|
},
|
|
35
|
+
"./producer": {
|
|
36
|
+
"types": "./dist/producer.d.ts",
|
|
37
|
+
"default": "./dist/producer.js"
|
|
38
|
+
},
|
|
35
39
|
"./schema.json": "./txco-web.schema.json"
|
|
36
40
|
},
|
|
37
41
|
"bin": {
|
package/src/producer.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
/** An HTTP GET or HEAD: what a navigation is made of. */
|
|
15
|
+
export const METHOD_GUARD = '@src == "http" && (@web.req.method == "GET" || @web.req.method == "HEAD")';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A navigation: an HTTP GET or HEAD of a path whose last segment has no
|
|
19
|
+
* extension. Everything else (an asset miss, a POST) is the catch-all's.
|
|
20
|
+
*
|
|
21
|
+
* A producer with a route table ("routes" mode) trusts the table instead for
|
|
22
|
+
* its 200: a path a page route knows is a page whatever dots it carries (a
|
|
23
|
+
* param may hold one — /p/mister.parade), so that op opens with METHOD_GUARD
|
|
24
|
+
* alone; its 404 op and every producer without a table keep this guard, so
|
|
25
|
+
* an asset miss is still the catch-all's plain 404.
|
|
26
|
+
*/
|
|
27
|
+
export const NAV_GUARD = METHOD_GUARD + "\n" + " && @web.req.url.path !~ /(?i)\\.[a-z0-9]+$/";
|
|
28
|
+
|
|
29
|
+
/** The page a 404 op serves when the build has no 404.html. */
|
|
30
|
+
export const BUILTIN_404 =
|
|
31
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head>' +
|
|
32
|
+
"<body><h1>404 Not Found</h1></body></html>\n";
|
|
33
|
+
|
|
34
|
+
/** An HTML page from public/, embedded in an op. */
|
|
35
|
+
export interface Page {
|
|
36
|
+
/** Its file name in public/, for the comments. */
|
|
37
|
+
name: string;
|
|
38
|
+
html: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How a page navigation nothing else answered is handled:
|
|
43
|
+
*
|
|
44
|
+
* - "spa": the app routes in the browser: 200, with the app shell;
|
|
45
|
+
* - "routes": the same, but only for a path the framework's route table
|
|
46
|
+
* knows (`routes`); any other navigation gets the shell with 404;
|
|
47
|
+
* - "404": every page is a file in public/, so a path that reached the ops
|
|
48
|
+
* has no page: 404, with the 404 page;
|
|
49
|
+
* - "none": no navigation op, only the catch-all.
|
|
50
|
+
*/
|
|
51
|
+
export type NavMode = "spa" | "routes" | "404" | "none";
|
|
52
|
+
|
|
53
|
+
export interface RenderOptions {
|
|
54
|
+
mode: NavMode;
|
|
55
|
+
/** The navigation op's scope; the catch-all goes 900 above it. Default 900000. */
|
|
56
|
+
scope?: number;
|
|
57
|
+
/** The generated-file header's producer name. */
|
|
58
|
+
producer: string;
|
|
59
|
+
/**
|
|
60
|
+
* The shell ("spa" and "routes": required), the 404 page ("404": null
|
|
61
|
+
* writes a built-in one), or null ("none").
|
|
62
|
+
*/
|
|
63
|
+
page: Page | null;
|
|
64
|
+
/**
|
|
65
|
+
* For "routes": an RE2 regex source, anchored, matching exactly the app's
|
|
66
|
+
* page paths, with "/" escaped for txcl's /…/ literal.
|
|
67
|
+
*/
|
|
68
|
+
routes?: string;
|
|
69
|
+
/** The producer's own comment lines, added to the navigation op's header. */
|
|
70
|
+
notes?: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders the build's ops, as path (relative to ops/) → file text: one
|
|
75
|
+
* navigation op for the mode, and the catch-all, a plain 404 for every other
|
|
76
|
+
* HTTP request, so every request reaching the end of the stack is answered.
|
|
77
|
+
*/
|
|
78
|
+
export function renderOps(o: RenderOptions): Record<string, string> {
|
|
79
|
+
const scope = o.scope ?? PRODUCER_SCOPE;
|
|
80
|
+
const notes = (o.notes ?? []).map((l) => (l ? `# ${l}` : "#")).join("\n");
|
|
81
|
+
const extra = notes ? `${notes}\n` : "";
|
|
82
|
+
const out: Record<string, string> = {};
|
|
83
|
+
if (o.mode === "routes") {
|
|
84
|
+
if (o.page === null) throw new Error("renderOps: a routes build needs its shell");
|
|
85
|
+
if (!o.routes) throw new Error("renderOps: a routes build needs its route matcher");
|
|
86
|
+
out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback: known routes (generated; do not edit by hand).
|
|
87
|
+
#
|
|
88
|
+
# Serves the app shell (${o.page.name}) with 200 for a navigation to a known
|
|
89
|
+
# page route that no file or earlier op answered. Paired with spa-404.txcl.
|
|
90
|
+
# The route table decides what a page is: a path it knows is one whatever
|
|
91
|
+
# dots it carries (a param may hold one), so this op has no extension guard;
|
|
92
|
+
# spa-404 and the catch-all keep it, and an asset miss is still a plain 404.
|
|
93
|
+
${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
|
|
94
|
+
WHEN ${METHOD_GUARD}
|
|
95
|
+
&& @web.req.url.path =~ /${o.routes}/
|
|
96
|
+
${emitPage(200, o.page.html)}`;
|
|
97
|
+
out[`${scope}/spa-404.txcl`] = `# ${o.producer} — SPA 404 (generated; do not edit by hand).
|
|
98
|
+
#
|
|
99
|
+
# A navigation matching no known page route is a real miss: 404, with the
|
|
100
|
+
# shell as the body so the client renders its error page.
|
|
101
|
+
WHEN ${NAV_GUARD}
|
|
102
|
+
&& @web.req.url.path !~ /${o.routes}/
|
|
103
|
+
${emitPage(404, o.page.html)}`;
|
|
104
|
+
} else if (o.mode === "spa") {
|
|
105
|
+
if (o.page === null) throw new Error("renderOps: an spa build needs its shell");
|
|
106
|
+
out[`${scope}/spa-fallback.txcl`] = `# ${o.producer} — SPA fallback (generated; do not edit by hand).
|
|
107
|
+
#
|
|
108
|
+
# Serves the app shell (${o.page.name}) with 200 for any page navigation
|
|
109
|
+
# nothing else answered: the app routes in the browser, so unknown pages get
|
|
110
|
+
# 200 too and the client renders its not-found view.
|
|
111
|
+
${extra}# Regenerated on every build — the shell embeds content-hashed asset URLs.
|
|
112
|
+
WHEN ${NAV_GUARD}
|
|
113
|
+
${emitPage(200, o.page.html)}`;
|
|
114
|
+
} else if (o.mode === "404") {
|
|
115
|
+
const what = o.page
|
|
116
|
+
? `Serves ${o.page.name} with 404`
|
|
117
|
+
: "Serves a plain 404 page (the build has no 404.html)";
|
|
118
|
+
out[`${scope}/page-404.txcl`] = `# ${o.producer} — 404 page (generated; do not edit by hand).
|
|
119
|
+
#
|
|
120
|
+
# ${what} for any page navigation nothing else answered:
|
|
121
|
+
# no file in public/ and no op of the stack had a page for it.
|
|
122
|
+
${extra}# Regenerated on every build — the page embeds content-hashed asset URLs.
|
|
123
|
+
WHEN ${NAV_GUARD}
|
|
124
|
+
${emitPage(404, o.page ? o.page.html : BUILTIN_404)}`;
|
|
125
|
+
}
|
|
126
|
+
out[`${catchAllScope(scope)}/not-found.txcl`] = `# ${o.producer} — not found (generated; do not edit by hand).
|
|
127
|
+
#
|
|
128
|
+
# Every other HTTP request that reached the end of the stack: an asset that
|
|
129
|
+
# doesn't exist, a method nothing handles. Without it the request would get
|
|
130
|
+
# the envelope back as JSON. It sits after the navigation ops on purpose: ops
|
|
131
|
+
# in one scope run concurrently.
|
|
132
|
+
WHEN @src == "http"
|
|
133
|
+
EMIT @web.res.status = 404,
|
|
134
|
+
@web.res.headers.content-type.0 = "text/plain; charset=utf-8",
|
|
135
|
+
@web.res.body = b64"404 not found\\n",
|
|
136
|
+
@halt = true
|
|
137
|
+
`;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The EMIT tail for a page answer: the page as a readable b64"…" literal, and halt. */
|
|
142
|
+
export function emitPage(status: number, html: string): string {
|
|
143
|
+
// The lexer base64-encodes a b64"…" literal at parse time, so the file stays
|
|
144
|
+
// readable HTML. Only backslash and double-quote need escaping.
|
|
145
|
+
const escaped = html.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
146
|
+
return ` EMIT @web.res.status = ${status},
|
|
147
|
+
@web.res.headers.content-type.0 = "text/html; charset=utf-8",
|
|
148
|
+
@web.res.headers.cache-control.0 = "no-cache",
|
|
149
|
+
@web.res.body = b64"${escaped}",
|
|
150
|
+
@halt = true
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What may already be in an output dir: a previous build, and nothing else. */
|
|
155
|
+
export const ABI_ENTRIES: ReadonlySet<string> = new Set([MANIFEST_NAME, "public", "server", "ops", ".gitignore", ".DS_Store"]);
|
|
156
|
+
|
|
157
|
+
export interface OutDirCheck {
|
|
158
|
+
/** The output dir the build is about to wipe and rewrite. */
|
|
159
|
+
dir: string;
|
|
160
|
+
/** Paths the wipe must not reach: the project root, the sources, another build's output. */
|
|
161
|
+
protect?: readonly string[];
|
|
162
|
+
/** Paths the output dir must not sit inside (a bundler's own outDir, which it empties). */
|
|
163
|
+
notInside?: readonly string[];
|
|
164
|
+
/** The dir's current entries, or null when it doesn't exist. */
|
|
165
|
+
entries: readonly string[] | null;
|
|
166
|
+
/** Entries a producer's own previous build leaves, besides ABI_ENTRIES. */
|
|
167
|
+
allow?: readonly string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const within = (child: string, parent: string) => {
|
|
171
|
+
const rel = relative(parent, child);
|
|
172
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Every reason a build mustn't wipe and rewrite the output dir. Empty when it may. */
|
|
176
|
+
export function checkOutDir(c: OutDirCheck): string[] {
|
|
177
|
+
const dir = resolve(c.dir);
|
|
178
|
+
const problems: string[] = [];
|
|
179
|
+
if (dir.split(sep).includes("OPS")) {
|
|
180
|
+
problems.push(
|
|
181
|
+
`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>).`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
for (const p of c.protect ?? []) {
|
|
185
|
+
if (p && within(resolve(p), dir)) {
|
|
186
|
+
problems.push(`the output dir ${dir} holds ${resolve(p)}, and the build wipes the output dir. Point it at a directory of its own.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const p of c.notInside ?? []) {
|
|
190
|
+
if (p && within(dir, resolve(p))) {
|
|
191
|
+
problems.push(`the output dir ${dir} is inside ${resolve(p)}, another build's output. Point it at a directory of its own.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const allowed = new Set([...ABI_ENTRIES, ...(c.allow ?? [])]);
|
|
195
|
+
const foreign = (c.entries ?? []).filter((e) => !allowed.has(e));
|
|
196
|
+
if (foreign.length > 0) {
|
|
197
|
+
problems.push(
|
|
198
|
+
`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.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return problems;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Replaces <out>/ops/ with the given ops (path relative to ops/ → text). */
|
|
205
|
+
export async function writeOps(out: string, ops: Record<string, string>): Promise<void> {
|
|
206
|
+
await rm(join(out, "ops"), { recursive: true, force: true });
|
|
207
|
+
for (const [rel, text] of Object.entries(ops)) {
|
|
208
|
+
const file = join(out, "ops", rel);
|
|
209
|
+
await mkdir(dirname(file), { recursive: true });
|
|
210
|
+
await writeFile(file, text);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Validates the manifest, then writes <out>/txco-web.json. Throws on an invalid one. */
|
|
215
|
+
export async function writeManifest(out: string, manifest: Record<string, unknown>): Promise<void> {
|
|
216
|
+
const r = validateManifest(manifest);
|
|
217
|
+
if (!r.ok) {
|
|
218
|
+
throw new Error(`${MANIFEST_NAME}: ` + r.errors.map((p) => `${p.pointer || "/"}: ${p.message}`).join("; "));
|
|
219
|
+
}
|
|
220
|
+
await mkdir(out, { recursive: true });
|
|
221
|
+
await writeFile(join(out, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Every file under root, as "/"-separated paths relative to it, sorted. */
|
|
225
|
+
export async function listFiles(root: string): Promise<string[]> {
|
|
226
|
+
const out: string[] = [];
|
|
227
|
+
for (const e of await readdir(root, { recursive: true, withFileTypes: true })) {
|
|
228
|
+
// parentPath is Node ≥ 20.12; earlier 20.x calls it path.
|
|
229
|
+
const parent = (e as { parentPath?: string; path?: string }).parentPath ?? (e as { path?: string }).path ?? root;
|
|
230
|
+
if (e.isFile()) out.push(relative(root, join(parent, e.name)).split(sep).join("/"));
|
|
231
|
+
}
|
|
232
|
+
return out.sort();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether a file name carries a content hash: Vite's `main-BxYz12Ab.js`,
|
|
237
|
+
* Nuxt's `entry.CdEf34Gh.css`. A heuristic, for warnings only.
|
|
238
|
+
*/
|
|
239
|
+
export function looksHashed(name: string): boolean {
|
|
240
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
241
|
+
const run = stem.match(/[A-Za-z0-9_-]{8,}/);
|
|
242
|
+
return run !== null && /[0-9A-Z]/.test(run[0]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Where a build lands under public/, from its base path: "/app/" serves the
|
|
247
|
+
* site under /app/, so its files go under public/app/. A relative or root
|
|
248
|
+
* base serves it at the root. A full URL (a CDN) loads the assets from
|
|
249
|
+
* elsewhere: the files still go to the root, and the producer should warn.
|
|
250
|
+
*/
|
|
251
|
+
export function publicPrefix(base: string): { prefix: string; external: boolean } {
|
|
252
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(base) || base.startsWith("//")) return { prefix: "", external: true };
|
|
253
|
+
const path = base.replace(/^\.?\/+|\/+$/g, "");
|
|
254
|
+
return { prefix: path && path !== "." ? `${path}/` : "", external: false };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether a "/"-separated relative path is one a Web ABI build never deploys: a dot segment. */
|
|
258
|
+
export function isDotPath(rel: string): boolean {
|
|
259
|
+
return rel.split("/").some((s) => s.startsWith("."));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Copies a framework's static output dir into <out>/public/<prefix>,
|
|
264
|
+
* leaving out every dot path (they never deploy). Returns the ones it left
|
|
265
|
+
* out, other than .vite/ (build metadata), for the producer to warn about.
|
|
266
|
+
*/
|
|
267
|
+
export async function copyPublic(from: string, out: string, prefix = ""): Promise<{ skipped: string[] }> {
|
|
268
|
+
const skipped: string[] = [];
|
|
269
|
+
await cp(from, join(out, "public", prefix), {
|
|
270
|
+
recursive: true,
|
|
271
|
+
filter: (src) => {
|
|
272
|
+
const rel = relative(from, src).split(sep).join("/");
|
|
273
|
+
if (rel === "" || !isDotPath(rel)) return true;
|
|
274
|
+
if (rel !== ".vite" && !rel.startsWith(".vite/")) skipped.push(rel);
|
|
275
|
+
return false;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return { skipped };
|
|
279
|
+
}
|