@nifrajs/web 5.3.0 → 5.5.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 (118) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/build-plan.d.ts +6 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +25 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +2 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +2 -0
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +17 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +174 -54
  13. package/dist/build.js.map +1 -1
  14. package/dist/deferred.d.ts.map +1 -1
  15. package/dist/deferred.js +20 -6
  16. package/dist/deferred.js.map +1 -1
  17. package/dist/dev.d.ts +5 -0
  18. package/dist/dev.d.ts.map +1 -1
  19. package/dist/dev.js +7 -6
  20. package/dist/dev.js.map +1 -1
  21. package/dist/diagnostic.d.ts +6 -0
  22. package/dist/diagnostic.d.ts.map +1 -1
  23. package/dist/diagnostic.js +41 -0
  24. package/dist/diagnostic.js.map +1 -1
  25. package/dist/host-policy.d.ts +72 -0
  26. package/dist/host-policy.d.ts.map +1 -0
  27. package/dist/host-policy.js +149 -0
  28. package/dist/host-policy.js.map +1 -0
  29. package/dist/host-reach.d.ts +49 -0
  30. package/dist/host-reach.d.ts.map +1 -0
  31. package/dist/host-reach.js +144 -0
  32. package/dist/host-reach.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/certified-components.d.ts +8 -0
  38. package/dist/internal/certified-components.d.ts.map +1 -0
  39. package/dist/internal/certified-components.js +8 -0
  40. package/dist/internal/certified-components.js.map +1 -0
  41. package/dist/internal/codegen.d.ts +8 -0
  42. package/dist/internal/codegen.d.ts.map +1 -1
  43. package/dist/internal/codegen.js +15 -4
  44. package/dist/internal/codegen.js.map +1 -1
  45. package/dist/internal/node-adapter.d.ts +19 -0
  46. package/dist/internal/node-adapter.d.ts.map +1 -0
  47. package/dist/internal/node-adapter.js +48 -0
  48. package/dist/internal/node-adapter.js.map +1 -0
  49. package/dist/internal/output-guard.d.ts +6 -0
  50. package/dist/internal/output-guard.d.ts.map +1 -1
  51. package/dist/internal/output-guard.js +118 -16
  52. package/dist/internal/output-guard.js.map +1 -1
  53. package/dist/internal/page-execution.d.ts +2 -0
  54. package/dist/internal/page-execution.d.ts.map +1 -1
  55. package/dist/internal/page-execution.js +16 -11
  56. package/dist/internal/page-execution.js.map +1 -1
  57. package/dist/internal/parity.d.ts.map +1 -1
  58. package/dist/internal/parity.js +3 -2
  59. package/dist/internal/parity.js.map +1 -1
  60. package/dist/internal/public-url.d.ts +0 -7
  61. package/dist/internal/public-url.d.ts.map +1 -1
  62. package/dist/internal/public-url.js +0 -13
  63. package/dist/internal/public-url.js.map +1 -1
  64. package/dist/internal/render-document.d.ts +3 -1
  65. package/dist/internal/render-document.d.ts.map +1 -1
  66. package/dist/internal/render-document.js +10 -4
  67. package/dist/internal/render-document.js.map +1 -1
  68. package/dist/internal/web-app.d.ts +15 -1
  69. package/dist/internal/web-app.d.ts.map +1 -1
  70. package/dist/internal/web-app.js +8 -0
  71. package/dist/internal/web-app.js.map +1 -1
  72. package/dist/public-dir.d.ts +5 -7
  73. package/dist/public-dir.d.ts.map +1 -1
  74. package/dist/public-dir.js +54 -96
  75. package/dist/public-dir.js.map +1 -1
  76. package/dist/router.d.ts +2 -1
  77. package/dist/router.d.ts.map +1 -1
  78. package/dist/router.js +9 -8
  79. package/dist/router.js.map +1 -1
  80. package/dist/server-components/vite.d.ts.map +1 -1
  81. package/dist/server-components/vite.js +8 -6
  82. package/dist/server-components/vite.js.map +1 -1
  83. package/dist/service-worker.d.ts +3 -1
  84. package/dist/service-worker.d.ts.map +1 -1
  85. package/dist/service-worker.js +29 -8
  86. package/dist/service-worker.js.map +1 -1
  87. package/dist/vite.d.ts.map +1 -1
  88. package/dist/vite.js +3 -3
  89. package/dist/vite.js.map +1 -1
  90. package/dist/zones.d.ts +5 -1
  91. package/dist/zones.d.ts.map +1 -1
  92. package/dist/zones.js +17 -4
  93. package/dist/zones.js.map +1 -1
  94. package/package.json +4 -4
  95. package/src/build-plan.ts +26 -0
  96. package/src/build-vite.ts +4 -0
  97. package/src/build.ts +228 -68
  98. package/src/deferred.ts +20 -6
  99. package/src/dev.ts +7 -6
  100. package/src/diagnostic.ts +43 -0
  101. package/src/host-policy.ts +219 -0
  102. package/src/host-reach.ts +177 -0
  103. package/src/index.ts +10 -2
  104. package/src/internal/certified-components.ts +7 -0
  105. package/src/internal/codegen.ts +24 -5
  106. package/src/internal/node-adapter.ts +57 -0
  107. package/src/internal/output-guard.ts +166 -15
  108. package/src/internal/page-execution.ts +23 -9
  109. package/src/internal/parity.ts +3 -2
  110. package/src/internal/public-url.ts +0 -13
  111. package/src/internal/render-document.ts +11 -4
  112. package/src/internal/web-app.ts +27 -1
  113. package/src/public-dir.ts +70 -97
  114. package/src/router.ts +9 -8
  115. package/src/server-components/vite.ts +11 -6
  116. package/src/service-worker.ts +32 -9
  117. package/src/vite.ts +3 -3
  118. package/src/zones.ts +27 -5
