@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 +208 -0
- package/dist/finalize.d.ts +59 -0
- package/dist/finalize.js +145 -0
- package/dist/guard.d.ts +16 -0
- package/dist/guard.js +19 -0
- package/dist/immutable.d.ts +30 -0
- package/dist/immutable.js +50 -0
- package/dist/module.d.ts +5 -0
- package/dist/module.js +51 -0
- package/dist/ops.d.ts +31 -0
- package/dist/ops.js +29 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +39 -0
- package/dist/runtime/entry.d.ts +10 -0
- package/dist/runtime/entry.js +26 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +1 -0
- package/package.json +64 -0
- package/src/finalize.ts +181 -0
- package/src/guard.ts +38 -0
- package/src/immutable.ts +73 -0
- package/src/module.ts +53 -0
- package/src/ops.ts +57 -0
- package/src/preset.ts +44 -0
- package/src/runtime/entry.ts +32 -0
- package/src/runtime/virtual.d.ts +2 -0
- package/src/types.ts +23 -0
package/dist/preset.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type { ThanksComputerOptions } from "./types.js";
|
|
2
|
+
declare const preset: {
|
|
3
|
+
entry: string;
|
|
4
|
+
node: boolean;
|
|
5
|
+
noExternals: boolean;
|
|
6
|
+
serveStatic: boolean;
|
|
7
|
+
output: {
|
|
8
|
+
dir: string;
|
|
9
|
+
serverDir: string;
|
|
10
|
+
publicDir: string;
|
|
11
|
+
};
|
|
12
|
+
commands: {
|
|
13
|
+
deploy: string;
|
|
14
|
+
};
|
|
15
|
+
modules: {
|
|
16
|
+
name: string;
|
|
17
|
+
setup(nitro: import("nitropack").Nitro): Promise<void>;
|
|
18
|
+
}[];
|
|
19
|
+
_meta: {
|
|
20
|
+
name: string;
|
|
21
|
+
url: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export default preset;
|
package/dist/preset.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @txco/nitro-preset: the `thanks-computer` Nitro preset. It builds a Nitro
|
|
2
|
+
// app (Nuxt, Analog) as a Web ABI build for `txco apply` to install:
|
|
3
|
+
//
|
|
4
|
+
// txco-web/
|
|
5
|
+
// txco-web.json the manifest
|
|
6
|
+
// public/ the site: Nitro's public output, prerendered pages included
|
|
7
|
+
// ops/ the navigation op and the catch-all
|
|
8
|
+
// server/ the app's server as a Fetch handler (`nuxi build` only)
|
|
9
|
+
//
|
|
10
|
+
// // nuxt.config.ts
|
|
11
|
+
// export default defineNuxtConfig({ nitro: { preset: "@txco/nitro-preset" } })
|
|
12
|
+
//
|
|
13
|
+
// Written in Nitro's in-tree preset shape, so it can move upstream. It
|
|
14
|
+
// doesn't import nitropack at runtime: a Nuxt app doesn't depend on it
|
|
15
|
+
// directly, and a strict package manager won't resolve it from here.
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { PRESET } from "./finalize.js";
|
|
18
|
+
import { thanksComputerModule } from "./module.js";
|
|
19
|
+
const preset = {
|
|
20
|
+
entry: fileURLToPath(new URL("./runtime/entry.js", import.meta.url)),
|
|
21
|
+
// Node built-ins stay native; every dependency is bundled into server/,
|
|
22
|
+
// which deploys without an install step.
|
|
23
|
+
node: true,
|
|
24
|
+
noExternals: true,
|
|
25
|
+
// The chassis serves public/ before the app's stack runs.
|
|
26
|
+
serveStatic: false,
|
|
27
|
+
output: {
|
|
28
|
+
dir: "{{ rootDir }}/txco-web",
|
|
29
|
+
serverDir: "{{ output.dir }}/server",
|
|
30
|
+
publicDir: "{{ output.dir }}/public/{{ baseURL }}",
|
|
31
|
+
},
|
|
32
|
+
commands: {
|
|
33
|
+
deploy: "txco apply",
|
|
34
|
+
},
|
|
35
|
+
modules: [thanksComputerModule],
|
|
36
|
+
// What defineNitroPreset (nitropack/kit) adds, inlined.
|
|
37
|
+
_meta: { name: PRESET, url: import.meta.url },
|
|
38
|
+
};
|
|
39
|
+
export default preset;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The server entry: Nitro's app as a Fetch handler, in the Web ABI's server
|
|
2
|
+
// shape, `export default { fetch(request, ctx) }` with ctx = { client: { ip } }.
|
|
3
|
+
// Nitro bundles this file into server/index.mjs; the imports below are its
|
|
4
|
+
// own, resolved at bundle time.
|
|
5
|
+
import "#nitro-internal-pollyfills";
|
|
6
|
+
import { useNitroApp } from "nitropack/runtime";
|
|
7
|
+
const nitroApp = useNitroApp();
|
|
8
|
+
export default {
|
|
9
|
+
async fetch(request, ctx) {
|
|
10
|
+
const url = new URL(request.url);
|
|
11
|
+
const method = request.method.toUpperCase();
|
|
12
|
+
const body = method === "GET" || method === "HEAD" ? undefined : Buffer.from(await request.arrayBuffer());
|
|
13
|
+
return nitroApp.localFetch(url.pathname + url.search, {
|
|
14
|
+
host: url.hostname,
|
|
15
|
+
protocol: url.protocol,
|
|
16
|
+
method,
|
|
17
|
+
headers: request.headers,
|
|
18
|
+
body,
|
|
19
|
+
// Nitro spreads _platform into event.context; h3's getRequestIP reads
|
|
20
|
+
// context.clientAddress first.
|
|
21
|
+
context: {
|
|
22
|
+
_platform: { clientAddress: ctx?.client?.ip, thanksComputer: { client: ctx?.client } },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Options, set as `nitro: { thanksComputer: { … } }` (Nuxt) or the Nitro config's `thanksComputer`. */
|
|
2
|
+
export interface ThanksComputerOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Answer every unknown page path with the app shell and 200, for an app
|
|
5
|
+
* that routes in the browser. Detected for Nuxt `ssr: false` and Analog's
|
|
6
|
+
* client renderer; set it to force the choice either way.
|
|
7
|
+
*/
|
|
8
|
+
spa?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The public/ prefixes cached for a year (`["assets/"]`), each a directory
|
|
11
|
+
* ending in "/". Derived from the framework's asset dirs when unset (Nuxt:
|
|
12
|
+
* `_nuxt/`).
|
|
13
|
+
*/
|
|
14
|
+
immutable?: string[];
|
|
15
|
+
/** The scope of the navigation op; the catch-all goes 900 above it. Default 900000. */
|
|
16
|
+
scope?: number;
|
|
17
|
+
}
|
|
18
|
+
declare module "nitropack/presets" {
|
|
19
|
+
interface PresetOptions {
|
|
20
|
+
thanksComputer?: ThanksComputerOptions;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@txco/nitro-preset",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nitro preset for Thanks, Computer (txco): builds a Nuxt or other Nitro app as a Web ABI build — your site as public/, the ops it needs as ops/, its server as server/, and txco-web.json — for `txco apply` to install.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"nitro",
|
|
10
|
+
"nitro-preset",
|
|
11
|
+
"nuxt",
|
|
12
|
+
"analog",
|
|
13
|
+
"txco",
|
|
14
|
+
"thanks-computer",
|
|
15
|
+
"static",
|
|
16
|
+
"web-abi"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/LoremLabs/thanks-computer.git",
|
|
21
|
+
"directory": "sdk/nitro-preset"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/LoremLabs/thanks-computer/tree/master/sdk/nitro-preset#readme",
|
|
24
|
+
"bugs": "https://github.com/LoremLabs/thanks-computer/issues",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/preset.d.ts",
|
|
33
|
+
"default": "./dist/preset.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"clean": "rm -rf dist",
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
46
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@txco/web-abi": "^0.2.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"nitropack": "^2.11.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"nitropack": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^20.0.0",
|
|
61
|
+
"nitropack": "^2.13.4",
|
|
62
|
+
"typescript": "^5.4.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/finalize.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
|
|
6
|
+
import { listFiles, writeManifest, writeOps } from "@txco/web-abi/producer";
|
|
7
|
+
|
|
8
|
+
import { checkImmutable, deriveImmutable, looksHashed, type AssetDir } from "./immutable.js";
|
|
9
|
+
import { renderOps, type Mode, type Page } from "./ops.js";
|
|
10
|
+
import type { ThanksComputerOptions } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const NAME = "@txco/nitro-preset";
|
|
13
|
+
export const VERSION = "0.1.0";
|
|
14
|
+
export const PRESET = "thanks-computer";
|
|
15
|
+
export const MANIFEST = "txco-web.json";
|
|
16
|
+
/** The producer band (chassis/webabi ProducerScope). */
|
|
17
|
+
export const PRODUCER_SCOPE = 900000;
|
|
18
|
+
/** An op answer is capped at 4 MiB (--op-payload-max), base64 included. */
|
|
19
|
+
const PAGE_WARN_BYTES = 1 << 20;
|
|
20
|
+
|
|
21
|
+
/** The part of a Nitro instance finalize reads. A `Nitro` from nitropack 2 is one. */
|
|
22
|
+
export interface FinalizeNitro {
|
|
23
|
+
options: {
|
|
24
|
+
static: boolean;
|
|
25
|
+
baseURL: string;
|
|
26
|
+
output: { dir: string; publicDir: string; serverDir: string };
|
|
27
|
+
publicAssets: readonly AssetDir[];
|
|
28
|
+
routeRules: Record<string, { ssr?: boolean } | undefined>;
|
|
29
|
+
renderer?: string;
|
|
30
|
+
framework: { name?: string; version?: string };
|
|
31
|
+
compressPublicAssets?: unknown;
|
|
32
|
+
rollupConfig?: { output?: unknown };
|
|
33
|
+
thanksComputer?: ThanksComputerOptions;
|
|
34
|
+
};
|
|
35
|
+
_prerenderedRoutes?: readonly { route: string }[];
|
|
36
|
+
logger: { info(...args: unknown[]): void; warn(...args: unknown[]): void; success(...args: unknown[]): void };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FinalizeResult {
|
|
40
|
+
mode: Mode;
|
|
41
|
+
manifest: Record<string, unknown>;
|
|
42
|
+
ops: string[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const exists = (p: string) => stat(p).then(() => true, () => false);
|
|
47
|
+
const posix = (p: string) => p.split(sep).join("/");
|
|
48
|
+
|
|
49
|
+
/** Whether the app routes in the browser: the option, or what Nuxt and Analog tell us. */
|
|
50
|
+
export function isSpa(nitro: FinalizeNitro): boolean {
|
|
51
|
+
const forced = nitro.options.thanksComputer?.spa;
|
|
52
|
+
if (typeof forced === "boolean") return forced;
|
|
53
|
+
// Nuxt prerenders /index.html as the shell only for an ssr: false app;
|
|
54
|
+
// with component islands it turns that into a routeRule instead.
|
|
55
|
+
if (nitro._prerenderedRoutes?.some((r) => r.route === "/index.html")) return true;
|
|
56
|
+
if (nitro.options.routeRules?.["/**"]?.ssr === false) return true;
|
|
57
|
+
// Analog's ssr: false renderer.
|
|
58
|
+
return typeof nitro.options.renderer === "string" && /CLIENT_RENDERER/.test(nitro.options.renderer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Writes the build's ops and manifest into Nitro's output dir and removes
|
|
63
|
+
* what isn't part of a Web ABI build. `server` says whether Nitro built
|
|
64
|
+
* server/.
|
|
65
|
+
*/
|
|
66
|
+
export async function finalize(nitro: FinalizeNitro, { server }: { server: boolean }): Promise<FinalizeResult> {
|
|
67
|
+
const o = nitro.options;
|
|
68
|
+
const { dir, publicDir, serverDir } = o.output;
|
|
69
|
+
const opts = o.thanksComputer ?? {};
|
|
70
|
+
const warnings: string[] = [];
|
|
71
|
+
const warn = (msg: string) => {
|
|
72
|
+
warnings.push(msg);
|
|
73
|
+
nitro.logger.warn(`[${PRESET}] ${msg}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Nitro's build info: read for the manifest, then dropped (it isn't part
|
|
77
|
+
// of a Web ABI build, and Nuxt 4's CLI keeps its own).
|
|
78
|
+
let nitroVersion: string | undefined;
|
|
79
|
+
try {
|
|
80
|
+
nitroVersion = JSON.parse(await readFile(join(dir, "nitro.json"), "utf8"))?.versions?.nitro;
|
|
81
|
+
} catch {}
|
|
82
|
+
await rm(join(dir, "nitro.json"), { force: true });
|
|
83
|
+
|
|
84
|
+
let entry: string | undefined;
|
|
85
|
+
if (server) {
|
|
86
|
+
const out = o.rollupConfig?.output as { entryFileNames?: unknown } | undefined;
|
|
87
|
+
const name = typeof out?.entryFileNames === "string" ? out.entryFileNames : "index.mjs";
|
|
88
|
+
const file = join(serverDir, name);
|
|
89
|
+
if (!(await exists(file))) throw new Error(`[${PRESET}] the server build has no entry at ${file}`);
|
|
90
|
+
entry = posix(relative(dir, file));
|
|
91
|
+
} else if (await exists(serverDir)) {
|
|
92
|
+
// No server was built, so server/ holds only build-time leftovers: a
|
|
93
|
+
// framework can prepare it and then skip the server build, and one that
|
|
94
|
+
// passes its own output paths into the config (Analog) has the
|
|
95
|
+
// prerenderer bundle into it.
|
|
96
|
+
await rm(serverDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mode: Mode = server ? "server" : isSpa(nitro) ? "spa" : "static";
|
|
100
|
+
const readPage = async (name: string): Promise<Page | null> => {
|
|
101
|
+
const html = await readFile(join(publicDir, name), "utf8").catch(() => null);
|
|
102
|
+
return html === null ? null : { name, html };
|
|
103
|
+
};
|
|
104
|
+
const page = mode === "spa" ? ((await readPage("200.html")) ?? (await readPage("index.html"))) : await readPage("404.html");
|
|
105
|
+
if (mode === "spa" && page === null) {
|
|
106
|
+
throw new Error(`[${PRESET}] an ssr: false build needs its shell, ${join(publicDir, "200.html")} or index.html, and has neither`);
|
|
107
|
+
}
|
|
108
|
+
if (page && Buffer.byteLength(page.html) > PAGE_WARN_BYTES) {
|
|
109
|
+
warn(`${page.name} is ${Buffer.byteLength(page.html)} bytes; the op that serves it is capped at 4 MiB, base64 included`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ops = renderOps({ scope: opts.scope ?? PRODUCER_SCOPE, mode, page, producer: NAME });
|
|
113
|
+
await writeOps(dir, ops);
|
|
114
|
+
|
|
115
|
+
// public/ as installed: paths relative to it (they include the app's baseURL).
|
|
116
|
+
const publicRoot = join(dir, "public");
|
|
117
|
+
let files = (await exists(publicRoot)) ? await listFiles(publicRoot) : [];
|
|
118
|
+
|
|
119
|
+
// What never deploys, taken out rather than shipped: a bundler's .vite/
|
|
120
|
+
// metadata (vinxi leaves one under _build/), and the .gz/.br copies
|
|
121
|
+
// compressPublicAssets writes beside a file (nothing serves them; the edge
|
|
122
|
+
// compresses).
|
|
123
|
+
const have = new Set(files);
|
|
124
|
+
const drop = files.filter(
|
|
125
|
+
(f) =>
|
|
126
|
+
f.split("/").includes(".vite") ||
|
|
127
|
+
(Boolean(o.compressPublicAssets) && /\.(gz|br)$/.test(f) && have.has(f.replace(/\.(gz|br)$/, ""))),
|
|
128
|
+
);
|
|
129
|
+
for (const f of drop) await rm(join(publicRoot, f), { force: true });
|
|
130
|
+
for (const d of new Set(files.filter((f) => f.split("/").includes(".vite")).map((f) => f.slice(0, f.indexOf(".vite/") + 5)))) {
|
|
131
|
+
await rm(join(publicRoot, d), { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
const compressed = drop.filter((f) => /\.(gz|br)$/.test(f)).length;
|
|
134
|
+
if (compressed > 0) nitro.logger.info(`[${PRESET}] left out ${compressed} precompressed .gz/.br copies (the edge compresses)`);
|
|
135
|
+
files = files.filter((f) => !drop.includes(f));
|
|
136
|
+
|
|
137
|
+
let immutable: string[];
|
|
138
|
+
if (opts.immutable) {
|
|
139
|
+
const problems = checkImmutable(opts.immutable);
|
|
140
|
+
if (problems.length > 0) throw new Error(`[${PRESET}] thanksComputer.immutable: ${problems.join("; ")}`);
|
|
141
|
+
immutable = [...opts.immutable];
|
|
142
|
+
} else {
|
|
143
|
+
immutable = deriveImmutable({ assets: o.publicAssets, appBaseURL: o.baseURL, framework: o.framework.name ?? "" });
|
|
144
|
+
}
|
|
145
|
+
for (const prefix of immutable) {
|
|
146
|
+
const under = files.filter((f) => f.startsWith(prefix));
|
|
147
|
+
if (under.length === 0) {
|
|
148
|
+
warn(`the immutable prefix ${prefix} matches no file in public/`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Nuxt's builds/latest.json is fetched with a cache-busting query.
|
|
152
|
+
const unhashed = under.filter((f) => !looksHashed(f.split("/").pop()!) && !/\/builds\/latest\.json$/.test(f));
|
|
153
|
+
if (unhashed.length > 0) {
|
|
154
|
+
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`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const dots = files.filter((f) => f.split("/").some((s) => s.startsWith(".")));
|
|
159
|
+
if (dots.length > 0) warn(`public/ has ${dots.length} dot path(s) (${dots.slice(0, 3).join(", ")}); they never deploy`);
|
|
160
|
+
|
|
161
|
+
const manifest: Record<string, unknown> = {
|
|
162
|
+
abi: 1,
|
|
163
|
+
...(entry ? { server: { entry } } : {}),
|
|
164
|
+
...(immutable.length > 0 ? { immutable } : {}),
|
|
165
|
+
"x-producer": { name: NAME, version: VERSION },
|
|
166
|
+
"x-nitro": {
|
|
167
|
+
...(nitroVersion ? { nitro: nitroVersion } : {}),
|
|
168
|
+
preset: PRESET,
|
|
169
|
+
framework: { name: o.framework.name || "nitro", ...(o.framework.version ? { version: o.framework.version } : {}) },
|
|
170
|
+
mode,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
await writeManifest(dir, manifest);
|
|
174
|
+
|
|
175
|
+
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}` : ""}`);
|
|
176
|
+
nitro.logger.info(
|
|
177
|
+
`[${PRESET}] Next: \`txco web check ${relative(process.cwd(), dir) || "."}${entry ? " --static-only" : ""}\`, bind it in txco.yaml (stacks: <name>: abi: <dir>), then \`txco apply\`.` +
|
|
178
|
+
(entry ? " No chassis runs server/ yet: deploy the static half with --static-only." : ""),
|
|
179
|
+
);
|
|
180
|
+
return { mode, manifest, ops: Object.keys(ops).sort(), warnings };
|
|
181
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
6
|
+
import { checkOutDir as checkABIOutDir } from "@txco/web-abi/producer";
|
|
7
|
+
|
|
8
|
+
export interface OutDirInput {
|
|
9
|
+
/** `nitro.options.output.dir`. */
|
|
10
|
+
dir: string;
|
|
11
|
+
/** `nitro.options.output.publicDir`. */
|
|
12
|
+
publicDir: string;
|
|
13
|
+
/** `nitro.options.output.serverDir`. */
|
|
14
|
+
serverDir: string;
|
|
15
|
+
/** The app's baseURL ("/" for most apps). */
|
|
16
|
+
baseURL: string;
|
|
17
|
+
/** Dirs the wipe must not touch: rootDir, srcDir, buildDir, the public asset sources. */
|
|
18
|
+
protect: readonly string[];
|
|
19
|
+
/** The dir's current entries, or null when it doesn't exist. */
|
|
20
|
+
entries: readonly string[] | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every reason the build mustn't write (and so wipe) the output dir. Empty when it may. */
|
|
24
|
+
export function checkOutDir(i: OutDirInput): string[] {
|
|
25
|
+
const dir = resolve(i.dir);
|
|
26
|
+
const problems: string[] = [];
|
|
27
|
+
const base = i.baseURL.replace(/^\/+|\/+$/g, "");
|
|
28
|
+
const wantPublic = resolve(join(dir, "public", base));
|
|
29
|
+
const wantServer = resolve(join(dir, "server"));
|
|
30
|
+
if (resolve(i.publicDir) !== wantPublic || resolve(i.serverDir) !== wantServer) {
|
|
31
|
+
problems.push(
|
|
32
|
+
`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}). ` +
|
|
33
|
+
`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.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
// Nitro's build info is a previous build's own file.
|
|
37
|
+
return [...checkABIOutDir({ dir, protect: i.protect, entries: i.entries, allow: ["nitro.json"] }), ...problems];
|
|
38
|
+
}
|
package/src/immutable.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Which public/ prefixes the manifest may call immutable (cached for a year).
|
|
2
|
+
// Pure, so it's unit-tested.
|
|
3
|
+
|
|
4
|
+
/** One of Nitro's public asset dirs, as `nitro.options.publicAssets` holds it. */
|
|
5
|
+
export interface AssetDir {
|
|
6
|
+
baseURL?: string;
|
|
7
|
+
maxAge?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ImmutableInput {
|
|
11
|
+
assets: readonly AssetDir[];
|
|
12
|
+
/** The app's baseURL ("/" for most apps). */
|
|
13
|
+
appBaseURL: string;
|
|
14
|
+
/** `nitro.options.framework.name` ("nuxt", "analog", "nitro", …). */
|
|
15
|
+
framework: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A year: what a framework sets on its content-hashed asset dir. */
|
|
19
|
+
export const YEAR = 31536000;
|
|
20
|
+
|
|
21
|
+
const trim = (u: string) => u.replace(/^\/+|\/+$/g, "");
|
|
22
|
+
const norm = (u: string | undefined) => "/" + trim(u ?? "/");
|
|
23
|
+
const inside = (inner: string, outer: string) => outer === "/" || inner === outer || inner.startsWith(outer + "/");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The prefixes (relative to public/, ending in "/") whose files the
|
|
27
|
+
* framework already caches for a year: its content-hashed asset dir, like
|
|
28
|
+
* Nuxt's `_nuxt/`.
|
|
29
|
+
*
|
|
30
|
+
* A candidate is a non-root asset dir with a year's maxAge. It is dropped
|
|
31
|
+
* when a dir with a shorter maxAge shares or sits inside it, since those files
|
|
32
|
+
* change between builds. The exception is Nuxt's `<buildAssetsDir>/builds`:
|
|
33
|
+
* Nuxt fetches its `latest.json` with a cache-busting query. Nested
|
|
34
|
+
* candidates collapse into the outer one.
|
|
35
|
+
*/
|
|
36
|
+
export function deriveImmutable(input: ImmutableInput): string[] {
|
|
37
|
+
const dirs = input.assets.map((a) => ({ base: norm(a.baseURL), maxAge: a.maxAge ?? 0 }));
|
|
38
|
+
const kept = dirs
|
|
39
|
+
.filter((c) => c.maxAge >= YEAR && c.base !== "/")
|
|
40
|
+
.filter(
|
|
41
|
+
(c) =>
|
|
42
|
+
!dirs.some(
|
|
43
|
+
(o) =>
|
|
44
|
+
o.maxAge < YEAR &&
|
|
45
|
+
o.base !== "/" &&
|
|
46
|
+
inside(o.base, c.base) &&
|
|
47
|
+
!(input.framework === "nuxt" && o.base === c.base + "/builds"),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
.map((c) => c.base);
|
|
51
|
+
const outer = [...new Set(kept)].filter((b) => !kept.some((o) => o !== b && inside(b, o)));
|
|
52
|
+
const app = trim(input.appBaseURL);
|
|
53
|
+
return outer.map((b) => (app ? `${app}/` : "") + `${trim(b)}/`).sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What's wrong with an explicit `immutable` list, if anything. */
|
|
57
|
+
export function checkImmutable(prefixes: readonly string[]): string[] {
|
|
58
|
+
const problems: string[] = [];
|
|
59
|
+
for (const p of prefixes) {
|
|
60
|
+
const segs = p.split("/").slice(0, -1);
|
|
61
|
+
if (!p.endsWith("/") || p.startsWith("/") || segs.length === 0) {
|
|
62
|
+
problems.push(`"${p}": an immutable prefix is a directory relative to public/, ending in "/" (e.g. "assets/")`);
|
|
63
|
+
} else if (segs.some((s) => s === "" || s.startsWith("."))) {
|
|
64
|
+
problems.push(`"${p}": no empty or dot segments`);
|
|
65
|
+
} else if (segs[0] === "_txco") {
|
|
66
|
+
problems.push(`"${p}": _txco/ is reserved for the installer`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a file name carries a content hash (the kit's heuristic, shared by every producer). */
|
|
73
|
+
export { looksHashed } from "@txco/web-abi/producer";
|
package/src/module.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
|
|
6
|
+
import type { Nitro } from "nitropack/types";
|
|
7
|
+
|
|
8
|
+
import { finalize, PRESET, type FinalizeNitro } from "./finalize.js";
|
|
9
|
+
import { checkOutDir } from "./guard.js";
|
|
10
|
+
|
|
11
|
+
export const thanksComputerModule = {
|
|
12
|
+
name: PRESET,
|
|
13
|
+
async setup(nitro: Nitro): Promise<void> {
|
|
14
|
+
const o = nitro.options;
|
|
15
|
+
const entries = await readdir(o.output.dir).catch(() => null);
|
|
16
|
+
const problems = checkOutDir({
|
|
17
|
+
dir: o.output.dir,
|
|
18
|
+
publicDir: o.output.publicDir,
|
|
19
|
+
serverDir: o.output.serverDir,
|
|
20
|
+
baseURL: o.baseURL,
|
|
21
|
+
protect: [o.rootDir, o.srcDir, o.buildDir, ...o.publicAssets.map((a) => a.dir)],
|
|
22
|
+
entries,
|
|
23
|
+
});
|
|
24
|
+
if (problems.length > 0) throw new Error(`[${PRESET}] ${problems.join("\n")}`);
|
|
25
|
+
|
|
26
|
+
// A static build (nuxi generate) crawls from the home page, as Nitro's
|
|
27
|
+
// `static` preset does: Nuxt seeds "/" only when crawlLinks is on, and
|
|
28
|
+
// reads it in nitro:init, after this module. An explicit setting stands.
|
|
29
|
+
const asked = (o._config as { prerender?: { crawlLinks?: boolean } } | undefined)?.prerender?.crawlLinks;
|
|
30
|
+
if (o.static && asked === undefined) o.prerender.crawlLinks = true;
|
|
31
|
+
|
|
32
|
+
// Hooks registered here, not in the preset's `hooks`: the config merge
|
|
33
|
+
// lets a user's own `hooks.compiled` replace a preset's.
|
|
34
|
+
let finalized = false;
|
|
35
|
+
let prerendered = false;
|
|
36
|
+
const run = async (server: boolean) => {
|
|
37
|
+
finalized = true;
|
|
38
|
+
await finalize(nitro as unknown as FinalizeNitro, { server });
|
|
39
|
+
};
|
|
40
|
+
nitro.hooks.hook("prerender:done", ({ failedRoutes }) => {
|
|
41
|
+
prerendered = !(o.prerender.failOnError && failedRoutes.length > 0);
|
|
42
|
+
});
|
|
43
|
+
nitro.hooks.hook("compiled", async () => {
|
|
44
|
+
if (!finalized) await run(!o.static);
|
|
45
|
+
});
|
|
46
|
+
// A framework that prerenders and never calls build() (Analog's static
|
|
47
|
+
// build) never fires `compiled`; it always closes. Without a finished
|
|
48
|
+
// prerender (nuxi prepare, a failed build) there is nothing to finish.
|
|
49
|
+
nitro.hooks.hook("close", async () => {
|
|
50
|
+
if (!finalized && prerendered) await run(false);
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
};
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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, type Page } from "@txco/web-abi/producer";
|
|
6
|
+
|
|
7
|
+
export { BUILTIN_404, catchAllScope, NAV_GUARD, type Page };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How the build answers a page navigation nothing else answered:
|
|
11
|
+
*
|
|
12
|
+
* - "static": every page was prerendered into public/, so a path that
|
|
13
|
+
* reaches the ops has no page: 404, with the 404 page;
|
|
14
|
+
* - "spa": an `ssr: false` app routes in the browser: 200, with the shell;
|
|
15
|
+
* - "server": the build has a server (server/). No chassis runs one yet, so
|
|
16
|
+
* a --static-only install answers like "static".
|
|
17
|
+
*/
|
|
18
|
+
export type Mode = "static" | "spa" | "server";
|
|
19
|
+
|
|
20
|
+
export interface RenderOptions {
|
|
21
|
+
/** The producer band's scope for the navigation op (900000). */
|
|
22
|
+
scope: number;
|
|
23
|
+
mode: Mode;
|
|
24
|
+
/**
|
|
25
|
+
* The page the navigation op serves: 404.html (static, server) or the
|
|
26
|
+
* shell (spa). null in static or server mode writes a small built-in 404
|
|
27
|
+
* page instead. spa mode needs one.
|
|
28
|
+
*/
|
|
29
|
+
page: Page | null;
|
|
30
|
+
/** The generated-file header's producer name. */
|
|
31
|
+
producer: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NOTES: Record<Mode, string[]> = {
|
|
35
|
+
spa: ["An ssr: false app: the client router renders every page."],
|
|
36
|
+
static: ["A client route that wasn't prerendered still renders: Nuxt's 404.html", "is its app shell."],
|
|
37
|
+
server: [
|
|
38
|
+
"This build has a server (server/), and no chassis runs one yet: a",
|
|
39
|
+
"--static-only install serves its prerendered pages, and every other page",
|
|
40
|
+
"navigation ends here. A dispatch op replaces this one once a runner exists.",
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Renders the build's ops, as path (relative to ops/) → file text: one
|
|
46
|
+
* navigation op for the mode, and the catch-all, a plain 404 for every other
|
|
47
|
+
* HTTP request, so every request reaching the end of the stack is answered.
|
|
48
|
+
*/
|
|
49
|
+
export function renderOps(opts: RenderOptions): Record<string, string> {
|
|
50
|
+
return renderABIOps({
|
|
51
|
+
scope: opts.scope,
|
|
52
|
+
producer: opts.producer,
|
|
53
|
+
mode: opts.mode === "spa" ? "spa" : "404",
|
|
54
|
+
page: opts.page,
|
|
55
|
+
notes: NOTES[opts.mode],
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/preset.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @txco/nitro-preset: the `thanks-computer` Nitro preset. It builds a Nitro
|
|
2
|
+
// app (Nuxt, Analog) as a Web ABI build for `txco apply` to install:
|
|
3
|
+
//
|
|
4
|
+
// txco-web/
|
|
5
|
+
// txco-web.json the manifest
|
|
6
|
+
// public/ the site: Nitro's public output, prerendered pages included
|
|
7
|
+
// ops/ the navigation op and the catch-all
|
|
8
|
+
// server/ the app's server as a Fetch handler (`nuxi build` only)
|
|
9
|
+
//
|
|
10
|
+
// // nuxt.config.ts
|
|
11
|
+
// export default defineNuxtConfig({ nitro: { preset: "@txco/nitro-preset" } })
|
|
12
|
+
//
|
|
13
|
+
// Written in Nitro's in-tree preset shape, so it can move upstream. It
|
|
14
|
+
// doesn't import nitropack at runtime: a Nuxt app doesn't depend on it
|
|
15
|
+
// directly, and a strict package manager won't resolve it from here.
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { PRESET } from "./finalize.js";
|
|
19
|
+
import { thanksComputerModule } from "./module.js";
|
|
20
|
+
|
|
21
|
+
export type { ThanksComputerOptions } from "./types.js";
|
|
22
|
+
|
|
23
|
+
const preset = {
|
|
24
|
+
entry: fileURLToPath(new URL("./runtime/entry.js", import.meta.url)),
|
|
25
|
+
// Node built-ins stay native; every dependency is bundled into server/,
|
|
26
|
+
// which deploys without an install step.
|
|
27
|
+
node: true,
|
|
28
|
+
noExternals: true,
|
|
29
|
+
// The chassis serves public/ before the app's stack runs.
|
|
30
|
+
serveStatic: false,
|
|
31
|
+
output: {
|
|
32
|
+
dir: "{{ rootDir }}/txco-web",
|
|
33
|
+
serverDir: "{{ output.dir }}/server",
|
|
34
|
+
publicDir: "{{ output.dir }}/public/{{ baseURL }}",
|
|
35
|
+
},
|
|
36
|
+
commands: {
|
|
37
|
+
deploy: "txco apply",
|
|
38
|
+
},
|
|
39
|
+
modules: [thanksComputerModule],
|
|
40
|
+
// What defineNitroPreset (nitropack/kit) adds, inlined.
|
|
41
|
+
_meta: { name: PRESET, url: import.meta.url },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default preset;
|