@nifrajs/web 5.1.0 → 5.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +3042 -0
  2. package/dist/build-plan.d.ts +7 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +19 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +1 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +51 -3
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +23 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +224 -16
  13. package/dist/build.js.map +1 -1
  14. package/dist/client.d.ts +2 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +14 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/csp.d.ts +2 -0
  19. package/dist/csp.d.ts.map +1 -1
  20. package/dist/csp.js +2 -0
  21. package/dist/csp.js.map +1 -1
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +23 -3
  24. package/dist/deferred.js.map +1 -1
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/codegen.d.ts +6 -0
  30. package/dist/internal/codegen.d.ts.map +1 -1
  31. package/dist/internal/codegen.js +23 -5
  32. package/dist/internal/codegen.js.map +1 -1
  33. package/dist/internal/copy-split.d.ts +27 -0
  34. package/dist/internal/copy-split.d.ts.map +1 -0
  35. package/dist/internal/copy-split.js +173 -0
  36. package/dist/internal/copy-split.js.map +1 -0
  37. package/dist/internal/deferred-free.d.ts +3 -0
  38. package/dist/internal/deferred-free.d.ts.map +1 -0
  39. package/dist/internal/deferred-free.js +15 -0
  40. package/dist/internal/deferred-free.js.map +1 -0
  41. package/dist/internal/module-preparation.d.ts +7 -0
  42. package/dist/internal/module-preparation.d.ts.map +1 -0
  43. package/dist/internal/module-preparation.js +24 -0
  44. package/dist/internal/module-preparation.js.map +1 -0
  45. package/dist/internal/output-guard.d.ts +2 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +51 -9
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +20 -1
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +115 -50
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/parity.d.ts.map +1 -1
  54. package/dist/internal/parity.js +3 -1
  55. package/dist/internal/parity.js.map +1 -1
  56. package/dist/internal/prerendered-pages.d.ts +42 -0
  57. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  58. package/dist/internal/prerendered-pages.js +92 -0
  59. package/dist/internal/prerendered-pages.js.map +1 -0
  60. package/dist/internal/public-url.d.ts +7 -0
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +13 -0
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +11 -2
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +131 -41
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/request-url.d.ts +4 -0
  69. package/dist/internal/request-url.d.ts.map +1 -1
  70. package/dist/internal/request-url.js +24 -0
  71. package/dist/internal/request-url.js.map +1 -1
  72. package/dist/internal/secret-scan.d.ts +4 -0
  73. package/dist/internal/secret-scan.d.ts.map +1 -1
  74. package/dist/internal/secret-scan.js +8 -1
  75. package/dist/internal/secret-scan.js.map +1 -1
  76. package/dist/internal/typescript-api.d.ts +4 -0
  77. package/dist/internal/typescript-api.d.ts.map +1 -0
  78. package/dist/internal/typescript-api.js +8 -0
  79. package/dist/internal/typescript-api.js.map +1 -0
  80. package/dist/internal/vite-plugin-compat.d.ts +28 -0
  81. package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
  82. package/dist/internal/vite-plugin-compat.js +82 -0
  83. package/dist/internal/vite-plugin-compat.js.map +1 -0
  84. package/dist/internal/web-app.d.ts +45 -0
  85. package/dist/internal/web-app.d.ts.map +1 -1
  86. package/dist/internal/web-app.js +32 -1
  87. package/dist/internal/web-app.js.map +1 -1
  88. package/dist/isr.d.ts +1 -0
  89. package/dist/isr.d.ts.map +1 -1
  90. package/dist/isr.js +7 -4
  91. package/dist/isr.js.map +1 -1
  92. package/dist/plugins/import-glob.d.ts +20 -0
  93. package/dist/plugins/import-glob.d.ts.map +1 -0
  94. package/dist/plugins/import-glob.js +401 -0
  95. package/dist/plugins/import-glob.js.map +1 -0
  96. package/dist/plugins/kit.d.ts +11 -0
  97. package/dist/plugins/kit.d.ts.map +1 -1
  98. package/dist/plugins/kit.js +32 -0
  99. package/dist/plugins/kit.js.map +1 -1
  100. package/dist/plugins/stylex.d.ts.map +1 -1
  101. package/dist/plugins/stylex.js +2 -2
  102. package/dist/plugins/stylex.js.map +1 -1
  103. package/dist/prerender.d.ts +4 -0
  104. package/dist/prerender.d.ts.map +1 -1
  105. package/dist/prerender.js +9 -1
  106. package/dist/prerender.js.map +1 -1
  107. package/dist/public-dir.d.ts +15 -0
  108. package/dist/public-dir.d.ts.map +1 -1
  109. package/dist/public-dir.js +91 -12
  110. package/dist/public-dir.js.map +1 -1
  111. package/dist/render-seam.d.ts +42 -2
  112. package/dist/render-seam.d.ts.map +1 -1
  113. package/dist/render-seam.js +33 -0
  114. package/dist/render-seam.js.map +1 -1
  115. package/dist/router.d.ts +3 -2
  116. package/dist/router.d.ts.map +1 -1
  117. package/dist/router.js +4 -4
  118. package/dist/router.js.map +1 -1
  119. package/dist/search.d.ts.map +1 -1
  120. package/dist/search.js +2 -0
  121. package/dist/search.js.map +1 -1
  122. package/dist/server-components/typescript.d.ts.map +1 -1
  123. package/dist/server-components/typescript.js +2 -7
  124. package/dist/server-components/typescript.js.map +1 -1
  125. package/dist/vite.d.ts +7 -28
  126. package/dist/vite.d.ts.map +1 -1
  127. package/dist/vite.js +130 -123
  128. package/dist/vite.js.map +1 -1
  129. package/package.json +5 -4
  130. package/src/build-plan.ts +21 -0
  131. package/src/build-vite.ts +60 -3
  132. package/src/build.ts +283 -39
  133. package/src/client.ts +15 -7
  134. package/src/csp.ts +4 -0
  135. package/src/deferred.ts +20 -3
  136. package/src/index.ts +9 -0
  137. package/src/internal/codegen.ts +32 -3
  138. package/src/internal/copy-split.ts +220 -0
  139. package/src/internal/deferred-free.ts +15 -0
  140. package/src/internal/module-preparation.ts +30 -0
  141. package/src/internal/output-guard.ts +51 -9
  142. package/src/internal/page-execution.ts +137 -53
  143. package/src/internal/parity.ts +2 -1
  144. package/src/internal/prerendered-pages.ts +124 -0
  145. package/src/internal/public-url.ts +13 -0
  146. package/src/internal/render-document.ts +179 -58
  147. package/src/internal/request-url.ts +24 -0
  148. package/src/internal/secret-scan.ts +9 -1
  149. package/src/internal/typescript-api.ts +11 -0
  150. package/src/internal/vite-plugin-compat.ts +81 -0
  151. package/src/internal/web-app.ts +82 -2
  152. package/src/isr.ts +7 -4
  153. package/src/plugins/import-glob.ts +392 -0
  154. package/src/plugins/kit.ts +36 -0
  155. package/src/plugins/stylex.ts +2 -1
  156. package/src/prerender.ts +13 -1
  157. package/src/public-dir.ts +107 -12
  158. package/src/render-seam.ts +65 -2
  159. package/src/router.ts +7 -6
  160. package/src/search.ts +1 -0
  161. package/src/server-components/typescript.ts +2 -10
  162. package/src/vite.ts +133 -135
