@txco/nitro-preset 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 ADDED
@@ -0,0 +1,208 @@
1
+ # @txco/nitro-preset
2
+
3
+ The `thanks-computer` [Nitro](https://nitro.build) preset. It builds a
4
+ Nitro app ([Nuxt](https://nuxt.com), [Analog](https://analogjs.org),
5
+ [SolidStart](https://start.solidjs.com)) as a
6
+ **Web ABI build** for [Thanks, Computer](https://www.thanks.computer) (txco):
7
+
8
+ - your site as `public/`;
9
+ - the ops it needs as `ops/`;
10
+ - its server as `server/` (`nuxt build` only);
11
+ - a `txco-web.json` manifest.
12
+
13
+ `txco apply` installs the build into a stack.
14
+
15
+ ```sh
16
+ npm install -D @txco/nitro-preset
17
+ ```
18
+
19
+ It works with Nitro 2 (`nitropack`), which is what Nuxt 4, Analog 2 and
20
+ SolidStart 1 run on.
21
+
22
+ ## Nuxt
23
+
24
+ ```ts
25
+ // nuxt.config.ts
26
+ export default defineNuxtConfig({
27
+ nitro: { preset: "@txco/nitro-preset" },
28
+ });
29
+ ```
30
+
31
+ Bind the build to a stack in your workspace's `txco.yaml`. The path is
32
+ relative to the workspace root, and must lie outside `OPS/`:
33
+
34
+ ```yaml
35
+ stacks:
36
+ web:
37
+ abi: txco-web
38
+ ```
39
+
40
+ Then build, check and deploy:
41
+
42
+ ```sh
43
+ nuxt generate # writes txco-web/
44
+ txco web check txco-web # installs it on a scratch chassis and probes it
45
+ txco apply # or: txco push web
46
+ ```
47
+
48
+ Gitignore `txco-web/`. It's all build output, and the 404 op embeds the page,
49
+ asset hashes included, so it changes on every build.
50
+
51
+ ## Analog
52
+
53
+ Analog sets Nitro's output paths itself, so set all three:
54
+
55
+ ```ts
56
+ // vite.config.ts
57
+ import { resolve } from "node:path";
58
+
59
+ const out = resolve(__dirname, "txco-web");
60
+
61
+ export default defineConfig({
62
+ plugins: [
63
+ analog({
64
+ static: true,
65
+ prerender: { routes: ["/", "/about"] },
66
+ nitro: {
67
+ preset: "@txco/nitro-preset",
68
+ output: { dir: out, publicDir: `${out}/public`, serverDir: `${out}/server` },
69
+ thanksComputer: { immutable: ["assets/"] },
70
+ },
71
+ }),
72
+ ],
73
+ });
74
+ ```
75
+
76
+ Analog doesn't mark its hashed assets for caching, so name the directory with
77
+ `immutable` (above) to get the year-long cache.
78
+
79
+ ## SolidStart
80
+
81
+ **SolidStart 1** builds through vinxi on Nitro 2, so the preset goes in
82
+ `app.config.ts`:
83
+
84
+ ```ts
85
+ // app.config.ts
86
+ import { defineConfig } from "@solidjs/start/config";
87
+
88
+ export default defineConfig({
89
+ server: {
90
+ preset: "@txco/nitro-preset",
91
+ static: true,
92
+ prerender: { crawlLinks: true },
93
+ // @ts-expect-error: the preset's own option, not in vinxi's types
94
+ thanksComputer: { immutable: ["_build/assets/"] },
95
+ },
96
+ });
97
+ ```
98
+
99
+ **SolidStart 2** runs on Nitro 3, which doesn't load this preset. Until a
100
+ built-in Nitro 3 preset exists, SolidStart's Nitro 2 bridge
101
+ (`@solidjs/vite-plugin-nitro-2`, deprecated upstream) takes the same
102
+ options:
103
+
104
+ ```ts
105
+ // vite.config.ts
106
+ import { solidStart } from "@solidjs/start/config";
107
+ import { nitroV2Plugin } from "@solidjs/vite-plugin-nitro-2";
108
+
109
+ export default defineConfig({
110
+ plugins: [
111
+ solidStart(),
112
+ nitroV2Plugin({
113
+ preset: "@txco/nitro-preset",
114
+ static: true,
115
+ prerender: { routes: ["/"], crawlLinks: true },
116
+ // @ts-expect-error: the preset's own option
117
+ thanksComputer: { immutable: ["_build/assets/"] },
118
+ }),
119
+ ],
120
+ });
121
+ ```
122
+
123
+ SolidStart doesn't mark `_build/assets/` for caching, so `immutable` names it.
124
+ For `ssr: false`, add `thanksComputer: { spa: true }`; SolidStart's SPA build
125
+ isn't detected on its own.
126
+
127
+ ## What it writes
128
+
129
+ ```
130
+ txco-web/
131
+ txco-web.json { "abi": 1, "immutable": ["_nuxt/"], … }
132
+ public/ Nitro's public output, prerendered pages included
133
+ ops/900000/page-404.txcl 404 + 404.html, for a navigation to a page that doesn't exist
134
+ ops/900900/not-found.txcl a plain 404 for everything else (an asset miss, a POST)
135
+ server/index.mjs nuxt build only: the app as a Fetch handler
136
+ ```
137
+
138
+ How a navigation that no page answers is handled depends on the build:
139
+
140
+ - **A prerendered site** (`nuxt generate`) gets `404.html` with status 404.
141
+ Nuxt's `404.html` is its app shell, so a client route that wasn't
142
+ prerendered still renders. For a server-rendered error page instead, set
143
+ `experimental: { prerenderErrorPages: true }` in `nuxt.config`.
144
+ - **An `ssr: false` app** gets the shell (`200.html`) with status 200, since
145
+ the client router renders every page. It's detected for Nuxt and Analog; set
146
+ `thanksComputer: { spa: true }` (or `false`) to force it.
147
+ - **A server build** (`nuxt build`) gets the 404 op too, for a static-only
148
+ install.
149
+
150
+ `compressPublicAssets`' `.gz`/`.br` copies, and a bundler's `.vite/`
151
+ metadata, are left out of `public/`: nothing serves them, and the edge
152
+ compresses.
153
+
154
+ `_` paths (`_nuxt/`, `_payload.json`) are served: the installer marks every
155
+ `_` path in `public/` as public.
156
+
157
+ ## The server
158
+
159
+ `nuxt build` adds `server/index.mjs`. It exports
160
+ `{ fetch(request, ctx) }`, and the client's IP from `ctx.client.ip` reaches
161
+ the app through h3's `getRequestIP`. Every dependency is bundled in, so the
162
+ server needs no install step. A native addon (`sharp`, say) can't be bundled.
163
+
164
+ No chassis runs `server/` yet. `txco apply` and `txco web check` refuse a
165
+ build with a server unless you pass `--static-only`, which deploys
166
+ `public/` and `ops/` and leaves the server out:
167
+
168
+ ```sh
169
+ nuxt build
170
+ npx -p @txco/web-abi txco-web-abi check-server txco-web # the server half
171
+ txco web check txco-web --static-only # the static half
172
+ txco apply --static-only
173
+ ```
174
+
175
+ ## Options
176
+
177
+ Set them as `nitro: { thanksComputer: { … } }` (Nuxt), or as `thanksComputer`
178
+ in a Nitro config.
179
+
180
+ | Option | Default | Meaning |
181
+ | ----------- | -------------- | ------- |
182
+ | `spa` | detected | Answer every unknown page path with the shell and 200. |
183
+ | `immutable` | derived | The `public/` prefixes cached for a year (`["assets/"]`). For Nuxt it's derived as `_nuxt/`. |
184
+ | `scope` | `900000` | The navigation op's scope. The catch-all goes 900 above it. |
185
+
186
+ For the types in `nuxt.config.ts`, add `import type {} from "@txco/nitro-preset"`.
187
+
188
+ ## Safety
189
+
190
+ Nitro empties its output directory on every build. Before that happens, the
191
+ preset refuses an output directory that:
192
+
193
+ - is inside `OPS/`;
194
+ - holds anything that isn't part of a Web ABI build;
195
+ - holds your sources.
196
+
197
+ A refused build leaves the directory untouched.
198
+
199
+ ## Limits
200
+
201
+ - **Nitro 2 only.** Nitro 3 doesn't load external presets; a built-in
202
+ `thanks-computer` preset there is the plan.
203
+ - **Dot paths** (`.well-known/…`) in `public/` never deploy; the preset warns.
204
+ - **A non-root `app.baseURL`** is untested.
205
+
206
+ ## License
207
+
208
+ MIT
@@ -0,0 +1,59 @@
1
+ import { type AssetDir } from "./immutable.js";
2
+ import { type Mode } from "./ops.js";
3
+ import type { ThanksComputerOptions } from "./types.js";
4
+ export declare const NAME = "@txco/nitro-preset";
5
+ export declare const VERSION = "0.1.0";
6
+ export declare const PRESET = "thanks-computer";
7
+ export declare const MANIFEST = "txco-web.json";
8
+ /** The producer band (chassis/webabi ProducerScope). */
9
+ export declare const PRODUCER_SCOPE = 900000;
10
+ /** The part of a Nitro instance finalize reads. A `Nitro` from nitropack 2 is one. */
11
+ export interface FinalizeNitro {
12
+ options: {
13
+ static: boolean;
14
+ baseURL: string;
15
+ output: {
16
+ dir: string;
17
+ publicDir: string;
18
+ serverDir: string;
19
+ };
20
+ publicAssets: readonly AssetDir[];
21
+ routeRules: Record<string, {
22
+ ssr?: boolean;
23
+ } | undefined>;
24
+ renderer?: string;
25
+ framework: {
26
+ name?: string;
27
+ version?: string;
28
+ };
29
+ compressPublicAssets?: unknown;
30
+ rollupConfig?: {
31
+ output?: unknown;
32
+ };
33
+ thanksComputer?: ThanksComputerOptions;
34
+ };
35
+ _prerenderedRoutes?: readonly {
36
+ route: string;
37
+ }[];
38
+ logger: {
39
+ info(...args: unknown[]): void;
40
+ warn(...args: unknown[]): void;
41
+ success(...args: unknown[]): void;
42
+ };
43
+ }
44
+ export interface FinalizeResult {
45
+ mode: Mode;
46
+ manifest: Record<string, unknown>;
47
+ ops: string[];
48
+ warnings: string[];
49
+ }
50
+ /** Whether the app routes in the browser: the option, or what Nuxt and Analog tell us. */
51
+ export declare function isSpa(nitro: FinalizeNitro): boolean;
52
+ /**
53
+ * Writes the build's ops and manifest into Nitro's output dir and removes
54
+ * what isn't part of a Web ABI build. `server` says whether Nitro built
55
+ * server/.
56
+ */
57
+ export declare function finalize(nitro: FinalizeNitro, { server }: {
58
+ server: boolean;
59
+ }): Promise<FinalizeResult>;
@@ -0,0 +1,145 @@
1
+ // Turns Nitro's output dir into a Web ABI build: the ops, the manifest, and
2
+ // the cleanup. It runs once, after Nitro has written public/ (and server/).
3
+ import { readFile, rm, stat } from "node:fs/promises";
4
+ import { join, relative, sep } from "node:path";
5
+ import { listFiles, writeManifest, writeOps } from "@txco/web-abi/producer";
6
+ import { checkImmutable, deriveImmutable, looksHashed } from "./immutable.js";
7
+ import { renderOps } from "./ops.js";
8
+ export const NAME = "@txco/nitro-preset";
9
+ export const VERSION = "0.1.0";
10
+ export const PRESET = "thanks-computer";
11
+ export const MANIFEST = "txco-web.json";
12
+ /** The producer band (chassis/webabi ProducerScope). */
13
+ export const PRODUCER_SCOPE = 900000;
14
+ /** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
15
+ const PAGE_WARN_BYTES = 1 << 20;
16
+ const exists = (p) => stat(p).then(() => true, () => false);
17
+ const posix = (p) => p.split(sep).join("/");
18
+ /** Whether the app routes in the browser: the option, or what Nuxt and Analog tell us. */
19
+ export function isSpa(nitro) {
20
+ const forced = nitro.options.thanksComputer?.spa;
21
+ if (typeof forced === "boolean")
22
+ return forced;
23
+ // Nuxt prerenders /index.html as the shell only for an ssr: false app;
24
+ // with component islands it turns that into a routeRule instead.
25
+ if (nitro._prerenderedRoutes?.some((r) => r.route === "/index.html"))
26
+ return true;
27
+ if (nitro.options.routeRules?.["/**"]?.ssr === false)
28
+ return true;
29
+ // Analog's ssr: false renderer.
30
+ return typeof nitro.options.renderer === "string" && /CLIENT_RENDERER/.test(nitro.options.renderer);
31
+ }
32
+ /**
33
+ * Writes the build's ops and manifest into Nitro's output dir and removes
34
+ * what isn't part of a Web ABI build. `server` says whether Nitro built
35
+ * server/.
36
+ */
37
+ export async function finalize(nitro, { server }) {
38
+ const o = nitro.options;
39
+ const { dir, publicDir, serverDir } = o.output;
40
+ const opts = o.thanksComputer ?? {};
41
+ const warnings = [];
42
+ const warn = (msg) => {
43
+ warnings.push(msg);
44
+ nitro.logger.warn(`[${PRESET}] ${msg}`);
45
+ };
46
+ // Nitro's build info: read for the manifest, then dropped (it isn't part
47
+ // of a Web ABI build, and Nuxt 4's CLI keeps its own).
48
+ let nitroVersion;
49
+ try {
50
+ nitroVersion = JSON.parse(await readFile(join(dir, "nitro.json"), "utf8"))?.versions?.nitro;
51
+ }
52
+ catch { }
53
+ await rm(join(dir, "nitro.json"), { force: true });
54
+ let entry;
55
+ if (server) {
56
+ const out = o.rollupConfig?.output;
57
+ const name = typeof out?.entryFileNames === "string" ? out.entryFileNames : "index.mjs";
58
+ const file = join(serverDir, name);
59
+ if (!(await exists(file)))
60
+ throw new Error(`[${PRESET}] the server build has no entry at ${file}`);
61
+ entry = posix(relative(dir, file));
62
+ }
63
+ else if (await exists(serverDir)) {
64
+ // No server was built, so server/ holds only build-time leftovers: a
65
+ // framework can prepare it and then skip the server build, and one that
66
+ // passes its own output paths into the config (Analog) has the
67
+ // prerenderer bundle into it.
68
+ await rm(serverDir, { recursive: true, force: true });
69
+ }
70
+ const mode = server ? "server" : isSpa(nitro) ? "spa" : "static";
71
+ const readPage = async (name) => {
72
+ const html = await readFile(join(publicDir, name), "utf8").catch(() => null);
73
+ return html === null ? null : { name, html };
74
+ };
75
+ const page = mode === "spa" ? ((await readPage("200.html")) ?? (await readPage("index.html"))) : await readPage("404.html");
76
+ if (mode === "spa" && page === null) {
77
+ throw new Error(`[${PRESET}] an ssr: false build needs its shell, ${join(publicDir, "200.html")} or index.html, and has neither`);
78
+ }
79
+ if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
80
+ warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
81
+ }
82
+ const ops = renderOps({ scope: opts.scope ?? PRODUCER_SCOPE, mode, page, producer: NAME });
83
+ await writeOps(dir, ops);
84
+ // public/ as installed: paths relative to it (they include the app's baseURL).
85
+ const publicRoot = join(dir, "public");
86
+ let files = (await exists(publicRoot)) ? await listFiles(publicRoot) : [];
87
+ // What never deploys, taken out rather than shipped: a bundler's .vite/
88
+ // metadata (vinxi leaves one under _build/), and the .gz/.br copies
89
+ // compressPublicAssets writes beside a file (nothing serves them; the edge
90
+ // compresses).
91
+ const have = new Set(files);
92
+ const drop = files.filter((f) => f.split("/").includes(".vite") ||
93
+ (Boolean(o.compressPublicAssets) && /\.(gz|br)$/.test(f) && have.has(f.replace(/\.(gz|br)$/, ""))));
94
+ for (const f of drop)
95
+ await rm(join(publicRoot, f), { force: true });
96
+ for (const d of new Set(files.filter((f) => f.split("/").includes(".vite")).map((f) => f.slice(0, f.indexOf(".vite/") + 5)))) {
97
+ await rm(join(publicRoot, d), { recursive: true, force: true });
98
+ }
99
+ const compressed = drop.filter((f) => /\.(gz|br)$/.test(f)).length;
100
+ if (compressed > 0)
101
+ nitro.logger.info(`[${PRESET}] left out ${compressed} precompressed .gz/.br copies (the edge compresses)`);
102
+ files = files.filter((f) => !drop.includes(f));
103
+ let immutable;
104
+ if (opts.immutable) {
105
+ const problems = checkImmutable(opts.immutable);
106
+ if (problems.length > 0)
107
+ throw new Error(`[${PRESET}] thanksComputer.immutable: ${problems.join("; ")}`);
108
+ immutable = [...opts.immutable];
109
+ }
110
+ else {
111
+ immutable = deriveImmutable({ assets: o.publicAssets, appBaseURL: o.baseURL, framework: o.framework.name ?? "" });
112
+ }
113
+ for (const prefix of immutable) {
114
+ const under = files.filter((f) => f.startsWith(prefix));
115
+ if (under.length === 0) {
116
+ warn(`the immutable prefix ${prefix} matches no file in public/`);
117
+ continue;
118
+ }
119
+ // Nuxt's builds/latest.json is fetched with a cache-busting query.
120
+ const unhashed = under.filter((f) => !looksHashed(f.split("/").pop()) && !/\/builds\/latest\.json$/.test(f));
121
+ if (unhashed.length > 0) {
122
+ warn(`${unhashed.length} file(s) under the immutable prefix ${prefix} carry no content hash (${unhashed.slice(0, 3).join(", ")}), so a change to them wouldn't reach a browser for a year`);
123
+ }
124
+ }
125
+ const dots = files.filter((f) => f.split("/").some((s) => s.startsWith(".")));
126
+ if (dots.length > 0)
127
+ warn(`public/ has ${dots.length} dot path(s) (${dots.slice(0, 3).join(", ")}); they never deploy`);
128
+ const manifest = {
129
+ abi: 1,
130
+ ...(entry ? { server: { entry } } : {}),
131
+ ...(immutable.length > 0 ? { immutable } : {}),
132
+ "x-producer": { name: NAME, version: VERSION },
133
+ "x-nitro": {
134
+ ...(nitroVersion ? { nitro: nitroVersion } : {}),
135
+ preset: PRESET,
136
+ framework: { name: o.framework.name || "nitro", ...(o.framework.version ? { version: o.framework.version } : {}) },
137
+ mode,
138
+ },
139
+ };
140
+ await writeManifest(dir, manifest);
141
+ nitro.logger.success(`[${PRESET}] Web ABI build (${mode}) at ${dir}: ${files.length} public file(s), ${Object.keys(ops).length} op(s)${entry ? `, server entry ${entry}` : ""}`);
142
+ nitro.logger.info(`[${PRESET}] Next: \`txco web check ${relative(process.cwd(), dir) || "."}${entry ? " --static-only" : ""}\`, bind it in txco.yaml (stacks: <name>: abi: <dir>), then \`txco apply\`.` +
143
+ (entry ? " No chassis runs server/ yet: deploy the static half with --static-only." : ""));
144
+ return { mode, manifest, ops: Object.keys(ops).sort(), warnings };
145
+ }
@@ -0,0 +1,16 @@
1
+ export interface OutDirInput {
2
+ /** `nitro.options.output.dir`. */
3
+ dir: string;
4
+ /** `nitro.options.output.publicDir`. */
5
+ publicDir: string;
6
+ /** `nitro.options.output.serverDir`. */
7
+ serverDir: string;
8
+ /** The app's baseURL ("/" for most apps). */
9
+ baseURL: string;
10
+ /** Dirs the wipe must not touch: rootDir, srcDir, buildDir, the public asset sources. */
11
+ protect: readonly string[];
12
+ /** The dir's current entries, or null when it doesn't exist. */
13
+ entries: readonly string[] | null;
14
+ }
15
+ /** Every reason the build mustn't write (and so wipe) the output dir. Empty when it may. */
16
+ export declare function checkOutDir(i: OutDirInput): string[];
package/dist/guard.js ADDED
@@ -0,0 +1,19 @@
1
+ // The check that runs before Nitro wipes the output dir. Pure (paths in,
2
+ // problems out), so it's unit-tested; the module reads the dir's entries.
3
+ // The generic checks are the kit's; the layout check is Nitro's.
4
+ import { join, resolve } from "node:path";
5
+ import { checkOutDir as checkABIOutDir } from "@txco/web-abi/producer";
6
+ /** Every reason the build mustn't write (and so wipe) the output dir. Empty when it may. */
7
+ export function checkOutDir(i) {
8
+ const dir = resolve(i.dir);
9
+ const problems = [];
10
+ const base = i.baseURL.replace(/^\/+|\/+$/g, "");
11
+ const wantPublic = resolve(join(dir, "public", base));
12
+ const wantServer = resolve(join(dir, "server"));
13
+ if (resolve(i.publicDir) !== wantPublic || resolve(i.serverDir) !== wantServer) {
14
+ problems.push(`the output dirs don't follow the Web ABI layout: publicDir is ${resolve(i.publicDir)} (want ${wantPublic}) and serverDir is ${resolve(i.serverDir)} (want ${wantServer}). ` +
15
+ `If your framework sets its own output paths (Analog does), set nitro.output.dir and nitro.output.publicDir together, or leave all three to the preset.`);
16
+ }
17
+ // Nitro's build info is a previous build's own file.
18
+ return [...checkABIOutDir({ dir, protect: i.protect, entries: i.entries, allow: ["nitro.json"] }), ...problems];
19
+ }
@@ -0,0 +1,30 @@
1
+ /** One of Nitro's public asset dirs, as `nitro.options.publicAssets` holds it. */
2
+ export interface AssetDir {
3
+ baseURL?: string;
4
+ maxAge?: number;
5
+ }
6
+ export interface ImmutableInput {
7
+ assets: readonly AssetDir[];
8
+ /** The app's baseURL ("/" for most apps). */
9
+ appBaseURL: string;
10
+ /** `nitro.options.framework.name` ("nuxt", "analog", "nitro", …). */
11
+ framework: string;
12
+ }
13
+ /** A year: what a framework sets on its content-hashed asset dir. */
14
+ export declare const YEAR = 31536000;
15
+ /**
16
+ * The prefixes (relative to public/, ending in "/") whose files the
17
+ * framework already caches for a year: its content-hashed asset dir, like
18
+ * Nuxt's `_nuxt/`.
19
+ *
20
+ * A candidate is a non-root asset dir with a year's maxAge. It is dropped
21
+ * when a dir with a shorter maxAge shares or sits inside it, since those files
22
+ * change between builds. The exception is Nuxt's `<buildAssetsDir>/builds`:
23
+ * Nuxt fetches its `latest.json` with a cache-busting query. Nested
24
+ * candidates collapse into the outer one.
25
+ */
26
+ export declare function deriveImmutable(input: ImmutableInput): string[];
27
+ /** What's wrong with an explicit `immutable` list, if anything. */
28
+ export declare function checkImmutable(prefixes: readonly string[]): string[];
29
+ /** Whether a file name carries a content hash (the kit's heuristic, shared by every producer). */
30
+ export { looksHashed } from "@txco/web-abi/producer";
@@ -0,0 +1,50 @@
1
+ // Which public/ prefixes the manifest may call immutable (cached for a year).
2
+ // Pure, so it's unit-tested.
3
+ /** A year: what a framework sets on its content-hashed asset dir. */
4
+ export const YEAR = 31536000;
5
+ const trim = (u) => u.replace(/^\/+|\/+$/g, "");
6
+ const norm = (u) => "/" + trim(u ?? "/");
7
+ const inside = (inner, outer) => outer === "/" || inner === outer || inner.startsWith(outer + "/");
8
+ /**
9
+ * The prefixes (relative to public/, ending in "/") whose files the
10
+ * framework already caches for a year: its content-hashed asset dir, like
11
+ * Nuxt's `_nuxt/`.
12
+ *
13
+ * A candidate is a non-root asset dir with a year's maxAge. It is dropped
14
+ * when a dir with a shorter maxAge shares or sits inside it, since those files
15
+ * change between builds. The exception is Nuxt's `<buildAssetsDir>/builds`:
16
+ * Nuxt fetches its `latest.json` with a cache-busting query. Nested
17
+ * candidates collapse into the outer one.
18
+ */
19
+ export function deriveImmutable(input) {
20
+ const dirs = input.assets.map((a) => ({ base: norm(a.baseURL), maxAge: a.maxAge ?? 0 }));
21
+ const kept = dirs
22
+ .filter((c) => c.maxAge >= YEAR && c.base !== "/")
23
+ .filter((c) => !dirs.some((o) => o.maxAge < YEAR &&
24
+ o.base !== "/" &&
25
+ inside(o.base, c.base) &&
26
+ !(input.framework === "nuxt" && o.base === c.base + "/builds")))
27
+ .map((c) => c.base);
28
+ const outer = [...new Set(kept)].filter((b) => !kept.some((o) => o !== b && inside(b, o)));
29
+ const app = trim(input.appBaseURL);
30
+ return outer.map((b) => (app ? `${app}/` : "") + `${trim(b)}/`).sort();
31
+ }
32
+ /** What's wrong with an explicit `immutable` list, if anything. */
33
+ export function checkImmutable(prefixes) {
34
+ const problems = [];
35
+ for (const p of prefixes) {
36
+ const segs = p.split("/").slice(0, -1);
37
+ if (!p.endsWith("/") || p.startsWith("/") || segs.length === 0) {
38
+ problems.push(`"${p}": an immutable prefix is a directory relative to public/, ending in "/" (e.g. "assets/")`);
39
+ }
40
+ else if (segs.some((s) => s === "" || s.startsWith("."))) {
41
+ problems.push(`"${p}": no empty or dot segments`);
42
+ }
43
+ else if (segs[0] === "_txco") {
44
+ problems.push(`"${p}": _txco/ is reserved for the installer`);
45
+ }
46
+ }
47
+ return problems;
48
+ }
49
+ /** Whether a file name carries a content hash (the kit's heuristic, shared by every producer). */
50
+ export { looksHashed } from "@txco/web-abi/producer";
@@ -0,0 +1,5 @@
1
+ import type { Nitro } from "nitropack/types";
2
+ export declare const thanksComputerModule: {
3
+ name: string;
4
+ setup(nitro: Nitro): Promise<void>;
5
+ };
package/dist/module.js ADDED
@@ -0,0 +1,51 @@
1
+ // The preset's Nitro module. Nitro runs modules inside createNitro, before
2
+ // prepare() wipes the output dir, so this is where the guard goes; it also
3
+ // registers the hooks that finish the build.
4
+ import { readdir } from "node:fs/promises";
5
+ import { finalize, PRESET } from "./finalize.js";
6
+ import { checkOutDir } from "./guard.js";
7
+ export const thanksComputerModule = {
8
+ name: PRESET,
9
+ async setup(nitro) {
10
+ const o = nitro.options;
11
+ const entries = await readdir(o.output.dir).catch(() => null);
12
+ const problems = checkOutDir({
13
+ dir: o.output.dir,
14
+ publicDir: o.output.publicDir,
15
+ serverDir: o.output.serverDir,
16
+ baseURL: o.baseURL,
17
+ protect: [o.rootDir, o.srcDir, o.buildDir, ...o.publicAssets.map((a) => a.dir)],
18
+ entries,
19
+ });
20
+ if (problems.length > 0)
21
+ throw new Error(`[${PRESET}] ${problems.join("\n")}`);
22
+ // A static build (nuxi generate) crawls from the home page, as Nitro's
23
+ // `static` preset does: Nuxt seeds "/" only when crawlLinks is on, and
24
+ // reads it in nitro:init, after this module. An explicit setting stands.
25
+ const asked = o._config?.prerender?.crawlLinks;
26
+ if (o.static && asked === undefined)
27
+ o.prerender.crawlLinks = true;
28
+ // Hooks registered here, not in the preset's `hooks`: the config merge
29
+ // lets a user's own `hooks.compiled` replace a preset's.
30
+ let finalized = false;
31
+ let prerendered = false;
32
+ const run = async (server) => {
33
+ finalized = true;
34
+ await finalize(nitro, { server });
35
+ };
36
+ nitro.hooks.hook("prerender:done", ({ failedRoutes }) => {
37
+ prerendered = !(o.prerender.failOnError && failedRoutes.length > 0);
38
+ });
39
+ nitro.hooks.hook("compiled", async () => {
40
+ if (!finalized)
41
+ await run(!o.static);
42
+ });
43
+ // A framework that prerenders and never calls build() (Analog's static
44
+ // build) never fires `compiled`; it always closes. Without a finished
45
+ // prerender (nuxi prepare, a failed build) there is nothing to finish.
46
+ nitro.hooks.hook("close", async () => {
47
+ if (!finalized && prerendered)
48
+ await run(false);
49
+ });
50
+ },
51
+ };
package/dist/ops.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { BUILTIN_404, catchAllScope, NAV_GUARD, type Page } from "@txco/web-abi/producer";
2
+ export { BUILTIN_404, catchAllScope, NAV_GUARD, type Page };
3
+ /**
4
+ * How the build answers a page navigation nothing else answered:
5
+ *
6
+ * - "static": every page was prerendered into public/, so a path that
7
+ * reaches the ops has no page: 404, with the 404 page;
8
+ * - "spa": an `ssr: false` app routes in the browser: 200, with the shell;
9
+ * - "server": the build has a server (server/). No chassis runs one yet, so
10
+ * a --static-only install answers like "static".
11
+ */
12
+ export type Mode = "static" | "spa" | "server";
13
+ export interface RenderOptions {
14
+ /** The producer band's scope for the navigation op (900000). */
15
+ scope: number;
16
+ mode: Mode;
17
+ /**
18
+ * The page the navigation op serves: 404.html (static, server) or the
19
+ * shell (spa). null in static or server mode writes a small built-in 404
20
+ * page instead. spa mode needs one.
21
+ */
22
+ page: Page | null;
23
+ /** The generated-file header's producer name. */
24
+ producer: string;
25
+ }
26
+ /**
27
+ * Renders the build's ops, as path (relative to ops/) → file text: one
28
+ * navigation op for the mode, and the catch-all, a plain 404 for every other
29
+ * HTTP request, so every request reaching the end of the stack is answered.
30
+ */
31
+ export declare function renderOps(opts: RenderOptions): Record<string, string>;
package/dist/ops.js ADDED
@@ -0,0 +1,29 @@
1
+ // The ops this preset writes into a Web ABI build's ops/. Pure: no filesystem
2
+ // and no Nitro runtime, so the output is golden-tested. The rules come from
3
+ // @txco/web-abi/producer, which every producer shares; this file maps the
4
+ // preset's build modes onto them.
5
+ import { BUILTIN_404, catchAllScope, NAV_GUARD, renderOps as renderABIOps } from "@txco/web-abi/producer";
6
+ export { BUILTIN_404, catchAllScope, NAV_GUARD };
7
+ const NOTES = {
8
+ spa: ["An ssr: false app: the client router renders every page."],
9
+ static: ["A client route that wasn't prerendered still renders: Nuxt's 404.html", "is its app shell."],
10
+ server: [
11
+ "This build has a server (server/), and no chassis runs one yet: a",
12
+ "--static-only install serves its prerendered pages, and every other page",
13
+ "navigation ends here. A dispatch op replaces this one once a runner exists.",
14
+ ],
15
+ };
16
+ /**
17
+ * Renders the build's ops, as path (relative to ops/) → file text: one
18
+ * navigation op for the mode, and the catch-all, a plain 404 for every other
19
+ * HTTP request, so every request reaching the end of the stack is answered.
20
+ */
21
+ export function renderOps(opts) {
22
+ return renderABIOps({
23
+ scope: opts.scope,
24
+ producer: opts.producer,
25
+ mode: opts.mode === "spa" ? "spa" : "404",
26
+ page: opts.page,
27
+ notes: NOTES[opts.mode],
28
+ });
29
+ }