package/dist/build.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { formatBuildLog } from "./diagnostic.js";
1
2
  import { publicEnvDefines } from "./internal/public-env.js";
2
3
  export { publicEnvDefines } from "./internal/public-env.js";
3
4
  /**
@@ -11,20 +12,23 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
11
12
  import { cp, lstat, mkdir, realpath } from "node:fs/promises";
12
13
  import { dirname, isAbsolute, join, basename as pathBasename, posix, relative, resolve as resolvePath, sep, } from "node:path";
13
14
  import { singleCopyPlugin as declaredSingleCopyPlugin, readSingleCopyDeclaration, } from "@nifrajs/core/single-copy";
15
+ import { isHiddenPath } from "@nifrajs/core/static-file";
14
16
  import { Glob } from "bun";
15
- import { aggregateSizeReport, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
17
+ import { aggregateSizeReport, buildAssetPaths, detectNodeBuiltinsInClient, detectServerOnlyInClient, formatNodeBuiltinLeak, formatServerOnlyLeak, parseBuildTarget, parseManifestBuildAssets, parseManifestClientEntry, parseManifestCopyChunks, parseManifestCssLoading, parseManifestRouteStyles, parseManifestStyles, planBuildTarget, } from "./build-plan.js";
16
18
  import { sanitizeOutputNames } from "./chunk-names.js";
17
19
  import { normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.js";
18
20
  import { discoverRoutes } from "./fs.js";
19
21
  import { generateClientEntry, generateServerManifest } from "./index.js";
22
+ import { missingClientEntry } from "./internal/codegen.js";
20
23
  import { loadCopySplit } from "./internal/copy-split.js";
21
24
  import { assertDisposableDir } from "./internal/disposable-dir.js";
22
25
  import { entryChunks } from "./internal/entry-chunks.js";
23
26
  import { dedupePolicyFor } from "./internal/identity-policy.js";
27
+ import { nodeAdapterRefusal } from "./internal/node-adapter.js";
24
28
  import { assertDevelopmentProductionParity, assertIdentityParity, collectDevelopmentParityInput, } from "./internal/parity.js";
25
29
  import { servablePrerenderedPages, } from "./internal/prerendered-pages.js";
26
30
  import { privateEnvCheck } from "./internal/private-env.js";
27
- import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.js";
31
+ import { publicUrlPath } from "./internal/public-url.js";
28
32
  import { assertNoSecrets, buildEnvironment, emittedScanFiles, formatSecretFindings, graphScanInput, originName, publicScanFiles, SecretScanError, scanForSecrets, textOf, } from "./internal/secret-scan.js";
29
33
  import { generateServerFnStub, SERVER_FN_MODULE, serverFnNamespace, } from "./internal/server-boundary.js";
30
34
  import { formatUnsupportedBuiltins, unsupportedBuiltins } from "./internal/target-compat.js";
@@ -34,7 +38,7 @@ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.js";
34
38
  import { zoneGuardPlugin } from "./plugins/zone-guard.js";
35
39
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
36
40
  import { prerenderRoutes } from "./prerender.js";
37
- import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.js";
41
+ import { IMMUTABLE, ONE_DAY } from "./public-dir.js";
38
42
  import { createZoneClassifier } from "./zones.js";
39
43
  export * from "./build-plan.js";
40
44
  const basename = (path) => pathBasename(path);
@@ -180,7 +184,7 @@ export async function copyPublicDir(from, to) {
180
184
  const sourceReal = await realpath(source);
181
185
  const copied = [];
182
186
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
183
- if (isHiddenPublicPath(rel))
187
+ if (isHiddenPath(rel))
184
188
  continue;
185
189
  const candidate = join(source, rel);
186
190
  const candidateStat = await lstat(candidate);
@@ -211,6 +215,7 @@ export async function copyPublicDir(from, to) {
211
215
  export function resyncServerManifestSource(source, manifest, routesPrefix) {
212
216
  const cssLoading = parseManifestCssLoading(source);
213
217
  const copyChunks = parseManifestCopyChunks(source);
218
+ const buildAssets = parseManifestBuildAssets(source);
214
219
  return generateServerManifest(manifest, {
215
220
  resolve: (file) => `${routesPrefix}${file}`,
216
221
  clientEntry: parseManifestClientEntry(source) ?? "",
@@ -218,6 +223,7 @@ export function resyncServerManifestSource(source, manifest, routesPrefix) {
218
223
  routeStyles: parseManifestRouteStyles(source),
219
224
  ...(cssLoading !== undefined ? { cssLoading } : {}),
220
225
  ...(copyChunks !== undefined ? { copyChunks } : {}),
226
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
221
227
  lazy: MANIFEST_IS_LAZY.test(source),
222
228
  });
223
229
  }
@@ -371,7 +377,7 @@ export async function buildClient(options) {
371
377
  throw new Error(verdict);
372
378
  if (refused.size > 0)
373
379
  throw new Error(formatRefusedFiles(refused, root));
374
- throw new Error(`[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`);
380
+ throw new Error(`[nifra/web] client build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`);
375
381
  }
376
382
  const emitted = await Promise.all(result.outputs.map(async (out) => {
377
383
  if (out.kind === "sourcemap")
@@ -815,6 +821,7 @@ function formatRefusedFiles(refused, root) {
815
821
  });
816
822
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`;
817
823
  }
824
+ export { checkHostReach, } from "./host-reach.js";
818
825
  export { importGlobPlugin } from "./plugins/import-glob.js";
819
826
  export { zoneGuardPlugin } from "./plugins/zone-guard.js";
820
827
  /**
@@ -864,7 +871,22 @@ const solidWebServerPlugin = (from) => ({
864
871
  * silently ships a broken worker.
865
872
  */