package/dist/build.js CHANGED
@@ -12,22 +12,25 @@ import { cp, lstat, mkdir, realpath } from "node:fs/promises";
12
12
  import { dirname, isAbsolute, join, basename as pathBasename, posix, relative, resolve as resolvePath, sep, } from "node:path";
13
13
  import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy";
14
14
  import { Glob } from "bun";
15
- import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
15
+ import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
16
16
  import { sanitizeOutputNames } from "./chunk-names.js";
17
17
  import { normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.js";
18
18
  import { discoverRoutes } from "./fs.js";
19
19
  import { generateClientEntry, generateServerManifest } from "./index.js";
20
+ import { loadCopySplit } from "./internal/copy-split.js";
20
21
  import { assertDisposableDir } from "./internal/disposable-dir.js";
21
22
  import { entryChunks } from "./internal/entry-chunks.js";
22
23
  import { dedupePolicyFor } from "./internal/identity-policy.js";
23
24
  import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.js";
25
+ import { servablePrerenderedPages, } from "./internal/prerendered-pages.js";
24
26
  import { privateEnvCheck } from "./internal/private-env.js";
25
- import { publicUrlPath } from "./internal/public-url.js";
26
- import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, scanForSecrets, textOf, } from "./internal/secret-scan.js";
27
+ import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.js";
28
+ import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, SecretScanError, scanForSecrets, textOf, } from "./internal/secret-scan.js";
27
29
  import { generateServerFnStub, SERVER_FN_MODULE, serverFnNamespace, } from "./internal/server-boundary.js";
