@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.
Files changed (49) hide show
  1. package/README.md +8 -0
  2. package/dist/build.d.ts +252 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +755 -6
  5. package/dist/build.js.map +1 -1
  6. package/dist/chunk-names.d.ts +4 -0
  7. package/dist/chunk-names.d.ts.map +1 -0
  8. package/dist/chunk-names.js +46 -0
  9. package/dist/chunk-names.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +19 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/dev.d.ts +1 -1
  14. package/dist/dev.d.ts.map +1 -1
  15. package/dist/dev.js +2 -1
  16. package/dist/dev.js.map +1 -1
  17. package/dist/fonts-google.d.ts +2 -1
  18. package/dist/fonts-google.d.ts.map +1 -1
  19. package/dist/fonts-google.js.map +1 -1
  20. package/dist/fonts.d.ts +2 -1
  21. package/dist/fonts.d.ts.map +1 -1
  22. package/dist/fonts.js +0 -16
  23. package/dist/fonts.js.map +1 -1
  24. package/dist/index.d.ts +138 -6
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +268 -23
  27. package/dist/index.js.map +1 -1
  28. package/dist/manifest.d.ts +82 -5
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js.map +1 -1
  31. package/dist/server-only.d.ts +21 -0
  32. package/dist/server-only.d.ts.map +1 -0
  33. package/dist/server-only.js +25 -0
  34. package/dist/server-only.js.map +1 -0
  35. package/dist/vite.d.ts +21 -1
  36. package/dist/vite.d.ts.map +1 -1
  37. package/dist/vite.js +68 -4
  38. package/dist/vite.js.map +1 -1
  39. package/package.json +6 -1
  40. package/src/build.ts +1066 -12
  41. package/src/chunk-names.ts +45 -0
  42. package/src/client.ts +19 -2
  43. package/src/dev.ts +3 -2
  44. package/src/fonts-google.ts +3 -2
  45. package/src/fonts.ts +2 -1
  46. package/src/index.ts +373 -32
  47. package/src/manifest.ts +84 -6
  48. package/src/server-only.ts +25 -0
  49. 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
- writeFileSync(entryFile, generateClientEntry(routeManifest, { clientModule, resolve }));
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: [...(options.plugins ?? [])],
477
+ plugins: [
478
+ reactDedupePlugin(routesDir),
479
+ preactDedupePlugin(routesDir),
480
+ serverOnlyEmptyPlugin(),
481
+ ...(options.plugins ?? []),
482
+ ],
67
483
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
68
- ...(options.define ? { define: { ...options.define } } : {}),
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
- const toUrl = (path) => `${publicPath}${basename(path)}`;
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(meta?.outputs ?? {})) {
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