866
873
  export async function buildServer(options) {
867
- const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles, cssLoading } = options;
874
+ const { routesDir, serverEntry, outDir, client } = options;
875
+ if (client !== undefined &&
876
+ (typeof client !== "object" ||
877
+ client === null ||
878
+ typeof client.entry !== "string" ||
879
+ !Array.isArray(client.assets))) {
880
+ throw new TypeError("[nifra/web] buildServer `client` must be the manifest buildClient returned ({ entry, assets, ... })");
881
+ }
882
+ const clientEntry = options.clientEntry ?? client?.entry;
883
+ if (clientEntry === undefined)
884
+ throw missingClientEntry(clientEntry);
885
+ const styles = options.styles ?? client?.css;
886
+ const routeStyles = options.routeStyles ?? client?.routeStyles;
887
+ const copyChunks = options.copyChunks ?? client?.copyChunks;
888
+ const cssLoading = options.cssLoading ?? client?.cssLoading;
889
+ const buildAssets = options.buildAssets ?? (client === undefined ? undefined : buildAssetPaths(client));
868
890
  const entryDir = dirname(serverEntry);
869
891
  const manifestFile = options.manifestFile ?? "server-manifest.ts";
870
892
  // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
@@ -886,7 +908,8 @@ export async function buildServer(options) {
886
908
  styles,
887
909
  routeStyles,
888
910
  ...(cssLoading !== undefined ? { cssLoading } : {}),
889
- ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
911
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
912
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
890
913
  lazy,
891
914
  }));