28
30
  import { formatUnsupportedBuiltins, unsupportedBuiltins } from "./internal/target-compat.js";
29
31
  import { accountEmittedFiles, bunModuleSource, formatClientGraphVerdict, formatServerGraphVerdict, formatUnaccountedOutput, verifyClientGraph, verifyServerGraph, } from "./internal/zone-graph.js";
30
32
  import { fromBunMetafile } from "./module-graph.js";
33
+ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.js";
31
34
  import { zoneGuardPlugin } from "./plugins/zone-guard.js";
32
35
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
33
36
  import { prerenderRoutes } from "./prerender.js";
@@ -164,7 +167,9 @@ routePatterns) {
164
167
  }
165
168
  /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
166
169
  /**
167
- * Copy `from` into `to`, returning the URL paths copied (sorted).
170
+ * Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
171
+ * `.well-known` are skipped, as development never serves them: every target ships this list, and a
172
+ * CDN-hosted one serves whatever was copied.
168
173
  *
169
174
  * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
170
175
  * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
@@ -175,6 +180,8 @@ export async function copyPublicDir(from, to) {
175
180
  const sourceReal = await realpath(source);
176
181
  const copied = [];
177
182
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
183
+ if (isHiddenPublicPath(rel))
184
+ continue;
178
185
  const candidate = join(source, rel);
179
186
  const candidateStat = await lstat(candidate);
180
187
  if (candidateStat.isSymbolicLink()) {
@@ -203,12 +210,14 @@ export async function copyPublicDir(from, to) {
203
210
  */
204
211
  export function resyncServerManifestSource(source, manifest, routesPrefix) {
205
212
  const cssLoading = parseManifestCssLoading(source);
213
+ const copyChunks = parseManifestCopyChunks(source);
206
214
  return generateServerManifest(manifest, {
207
215
  resolve: (file) => `${routesPrefix}${file}`,
208
216
  clientEntry: parseManifestClientEntry(source) ?? "",
209
217
  styles: parseManifestStyles(source),
210
218
  routeStyles: parseManifestRouteStyles(source),
211
219
  ...(cssLoading !== undefined ? { cssLoading } : {}),
220
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
212
221
  lazy: MANIFEST_IS_LAZY.test(source),
213
222
  });
214
223
  }
