@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/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>;
|
package/dist/finalize.js
ADDED
|
@@ -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
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -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";
|
package/dist/module.d.ts
ADDED
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
|
+
}
|