@nifrajs/web 0.1.0-alpha.1 → 0.1.0-alpha.2
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 +8 -0
- package/dist/build.d.ts +252 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +755 -6
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts +4 -0
- package/dist/chunk-names.d.ts.map +1 -0
- package/dist/chunk-names.js +46 -0
- package/dist/chunk-names.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/dev.d.ts +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +2 -1
- package/dist/dev.js.map +1 -1
- package/dist/fonts-google.d.ts +2 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fonts.d.ts +2 -1
- package/dist/fonts.d.ts.map +1 -1
- package/dist/fonts.js +0 -16
- package/dist/fonts.js.map +1 -1
- package/dist/index.d.ts +138 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +268 -23
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +82 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server-only.d.ts +21 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/server-only.js +25 -0
- package/dist/server-only.js.map +1 -0
- package/dist/vite.d.ts +21 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +68 -4
- package/dist/vite.js.map +1 -1
- package/package.json +6 -1
- package/src/build.ts +1066 -12
- package/src/chunk-names.ts +45 -0
- package/src/client.ts +19 -2
- package/src/dev.ts +3 -2
- package/src/fonts-google.ts +3 -2
- package/src/fonts.ts +2 -1
- package/src/index.ts +373 -32
- package/src/manifest.ts +84 -6
- package/src/server-only.ts +25 -0
- package/src/vite.ts +82 -5
package/dist/build.js
CHANGED
|
@@ -7,11 +7,300 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, relative, resolve as resolvePath } from "node:path";
|
|
10
|
+
import { sanitizeOutputNames } from "./chunk-names.js";
|
|
10
11
|
import { discoverRoutes } from "./fs.js";
|
|
11
12
|
import { generateClientEntry, generateServerManifest } from "./index.js";
|
|
13
|
+
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
14
|
+
import { prerenderRoutes } from "./prerender.js";
|
|
12
15
|
// Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
|
|
13
16
|
// run after `buildClient`.
|
|
14
17
|
export { cloudflarePagesRoutes, dataFileFor, htmlFileFor, prerenderRoutes, } from "./prerender.js";
|
|
18
|
+
/**
|
|
19
|
+
* The `process.env.<NAME>` → `JSON.stringify(value)` define entries for every env var whose name
|
|
20
|
+
* carries `prefix` (the Vite/Next public-env convention). Exposing ONLY the prefixed vars is the
|
|
21
|
+
* security boundary: an unprefixed var (a secret) never gets a define, so the bare `process.env`
|
|
22
|
+
* define resolves it to `undefined` in the client bundle. An empty `prefix` exposes nothing (the
|
|
23
|
+
* opt-out). Pure + exported so the prefix/redaction contract is unit-testable without a real build.
|
|
24
|
+
*/
|
|
25
|
+
export function publicEnvDefines(prefix, env) {
|
|
26
|
+
const defines = {};
|
|
27
|
+
if (prefix === "")
|
|
28
|
+
return defines; // opt-out: bake in nothing
|
|
29
|
+
for (const [name, value] of Object.entries(env)) {
|
|
30
|
+
// Skip `undefined` (a deleted/unset key can still enumerate) so we never bake in `"undefined"`.
|
|
31
|
+
if (name.startsWith(prefix) && value !== undefined) {
|
|
32
|
+
defines[`process.env.${name}`] = JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return defines;
|
|
36
|
+
}
|
|
37
|
+
/** The `node:` specifier of an import edge, or undefined if it isn't a Node built-in. Reads the
|
|
38
|
+
* `original` (as-written) specifier first, falling back to the resolved `path` (an `external` node:
|
|
39
|
+
* import keeps `node:` in its path). */
|
|
40
|
+
const nodeBuiltinOf = (im) => {
|
|
41
|
+
if (im.original?.startsWith("node:"))
|
|
42
|
+
return im.original;
|
|
43
|
+
if (im.path?.startsWith("node:"))
|
|
44
|
+
return im.path;
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
|
|
49
|
+
* bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
|
|
50
|
+
* starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
|
|
51
|
+
* actually wrote `import` — the actionable root, not an arbitrary internal module. Traversal crosses
|
|
52
|
+
* only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
|
|
53
|
+
* whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
|
|
54
|
+
* is reachable by construction). Pure — operates on the graph, never the emitted text.
|
|
55
|
+
*/
|
|
56
|
+
function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
57
|
+
// Each queue item carries the resolved-path node + the display chain to reach it (entry + the
|
|
58
|
+
// as-written specifier of every hop crossed so far). `seen` dedupes nodes so the BFS is linear and
|
|
59
|
+
// the first time we touch the builtin's importer is via a shortest path.
|
|
60
|
+
const seen = new Set(entryInputs);
|
|
61
|
+
let frontier = entryInputs.map((node) => ({
|
|
62
|
+
node,
|
|
63
|
+
chain: [node],
|
|
64
|
+
}));
|
|
65
|
+
while (frontier.length > 0) {
|
|
66
|
+
const next = [];
|
|
67
|
+
for (const { node, chain } of frontier) {
|
|
68
|
+
for (const im of inputs[node]?.imports ?? []) {
|
|
69
|
+
// The builtin reached via THIS edge → the chain ends here (append the builtin label). Match on
|
|
70
|
+
// the same `node:` detection the finding used (`original` first, then resolved `path`).
|
|
71
|
+
if (nodeBuiltinOf(im) === builtin)
|
|
72
|
+
return [...chain, builtin];
|
|
73
|
+
const target = im.path;
|
|
74
|
+
// Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
|
|
75
|
+
// been visited — so the polyfill subtree can't lengthen the path and there are no cycles.
|
|
76
|
+
if (target === undefined ||
|
|
77
|
+
target.startsWith("node:") ||
|
|
78
|
+
inputs[target] === undefined ||
|
|
79
|
+
seen.has(target)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
seen.add(target);
|
|
83
|
+
// Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
|
|
84
|
+
// the resolved path — that's what the dev recognizes in their source, not the resolved path.
|
|
85
|
+
next.push({ node: target, chain: [...chain, im.original ?? target] });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
frontier = next;
|
|
89
|
+
}
|
|
90
|
+
return [builtin]; // unreachable from any entry — degrade to just the builtin (shouldn't happen)
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
|
|
94
|
+
* chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
|
|
95
|
+
* the report is precise AND actionable:
|
|
96
|
+
* 1. **What the user wrote** — only builtins imported by a NON-`node:` input count, so Bun's own
|
|
97
|
+
* polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
|
|
98
|
+
* 2. **Where it landed** — the chunk is read from the per-output `inputs`, so the error names the
|
|
99
|
+
* emitted file to look at.
|
|
100
|
+
* 3. **How it got there** — the shortest import chain from a user entry to the builtin
|
|
101
|
+
* (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
|
|
102
|
+
* leaving the dev to grep the dependency tree (the DX gap this closes).
|
|
103
|
+
* Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
|
|
104
|
+
* literal that merely contains `"node:crypto"`. Pure + exported for unit testing. Empty ⇒ clean.
|
|
105
|
+
*/
|
|
106
|
+
export function detectNodeBuiltinsInClient(meta) {
|
|
107
|
+
const inputs = meta?.inputs ?? {};
|
|
108
|
+
// (1) The builtins a user (non-polyfill) module imports directly — the ones the author controls.
|
|
109
|
+
const userImported = new Set();
|
|
110
|
+
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
111
|
+
if (inputKey.startsWith("node:"))
|
|
112
|
+
continue; // a polyfill importing another builtin — not the cause
|
|
113
|
+
for (const im of input.imports ?? []) {
|
|
114
|
+
const builtin = nodeBuiltinOf(im);
|
|
115
|
+
if (builtin !== undefined)
|
|
116
|
+
userImported.add(builtin);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// The entry inputs (route/user entrypoints) — the chain BFS starts here so the reported path begins
|
|
120
|
+
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
121
|
+
const entryInputs = [
|
|
122
|
+
...new Set(Object.values(meta?.outputs ?? {})
|
|
123
|
+
.map((o) => o.entryPoint)
|
|
124
|
+
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
125
|
+
];
|
|
126
|
+
// The chain per builtin is independent of the chunk, so compute it once per builtin (memoized).
|
|
127
|
+
const chainCache = new Map();
|
|
128
|
+
const chainFor = (builtin) => {
|
|
129
|
+
const cached = chainCache.get(builtin);
|
|
130
|
+
if (cached !== undefined)
|
|
131
|
+
return cached;
|
|
132
|
+
const chain = shortestBuiltinChain(inputs, entryInputs, builtin);
|
|
133
|
+
chainCache.set(builtin, chain);
|
|
134
|
+
return chain;
|
|
135
|
+
};
|
|
136
|
+
// (2) Locate which emitted chunk each user-imported builtin reached, via the per-output `inputs`.
|
|
137
|
+
const findings = new Map();
|
|
138
|
+
for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
|
|
139
|
+
for (const inputKey of Object.keys(out.inputs ?? {})) {
|
|
140
|
+
if (userImported.has(inputKey)) {
|
|
141
|
+
const chunk = basename(outPath);
|
|
142
|
+
findings.set(`${inputKey}\0${chunk}`, {
|
|
143
|
+
builtin: inputKey,
|
|
144
|
+
chunk,
|
|
145
|
+
chain: chainFor(inputKey),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return [...findings.values()].sort((a, b) => a.builtin === b.builtin ? a.chunk.localeCompare(b.chunk) : a.builtin.localeCompare(b.builtin));
|
|
151
|
+
}
|
|
152
|
+
// ---------------------------------------------------------------------------------------------------
|
|
153
|
+
// `server-only` poison-import guard (§3.3/§5.1). The complement to the `.server` convention + the
|
|
154
|
+
// node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
|
|
155
|
+
// a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
|
|
156
|
+
// side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
|
|
157
|
+
// the marker is an empty no-op; the CLIENT build detects — via the SAME Bun metafile graph the
|
|
158
|
+
// node-builtin guard walks — any module that imports the marker AND lands in a client chunk, and fails
|
|
159
|
+
// the build with the import chain. Graph-based (never the emitted text), so it survives minification.
|
|
160
|
+
// ---------------------------------------------------------------------------------------------------
|
|
161
|
+
/** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
|
|
162
|
+
* import edge's *as-written* `original` first (the robust signal: it's exactly what the author typed,
|
|
163
|
+
* before Bun resolves it to `src/server-only.ts` / `dist/server-only.js`). */
|
|
164
|
+
export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
|
|
165
|
+
/** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
|
|
166
|
+
* specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
|
|
167
|
+
* (no `original`) — or a workspace-relative resolution — is still recognised. */
|
|
168
|
+
const isServerOnlyMarkerImport = (im) => {
|
|
169
|
+
if (im.original === SERVER_ONLY_MARKER)
|
|
170
|
+
return true;
|
|
171
|
+
// Defensive fallback: an edge that lost its `original` but resolved to the marker module file. The
|
|
172
|
+
// marker is the only `server-only.{ts,js}` under `@nifrajs/web`, so the basename is unambiguous.
|
|
173
|
+
const path = im.path;
|
|
174
|
+
return path !== undefined && /(^|\/)server-only\.[cm]?[jt]s$/.test(path);
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* BFS the metafile import graph for the SHORTEST user-module path that reaches a module which imports
|
|
178
|
+
* the `server-only` marker, returning it as display labels `[entryFile, …as-written specifiers…,
|
|
179
|
+
* "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
|
|
180
|
+
* every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
|
|
181
|
+
* only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
|
|
182
|
+
* contains the marker (the marked module — the actionable tail), labelling that node by the as-written
|
|
183
|
+
* specifier the previous hop used to reach it. Pure — operates on the graph, never the emitted text.
|
|
184
|
+
*/
|
|
185
|
+
function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
|
|
186
|
+
// The label for the marked module's tail: its as-written specifier (filled when we cross the edge
|
|
187
|
+
// that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
|
|
188
|
+
const tail = (label) => `${label} (marked server-only)`;
|
|
189
|
+
// An entry that is ITSELF the marked module — the chain is just that one node.
|
|
190
|
+
if (entryInputs.includes(markedModule))
|
|
191
|
+
return [tail(markedModule)];
|
|
192
|
+
const seen = new Set(entryInputs);
|
|
193
|
+
let frontier = entryInputs.map((node) => ({
|
|
194
|
+
node,
|
|
195
|
+
chain: [node],
|
|
196
|
+
}));
|
|
197
|
+
while (frontier.length > 0) {
|
|
198
|
+
const next = [];
|
|
199
|
+
for (const { node, chain } of frontier) {
|
|
200
|
+
for (const im of inputs[node]?.imports ?? []) {
|
|
201
|
+
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY — in a real build the edge `path`
|
|
202
|
+
// is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
|
|
203
|
+
// multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
|
|
204
|
+
const target = resolveTarget(im);
|
|
205
|
+
if (target === undefined || target.startsWith("node:"))
|
|
206
|
+
continue;
|
|
207
|
+
// The marked module reached via THIS edge → the chain ends here (label it as the marked tail
|
|
208
|
+
// using the as-written specifier the author wrote, falling back to the resolved key).
|
|
209
|
+
if (target === markedModule)
|
|
210
|
+
return [...chain, tail(im.original ?? target)];
|
|
211
|
+
if (seen.has(target))
|
|
212
|
+
continue;
|
|
213
|
+
seen.add(target);
|
|
214
|
+
next.push({ node: target, chain: [...chain, im.original ?? target] });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
frontier = next;
|
|
218
|
+
}
|
|
219
|
+
return [tail(markedModule)]; // unreachable from any entry (defensive; a flagged module is reachable)
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build a resolver from an import EDGE to its INPUT-GRAPH KEY. Bun's metafile records edge `path`s as
|
|
223
|
+
* ABSOLUTE paths but keys `inputs` by CWD-RELATIVE paths, so a raw `inputs[im.path]` lookup misses
|
|
224
|
+
* every user edge in a real build. The resolver: (1) an exact key match (the synthetic-metafile / unit
|
|
225
|
+
* case); else (2) the input key the absolute path ends with (`…/secrets.ts` → `packages/web/.../secrets.ts`).
|
|
226
|
+
* The longest-suffix match is taken so a shorter key can't shadow a more specific one. Pure.
|
|
227
|
+
*/
|
|
228
|
+
function inputKeyResolver(inputs) {
|
|
229
|
+
const keys = Object.keys(inputs);
|
|
230
|
+
return (im) => {
|
|
231
|
+
const path = im.path;
|
|
232
|
+
if (path === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
if (inputs[path] !== undefined)
|
|
235
|
+
return path; // exact (synthetic keys, or already-relative paths)
|
|
236
|
+
let best;
|
|
237
|
+
for (const key of keys) {
|
|
238
|
+
// `/<key>` so a suffix match aligns on a path boundary (never a partial segment), and the longest
|
|
239
|
+
// such key wins (the most specific file).
|
|
240
|
+
if (path.endsWith(`/${key}`) && (best === undefined || key.length > best.length))
|
|
241
|
+
best = key;
|
|
242
|
+
}
|
|
243
|
+
return best;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
|
|
248
|
+
* `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
|
|
249
|
+
* list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
|
|
250
|
+
* graph facts — which inputs import the marker (the "marked" modules), which chunk each landed in (the
|
|
251
|
+
* per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
|
|
252
|
+
* (which imports nothing) is excluded — only the modules that *opt in* are flagged. Pure + exported for
|
|
253
|
+
* unit testing. Empty ⇒ clean.
|
|
254
|
+
*/
|
|
255
|
+
export function detectServerOnlyInClient(meta) {
|
|
256
|
+
const inputs = meta?.inputs ?? {};
|
|
257
|
+
// (1) The modules that import the marker — the ones the author opted into the guard. The marker
|
|
258
|
+
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
259
|
+
const marked = new Set();
|
|
260
|
+
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
261
|
+
if (isServerOnlyMarkerModule(inputKey))
|
|
262
|
+
continue;
|
|
263
|
+
if ((input.imports ?? []).some(isServerOnlyMarkerImport))
|
|
264
|
+
marked.add(inputKey);
|
|
265
|
+
}
|
|
266
|
+
if (marked.size === 0)
|
|
267
|
+
return [];
|
|
268
|
+
// The entry inputs — the chain BFS starts here, so the reported path begins at the file the dev
|
|
269
|
+
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
270
|
+
const entryInputs = [
|
|
271
|
+
...new Set(Object.values(meta?.outputs ?? {})
|
|
272
|
+
.map((o) => o.entryPoint)
|
|
273
|
+
.filter((e) => e !== undefined && !e.startsWith("node:"))),
|
|
274
|
+
];
|
|
275
|
+
const resolveTarget = inputKeyResolver(inputs);
|
|
276
|
+
const chainCache = new Map();
|
|
277
|
+
const chainFor = (markedModule) => {
|
|
278
|
+
const cached = chainCache.get(markedModule);
|
|
279
|
+
if (cached !== undefined)
|
|
280
|
+
return cached;
|
|
281
|
+
const chain = shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget);
|
|
282
|
+
chainCache.set(markedModule, chain);
|
|
283
|
+
return chain;
|
|
284
|
+
};
|
|
285
|
+
// (2) Locate which emitted chunk each marked module reached, via the per-output `inputs`.
|
|
286
|
+
const findings = new Map();
|
|
287
|
+
for (const [outPath, out] of Object.entries(meta?.outputs ?? {})) {
|
|
288
|
+
for (const inputKey of Object.keys(out.inputs ?? {})) {
|
|
289
|
+
if (marked.has(inputKey)) {
|
|
290
|
+
const chunk = basename(outPath);
|
|
291
|
+
findings.set(`${inputKey}\0${chunk}`, { chunk, chain: chainFor(inputKey) });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return [...findings.values()].sort((a, b) => {
|
|
296
|
+
const am = a.chain[a.chain.length - 1] ?? "";
|
|
297
|
+
const bm = b.chain[b.chain.length - 1] ?? "";
|
|
298
|
+
return am === bm ? a.chunk.localeCompare(b.chunk) : am.localeCompare(bm);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
|
|
302
|
+
* Excluded from the "marked" set — the marker is the import target, not an opt-in module. */
|
|
303
|
+
const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
|
|
15
304
|
const basename = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
16
305
|
/** `[name]` Bun derives for an entrypoint: basename without extension (`users/[id].tsx` → `[id]`). */
|
|
17
306
|
const entryName = (file) => {
|
|
@@ -19,6 +308,119 @@ const entryName = (file) => {
|
|
|
19
308
|
const dot = base.lastIndexOf(".");
|
|
20
309
|
return dot === -1 ? base : base.slice(0, dot);
|
|
21
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
|
|
313
|
+
* by raw bytes, then name for stable output) and sum the totals. Pure — the measurement (reading the
|
|
314
|
+
* file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
|
|
315
|
+
*/
|
|
316
|
+
export function aggregateSizeReport(chunks) {
|
|
317
|
+
const sorted = [...chunks].sort((a, b) => b.gzip - a.gzip || b.bytes - a.bytes || a.name.localeCompare(b.name));
|
|
318
|
+
let totalBytes = 0;
|
|
319
|
+
let totalGzip = 0;
|
|
320
|
+
for (const c of sorted) {
|
|
321
|
+
totalBytes += c.bytes;
|
|
322
|
+
totalGzip += c.gzip;
|
|
323
|
+
}
|
|
324
|
+
return { chunks: sorted, totalBytes, totalGzip };
|
|
325
|
+
}
|
|
326
|
+
/** Human-readable byte count: `B`/`KB`/`MB` with one decimal above 1 KB (e.g. `12.3 KB`). Pure. */
|
|
327
|
+
export function formatBytes(bytes) {
|
|
328
|
+
if (bytes < 1024)
|
|
329
|
+
return `${bytes} B`;
|
|
330
|
+
if (bytes < 1024 * 1024)
|
|
331
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
332
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row — the text
|
|
336
|
+
* `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
|
|
337
|
+
*/
|
|
338
|
+
export function renderSizeReport(report) {
|
|
339
|
+
const rows = report.chunks.map((c) => ({
|
|
340
|
+
name: c.name,
|
|
341
|
+
raw: formatBytes(c.bytes),
|
|
342
|
+
gz: formatBytes(c.gzip),
|
|
343
|
+
}));
|
|
344
|
+
// Column widths from the header + every row + the totals label, so the table never clips a value.
|
|
345
|
+
const nameW = Math.max(5, ...rows.map((r) => r.name.length), "Total".length);
|
|
346
|
+
const rawW = Math.max(3, ...rows.map((r) => r.raw.length), formatBytes(report.totalBytes).length);
|
|
347
|
+
const gzW = Math.max(4, ...rows.map((r) => r.gz.length), formatBytes(report.totalGzip).length);
|
|
348
|
+
const padEnd = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
|
|
349
|
+
const padStart = (s, w) => " ".repeat(Math.max(0, w - s.length)) + s;
|
|
350
|
+
const line = (name, raw, gz) => ` ${padEnd(name, nameW)} ${padStart(raw, rawW)} ${padStart(gz, gzW)}`;
|
|
351
|
+
const out = [
|
|
352
|
+
line("Chunk", "Raw", "Gzip"),
|
|
353
|
+
line("-".repeat(nameW), "-".repeat(rawW), "-".repeat(gzW)),
|
|
354
|
+
...rows.map((r) => line(r.name, r.raw, r.gz)),
|
|
355
|
+
line("Total", formatBytes(report.totalBytes), formatBytes(report.totalGzip)),
|
|
356
|
+
];
|
|
357
|
+
return out.join("\n");
|
|
358
|
+
}
|
|
359
|
+
// The route-relative specifiers the generated manifest imports. Both shapes `generateServerManifest`
|
|
360
|
+
// emits are matched: eager `import * as m0 from "./routes/x.tsx"` and lazy `() => import("./routes/x")`.
|
|
361
|
+
// Captures the path inside the quotes; the caller strips the routes-dir prefix to compare with discovery.
|
|
362
|
+
const MANIFEST_IMPORT = /(?:import\s+\*\s+as\s+\w+\s+from|import)\s*\(?\s*["']([^"']+)["']\)?/g;
|
|
363
|
+
// The baked client-entry line: `export const clientEntry = "…"`.
|
|
364
|
+
const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["']/;
|
|
365
|
+
/**
|
|
366
|
+
* Extract the route-relative file list the committed server-manifest imports, normalized to the same
|
|
367
|
+
* `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
|
|
368
|
+
* specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
|
|
369
|
+
* default `resolve` emits). Only import specifiers under that prefix are route files; the
|
|
370
|
+
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure — operates on source text.
|
|
371
|
+
*/
|
|
372
|
+
export function parseManifestRouteFiles(source, routesPrefix = "./routes/") {
|
|
373
|
+
const files = new Set();
|
|
374
|
+
for (const match of source.matchAll(MANIFEST_IMPORT)) {
|
|
375
|
+
const spec = match[1];
|
|
376
|
+
if (spec === undefined || !spec.startsWith(routesPrefix))
|
|
377
|
+
continue;
|
|
378
|
+
files.add(spec.slice(routesPrefix.length));
|
|
379
|
+
}
|
|
380
|
+
return [...files].sort();
|
|
381
|
+
}
|
|
382
|
+
/** The baked `clientEntry` URL in a committed server-manifest, or `undefined` if absent. Pure. */
|
|
383
|
+
export function parseManifestClientEntry(source) {
|
|
384
|
+
return MANIFEST_CLIENT_ENTRY.exec(source)?.[1];
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Diff the route files a committed server-manifest imports against the files freshly discovered in
|
|
388
|
+
* `routes/`. Returns the `missing` (in routes/, not in manifest — stale manifest) and `extra` (in
|
|
389
|
+
* manifest, gone from routes/ — dangling import) sets. Empty arrays ⇒ in sync. Pure — the caller
|
|
390
|
+
* supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
|
|
391
|
+
* fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
|
|
392
|
+
*/
|
|
393
|
+
export function diffManifestRoutes(manifestFiles, discoveredFiles) {
|
|
394
|
+
const inManifest = new Set(manifestFiles);
|
|
395
|
+
const inRoutes = new Set(discoveredFiles);
|
|
396
|
+
const missing = discoveredFiles.filter((f) => !inManifest.has(f)).sort();
|
|
397
|
+
const extra = manifestFiles.filter((f) => !inRoutes.has(f)).sort();
|
|
398
|
+
return { missing, extra };
|
|
399
|
+
}
|
|
400
|
+
/** True when a drift report is clean (no missing + no extra routes). */
|
|
401
|
+
export function isManifestInSync(drift) {
|
|
402
|
+
return drift.missing.length === 0 && drift.extra.length === 0;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Format a {@link ManifestDrift} as a named, actionable error message, or `undefined` when in sync.
|
|
406
|
+
* Names the exact missing/extra routes + the one fix (regenerate the manifest by re-running the build).
|
|
407
|
+
* `manifestPath` is shown for the dev to locate the stale file. Pure.
|
|
408
|
+
*/
|
|
409
|
+
export function formatManifestDrift(drift, manifestPath = "server-manifest.ts") {
|
|
410
|
+
if (isManifestInSync(drift))
|
|
411
|
+
return undefined;
|
|
412
|
+
const lines = [
|
|
413
|
+
`[nifra/web] server-manifest drift — \`${manifestPath}\` is out of sync with routes/.`,
|
|
414
|
+
];
|
|
415
|
+
if (drift.missing.length > 0) {
|
|
416
|
+
lines.push(` Missing (in routes/, not in the manifest — these routes won't be served): ${drift.missing.join(", ")}`);
|
|
417
|
+
}
|
|
418
|
+
if (drift.extra.length > 0) {
|
|
419
|
+
lines.push(` Extra (imported by the manifest, gone from routes/ — a dangling import): ${drift.extra.join(", ")}`);
|
|
420
|
+
}
|
|
421
|
+
lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
|
|
422
|
+
return lines.join("\n");
|
|
423
|
+
}
|
|
22
424
|
/**
|
|
23
425
|
* Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
|
|
24
426
|
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure — never
|
|
@@ -32,8 +434,17 @@ export async function buildClient(options) {
|
|
|
32
434
|
const routeManifest = discoverRoutes(routesDir);
|
|
33
435
|
// The bootstrap's filename is `_velo`-namespaced (not `entry.ts`) so its `[name]` can't collide with
|
|
34
436
|
// a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
|
|
437
|
+
const mode = options.minify === false ? "development" : "production";
|
|
438
|
+
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
439
|
+
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
440
|
+
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
441
|
+
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck — guard it.
|
|
442
|
+
const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env;
|
|
443
|
+
const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv);
|
|
35
444
|
const entryFile = `${outDir}/_nifra-entry.ts`;
|
|
36
|
-
|
|
445
|
+
// A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
|
|
446
|
+
// an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
|
|
447
|
+
writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
|
|
37
448
|
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints — Bun emits a
|
|
38
449
|
// named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
|
|
39
450
|
// can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
|
|
@@ -63,14 +474,65 @@ export async function buildClient(options) {
|
|
|
63
474
|
// below — both the aggregate and per-route — via the metafile, for `<link>` injection.
|
|
64
475
|
...buildExtras,
|
|
65
476
|
minify: options.minify ?? true,
|
|
66
|
-
plugins: [
|
|
477
|
+
plugins: [
|
|
478
|
+
reactDedupePlugin(routesDir),
|
|
479
|
+
preactDedupePlugin(routesDir),
|
|
480
|
+
serverOnlyEmptyPlugin(),
|
|
481
|
+
...(options.plugins ?? []),
|
|
482
|
+
],
|
|
67
483
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
68
|
-
|
|
484
|
+
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
485
|
+
// hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
|
|
486
|
+
// the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
|
|
487
|
+
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback — so secrets
|
|
488
|
+
// never leak). Callers can override any of these via `options.define` (layered last).
|
|
489
|
+
define: {
|
|
490
|
+
"process.env": "({})",
|
|
491
|
+
"process.env.NODE_ENV": JSON.stringify(mode),
|
|
492
|
+
...publicDefines,
|
|
493
|
+
...options.define,
|
|
494
|
+
},
|
|
69
495
|
});
|
|
70
496
|
if (!result.success) {
|
|
71
497
|
throw new Error(`[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`);
|
|
72
498
|
}
|
|
73
|
-
|
|
499
|
+
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
|
|
500
|
+
// a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
|
|
501
|
+
// instead — caught at build time, not by a confused user in the browser. Graph-based (the metafile's
|
|
502
|
+
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
503
|
+
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
504
|
+
const clientMeta = result.metafile;
|
|
505
|
+
const nodeBuiltins = detectNodeBuiltinsInClient(clientMeta);
|
|
506
|
+
if (nodeBuiltins.length > 0) {
|
|
507
|
+
// Name the offending builtin AND the import chain that pulled it in (entry → … → builtin), so the
|
|
508
|
+
// dev sees the exact path from a route file to the leak instead of just the emitted chunk name.
|
|
509
|
+
// The chain always starts at a user entry; fall back to the chunk name if it somehow came back empty.
|
|
510
|
+
const lines = nodeBuiltins.map((f) => f.chain.length > 1
|
|
511
|
+
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
512
|
+
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
|
|
513
|
+
throw new Error(`[nifra/web] Node built-in(s) in the client bundle — move them behind a server-only path ` +
|
|
514
|
+
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
515
|
+
`top level):\n${lines.join("\n")}`);
|
|
516
|
+
}
|
|
517
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker (`import "@nifrajs/web/server-only"`)
|
|
518
|
+
// yet reached a CLIENT chunk — fail the build with the same chain-bearing, "reached the client bundle
|
|
519
|
+
// via" message shape as the node-builtin guard above. This catches pure-server logic (a secret, a
|
|
520
|
+
// server-only API call) that carries no `node:` import and isn't named `*.server`, so neither of the
|
|
521
|
+
// other two guards fires. Graph-based (the same metafile), so it survives minification.
|
|
522
|
+
const serverOnly = detectServerOnlyInClient(clientMeta);
|
|
523
|
+
if (serverOnly.length > 0) {
|
|
524
|
+
const lines = serverOnly.map((f) => f.chain.length > 1
|
|
525
|
+
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
526
|
+
: ` - server-only module reached the client bundle via ${f.chunk}`);
|
|
527
|
+
throw new Error(`[nifra/web] server-only module(s) in the client bundle — a module marked ` +
|
|
528
|
+
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
529
|
+
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
530
|
+
`to the client:\n${lines.join("\n")}`);
|
|
531
|
+
}
|
|
532
|
+
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
533
|
+
// rewrite the references — otherwise the lazy import 404s and the route silently never hydrates.
|
|
534
|
+
const renamed = sanitizeOutputNames(result.outputs);
|
|
535
|
+
const toUrl = (path) => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`;
|
|
74
536
|
// Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
|
|
75
537
|
// to its chunk URL by that order (guarded against drift), then a route's chunks = its layout chain +
|
|
76
538
|
// own file.
|
|
@@ -117,10 +579,9 @@ export async function buildClient(options) {
|
|
|
117
579
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
|
|
118
580
|
// links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
|
|
119
581
|
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle — never silently incomplete.
|
|
120
|
-
const meta = result.metafile;
|
|
121
582
|
const cwd = process.cwd();
|
|
122
583
|
const cssByEntry = new Map();
|
|
123
|
-
for (const out of Object.values(
|
|
584
|
+
for (const out of Object.values(clientMeta?.outputs ?? {})) {
|
|
124
585
|
if (out.entryPoint !== undefined && out.cssBundle !== undefined) {
|
|
125
586
|
cssByEntry.set(resolvePath(cwd, out.entryPoint), toUrl(out.cssBundle));
|
|
126
587
|
}
|
|
@@ -167,6 +628,87 @@ const reactDomEdgePlugin = (from) => ({
|
|
|
167
628
|
}));
|
|
168
629
|
},
|
|
169
630
|
});
|
|
631
|
+
/**
|
|
632
|
+
* Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
|
|
633
|
+
* node_modules, so the bundle ends up with two React cores — each with its own hook dispatcher — and SSR
|
|
634
|
+
* throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
|
|
635
|
+
* is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
|
|
636
|
+
* copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
|
|
637
|
+
* shares it — so the class can't occur rather than needing a named error. No-op when React isn't used
|
|
638
|
+
* (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
|
|
639
|
+
* condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
|
|
640
|
+
* react-dom's build.
|
|
641
|
+
*/
|
|
642
|
+
const REACT_DEDUPE_SPECS = ["react", "react/jsx-runtime", "react/jsx-dev-runtime"];
|
|
643
|
+
export const reactDedupePlugin = (from) => ({
|
|
644
|
+
name: "nifra-react-dedupe",
|
|
645
|
+
setup(build) {
|
|
646
|
+
for (const spec of REACT_DEDUPE_SPECS) {
|
|
647
|
+
let resolved;
|
|
648
|
+
try {
|
|
649
|
+
resolved = Bun.resolveSync(spec, from);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
continue; // React (or this subpath) isn't resolvable here — nothing to dedupe
|
|
653
|
+
}
|
|
654
|
+
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
655
|
+
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
/**
|
|
660
|
+
* Dedupe Preact to a single copy — the Preact analogue of `reactDedupePlugin`, closing the same class of
|
|
661
|
+
* bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
|
|
662
|
+
* its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
|
|
663
|
+
* `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
|
|
664
|
+
* → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
|
|
665
|
+
* Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
|
|
666
|
+
* renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
|
|
667
|
+
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection — and
|
|
668
|
+
* unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
|
|
669
|
+
*/
|
|
670
|
+
const PREACT_DEDUPE_SPECS = [
|
|
671
|
+
"preact",
|
|
672
|
+
"preact/hooks",
|
|
673
|
+
"preact/compat",
|
|
674
|
+
"preact/jsx-runtime",
|
|
675
|
+
"preact/jsx-dev-runtime",
|
|
676
|
+
];
|
|
677
|
+
export const preactDedupePlugin = (from) => ({
|
|
678
|
+
name: "nifra-preact-dedupe",
|
|
679
|
+
setup(build) {
|
|
680
|
+
for (const spec of PREACT_DEDUPE_SPECS) {
|
|
681
|
+
let resolved;
|
|
682
|
+
try {
|
|
683
|
+
resolved = Bun.resolveSync(spec, from);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
continue; // Preact (or this subpath) isn't resolvable here — nothing to dedupe
|
|
687
|
+
}
|
|
688
|
+
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
689
|
+
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
/**
|
|
694
|
+
* Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
|
|
695
|
+
* `auth.server.ts`, …) is server-only — empty it in the browser bundle so its (possibly `node:` / native /
|
|
696
|
+
* Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
|
|
697
|
+
* import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
|
|
698
|
+
* import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
|
|
699
|
+
* in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
|
|
700
|
+
* module is the fix. CLIENT-only — buildServer keeps the real module, which runs server-side.
|
|
701
|
+
*/
|
|
702
|
+
const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
703
|
+
export const serverOnlyEmptyPlugin = () => ({
|
|
704
|
+
name: "nifra-server-only-empty",
|
|
705
|
+
setup(build) {
|
|
706
|
+
build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
|
|
707
|
+
contents: "module.exports = new Proxy({}, { get: () => undefined })",
|
|
708
|
+
loader: "js",
|
|
709
|
+
}));
|
|
710
|
+
},
|
|
711
|
+
});
|
|
170
712
|
/**
|
|
171
713
|
* `solid-js/web` selects its **server** runtime (`renderToStream`) via the `worker` condition, but
|
|
172
714
|
* `Bun.build` 1.3.14 **segfaults** when the `worker` condition is active (https://bun.report). And
|
|
@@ -223,6 +765,8 @@ export async function buildServer(options) {
|
|
|
223
765
|
splitting: lazy,
|
|
224
766
|
plugins: [
|
|
225
767
|
...(edge ? [reactDomEdgePlugin(entryDir), solidWebServerPlugin(entryDir)] : []),
|
|
768
|
+
reactDedupePlugin(entryDir),
|
|
769
|
+
preactDedupePlugin(entryDir),
|
|
226
770
|
...(options.plugins ?? []),
|
|
227
771
|
],
|
|
228
772
|
});
|
|
@@ -235,4 +779,209 @@ export async function buildServer(options) {
|
|
|
235
779
|
}
|
|
236
780
|
return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) };
|
|
237
781
|
}
|
|
782
|
+
// ===================================================================================================
|
|
783
|
+
// `nifra build --target` — package the engine above into one command that emits a full deploy dir.
|
|
784
|
+
//
|
|
785
|
+
// An app already declares everything the build needs through nifra's conventions: `adapter` +
|
|
786
|
+
// `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
|
|
787
|
+
// The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
|
|
788
|
+
// …) — so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
|
|
789
|
+
// ===================================================================================================
|
|
790
|
+
/** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
|
|
791
|
+
export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
|
|
792
|
+
/** A type guard narrowing an arbitrary string to a {@link BuildTarget}. */
|
|
793
|
+
export function isBuildTarget(value) {
|
|
794
|
+
return BUILD_TARGETS.includes(value);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
|
|
798
|
+
* the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
|
|
799
|
+
* generated `{ manifest, clientEntry }` (from `./server-manifest`), builds `createWebApp`, then wires
|
|
800
|
+
* the right host:
|
|
801
|
+
* - `cf-pages` / `vercel`: `export default` the fetch handler (the platform serves /assets/* itself).
|
|
802
|
+
* - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
|
|
803
|
+
* - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
|
|
804
|
+
* runtimes have a filesystem; the static `/assets/*` sit next to the entry).
|
|
805
|
+
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) —
|
|
806
|
+
* `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
|
|
807
|
+
* string out) so the generation is unit-testable without a real build.
|
|
808
|
+
*/
|
|
809
|
+
export function generateServerEntry(options) {
|
|
810
|
+
const { target, adapterImport, backendImport, title = "nifra" } = options;
|
|
811
|
+
if (target === "static") {
|
|
812
|
+
throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
|
|
813
|
+
}
|
|
814
|
+
const lines = ['import { createWebApp } from "@nifrajs/web"'];
|
|
815
|
+
if (backendImport !== undefined)
|
|
816
|
+
lines.push('import { inProcessClient } from "@nifrajs/client"');
|
|
817
|
+
lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`);
|
|
818
|
+
if (backendImport !== undefined) {
|
|
819
|
+
lines.push(`import { backend } from ${JSON.stringify(backendImport)}`);
|
|
820
|
+
}
|
|
821
|
+
lines.push('import { clientEntry, manifest } from "./server-manifest"');
|
|
822
|
+
// cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
|
|
823
|
+
const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno";
|
|
824
|
+
if (usesToFetch)
|
|
825
|
+
lines.push('import { toFetchHandler } from "@nifrajs/core"');
|
|
826
|
+
if (target === "node")
|
|
827
|
+
lines.push('import { serve } from "@nifrajs/node"');
|
|
828
|
+
lines.push("", "const app = createWebApp({", " adapter,", " manifest,", " clientEntry,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`, "})", "");
|
|
829
|
+
if (target === "cf-pages") {
|
|
830
|
+
// Cloudflare Pages advanced mode: `_routes.json` serves /assets/* from the CDN; everything else
|
|
831
|
+
// falls through to this fetch handler (SSR). The default export is the handler object.
|
|
832
|
+
lines.push("export default toFetchHandler(app)");
|
|
833
|
+
return `${lines.join("\n")}\n`;
|
|
834
|
+
}
|
|
835
|
+
if (target === "vercel") {
|
|
836
|
+
lines.push("// Vercel Edge Function — Vercel serves /assets/* from its CDN; this only SSRs page routes.", 'export const config = { runtime: "edge" }', "export default (req: Request): Response | Promise<Response> => app.fetch(req)");
|
|
837
|
+
return `${lines.join("\n")}\n`;
|
|
838
|
+
}
|
|
839
|
+
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
840
|
+
lines.push("// The client bundle lives next to this entry; serve /assets/* from disk, SSR everything else.", 'const ASSETS = new URL("./assets/", import.meta.url)', 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }');
|
|
841
|
+
if (target === "bun") {
|
|
842
|
+
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " const file = Bun.file(new URL(name, ASSETS))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
|
|
843
|
+
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
844
|
+
// source — split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
845
|
+
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
846
|
+
return `${lines.join("\n")}\n`;
|
|
847
|
+
}
|
|
848
|
+
if (target === "node") {
|
|
849
|
+
lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req) {", " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " const body = await readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
|
|
850
|
+
return `${lines.join("\n")}\n`;
|
|
851
|
+
}
|
|
852
|
+
// deno
|
|
853
|
+
lines.push("const handler = toFetchHandler(app)", "// @ts-ignore — Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {', " const { pathname } = new URL(req.url)", ' if (pathname.startsWith("/assets/")) {', ' const name = pathname.slice("/assets/".length)', ' if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })', " try {", " // @ts-ignore — Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(name, ASSETS))", ' const ext = name.slice(name.lastIndexOf(".") + 1)', ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return handler.fetch(req)", "})");
|
|
854
|
+
return `${lines.join("\n")}\n`;
|
|
855
|
+
}
|
|
856
|
+
/** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
|
|
857
|
+
const SERVER_BUILD_TARGET = {
|
|
858
|
+
"cf-pages": "browser", // edge conditions (workerd/edge-light)
|
|
859
|
+
vercel: "browser", // Vercel Edge runtime
|
|
860
|
+
deno: "browser", // Deno's Web-standard runtime runs the edge bundle
|
|
861
|
+
node: "node", // node:* external; react-dom → its Node SSR build
|
|
862
|
+
bun: "bun", // Bun's flagship runtime
|
|
863
|
+
};
|
|
864
|
+
/** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
|
|
865
|
+
* `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
|
|
866
|
+
* pure {@link aggregateSizeReport}. */
|
|
867
|
+
async function measureOutputs(paths) {
|
|
868
|
+
const chunks = [];
|
|
869
|
+
for (const path of paths) {
|
|
870
|
+
const bytes = await Bun.file(path).bytes();
|
|
871
|
+
chunks.push({
|
|
872
|
+
name: basename(path),
|
|
873
|
+
bytes: bytes.byteLength,
|
|
874
|
+
gzip: Bun.gzipSync(bytes).byteLength,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
return chunks;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Build a full deploy directory for `target` from a file-routed nifra app. Emits the client bundle to
|
|
881
|
+
* `<outDir>/assets/*`, then per target:
|
|
882
|
+
* - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
|
|
883
|
+
* `_data.json`); needs `prerenderApp`. No server.
|
|
884
|
+
* - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
|
|
885
|
+
* - `vercel`: a `.vercel/output`-shaped function isn't emitted here — `vercel` emits the bundled edge
|
|
886
|
+
* entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
|
|
887
|
+
* - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
|
|
888
|
+
* The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
|
|
889
|
+
* only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
|
|
890
|
+
*
|
|
891
|
+
* Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
|
|
892
|
+
* remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
|
|
893
|
+
*/
|
|
894
|
+
export async function buildTarget(target, options) {
|
|
895
|
+
const { routesDir, outDir, workDir } = options;
|
|
896
|
+
const { rmSync } = await import("node:fs");
|
|
897
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
898
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
899
|
+
const assetsDir = `${outDir}/assets`;
|
|
900
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
901
|
+
mkdirSync(workDir, { recursive: true });
|
|
902
|
+
// (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
|
|
903
|
+
const client = await buildClient({
|
|
904
|
+
routesDir,
|
|
905
|
+
outDir: assetsDir,
|
|
906
|
+
clientModule: options.clientModule,
|
|
907
|
+
...(options.clientPlugins ? { plugins: options.clientPlugins } : {}),
|
|
908
|
+
...(options.conditions ? { conditions: options.conditions } : {}),
|
|
909
|
+
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
910
|
+
});
|
|
911
|
+
if (target === "static") {
|
|
912
|
+
if (options.prerenderApp === undefined) {
|
|
913
|
+
throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a built createWebApp)");
|
|
914
|
+
}
|
|
915
|
+
const manifest = discoverRoutes(routesDir);
|
|
916
|
+
const result = await prerenderRoutes({
|
|
917
|
+
app: options.prerenderApp,
|
|
918
|
+
routes: manifest.routes,
|
|
919
|
+
outDir,
|
|
920
|
+
});
|
|
921
|
+
if (result.prerendered.length === 0) {
|
|
922
|
+
// A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
|
|
923
|
+
// getStaticPaths) — fail loudly rather than ship an empty dir the dev thinks is their site.
|
|
924
|
+
throw new Error("[nifra/web] buildTarget(static): no routes were prerendered — opt routes in with " +
|
|
925
|
+
"`export const prerender = true` (static) or `getStaticPaths` (dynamic).");
|
|
926
|
+
}
|
|
927
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
928
|
+
const size = aggregateSizeReport(await measureOutputs(client.assets.map((u) => assetUrlToPath(u, assetsDir))));
|
|
929
|
+
return {
|
|
930
|
+
target,
|
|
931
|
+
outDir,
|
|
932
|
+
client,
|
|
933
|
+
run: `static site → ${outDir} (serve the directory with any static host)`,
|
|
934
|
+
size,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
// (2) Generate + bundle the server entry. It's written into workDir; the generated server-manifest
|
|
938
|
+
// lands next to it (buildServer writes it there). The adapter/backend specifiers are resolved from
|
|
939
|
+
// workDir, so the caller passes paths relative to it (or absolute).
|
|
940
|
+
const serverEntryPath = `${workDir}/server-entry.ts`;
|
|
941
|
+
writeFileSync(serverEntryPath, generateServerEntry({
|
|
942
|
+
target,
|
|
943
|
+
adapterImport: options.adapterImport,
|
|
944
|
+
...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
|
|
945
|
+
...(options.title !== undefined ? { title: options.title } : {}),
|
|
946
|
+
}));
|
|
947
|
+
const serverTarget = SERVER_BUILD_TARGET[target];
|
|
948
|
+
const { worker } = await buildServer({
|
|
949
|
+
routesDir,
|
|
950
|
+
serverEntry: serverEntryPath,
|
|
951
|
+
outDir: `${workDir}/server`,
|
|
952
|
+
clientEntry: client.entry,
|
|
953
|
+
target: serverTarget,
|
|
954
|
+
...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
|
|
955
|
+
define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
|
|
956
|
+
});
|
|
957
|
+
// (3) Assemble the deploy dir for the target.
|
|
958
|
+
const { cpSync } = await import("node:fs");
|
|
959
|
+
let run;
|
|
960
|
+
if (target === "cf-pages") {
|
|
961
|
+
cpSync(worker, `${outDir}/_worker.js`);
|
|
962
|
+
writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: ["/*"], exclude: ["/assets/*"] }, null, 2)}\n`);
|
|
963
|
+
run = `Cloudflare Pages → ${outDir} (deploy: wrangler pages deploy ${basename(outDir)})`;
|
|
964
|
+
}
|
|
965
|
+
else if (target === "vercel") {
|
|
966
|
+
cpSync(worker, `${outDir}/index.js`);
|
|
967
|
+
run = `Vercel edge function → ${outDir}/index.js (wrap with your vercel.json or Build Output API)`;
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
// bun / node / deno: the runnable server bundle next to its /assets.
|
|
971
|
+
cpSync(worker, `${outDir}/server.js`);
|
|
972
|
+
const cmd = target === "node"
|
|
973
|
+
? `node ${basename(outDir)}/server.js`
|
|
974
|
+
: `${target} ${basename(outDir)}/server.js`;
|
|
975
|
+
run = `${target} server → ${outDir} (run: ${cmd})`;
|
|
976
|
+
}
|
|
977
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
978
|
+
// Size report over the client assets + the server bundle (its parse cost matters on the edge).
|
|
979
|
+
const clientPaths = client.assets.map((u) => assetUrlToPath(u, assetsDir));
|
|
980
|
+
const serverPath = `${outDir}/${target === "cf-pages" ? "_worker.js" : target === "vercel" ? "index.js" : "server.js"}`;
|
|
981
|
+
const size = aggregateSizeReport(await measureOutputs([...clientPaths, serverPath]));
|
|
982
|
+
return { target, outDir, client, run, size };
|
|
983
|
+
}
|
|
984
|
+
/** Map a client asset URL (`/assets/x-hash.js`) back to its on-disk path under `assetsDir`. The
|
|
985
|
+
* `publicPath` prefix is always `/assets/` for these builds, so strip it and rejoin. */
|
|
986
|
+
const assetUrlToPath = (url, assetsDir) => `${assetsDir}/${url.slice(url.lastIndexOf("/") + 1)}`;
|
|
238
987
|
//# sourceMappingURL=build.js.map
|