@@ -249,6 +258,9 @@ export async function buildClient(options) {
249
258
  // The bootstrap's filename is `_nifra`-namespaced (not `entry.ts`) so its `[name]` can't collide with
250
259
  // a user route named `entry.tsx` when the CSS mapping below excludes the bootstrap's aggregate CSS.
251
260
  const mode = options.minify === false ? "development" : "production";
261
+ const copySplit = (options.copySplit ?? mode === "production")
262
+ ? await loadCopySplit(root, { skip: [outDir] })
263
+ : undefined;
252
264
  // PUBLIC_*-prefixed env → client define (Vite/Next convention). Sourced from the build env (`Bun.env`,
253
265
  // falling back to `process.env`). Only prefixed vars are baked in; unprefixed secrets stay undefined
254
266
  // in the client via the bare `process.env` → `({})` define below. Caller's `options.define` wins (it's
@@ -263,7 +275,11 @@ export async function buildClient(options) {
263
275
  const entryFile = `${entryDir}/_nifra-entry.ts`;
264
276
  // A client bundle has no Node `process`; provide a minimal one so a stray bare `process` reference in
265
277
  // an app module doesn't crash hydration (`process.env.*` reads are handled at compile time by `define`).
266
- writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, { clientModule, resolve })}`);
278
+ writeFileSync(entryFile, `globalThis.process ??= { env: {} };\n${generateClientEntry(routeManifest, {
279
+ clientModule,
280
+ resolve,
281
+ copyChunks: copySplit !== undefined,
282
+ })}`);
267
283
  // Every unique route/layout/`_404` file (sorted, stable), as ADDITIONAL entrypoints - Bun emits a
268
284
  // named chunk per file that the bootstrap's lazy `import()` dedupes to (verified), so the manifest
269
285
  // can map each route to its chunk URLs for matched-route preload. `resolve(file)` is the same
@@ -314,6 +330,10 @@ export async function buildClient(options) {
314
330
  svelteDedupePlugin(routesDir),
315
331
  serverFnStubPlugin(),
316
332
  ...(options.plugins ?? []),
333
+ // Before the copy split: it claims a module that calls the glob and reads it through the split.
334
+ importGlobPlugin({ root }),
335
+ // Last: it rewrites a module no other plugin claims, and the ones that do read through it.
336
+ ...(copySplit === undefined ? [] : [copySplit.plugin]),
317
337
  ],
318
338
  ...(options.conditions ? { conditions: [...options.conditions] } : {}),
319
339
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
@@ -364,6 +384,7 @@ export async function buildClient(options) {
364
384
  }
365
385
  return { name: out.path, kind: "code", text: await out.text() };
366
386
  }));
387
+ assertNoImportGlob(emitted.flatMap((file) => file.kind === "code" ? [[relative(cwd, file.name), file.text ?? ""]] : []));
367
388
  const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath);
368
389
  const verdict = verifyClientGraph(clientGraph, {
369
390
  classifier,
@@ -405,6 +426,7 @@ export async function buildClient(options) {
405
426
  if (unaccounted !== undefined)
406
427
  throw new Error(unaccounted);
407
428
  const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public");
429
+ const copy = copySplit?.chunks(publicPath);
408
430
  const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier);
409
431
  const secrets = formatSecretFindings(scanForSecrets({
410
432
  sources,
@@ -415,6 +437,7 @@ export async function buildClient(options) {
415
437
  const name = file.name.replace(/^\.\//, "");
416
438
  return emittedScanFiles(name, file.text, originsOf(name));
417
439
  }),
440
+ ...(copy?.files ?? []).flatMap((file) => emittedScanFiles(file.name, file.text)),
418
441
  ...(publicDir !== undefined && existsSync(publicDir)
419
442
  ? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
420
443
  : []),
@@ -430,6 +453,8 @@ export async function buildClient(options) {
430
453
  await Bun.write(path, out);
431
454
  return { kind: out.kind, path };
432
455
  }));
456
+ for (const file of copy?.files ?? [])
457
+ writeFileSync(join(outDir, file.name), file.text);
433
458
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
434
459
  // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
435
460
  const renamed = sanitizeOutputNames(outputs);
@@ -553,13 +578,15 @@ export async function buildClient(options) {
553
578
  // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
554
579
  // apps have none) and must not fail the build.
555
580
  const publicFiles = publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : [];
581
+ const copyChunks = copy?.urls ?? {};
556
582
  const manifest = {
557
583
  entry: entryUrl,
558
- assets: outputs.map((o) => toUrl(o.path)),
584
+ assets: [...outputs.map((o) => toUrl(o.path)), ...Object.values(copyChunks)],
559
585
  routes,
560
586
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
561
587
  ...(css.length > 0 ? { css } : {}),
562
588
  ...(Object.keys(routeStyles).length > 0 ? { routeStyles } : {}),
589
+ ...(Object.keys(copyChunks).length > 0 ? { copyChunks } : {}),
563
590
  };
564
591
  assertDevelopmentProductionParity(collectDevelopmentParityInput(routesDir, publicDir), manifest);
565
592
  writeFileSync(`${outDir}/manifest.json`, JSON.stringify(manifest, null, 2));
@@ -788,6 +815,7 @@ function formatRefusedFiles(refused, root) {
788
815
  });
789
816
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`;
790
817
  }
