@nifrajs/web 2.2.0 → 2.3.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 +3 -3
- package/dist/build-vite.d.ts +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +201 -29
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +2 -2
- package/dist/dev-port.js +1 -1
- package/dist/dev.d.ts +3 -3
- package/dist/dev.js +9 -9
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +57 -28
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +15 -13
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +31 -21
- package/dist/vite.js.map +1 -1
- package/package.json +33 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +2 -2
- package/src/dev.ts +12 -12
- package/src/draft.ts +15 -15
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +2 -2
- package/src/index.ts +460 -181
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +2 -2
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +91 -41
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +41 -29
package/dist/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/build`
|
|
2
|
+
* `@nifrajs/web/build` - the production build (Bun-only, build-time). `buildClient` codegens + bundles
|
|
3
3
|
* the client entry (content-hashed, code-split); `buildServer` codegens the static-import server
|
|
4
4
|
* manifest + bundles a self-contained **worker** for the disk-less edge (Cloudflare Workers). Both
|
|
5
5
|
* are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output*
|
|
@@ -12,6 +12,7 @@ import { Glob } from "bun";
|
|
|
12
12
|
import { sanitizeOutputNames } from "./chunk-names.js";
|
|
13
13
|
import { discoverRoutes } from "./fs.js";
|
|
14
14
|
import { generateClientEntry, generateServerManifest } from "./index.js";
|
|
15
|
+
import { generateServerFnStub, SERVER_FN_MODULE, SERVER_ONLY_MODULE, SERVER_ONLY_REPLACEMENT, serverFnNamespace, } from "./internal/server-boundary.js";
|
|
15
16
|
// `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
|
|
16
17
|
import { fromBunMetafile } from "./module-graph.js";
|
|
17
18
|
import { prerenderRoutes } from "./prerender.js";
|
|
@@ -156,7 +157,7 @@ routePatterns) {
|
|
|
156
157
|
}
|
|
157
158
|
return { include, exclude, omitted };
|
|
158
159
|
}
|
|
159
|
-
/** The built asset map
|
|
160
|
+
/** The built asset map - the server reads `entry` for the client script + serves `assets`. */
|
|
160
161
|
/**
|
|
161
162
|
* Copy `from` into `to`, returning the URL paths copied (sorted).
|
|
162
163
|
*
|
|
@@ -201,10 +202,10 @@ const nodeBuiltinOf = (im) => {
|
|
|
201
202
|
* BFS the metafile import graph for the SHORTEST user-module path that pulls `builtin` into the
|
|
202
203
|
* bundle, returning it as display labels `[entryFile, …as-written specifiers…, builtin]`. The frontier
|
|
203
204
|
* starts at every `entryInput` (the route/user entrypoints) so the reported chain begins where the dev
|
|
204
|
-
* actually wrote `import`
|
|
205
|
+
* actually wrote `import` - the actionable root, not an arbitrary internal module. Traversal crosses
|
|
205
206
|
* only NON-`node:` edges (so Bun's polyfill chain never extends the path) and stops at the first edge
|
|
206
207
|
* whose target is the builtin. Returns `[builtin]` if no entry reaches it (defensive; a flagged builtin
|
|
207
|
-
* is reachable by construction). Pure
|
|
208
|
+
* is reachable by construction). Pure - operates on the graph, never the emitted text.
|
|
208
209
|
*/
|
|
209
210
|
function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
210
211
|
// Each queue item carries the resolved-path node + the display chain to reach it (entry + the
|
|
@@ -225,7 +226,7 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
225
226
|
return [...chain, builtin];
|
|
226
227
|
const target = im.path;
|
|
227
228
|
// Only follow edges into user (non-`node:`) modules that exist in the input graph and haven't
|
|
228
|
-
// been visited
|
|
229
|
+
// been visited - so the polyfill subtree can't lengthen the path and there are no cycles.
|
|
229
230
|
if (target === undefined ||
|
|
230
231
|
target.startsWith("node:") ||
|
|
231
232
|
inputs[target] === undefined ||
|
|
@@ -234,23 +235,23 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
234
235
|
}
|
|
235
236
|
seen.add(target);
|
|
236
237
|
// Display the hop by its as-written specifier (`../db/client.ts`, `postgres`), falling back to
|
|
237
|
-
// the resolved path
|
|
238
|
+
// the resolved path - that's what the dev recognizes in their source, not the resolved path.
|
|
238
239
|
next.push({ node: target, chain: [...chain, im.original ?? target] });
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
frontier = next;
|
|
242
243
|
}
|
|
243
|
-
return [builtin]; // unreachable from any entry
|
|
244
|
+
return [builtin]; // unreachable from any entry - degrade to just the builtin (shouldn't happen)
|
|
244
245
|
}
|
|
245
246
|
/**
|
|
246
247
|
* Scan a build's metafile for any `node:` builtin that a USER module pulled into a CLIENT output
|
|
247
248
|
* chunk, returning a sorted, deduped list of {@link NodeBuiltinFinding}s. Three graph facts combine so
|
|
248
249
|
* the report is precise AND actionable:
|
|
249
|
-
* 1. **What the user wrote**
|
|
250
|
+
* 1. **What the user wrote** - only builtins imported by a NON-`node:` input count, so Bun's own
|
|
250
251
|
* polyfill chain (`node:crypto` → `node:buffer`/`node:stream`/…) doesn't bury the real cause.
|
|
251
|
-
* 2. **Where it landed**
|
|
252
|
+
* 2. **Where it landed** - the chunk is read from the per-output `inputs`, so the error names the
|
|
252
253
|
* emitted file to look at.
|
|
253
|
-
* 3. **How it got there**
|
|
254
|
+
* 3. **How it got there** - the shortest import chain from a user entry to the builtin
|
|
254
255
|
* (`shortestBuiltinChain`), so the error points straight at the offending `import` line instead of
|
|
255
256
|
* leaving the dev to grep the dependency tree (the DX gap this closes).
|
|
256
257
|
* Graph-based (never the emitted text), so it survives minification and can't be fooled by a string
|
|
@@ -258,18 +259,18 @@ function shortestBuiltinChain(inputs, entryInputs, builtin) {
|
|
|
258
259
|
*/
|
|
259
260
|
export function detectNodeBuiltinsInClient(graph) {
|
|
260
261
|
const inputs = graph.modules;
|
|
261
|
-
// (1) The builtins a user (non-polyfill) module imports directly
|
|
262
|
+
// (1) The builtins a user (non-polyfill) module imports directly - the ones the author controls.
|
|
262
263
|
const userImported = new Set();
|
|
263
264
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
264
265
|
if (inputKey.startsWith("node:"))
|
|
265
|
-
continue; // a polyfill importing another builtin
|
|
266
|
+
continue; // a polyfill importing another builtin - not the cause
|
|
266
267
|
for (const im of input.imports ?? []) {
|
|
267
268
|
const builtin = nodeBuiltinOf(im);
|
|
268
269
|
if (builtin !== undefined)
|
|
269
270
|
userImported.add(builtin);
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
|
-
// The entry inputs (route/user entrypoints)
|
|
273
|
+
// The entry inputs (route/user entrypoints) - the chain BFS starts here so the reported path begins
|
|
273
274
|
// at the file the dev wrote an `import` in. Each output's `entryPoint` is one such input.
|
|
274
275
|
const entryInputs = [
|
|
275
276
|
...new Set(Object.values(graph.chunks)
|
|
@@ -307,8 +308,8 @@ export function detectNodeBuiltinsInClient(graph) {
|
|
|
307
308
|
// node-builtin guard: a module of PURE server logic with NO `node:` import (a secret-bearing constant,
|
|
308
309
|
// a server-only API call) that an author wants to FAIL LOUD if it reaches the client opts in with a
|
|
309
310
|
// side-effect `import "@nifrajs/web/server-only"` (Next's `import "server-only"`). On the SERVER build
|
|
310
|
-
// the marker is an empty no-op; the CLIENT build detects
|
|
311
|
-
// node-builtin guard walks
|
|
311
|
+
// the marker is an empty no-op; the CLIENT build detects - via the SAME Bun metafile graph the
|
|
312
|
+
// node-builtin guard walks - any module that imports the marker AND lands in a client chunk, and fails
|
|
312
313
|
// the build with the import chain. Graph-based (never the emitted text), so it survives minification.
|
|
313
314
|
// ---------------------------------------------------------------------------------------------------
|
|
314
315
|
/** The marker specifier an author imports to opt a module into the client-leak guard. Matched on the
|
|
@@ -317,7 +318,7 @@ export function detectNodeBuiltinsInClient(graph) {
|
|
|
317
318
|
export const SERVER_ONLY_MARKER = "@nifrajs/web/server-only";
|
|
318
319
|
/** True when an import edge is the `server-only` marker import. Reads the as-written `original` (the
|
|
319
320
|
* specifier the author typed); falls back to the resolved `path`'s basename so a pre-resolved edge
|
|
320
|
-
* (no `original`)
|
|
321
|
+
* (no `original`) - or a workspace-relative resolution - is still recognised. */
|
|
321
322
|
const isServerOnlyMarkerImport = (im) => {
|
|
322
323
|
if (im.original === SERVER_ONLY_MARKER)
|
|
323
324
|
return true;
|
|
@@ -332,14 +333,14 @@ const isServerOnlyMarkerImport = (im) => {
|
|
|
332
333
|
* "<module> (marked server-only)"]`. Mirrors {@link shortestBuiltinChain}: the frontier starts at
|
|
333
334
|
* every `entryInput` so the chain begins at the file the dev wrote an `import` in; traversal crosses
|
|
334
335
|
* only NON-`node:` user edges (no cycles via `seen`); it stops at the first node whose import set
|
|
335
|
-
* contains the marker (the marked module
|
|
336
|
-
* specifier the previous hop used to reach it. Pure
|
|
336
|
+
* contains the marker (the marked module - the actionable tail), labelling that node by the as-written
|
|
337
|
+
* specifier the previous hop used to reach it. Pure - operates on the graph, never the emitted text.
|
|
337
338
|
*/
|
|
338
339
|
function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarget) {
|
|
339
340
|
// The label for the marked module's tail: its as-written specifier (filled when we cross the edge
|
|
340
341
|
// that reaches it) suffixed with `(marked server-only)`; the entry case uses the entry key itself.
|
|
341
342
|
const tail = (label) => `${label} (marked server-only)`;
|
|
342
|
-
// An entry that is ITSELF the marked module
|
|
343
|
+
// An entry that is ITSELF the marked module - the chain is just that one node.
|
|
343
344
|
if (entryInputs.includes(markedModule))
|
|
344
345
|
return [tail(markedModule)];
|
|
345
346
|
const seen = new Set(entryInputs);
|
|
@@ -351,7 +352,7 @@ function shortestServerOnlyChain(inputs, entryInputs, markedModule, resolveTarge
|
|
|
351
352
|
const next = [];
|
|
352
353
|
for (const { node, chain } of frontier) {
|
|
353
354
|
for (const im of inputs[node]?.imports ?? []) {
|
|
354
|
-
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY
|
|
355
|
+
// Resolve the edge's `path` to the matching INPUT-GRAPH KEY - in a real build the edge `path`
|
|
355
356
|
// is absolute while the input keys are cwd-relative, so a raw equality/lookup would miss every
|
|
356
357
|
// multi-hop user edge (and the chain would degrade to just the tail). The resolver maps both.
|
|
357
358
|
const target = resolveTarget(im);
|
|
@@ -400,14 +401,14 @@ function inputKeyResolver(inputs) {
|
|
|
400
401
|
* Scan a build's metafile for any module that opts into the `server-only` marker (a side-effect
|
|
401
402
|
* `import "@nifrajs/web/server-only"`) yet landed in a CLIENT output chunk, returning a sorted, deduped
|
|
402
403
|
* list of {@link ServerOnlyFinding}s. Mirrors {@link detectNodeBuiltinsInClient}: it reads the SAME
|
|
403
|
-
* graph facts
|
|
404
|
+
* graph facts - which inputs import the marker (the "marked" modules), which chunk each landed in (the
|
|
404
405
|
* per-output `inputs`), and the shortest import chain from a user entry to it. The marker module ITSELF
|
|
405
|
-
* (which imports nothing) is excluded
|
|
406
|
+
* (which imports nothing) is excluded - only the modules that *opt in* are flagged. Pure + exported for
|
|
406
407
|
* unit testing. Empty ⇒ clean.
|
|
407
408
|
*/
|
|
408
409
|
export function detectServerOnlyInClient(graph) {
|
|
409
410
|
const inputs = graph.modules;
|
|
410
|
-
// (1) The modules that import the marker
|
|
411
|
+
// (1) The modules that import the marker - the ones the author opted into the guard. The marker
|
|
411
412
|
// module itself is skipped: it's the import TARGET, not an opt-in (it imports nothing of its own).
|
|
412
413
|
const marked = new Set();
|
|
413
414
|
for (const [inputKey, input] of Object.entries(inputs)) {
|
|
@@ -418,7 +419,7 @@ export function detectServerOnlyInClient(graph) {
|
|
|
418
419
|
}
|
|
419
420
|
if (marked.size === 0)
|
|
420
421
|
return [];
|
|
421
|
-
// The entry inputs
|
|
422
|
+
// The entry inputs - the chain BFS starts here, so the reported path begins at the file the dev
|
|
422
423
|
// wrote an `import` in. Same derivation as the node-builtin guard.
|
|
423
424
|
const entryInputs = [
|
|
424
425
|
...new Set(Object.values(graph.chunks)
|
|
@@ -452,10 +453,10 @@ export function detectServerOnlyInClient(graph) {
|
|
|
452
453
|
});
|
|
453
454
|
}
|
|
454
455
|
/** True when an INPUT graph key is the marker module file itself (`…/server-only.{ts,js}` under web).
|
|
455
|
-
* Excluded from the "marked" set
|
|
456
|
+
* Excluded from the "marked" set - the marker is the import target, not an opt-in module. */
|
|
456
457
|
const isServerOnlyMarkerModule = (inputKey) => /(^|\/)server-only\.[cm]?[jt]s$/.test(inputKey);
|
|
457
458
|
// ---------------------------------------------------------------------------------------------------
|
|
458
|
-
// Guard MESSAGES
|
|
459
|
+
// Guard MESSAGES - one owner for both production pipelines. The Bun build (below) and the Vite/Rollup
|
|
459
460
|
// leak-guard plugin (plugins/vite-leak-guard.ts) both call these, so a leak reads IDENTICALLY whichever
|
|
460
461
|
// bundler produced it. A second bundler must not grow a second, subtly-different wording of a security
|
|
461
462
|
// error; that is exactly the "mostly ported" outcome the neutral graph seam exists to prevent.
|
|
@@ -467,7 +468,7 @@ export function formatNodeBuiltinLeak(findings) {
|
|
|
467
468
|
const lines = findings.map((f) => f.chain.length > 1
|
|
468
469
|
? ` - ${f.builtin} reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
469
470
|
: ` - ${f.builtin} reached the client bundle via ${f.chunk}`);
|
|
470
|
-
return (`[nifra/web] Node built-in(s) in the client bundle
|
|
471
|
+
return (`[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path ` +
|
|
471
472
|
`(a loader/action runs on the server; import the \`node:\` module there, not at a route's ` +
|
|
472
473
|
`top level):\n${lines.join("\n")}`);
|
|
473
474
|
}
|
|
@@ -478,7 +479,7 @@ export function formatServerOnlyLeak(findings) {
|
|
|
478
479
|
const lines = findings.map((f) => f.chain.length > 1
|
|
479
480
|
? ` - server-only module reached the client bundle via ${f.chain.join(" → ")} (chunk: ${f.chunk})`
|
|
480
481
|
: ` - server-only module reached the client bundle via ${f.chunk}`);
|
|
481
|
-
return (`[nifra/web] server-only module(s) in the client bundle
|
|
482
|
+
return (`[nifra/web] server-only module(s) in the client bundle - a module marked ` +
|
|
482
483
|
`\`import "${SERVER_ONLY_MARKER}"\` reached the browser. Move it behind a server-only path ` +
|
|
483
484
|
`(reach it via a loader/action, or rename it \`*.server.ts\`), so its server logic never ships ` +
|
|
484
485
|
`to the client:\n${lines.join("\n")}`);
|
|
@@ -492,7 +493,7 @@ const entryName = (file) => {
|
|
|
492
493
|
};
|
|
493
494
|
/**
|
|
494
495
|
* Aggregate a list of measured chunks into a {@link SizeReport}: sort biggest-gzip-first (ties broken
|
|
495
|
-
* by raw bytes, then name for stable output) and sum the totals. Pure
|
|
496
|
+
* by raw bytes, then name for stable output) and sum the totals. Pure - the measurement (reading the
|
|
496
497
|
* file + `Bun.gzipSync`) happens in the orchestrator; this is the deterministic, unit-testable core.
|
|
497
498
|
*/
|
|
498
499
|
export function aggregateSizeReport(chunks) {
|
|
@@ -514,7 +515,7 @@ export function formatBytes(bytes) {
|
|
|
514
515
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
515
516
|
}
|
|
516
517
|
/**
|
|
517
|
-
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row
|
|
518
|
+
* Render a {@link SizeReport} as a terse aligned table (biggest first) with a totals row - the text
|
|
518
519
|
* `nifra build --report` prints. Pure (string in, string out) so the formatting is unit-testable.
|
|
519
520
|
*/
|
|
520
521
|
export function renderSizeReport(report) {
|
|
@@ -549,7 +550,7 @@ const MANIFEST_CLIENT_ENTRY = /export\s+const\s+clientEntry\s*=\s*["']([^"']+)["
|
|
|
549
550
|
* `routes/`-relative keys `discoverRoutes` produces (e.g. `docs/index.tsx`). `routesPrefix` is the
|
|
550
551
|
* specifier prefix the manifest used for the routes dir (default `./routes/`, what `buildServer`'s
|
|
551
552
|
* default `resolve` emits). Only import specifiers under that prefix are route files; the
|
|
552
|
-
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure
|
|
553
|
+
* `@nifrajs/web` import (and any other bare specifier) is ignored. Pure - operates on source text.
|
|
553
554
|
*/
|
|
554
555
|
export function parseManifestRouteFiles(source, routesPrefix = "./routes/") {
|
|
555
556
|
const files = new Set();
|
|
@@ -625,8 +626,8 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
|
|
|
625
626
|
}
|
|
626
627
|
/**
|
|
627
628
|
* Diff the route files a committed server-manifest imports against the files freshly discovered in
|
|
628
|
-
* `routes/`. Returns the `missing` (in routes/, not in manifest
|
|
629
|
-
* manifest, gone from routes/
|
|
629
|
+
* `routes/`. Returns the `missing` (in routes/, not in manifest - stale manifest) and `extra` (in
|
|
630
|
+
* manifest, gone from routes/ - dangling import) sets. Empty arrays ⇒ in sync. Pure - the caller
|
|
630
631
|
* supplies both file lists (the committed source is parsed via {@link parseManifestRouteFiles}; the
|
|
631
632
|
* fresh list comes from `discoverRoutes`). Lists need not be pre-sorted; the result is sorted.
|
|
632
633
|
*/
|
|
@@ -650,20 +651,20 @@ export function formatManifestDrift(drift, manifestPath = "server-manifest.ts")
|
|
|
650
651
|
if (isManifestInSync(drift))
|
|
651
652
|
return undefined;
|
|
652
653
|
const lines = [
|
|
653
|
-
`[nifra/web] server-manifest drift
|
|
654
|
+
`[nifra/web] server-manifest drift - \`${manifestPath}\` is out of sync with routes/.`,
|
|
654
655
|
];
|
|
655
656
|
if (drift.missing.length > 0) {
|
|
656
|
-
lines.push(` Missing (in routes/, not in the manifest
|
|
657
|
+
lines.push(` Missing (in routes/, not in the manifest - these routes won't be served): ${drift.missing.join(", ")}`);
|
|
657
658
|
}
|
|
658
659
|
if (drift.extra.length > 0) {
|
|
659
|
-
lines.push(` Extra (imported by the manifest, gone from routes/
|
|
660
|
+
lines.push(` Extra (imported by the manifest, gone from routes/ - a dangling import): ${drift.extra.join(", ")}`);
|
|
660
661
|
}
|
|
661
662
|
lines.push(" Fix: re-run the build to regenerate the server manifest, then commit it.");
|
|
662
663
|
return lines.join("\n");
|
|
663
664
|
}
|
|
664
665
|
/**
|
|
665
666
|
* Build the client bundle for a file-routed app. Writes the hashed assets + `manifest.json` to
|
|
666
|
-
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure
|
|
667
|
+
* `outDir` and returns the manifest. Throws (with the bundler logs) on build failure - never
|
|
667
668
|
* silently ships a broken bundle.
|
|
668
669
|
*/
|
|
669
670
|
export async function buildClient(options) {
|
|
@@ -678,7 +679,7 @@ export async function buildClient(options) {
|
|
|
678
679
|
// PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
|
|
679
680
|
// falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
|
|
680
681
|
// in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
|
|
681
|
-
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck
|
|
682
|
+
// layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
|
|
682
683
|
const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env;
|
|
683
684
|
const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv);
|
|
684
685
|
// Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
|
|
@@ -689,7 +690,7 @@ export async function buildClient(options) {
|
|
|
689
690
|
// A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
|
|
690
691
|
// an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
|
|
691
692
|
writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
|
|
692
|
-
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints
|
|
693
|
+
// Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
|
|
693
694
|
// named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
|
|
694
695
|
// can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
|
|
695
696
|
// specifier the bootstrap imports, so the entrypoint + lazy import are the same module (dedup).
|
|
@@ -700,7 +701,7 @@ export async function buildClient(options) {
|
|
|
700
701
|
...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
|
|
701
702
|
]),
|
|
702
703
|
].sort();
|
|
703
|
-
// `metafile: true` asks Bun for the input/output graph
|
|
704
|
+
// `metafile: true` asks Bun for the input/output graph - specifically `outputs[js].entryPoint`
|
|
704
705
|
// (the source file) + `outputs[js].cssBundle` (that entry's emitted stylesheet). It's the robust
|
|
705
706
|
// entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
|
|
706
707
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
|
|
@@ -717,7 +718,7 @@ export async function buildClient(options) {
|
|
|
717
718
|
splitting: true, // one chunk per lazily-imported route; shared deps deduped into shared chunks
|
|
718
719
|
// `import "./x.css"` in a route/component → bundled, minified, content-hashed `.css` asset (Bun
|
|
719
720
|
// strips the import from the JS; CSS bundling is on by default since Bun 1.2). Mapped to routes
|
|
720
|
-
// below
|
|
721
|
+
// below - both the aggregate and per-route - via the metafile, for `<link>` injection.
|
|
721
722
|
...buildExtras,
|
|
722
723
|
minify: options.minify ?? true,
|
|
723
724
|
plugins: [
|
|
@@ -725,13 +726,14 @@ export async function buildClient(options) {
|
|
|
725
726
|
preactDedupePlugin(routesDir),
|
|
726
727
|
svelteDedupePlugin(routesDir),
|
|
727
728
|
serverOnlyEmptyPlugin(),
|
|
729
|
+
serverFnStubPlugin(),
|
|
728
730
|
...(options.plugins ?? []),
|
|
729
731
|
],
|
|
730
732
|
...(options.conditions ? { conditions: [...options.conditions] } : {}),
|
|
731
733
|
// Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
|
|
732
734
|
// hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
|
|
733
735
|
// the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
|
|
734
|
-
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback
|
|
736
|
+
// other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
|
|
735
737
|
// never leak). Callers can override any of these via `options.define` (layered last).
|
|
736
738
|
define: {
|
|
737
739
|
"process.env": "({})",
|
|
@@ -750,7 +752,7 @@ export async function buildClient(options) {
|
|
|
750
752
|
}
|
|
751
753
|
// #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
|
|
752
754
|
// a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
|
|
753
|
-
// instead
|
|
755
|
+
// instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
|
|
754
756
|
// per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
|
|
755
757
|
// minification. Only the client build runs this; the server build's `node:` imports are legitimate.
|
|
756
758
|
const clientMeta = result.metafile;
|
|
@@ -761,14 +763,14 @@ export async function buildClient(options) {
|
|
|
761
763
|
const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph));
|
|
762
764
|
if (nodeBuiltinLeak !== undefined)
|
|
763
765
|
throw new Error(nodeBuiltinLeak);
|
|
764
|
-
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk
|
|
766
|
+
// §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
|
|
765
767
|
// pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
|
|
766
768
|
// `*.server`, so neither other guard fires. Same shared formatter as above.
|
|
767
769
|
const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph));
|
|
768
770
|
if (serverOnlyLeak !== undefined)
|
|
769
771
|
throw new Error(serverOnlyLeak);
|
|
770
772
|
// Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
|
|
771
|
-
// rewrite the references
|
|
773
|
+
// rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
|
|
772
774
|
const renamed = sanitizeOutputNames(result.outputs);
|
|
773
775
|
const toUrl = (path) => `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`;
|
|
774
776
|
// Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
|
|
@@ -783,7 +785,7 @@ export async function buildClient(options) {
|
|
|
783
785
|
}
|
|
784
786
|
const fileToChunk = new Map();
|
|
785
787
|
routeFiles.forEach((file, i) => {
|
|
786
|
-
const out = entryPoints[i + 1]; // in range
|
|
788
|
+
const out = entryPoints[i + 1]; // in range - length checked above
|
|
787
789
|
if (out !== undefined)
|
|
788
790
|
fileToChunk.set(file, toUrl(out.path));
|
|
789
791
|
});
|
|
@@ -797,9 +799,9 @@ export async function buildClient(options) {
|
|
|
797
799
|
}
|
|
798
800
|
if (routeManifest.notFound)
|
|
799
801
|
routes._404 = chunksFor([routeManifest.notFound.file]);
|
|
800
|
-
// CSS
|
|
802
|
+
// CSS - aggregate: an `import "./x.css"` anywhere → a content-hashed `.css` asset (Bun strips the
|
|
801
803
|
// import from the JS). The bootstrap lazily imports every route, so its **aggregate** stylesheet is
|
|
802
|
-
// the whole app's CSS
|
|
804
|
+
// the whole app's CSS - the always-safe fallback `createWebApp` links when a route has no per-route
|
|
803
805
|
// entry below. Fallback to all CSS assets if Bun emitted no distinct aggregate.
|
|
804
806
|
const bootstrapName = entryName(entryFile); // `_nifra-entry`
|
|
805
807
|
const cssNameOf = (path) => {
|
|
@@ -811,12 +813,12 @@ export async function buildClient(options) {
|
|
|
811
813
|
.filter((o) => cssNameOf(o.path) === bootstrapName)
|
|
812
814
|
.map((o) => toUrl(o.path));
|
|
813
815
|
const css = aggregate.length > 0 ? aggregate : cssAssets.map((o) => toUrl(o.path));
|
|
814
|
-
// CSS
|
|
815
|
-
// `cssBundle`
|
|
816
|
+
// CSS - per-route: each route/layout file is its own entrypoint, so the build metafile records its
|
|
817
|
+
// `cssBundle` - exactly the CSS that file's subtree uses (shared-component CSS is inlined into each
|
|
816
818
|
// consumer; verified). Keyed by the metafile's unique source `entryPoint`, so it survives
|
|
817
819
|
// same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
|
|
818
820
|
// links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
|
|
819
|
-
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle
|
|
821
|
+
// Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
|
|
820
822
|
const cwd = process.cwd();
|
|
821
823
|
const cssByEntry = new Map();
|
|
822
824
|
for (const out of Object.values(clientMeta?.outputs ?? {})) {
|
|
@@ -861,7 +863,7 @@ export async function buildClient(options) {
|
|
|
861
863
|
* on workerd, and `Bun.build` always applies the `bun` condition (it wins over `workerd`/`edge-light`),
|
|
862
864
|
* so conditions alone can't select the edge build. This shim pins `react-dom/server` to its edge build
|
|
863
865
|
* (`server.edge.js`, which exports `renderToReadableStream`). A no-op when nothing imports react-dom
|
|
864
|
-
* (e.g. a Solid worker)
|
|
866
|
+
* (e.g. a Solid worker) - the resolver only runs on a match.
|
|
865
867
|
*/
|
|
866
868
|
const reactDomEdgePlugin = (from) => ({
|
|
867
869
|
name: "nifra-react-dom-edge",
|
|
@@ -873,11 +875,11 @@ const reactDomEdgePlugin = (from) => ({
|
|
|
873
875
|
});
|
|
874
876
|
/**
|
|
875
877
|
* Dedupe React to a single copy. A `file:`-linked package can ship its OWN `react` under its own
|
|
876
|
-
* node_modules, so the bundle ends up with two React cores
|
|
878
|
+
* node_modules, so the bundle ends up with two React cores - each with its own hook dispatcher - and SSR
|
|
877
879
|
* throws the cryptic `null is not an object (evaluating '…H.useState')` (the second renderer's dispatcher
|
|
878
880
|
* is null). This was the #1 time-sink in app builds. Pinning `react` + its JSX runtimes to ONE resolved
|
|
879
881
|
* copy (the app's, from `from`) guarantees a single dispatcher; `react-dom`, which imports `react`, then
|
|
880
|
-
* shares it
|
|
882
|
+
* shares it - so the class can't occur rather than needing a named error. No-op when React isn't used
|
|
881
883
|
* (an unresolvable spec is skipped; the resolver only fires on an exact match). React core is
|
|
882
884
|
* condition-agnostic, so pinning it doesn't disturb the edge/browser/server conditions that select
|
|
883
885
|
* react-dom's build.
|
|
@@ -892,7 +894,7 @@ export const reactDedupePlugin = (from) => ({
|
|
|
892
894
|
resolved = Bun.resolveSync(spec, from);
|
|
893
895
|
}
|
|
894
896
|
catch {
|
|
895
|
-
continue; // React (or this subpath) isn't resolvable here
|
|
897
|
+
continue; // React (or this subpath) isn't resolvable here - nothing to dedupe
|
|
896
898
|
}
|
|
897
899
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
898
900
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
@@ -900,14 +902,14 @@ export const reactDedupePlugin = (from) => ({
|
|
|
900
902
|
},
|
|
901
903
|
});
|
|
902
904
|
/**
|
|
903
|
-
* Dedupe Preact to a single copy
|
|
905
|
+
* Dedupe Preact to a single copy - the Preact analogue of `reactDedupePlugin`, closing the same class of
|
|
904
906
|
* bug for the Preact framework (which had NO build-time dedup before). A `file:`-linked package can ship
|
|
905
907
|
* its OWN `preact`, so the bundle ends up with two Preact cores; since `preact-render-to-string` mutates
|
|
906
908
|
* `preact`'s shared `options` global and `preact/hooks` writes the SAME global, two copies → two `options`
|
|
907
909
|
* → SSR throws `undefined is not an object (… __H)` (the vnode's hook state was set up on the other copy).
|
|
908
910
|
* Pinning `preact` + its hooks/compat/jsx subpaths to ONE resolved copy (the app's, from `from`) makes the
|
|
909
911
|
* renderer and the components share one core. No-op when Preact isn't used (an unresolvable spec is
|
|
910
|
-
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection
|
|
912
|
+
* skipped). Preact core is condition-agnostic, so pinning it doesn't disturb any condition selection - and
|
|
911
913
|
* unlike react-dom there is no edge-vs-server build to preserve, so pinning the subpaths is safe.
|
|
912
914
|
*/
|
|
913
915
|
const PREACT_DEDUPE_SPECS = [
|
|
@@ -926,7 +928,7 @@ export const preactDedupePlugin = (from) => ({
|
|
|
926
928
|
resolved = Bun.resolveSync(spec, from);
|
|
927
929
|
}
|
|
928
930
|
catch {
|
|
929
|
-
continue; // Preact (or this subpath) isn't resolvable here
|
|
931
|
+
continue; // Preact (or this subpath) isn't resolvable here - nothing to dedupe
|
|
930
932
|
}
|
|
931
933
|
const escaped = spec.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
932
934
|
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: resolved }));
|
|
@@ -934,10 +936,10 @@ export const preactDedupePlugin = (from) => ({
|
|
|
934
936
|
},
|
|
935
937
|
});
|
|
936
938
|
/**
|
|
937
|
-
* Dedupe Svelte to a single copy
|
|
939
|
+
* Dedupe Svelte to a single copy - the Svelte analogue of `reactDedupePlugin`/`preactDedupePlugin`, closing
|
|
938
940
|
* the same class of bug for Svelte (which had NO build-time dedup before). A workspace- or file-linked
|
|
939
941
|
* `@nifrajs/web-svelte` can resolve its OWN `svelte` (e.g. a sibling repo's install store) while the app's
|
|
940
|
-
* components resolve another
|
|
942
|
+
* components resolve another - SAME version, two physical copies. Svelte 5's client runtime
|
|
941
943
|
* (`svelte/internal/client`) holds module-level component-context state, so two copies means the compiled
|
|
942
944
|
* components register on one runtime while `hydrate` runs on the other → hydration throws
|
|
943
945
|
* `Cannot read properties of undefined (reading 'call')` and the server-rendered markup is wiped.
|
|
@@ -956,26 +958,46 @@ export const svelteDedupePlugin = (from) => ({
|
|
|
956
958
|
return { path: Bun.resolveSync(args.path, from) };
|
|
957
959
|
}
|
|
958
960
|
catch {
|
|
959
|
-
return undefined; // not resolvable from the app root
|
|
961
|
+
return undefined; // not resolvable from the app root - leave Bun's default resolution
|
|
960
962
|
}
|
|
961
963
|
});
|
|
962
964
|
},
|
|
963
965
|
});
|
|
964
966
|
/**
|
|
965
967
|
* Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
|
|
966
|
-
* `auth.server.ts`, …) is server-only
|
|
968
|
+
* `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
|
|
967
969
|
* Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
|
|
968
970
|
* import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
|
|
969
971
|
* import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
|
|
970
972
|
* in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
|
|
971
|
-
* module is the fix. CLIENT-only
|
|
973
|
+
* module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
|
|
972
974
|
*/
|
|
973
|
-
const SERVER_ONLY_MODULE = /\.server(\.[cm]?[jt]sx?)?$/;
|
|
974
975
|
export const serverOnlyEmptyPlugin = () => ({
|
|
975
976
|
name: "nifra-server-only-empty",
|
|
976
977
|
setup(build) {
|
|
977
978
|
build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
|
|
978
|
-
contents:
|
|
979
|
+
contents: SERVER_ONLY_REPLACEMENT,
|
|
980
|
+
loader: "js",
|
|
981
|
+
}));
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
/**
|
|
985
|
+
* Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
|
|
986
|
+
* the server mounted, so the function bodies - and everything they import - never reach a browser.
|
|
987
|
+
*
|
|
988
|
+
* The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
|
|
989
|
+
* EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
|
|
990
|
+
* client is supposed to call it, just over HTTP. The generation itself is in
|
|
991
|
+
* `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
|
|
992
|
+
* hand-written copies would be a client that works in dev and 404s in production.
|
|
993
|
+
*
|
|
994
|
+
* CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
|
|
995
|
+
*/
|
|
996
|
+
export const serverFnStubPlugin = () => ({
|
|
997
|
+
name: "nifra-server-fn-stub",
|
|
998
|
+
setup(build) {
|
|
999
|
+
build.onLoad({ filter: SERVER_FN_MODULE }, async (args) => ({
|
|
1000
|
+
contents: generateServerFnStub(await Bun.file(args.path).text(), serverFnNamespace(args.path)),
|
|
979
1001
|
loader: "js",
|
|
980
1002
|
}));
|
|
981
1003
|
},
|
|
@@ -1004,7 +1026,7 @@ const solidWebServerPlugin = (from) => ({
|
|
|
1004
1026
|
* (`generateServerManifest`, written next to `serverEntry`), then bundles `serverEntry` with
|
|
1005
1027
|
* `Bun.build` using **edge conditions** + the adapter's SSR plugins. The output imports no `node:fs`
|
|
1006
1028
|
* and does no dynamic-path import, so it runs on workerd: point `wrangler.toml`'s `main` at it and
|
|
1007
|
-
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure
|
|
1029
|
+
* serve the client assets via Workers Assets. Throws (with the bundler logs) on failure - never
|
|
1008
1030
|
* silently ships a broken worker.
|
|
1009
1031
|
*/
|
|
1010
1032
|
export async function buildServer(options) {
|
|
@@ -1020,7 +1042,7 @@ export async function buildServer(options) {
|
|
|
1020
1042
|
// Edge (browser) target: Bun's `bun` condition contaminates react-dom's server build + the `worker`
|
|
1021
1043
|
// condition segfaults Bun.build on solid, so force the edge/server builds via shims. The `node`/`bun`
|
|
1022
1044
|
// targets resolve those correctly via their own condition, so the shims (and edge conditions) don't
|
|
1023
|
-
// apply
|
|
1045
|
+
// apply - defaults become `[target]` (e.g. react-dom → server.node.js under `node`).
|
|
1024
1046
|
const edge = target === "browser";
|
|
1025
1047
|
const conditions = options.conditions ?? (edge ? ["workerd", "edge-light", "browser"] : [target]);
|
|
1026
1048
|
const manifest = discoverRoutes(routesDir);
|
|
@@ -1035,10 +1057,10 @@ export async function buildServer(options) {
|
|
|
1035
1057
|
// Tag every BUNDLED SSR output so @nifrajs/web-react's react-dom adapter takes the static
|
|
1036
1058
|
// (bundled, deduped) react-dom instead of re-rooting react-dom/server to a DISK copy at runtime.
|
|
1037
1059
|
// A `target:"bun"` bundle still has `Bun.resolveSync` under the Bun runtime, so without this tag the
|
|
1038
|
-
// adapter re-imports a SECOND react-dom from node_modules
|
|
1060
|
+
// adapter re-imports a SECOND react-dom from node_modules - a second React core whose hook dispatcher
|
|
1039
1061
|
// is null for the bundled components → SSR throws `…H.useRef of null`. Always set (a structural fact
|
|
1040
1062
|
// of bundling, layered after any caller `define` so it can't be overridden). Unbundled Bun runtimes
|
|
1041
|
-
// (nifra dev/start, nifra_render) never define it, so they still re-root
|
|
1063
|
+
// (nifra dev/start, nifra_render) never define it, so they still re-root - the dev dual-install fix.
|
|
1042
1064
|
"process.env.NIFRA_SSR_BUNDLED": '"1"',
|
|
1043
1065
|
"globalThis.__NIFRA_EDGE_RUNTIME__": edge ? "true" : "false",
|
|
1044
1066
|
},
|
|
@@ -1062,12 +1084,12 @@ export async function buildServer(options) {
|
|
|
1062
1084
|
return { worker: entryOutput.path, outputs: result.outputs.map((o) => o.path) };
|
|
1063
1085
|
}
|
|
1064
1086
|
// ===================================================================================================
|
|
1065
|
-
// `nifra build --target`
|
|
1087
|
+
// `nifra build --target` - package the engine above into one command that emits a full deploy dir.
|
|
1066
1088
|
//
|
|
1067
1089
|
// An app already declares everything the build needs through nifra's conventions: `adapter` +
|
|
1068
1090
|
// `clientModule` (nifra.config.ts / framework.ts), an optional `backend` (backend.ts), and `routes/`.
|
|
1069
1091
|
// The ONLY thing apps used to hand-write per target was the server entry (`_worker.ts`, `server-bun.ts`,
|
|
1070
|
-
// …)
|
|
1092
|
+
// …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
|
|
1071
1093
|
// ===================================================================================================
|
|
1072
1094
|
/** A deploy target `nifra build --target <t>` can emit. `static` is pure SSG (no server). */
|
|
1073
1095
|
export const BUILD_TARGETS = ["bun", "node", "deno", "cf-pages", "vercel", "static"];
|
|
@@ -1084,7 +1106,7 @@ export function isBuildTarget(value) {
|
|
|
1084
1106
|
* - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
|
|
1085
1107
|
* - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
|
|
1086
1108
|
* runtimes have a filesystem; the static `/assets/*` sit next to the entry).
|
|
1087
|
-
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written)
|
|
1109
|
+
* `adapterImport`/`backendImport` are the specifiers the entry uses (relative to where it's written) -
|
|
1088
1110
|
* `buildServer` writes the entry next to `serverEntry`, so they're resolved from there. Pure (string in,
|
|
1089
1111
|
* string out) so the generation is unit-testable without a real build.
|
|
1090
1112
|
*/
|
|
@@ -1120,7 +1142,7 @@ export function generateServerEntry(options) {
|
|
|
1120
1142
|
return `${lines.join("\n")}\n`;
|
|
1121
1143
|
}
|
|
1122
1144
|
if (target === "vercel") {
|
|
1123
|
-
lines.push("// Vercel Edge Function
|
|
1145
|
+
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)");
|
|
1124
1146
|
return `${lines.join("\n")}\n`;
|
|
1125
1147
|
}
|
|
1126
1148
|
// bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
|
|
@@ -1128,7 +1150,7 @@ export function generateServerEntry(options) {
|
|
|
1128
1150
|
if (target === "bun") {
|
|
1129
1151
|
lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " const file = Bun.file(new URL(filePath, STATIC_ROOT))", ' if (!(await file.exists())) return new Response("not found", { status: 404 })', ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)', ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })', " }", " return app.fetch(req)", " },", "})",
|
|
1130
1152
|
// The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
|
|
1131
|
-
// source
|
|
1153
|
+
// source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
|
|
1132
1154
|
`console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
|
|
1133
1155
|
return `${lines.join("\n")}\n`;
|
|
1134
1156
|
}
|
|
@@ -1137,7 +1159,7 @@ export function generateServerEntry(options) {
|
|
|
1137
1159
|
return `${lines.join("\n")}\n`;
|
|
1138
1160
|
}
|
|
1139
1161
|
// deno
|
|
1140
|
-
lines.push("const handler = toFetchHandler(app)", "// @ts-ignore
|
|
1162
|
+
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)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " // @ts-ignore - Deno.readFile is present on the Deno runtime.", " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))", ' const ext = pathname.slice(pathname.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)", "})");
|
|
1141
1163
|
return `${lines.join("\n")}\n`;
|
|
1142
1164
|
}
|
|
1143
1165
|
/** The Bun.build `target` each deploy target compiles its server with (mirrors `buildServer`'s docs). */
|
|
@@ -1163,7 +1185,7 @@ async function measureOutputs(paths) {
|
|
|
1163
1185
|
}
|
|
1164
1186
|
return chunks;
|
|
1165
1187
|
}
|
|
1166
|
-
/** The default (Bun) strategy
|
|
1188
|
+
/** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
|
|
1167
1189
|
export const bunBundler = {
|
|
1168
1190
|
buildClient: (input) => buildClient({
|
|
1169
1191
|
routesDir: input.routesDir,
|
|
@@ -1239,8 +1261,8 @@ export async function buildTargetWith(target, options, bundler) {
|
|
|
1239
1261
|
});
|
|
1240
1262
|
if (result.prerendered.length === 0) {
|
|
1241
1263
|
// A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
|
|
1242
|
-
// getStaticPaths)
|
|
1243
|
-
throw new Error("[nifra/web] buildTarget(static): no routes were prerendered
|
|
1264
|
+
// getStaticPaths) - fail loudly rather than ship an empty dir the dev thinks is their site.
|
|
1265
|
+
throw new Error("[nifra/web] buildTarget(static): no routes were prerendered - opt routes in with " +
|
|
1244
1266
|
"`export const prerender = true` (static) or `getStaticPaths` (dynamic).");
|
|
1245
1267
|
}
|
|
1246
1268
|
rmSync(workDir, { recursive: true, force: true });
|