892
915
  const result = await Bun.build({
@@ -920,7 +943,8 @@ export async function buildServer(options) {
920
943
  ],
921
944
  });
922
945
  if (!result.success) {
923
- throw new Error(`[nifra/web] server build failed:\n${result.logs.map((l) => String(l)).join("\n")}`);
946
+ const root = resolvePath(options.root ?? dirname(routesDir));
947
+ throw new Error(`[nifra/web] server build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`);
924
948
  }
925
949
  // Named as the metafile names outputs: relative to `outdir`.
926
950
  const nameOf = (path) => relative(outDir, path).replaceAll("\\", "/");
@@ -976,6 +1000,7 @@ export const SERVER_ENTRY_OPTIONS = [
976
1000
  "csp",
977
1001
  "nonce",
978
1002
  "i18n",
1003
+ "hostPolicy",
979
1004
  ];
980
1005
  /**
981
1006
  * The header each edge platform sets to the caller's address, replacing any value the client sent.
@@ -987,6 +1012,10 @@ const PLATFORM_CLIENT_IP_HEADERS = {
987
1012
  cloudflare: "cf-connecting-ip",
988
1013
  vercel: "x-real-ip",
989
1014
  };
1015
+ // A static path a host's policy refuses is not answered from disk: it falls through to the app, whose
1016
+ // own copy of the policy refuses it the same way it refuses a page.
1017
+ const HOST_ALLOWS_STATIC = ' && hostPolicy.allows(request.headers.get("host") ?? "", pathname, buildAssets)';
1018
+ const NODE_STATIC_ALLOW = " allow: (host: string, pathname: string) => hostPolicy.allows(host, pathname, buildAssets),";
990
1019
  /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
991
1020
  const PRERENDERED_DIR = "_prerendered";
992
1021
  /**
@@ -1023,20 +1052,20 @@ export function generateServerEntry(options) {
1023
1052
  if (target === "static") {
1024
1053
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)");
1025
1054
  }
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.
1055
+ // Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
1056
+ // both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
1057
+ // stub, so its entry runs the same checks on Deno's own file API.
1028
1058
  const nodeFs = target === "bun" || target === "node";
1029
1059
  const prerendered = Object.entries(options.prerenderedPages ?? {});
1030
1060
  if (prerendered.length > 0 && !nodeFs) {
1031
1061
  throw new Error(`[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`);
1032
1062
  }
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
- ];
1063
+ const webImports = ["createWebApp"];
1064
+ if (prerendered.length > 0)
1065
+ webImports.push("prerenderedPageReader");
1066
+ if (target === "bun")
1067
+ webImports.push("servePublicDir");
1068
+ const lines = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`];
1040
1069
  if (backendImport !== undefined)
1041
1070
  lines.push('import { inProcessClient } from "@nifrajs/client"');
1042
1071
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`);
@@ -1048,9 +1077,18 @@ export function generateServerEntry(options) {
1048
1077
  if (backendImport !== undefined) {
1049
1078
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`);
1050
1079
  }
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"');
1080
+ // A host policy is checked before the static files too, which takes the build's own file set.
1081
+ const hostPolicy = importedOptions.includes("hostPolicy");
1082
+ const manifestImports = [
1083
+ ...(hostPolicy ? ["buildAssets"] : []),
1084
+ "clientEntry",
1085
+ ...(options.copyChunks === true ? ["copyChunks"] : []),
1086
+ "cssLoading",
1087
+ "manifest",
1088
+ "styles",
1089
+ "routeStyles",
1090
+ ];
1091
+ lines.push(`import { ${manifestImports.join(", ")} } from "./server-manifest"`);
1054
1092
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1055
1093
  if (target === "cloudflare")
1056
1094
  lines.push('import { toFetchHandler } from "@nifrajs/core/server"');
@@ -1058,14 +1096,15 @@ export function generateServerEntry(options) {
1058
1096
  lines.push('import { serve } from "@nifrajs/node"');
1059
1097
  if (nodeFs)
1060
1098
  lines.push('import { fileURLToPath } from "node:url"');
1061
- if (target === "deno")
1062
- lines.push('import { parseByteRange } from "@nifrajs/core/range"');
1099
+ if (target === "deno") {
1100
+ lines.push('import { parseByteRange } from "@nifrajs/core/range"', 'import { CONTENT_SNAPSHOT_MAX, contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf, streamedLastModifiedOf } from "@nifrajs/core/static-file"', 'import { createHash } from "node:crypto"');
1101
+ }
1063
1102
  if (prerendered.length > 0) {
1064
1103
  const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]));
1065
1104
  const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]));
1066
1105
  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
1106
  }
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)},`,
1107
+ lines.push("", "const app = createWebApp({", " adapter,", ...(useImport !== undefined ? [" use,"] : []), ...(prerendered.length > 0 ? [" prerenderedPages,"] : []), ...importedOptions.map((name) => ` ${name},`), ...(hostPolicy ? [" buildAssets,"] : []), " manifest,", " clientEntry,", " styles,", " routeStyles,", " cssLoading,", ...(options.copyChunks === true ? [" copyChunks,"] : []), ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []), ` title: ${JSON.stringify(title)},`,
1069
1108
  // Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
1070
1109
  ...(clientIpHeader !== undefined
1071
1110
  ? [` server: { clientIp: { header: ${JSON.stringify(clientIpHeader)} } },`]
@@ -1078,7 +1117,7 @@ export function generateServerEntry(options) {
1078
1117
  // rules - so a static request CAN arrive here. Serve it from the ASSETS binding instead of letting
1079
1118
  // the router 404 it, which makes correctness independent of how much of the list fit: the exclude
1080
1119
  // list decides only how many static requests skip the worker, never which files exist.
1081
- lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {', " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
1120
+ lines.push(`const PUBLIC_FILES = new Set(${JSON.stringify(publicFiles)})`, "const handler = toFetchHandler(app)", "type Assets = { readonly ASSETS?: { fetch(request: Request): Promise<Response> } }", "export default {", " fetch(request: Request, env: Assets, ctx: ExecutionContext) {", " const { pathname } = new URL(request.url)", " // Only the build's own outputs: the hashed bundle prefix and the files it copied.", ` if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))${hostPolicy ? HOST_ALLOWS_STATIC : ""}) {`, " return env.ASSETS.fetch(request)", " }", " return handler.fetch(request, env as never, ctx)", " },", "}");
1082
1121
  return `${lines.join("\n")}\n`;
1083
1122
  }
1084
1123
  if (target === "vercel") {
@@ -1088,12 +1127,19 @@ export function generateServerEntry(options) {
1088
1127
  // bun / node / deno self-host AND serve the client bundle from disk (it sits next to this entry).
1089
1128
  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
1090
1129
  ? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
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", "}",
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
1130
+ : []));
1131
+ if (target === "node") {
1132
+ // The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
1133
+ // wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
1134
+ // and every file went through one too. The root mount serves only the files the build listed.
1135
+ lines.push("await serve(app, {", " port: Number(process.env.PORT ?? 3000),", " static: [", " {", ' dir: fileURLToPath(new URL("./assets/", import.meta.url)),', ' prefix: "/assets",', ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []), ` cacheControl: ${JSON.stringify(ONE_DAY)},`, ...(hostPolicy ? [NODE_STATIC_ALLOW] : []), " },", " {", " dir: fileURLToPath(STATIC_ROOT),", ' prefix: "/",', " files: PUBLIC_FILES,", " immutable: false,", ` cacheControl: ${JSON.stringify(ONE_DAY)},`, ...(hostPolicy ? [NODE_STATIC_ALLOW] : []), " },", " ],", "})");
1136
+ return `${lines.join("\n")}\n`;
1137
+ }
1138
+ lines.push("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", "}",
1139
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
1140
+ // tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
1141
+ // inside the root, so a link planted in the deployed tree cannot reach a file outside it.
1142
+ ...(target === "bun"
1097
1143
  ? [
1098
1144
  "const serveStatic = servePublicDir({",
1099
1145
  " dir: fileURLToPath(STATIC_ROOT),",
@@ -1109,49 +1155,51 @@ export function generateServerEntry(options) {
1109
1155
  if (target === "bun") {
1110
1156
  lines.push("const server = Bun.serve({", " port: Number(Bun.env.PORT ?? 3000),", " async fetch(req, server) {", " const { pathname } = new URL(req.url)",
1111
1157
  // 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 } })", " },", "})",
1158
+ ` if (staticPath(pathname) !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`, " 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 } })", " },", "})",
1113
1159
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
1114
1160
  // source - split so biome's noTemplateCurlyInString doesn't flag it; the emitted line is unchanged.
1115
1161
  `console.log(\`nifra (Bun) → http://localhost:$${"{server.port}"}\`)`);
1116
1162
  return `${lines.join("\n")}\n`;
1117
1163
  }
1118
- if (target === "node") {
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) },", ")");
1120
- return `${lines.join("\n")}\n`;
1121
- }
1122
1164
  // deno
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 })", "})");
1165
+ 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${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`, " return staticResponse(req, filePath, pathname)", " }", ` ${markFramed}`, " return app.fetch(req, { clientIp: info.remoteAddr.hostname })", "})");
1124
1166
  return `${lines.join("\n")}\n`;
1125
1167
  }
1126
1168
  /**
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.
1169
+ * The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
1170
+ * path must sit inside the root and cross no hidden segment, and the opened file must still be the one
1171
+ * that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
1172
+ * inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
1173
+ * own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
1174
+ * range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
1175
+ * enough is then served from those bytes; otherwise one byte range is served from a seek, several as
1176
+ * the whole file.
1132
1177
  */