818
+ export { importGlobPlugin } from "./plugins/import-glob.js";
791
819
  export { zoneGuardPlugin } from "./plugins/zone-guard.js";
792
820
  /**
793
821
  * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
@@ -858,6 +886,7 @@ export async function buildServer(options) {
858
886
  styles,
859
887
  routeStyles,
860
888
  ...(cssLoading !== undefined ? { cssLoading } : {}),
889
+ ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
861
890
  lazy,
862
891
  }));
863
892
  const result = await Bun.build({
@@ -887,6 +916,7 @@ export async function buildServer(options) {
887
916
  reactDedupePlugin(entryDir),
888
917
  preactDedupePlugin(entryDir),
889
918
  ...(options.plugins ?? []),
919
+ importGlobPlugin({ root: resolvePath(options.root ?? dirname(routesDir)) }),
890
920
  ],
891
921
  });
892
922
  if (!result.success) {
@@ -939,7 +969,14 @@ export async function buildServer(options) {
939
969
  // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
940
970
  // ===================================================================================================
941
971
  /** The `createWebApp` options a generated server entry can import from the app's framework module. */
942
- export const SERVER_ENTRY_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"];
972
+ export const SERVER_ENTRY_OPTIONS = [
973
+ "apiPrefix",
974
+ "apiStrip",
975
+ "mounts",
976
+ "csp",
977
+ "nonce",
978
+ "i18n",
979
+ ];
943
980
  /**
944
981
  * The header each edge platform sets to the caller's address, replacing any value the client sent.
945
982
  * That replacement is the whole trust: the same bundle served without the platform's edge in front
@@ -950,6 +987,8 @@ const PLATFORM_CLIENT_IP_HEADERS = {
950
987
  cloudflare: "cf-connecting-ip",
951
988
  vercel: "x-real-ip",
952
989
  };
990
+ /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
991
+ const PRERENDERED_DIR = "_prerendered";
953
992
  /**
954
993
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
955
994
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -984,7 +1023,20 @@ export function generateServerEntry(options) {
984
1023
  if (target === "static") {
985
1024
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
986
1025
  }
987
- const lines = ['import { createWebApp } from "@nifrajs/web"'];
1026
+ // Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
1027
+ // condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
1028
+ const nodeFs = target === "bun" || target === "node";
1029
+ const prerendered = Object.entries(options.prerenderedPages ?? {});
1030
+ if (prerendered.length > 0 && !nodeFs) {
1031
+ throw new Error(`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`);
1032
+ }
1033
+ const lines = [
1034
+ !nodeFs
1035
+ ? 'import { createWebApp } from "@nifrajs/web"'
1036
+ : prerendered.length > 0
1037
+ ? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
1038
+ : 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
1039
+ ];
988
1040
  if (backendImport !== undefined)
989
1041
  lines.push('import { inProcessClient } from "@nifrajs/client"');
990
1042
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`);
@@ -996,13 +1048,24 @@ export function generateServerEntry(options) {
996
1048
  if (backendImport !== undefined) {
997
1049
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`);
998
1050
  }
999
- lines.push('import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"');
1051
+ lines.push(options.copyChunks === true
1052
+ ? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
1053
+ : 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"');
1000
1054
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1001
1055
  if (target === "cloudflare")
1002
1056
  lines.push('import { toFetchHandler } from "@nifrajs/core/server"');
1003
1057
  if (target === "node")
1004
1058
  lines.push('import { serve } from "@nifrajs/node"');
1005
- lines.push("", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), ...importedOptions.map((name) => ` ${name},`), " manifest,", " clientEntry,", " styles,", " routeStyles,", " cssLoading,", ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`,
1059
+ if (nodeFs)
1060
+ lines.push('import { fileURLToPath } from "node:url"');
1061
+ if (target === "deno")
1062
+ lines.push('import { parseByteRange } from "@nifrajs/core/range"');
1063
+ if (prerendered.length > 0) {
1064
+ const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]));
1065
+ const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]));
1066
+ lines.push("", `// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`, "const prerenderedPages = {", ` pages: ${JSON.stringify(headers)},`, " read: prerenderedPageReader({", ` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`, ` files: ${JSON.stringify(files)},`, " }),", "}");
1067
+ }
1068
+ lines.push("", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), ...(prerendered.length > 0 ? [" prerenderedPages,"] : []), ...importedOptions.map((name) => ` ${name},`), " manifest,", " clientEntry,", " styles,", " routeStyles,", " cssLoading,", ...(options.copyChunks === true ? [" copyChunks,"] : []), ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`,
1006
1069
  // Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
1007
1070
  ...(clientIpHeader !== undefined
1008
1071
  ? [` server: { clientIp: { header: ${JSON.stringify(clientIpHeader)} } },`]
@@ -1026,27 +1089,132 @@ export function generateServerEntry(options) {
1026
1089
  lines.push("// The client bundle and public files live next to this entry; serve only manifest-approved paths.", 'const STATIC_ROOT = new URL("./", import.meta.url)', `const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, ...(hashedFiles !== undefined
1027
1090
  ? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
1028
1091
  : []), "const staticPath = (pathname: string): string | undefined => {", " if (PUBLIC_FILES.has(pathname)) return '.' + pathname", ' if (!pathname.startsWith("/assets/")) return undefined', ' const segments = pathname.slice(1).split("/")', " // `.` and `..` match the character class, and the path is joined onto a base URL that resolves", " // dot segments - so they are rejected explicitly rather than relying on the request URL having", " // been normalized upstream.", " const safe = (segment: string): boolean =>", ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)', " return segments.every(safe) ? '.' + pathname : undefined", "}",
1029
- // The type table and cache policy `servePublicDir` uses. Without a known type, Bun answers a file
1030
- // as an `application/octet-stream` attachment, which a browser downloads instead of showing.
1031
- `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`, "const staticHeaders = (pathname: string): Record<string, string> => ({", ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",', ` "cache-control": ${hashedFiles !== undefined ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`, ' "x-content-type-options": "nosniff",', "})");
1092
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
1093
+ // answered without reading the file, byte ranges and conditional requests, and a descriptor opened
1094
+ // only once the real path is inside the root, so a link planted in the deployed tree cannot reach
1095
+ // a file outside it.
1096
+ ...(nodeFs
1097
+ ? [
1098
+ "const serveStatic = servePublicDir({",
1099
+ " dir: fileURLToPath(STATIC_ROOT),",
1100
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1101
+ "})",
1102
+ "const staticResponse = async (req: Request): Promise<Response> =>",
1103
+ ' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
1104
+ ]
1105
+ : denoStaticResponse(hashedFiles !== undefined)));
1032
1106
  // Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
1033
1107
  // frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
1034
1108
  const markFramed = '(req as unknown as Record<symbol, unknown>)[Symbol.for("nifra.body.trustedFraming")] = true';
1035
1109
  if (target === "bun") {
1036
- lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " 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 })', " return new Response(file, { headers: staticHeaders(pathname) })", " }", ` ${markFramed}`, " // The socket peer, looked up only when something reads it (as core's own listen() does).", " return app.fetch(req, { get clientIp() { return server.requestIP(req)?.address } })", " },", "})",
1110
+ lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " const { pathname } = new URL(req.url)",
1111
+ // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1112
+ " if (staticPath(pathname) !== undefined) {", " return staticResponse(req)", " }", ` ${markFramed}`, " // The socket peer, looked up only when something reads it (as core's own listen() does).", " return app.fetch(req, { get clientIp() { return server.requestIP(req)?.address } })", " },", "})",
1037
1113
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1038
1114
  // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1039
1115
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
1040
1116
  return `${lines.join("\n")}\n`;
1041
1117
  }
1042
1118
  if (target === "node") {
1043
- lines.push('import { readFile } from "node:fs/promises"', "await serve(", " {", " async fetch(req, platform) {", " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " try {", " const body = await readFile(new URL(filePath, STATIC_ROOT))", " return new Response(body, { headers: staticHeaders(pathname) })", " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", " return app.fetch(req, platform)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
1119
+ lines.push("await serve(", " {", " async fetch(req, platform) {", " const { pathname } = new URL(req.url)", " if (staticPath(pathname) !== undefined) return staticResponse(req)", " return app.fetch(req, platform)", " },", " },", " { port: Number(process.env.PORT ?? 3000) },", ")");
1044
1120
  return `${lines.join("\n")}\n`;
1045
1121
  }
1046
1122
  // deno
1047
- lines.push("// @ts-ignore - Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {', " 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))", " return new Response(body, { headers: staticHeaders(pathname) })", " } catch {", ' return new Response("not found", { status: 404 })', " }", " }", ` ${markFramed}`, " return app.fetch(req, { clientIp: info.remoteAddr.hostname })", "})");
1123
+ lines.push("// @ts-ignore - Deno global is present on the Deno runtime this output targets.", 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {', " const { pathname } = new URL(req.url)", " const filePath = staticPath(pathname)", " if (filePath !== undefined) {", " return staticResponse(req, filePath, pathname)", " }", ` ${markFramed}`, " return app.fetch(req, { clientIp: info.remoteAddr.hostname })", "})");
1048
1124
  return `${lines.join("\n")}\n`;
1049
1125
  }
1126
+ /**
1127
+ * The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
1128
+ * inside the root and cross no hidden segment, and the opened file must still be the one that path
1129
+ * names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
1130
+ * HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
1131
+ * whole file.
1132
+ */
1133
+ function denoStaticResponse(hashed) {
1134
+ return [
1135
+ `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1136
+ "const staticHeaders = (pathname: string): Record<string, string> => ({",
1137
+ ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1138
+ ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1139
+ ' "x-content-type-options": "nosniff",',
1140
+ ' "accept-ranges": "bytes",',
1141
+ "})",
1142
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1143
+ "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1144
+ "const hiddenSegment = (rel: string): boolean =>",
1145
+ ' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
1146
+ "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1147
+ ' const missing = () => new Response("not found", { status: 404 })',
1148
+ ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1149
+ " let file, info",
1150
+ " try {",
1151
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1152
+ " const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1153
+ ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1154
+ " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1155
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1156
+ " file = await Deno.open(real)",
1157
+ " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
1158
+ " // The opened file must be the one at `real`, which must still resolve to itself.",
1159
+ " const opened = await file.stat()",
1160
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1161
+ " const named = await Deno.stat(real)",
1162
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1163
+ " if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
1164
+ " file.close()",
1165
+ " return missing()",
1166
+ " }",
1167
+ " info = opened",
1168
+ " } catch {",
1169
+ " file?.close()",
1170
+ " return missing()",
1171
+ " }",
1172
+ " const size: number = info.size",
1173
+ " const headers = new Headers(staticHeaders(pathname))",
1174
+ ' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
1175
+ ' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
1176
+ " file.close()",
1177
+ " if (!info.isFile) return missing()",
1178
+ ' if (range.kind !== "unsatisfiable") {',
1179
+ ' headers.set("content-length", String(size))',
1180
+ " return new Response(null, { headers })",
1181
+ " }",
1182
+ ' headers.delete("content-type")',
1183
+ ' headers.set("content-range", "bytes */" + size)',
1184
+ " return new Response(null, { status: 416, headers })",
1185
+ " }",
1186
+ ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1187
+ " const { start, end } = range.ranges[0]!",
1188
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1189
+ " await file.seek(start, Deno.SeekMode.Start)",
1190
+ " let left = end - start + 1",
1191
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1192
+ ' headers.set("content-length", String(left))',
1193
+ " const body = new ReadableStream<Uint8Array>({",
1194
+ " async pull(controller) {",
1195
+ " const chunk = new Uint8Array(Math.min(left, 65_536))",
1196
+ " const read: number | null = await file.read(chunk)",
1197
+ " if (read === null || read === 0) {",
1198
+ " file.close()",
1199
+ " controller.close()",
1200
+ " return",
1201
+ " }",
1202
+ " controller.enqueue(chunk.subarray(0, read))",
1203
+ " left -= read",
1204
+ " if (left <= 0) {",
1205
+ " file.close()",
1206
+ " controller.close()",
1207
+ " }",
1208
+ " },",
1209
+ " cancel: () => file.close(),",
1210
+ " })",
1211
+ " return new Response(body, { status: 206, headers })",
1212
+ " }",
1213
+ ' headers.set("content-length", String(size))',
1214
+ " return new Response(file.readable, { headers })",
1215
+ "}",
1216
+ ];
1217
+ }
1050
1218
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1051
1219
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1052
1220
  * pure {@link aggregateSizeReport}. */
@@ -1103,6 +1271,7 @@ export const bunBundler = {
1103
1271
  target: input.target,
1104
1272
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1105
1273
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1274
+ ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1106
1275
  ...(input.plugins ? { plugins: input.plugins } : {}),
1107
1276
  ...(input.define ? { define: input.define } : {}),
1108
1277
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -1126,6 +1295,36 @@ function assertNoPublicCollision(plan, publicFiles, outDir) {
1126
1295
  `build's own output in ${outDir}. Rename or move them out of the public directory.`);
1127
1296
  }
1128
1297
  }
1298
+ /** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
1299
+ * answer from their file: those whose render was not specific to its request. */
1300
+ async function prerenderForServer(prerenderApp, client, routesDir, dir, publicFiles, secrets) {
1301
+ const owned = `/${PRERENDERED_DIR}/`;
1302
+ const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned));
1303
+ if (clashes.length > 0) {
1304
+ throw new Error(`[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
1305
+ "prerendered pages. Rename or move them out of the public directory.");
1306
+ }
1307
+ let result;
1308
+ try {
1309
+ result = await prerenderRoutes({
1310
+ app: await prerenderApp(client),
1311
+ routes: discoverRoutes(routesDir).routes,
1312
+ outDir: dir,
1313
+ secrets,
1314
+ });
1315
+ }
1316
+ catch (error) {
1317
+ // Prerendering loads every route module at build time, which a server build never needed before:
1318
+ // one that cannot load there (a connection opened on import, an env var only production sets) costs
1319
+ // the speedup, not the build. A secret in a page still fails it.
1320
+ if (error instanceof SecretScanError)
1321
+ throw error;
1322
+ rmSync(dir, { recursive: true, force: true });
1323
+ console.warn(`[nifra/web] prerendering failed, so the server renders every page on demand: ${error instanceof Error ? error.message : String(error)}`);
1324
+ return {};
1325
+ }
1326
+ return servablePrerenderedPages(result.prerendered);
1327
+ }
1129
1328
  /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1130
1329
  export async function buildTarget(target, options) {
1131
1330
  return buildTargetWith(target, options, bunBundler);
@@ -1208,6 +1407,12 @@ export async function buildTargetWith(target, options, bundler) {
1208
1407
  if (requestedCssLoading !== undefined || publicFiles.length > 0) {
1209
1408
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2));
1210
1409
  }
1410
+ // A self-hosting server answers its prerendered pages from their file. The static target is all
1411
+ // prerendered pages; the edge targets keep rendering them on demand.
1412
+ const prerenderedPages = options.prerenderApp !== undefined &&
1413
+ (targetPlan.target === "bun" || targetPlan.target === "node")
1414
+ ? await prerenderForServer(options.prerenderApp, client, routesDir, `${staticRoot}/${PRERENDERED_DIR}`, publicFiles, secretScan)
1415
+ : undefined;
1211
1416
  if (targetPlan.kind === "static") {
1212
1417
  if (options.prerenderApp === undefined) {
1213
1418
  throw new Error("[nifra/web] buildTarget(static) requires `prerenderApp` (a factory `(client) => createWebApp`)");
@@ -1253,6 +1458,8 @@ export async function buildTargetWith(target, options, bundler) {
1253
1458
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1254
1459
  hashedFiles: client.assets,
1255
1460
  ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1461
+ ...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
1462
+ ...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
1256
1463
  }));
1257
1464
  const { worker } = await bundler.buildServer({
1258
1465
  routesDir,
@@ -1262,6 +1469,7 @@ export async function buildTargetWith(target, options, bundler) {
1262
1469
  target: targetPlan.serverTarget,
1263
1470
  ...(client.css !== undefined ? { styles: client.css } : {}),
1264
1471
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
1472
+ ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
1265
1473
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1266
1474
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1267
1475
  ...(client.cssLoading !== undefined