1133
1178
  function denoStaticResponse(hashed) {
1134
1179
  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",',
1180
+ "const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
1181
+ ' "content-type": contentTypeOf(file),',
1138
1182
  ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1139
1183
  ' "x-content-type-options": "nosniff",',
1140
1184
  ' "accept-ranges": "bytes",',
1141
1185
  "})",
1142
1186
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1143
1187
  "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")',
1188
+ "// One per process: the tag of a file is remembered by its real path.",
1189
+ "// A stat costs Deno more than hashing a snapshot of up to 24 KiB, so only a larger one is restatted.",
1190
+ "const STATIC_TAGS = contentTags(createHash, { rehashMax: 24 * 1024 })",
1191
+ "// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
1192
+ "const STATIC_INLINE_MAX = CONTENT_SNAPSHOT_MAX",
1146
1193
  "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1147
1194
  ' const missing = () => new Response("not found", { status: 404 })',
1148
1195
  ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1149
1196
  " let file, info",
1197
+ ' let real = ""',
1150
1198
  " try {",
1151
1199
  " // @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))",
1200
+ " real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1153
1201
  ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1154
- " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1202
+ " if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
1155
1203
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1156
1204
  " file = await Deno.open(real)",
1157
1205
  " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
@@ -1169,12 +1217,60 @@ function denoStaticResponse(hashed) {
1169
1217
  " file?.close()",
1170
1218
  " return missing()",
1171
1219
  " }",
1220
+ " if (!info.isFile) {",
1221
+ " file.close()",
1222
+ " return missing()",
1223
+ " }",
1172
1224
  " 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") {',
1225
+ " const headers = new Headers(staticHeaders(pathname, real))",
1226
+ " // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
1227
+ " const versionOf = (stat: { dev: number; ino: number | null; size: number; mtime: Date | null; ctime: Date | null }) => ({",
1228
+ " dev: stat.dev,",
1229
+ " ino: stat.ino ?? Number.NaN,",
1230
+ " size: stat.size,",
1231
+ " mtimeMs: stat.mtime?.getTime() ?? Number.NaN,",
1232
+ " ctimeMs: stat.ctime?.getTime() ?? Number.NaN,",
1233
+ " })",
1234
+ " const version = versionOf(info)",
1235
+ " const snapshot = size <= STATIC_INLINE_MAX",
1236
+ " const lastModified = snapshot ? lastModifiedOf(version) : streamedLastModifiedOf(version)",
1237
+ ' const ifNoneMatch = req.headers.get("if-none-match")',
1238
+ ' const ifModifiedSince = req.headers.get("if-modified-since")',
1239
+ " let whole: Uint8Array<ArrayBuffer> | undefined",
1240
+ " // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version",
1241
+ " // answers it from the stat alone: a write since that tag would show in the stat.",
1242
+ " let etag: string | undefined = snapshot ? STATIC_TAGS.cached(real, version) : undefined",
1243
+ " try {",
1244
+ ' if (snapshot && (etag === undefined || !(req.method === "HEAD" || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))) {',
1245
+ " whole = new Uint8Array(size)",
1246
+ " let filled = 0",
1247
+ " while (filled < size) {",
1248
+ " const read: number | null = await file.read(whole.subarray(filled))",
1249
+ " if (read === null || read === 0) break",
1250
+ " filled += read",
1251
+ " }",
1252
+ " if (filled < size) whole = whole.subarray(0, filled)",
1253
+ " etag = await STATIC_TAGS.ofSnapshot(real, version, whole, async () => versionOf(await file.stat()))",
1254
+ " }",
1255
+ " } catch {",
1256
+ " file.close()",
1257
+ " return missing()",
1258
+ " }",
1259
+ ' if (etag !== undefined) headers.set("etag", etag)',
1260
+ ' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
1261
+ " if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {",
1262
+ " file.close()",
1263
+ ' headers.delete("content-type")',
1264
+ " return new Response(null, { status: 304, headers })",
1265
+ " }",
1266
+ ' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
1267
+ " const range =",
1268
+ " // A streamed body's date is weak: a write during the stream must never splice two versions.",
1269
+ ' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, snapshot ? lastModified : undefined)',
1270
+ " ? parseByteRange(rangeHeader, size)",
1271
+ ' : { kind: "none" as const }',
1272
+ ' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
1176
1273
  " file.close()",
1177
- " if (!info.isFile) return missing()",
1178
1274
  ' if (range.kind !== "unsatisfiable") {',
1179
1275
  ' headers.set("content-length", String(size))',
1180
1276
  " return new Response(null, { headers })",
@@ -1185,10 +1281,16 @@ function denoStaticResponse(hashed) {
1185
1281
  " }",
1186
1282
  ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1187
1283
  " const { start, end } = range.ranges[0]!",
1284
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1285
+ " if (whole !== undefined) {",
1286
+ " file.close()",
1287
+ " const part = whole.subarray(start, end + 1)",
1288
+ ' headers.set("content-length", String(part.byteLength))',
1289
+ " return new Response(part, { status: 206, headers })",
1290
+ " }",
1188
1291
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1189
1292
  " await file.seek(start, Deno.SeekMode.Start)",
1190
1293
  " let left = end - start + 1",
1191
- ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1192
1294
  ' headers.set("content-length", String(left))',
1193
1295
  " const body = new ReadableStream<Uint8Array>({",
1194
1296
  " async pull(controller) {",
@@ -1210,6 +1312,11 @@ function denoStaticResponse(hashed) {
1210
1312
  " })",
1211
1313
  " return new Response(body, { status: 206, headers })",
1212
1314
  " }",
1315
+ " if (whole !== undefined) {",
1316
+ " file.close()",
1317
+ ' headers.set("content-length", String(whole.byteLength))',
1318
+ " return new Response(whole, { headers })",
1319
+ " }",
1213
1320
  ' headers.set("content-length", String(size))',
1214
1321
  " return new Response(file.readable, { headers })",
1215
1322
  "}",
@@ -1272,6 +1379,7 @@ export const bunBundler = {
1272
1379
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1273
1380
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1274
1381
  ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1382
+ ...(input.buildAssets !== undefined ? { buildAssets: input.buildAssets } : {}),
1275
1383
  ...(input.plugins ? { plugins: input.plugins } : {}),
1276
1384
  ...(input.define ? { define: input.define } : {}),
1277
1385
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -1350,6 +1458,12 @@ export async function buildTargetWith(target, options, bundler) {
1350
1458
  ];
1351
1459
  assertDisposableDir("outDir", outDir, sources);
1352
1460
  assertDisposableDir("workDir", workDir, sources);
1461
+ // Before anything is cleared, so a refused build leaves the last deploy dir in place.
1462
+ if (targetPlan.target === "node") {
1463
+ const refusal = nodeAdapterRefusal(workDir);
1464
+ if (refusal !== undefined)
1465
+ throw new Error(refusal);
1466
+ }
1353
1467
  const { rmSync } = await import("node:fs");
1354
1468
  rmSync(outDir, { recursive: true, force: true });
1355
1469
  rmSync(workDir, { recursive: true, force: true });
@@ -1470,6 +1584,7 @@ export async function buildTargetWith(target, options, bundler) {
1470
1584
  ...(client.css !== undefined ? { styles: client.css } : {}),
1471
1585
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
1472
1586
  ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
1587
+ buildAssets: buildAssetPaths(client),
1473
1588
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1474
1589
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1475
1590
  ...(client.cssLoading !== undefined
@@ -1487,7 +1602,12 @@ export async function buildTargetWith(target, options, bundler) {
1487
1602
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`);
1488
1603
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
1489
1604
  // proves nothing can be served beneath it.
1490
- const rules = cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern));
1605
+ // A host policy must see every file request, which Pages answers from the CDN before the worker
1606
+ // when the path is excluded, so with one nothing is: each file costs an invocation, and the
1607
+ // worker serves the allowed ones from ASSETS.
1608
+ const rules = options.optionImports?.hostPolicy === undefined
1609
+ ? cloudflareRouteRules(publicFiles, discoverRoutes(routesDir).routes.map((route) => route.pattern))
1610
+ : { include: ["/*"], exclude: [], omitted: [] };
1491
1611
  writeFileSync(`${outDir}/_routes.json`, `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`);
1492
1612
  // Never cap silently: an omitted file still serves (the worker's ASSETS fallback), but it costs a
1493
1613
  // worker invocation, and that trade should be visible rather than inferred from a latency graph.