@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/src/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { formatBuildLog } from "./diagnostic.ts"
1
2
  import { publicEnvDefines } from "./internal/public-env.ts"
2
3
 
3
4
  export { publicEnvDefines } from "./internal/public-env.ts"
@@ -25,6 +26,7 @@ import {
25
26
  singleCopyPlugin as declaredSingleCopyPlugin,
26
27
  readSingleCopyDeclaration,
27
28
  } from "@nifrajs/core/single-copy"
29
+ import { isHiddenPath } from "@nifrajs/core/static-file"
28
30
  import { type BunPlugin, Glob } from "bun"
29
31
  import {
30
32
  aggregateSizeReport,
@@ -32,12 +34,14 @@ import {
32
34
  type BuildTarget,
33
35
  type BuildTargetPlan,
34
36
  type Bundler,
37
+ buildAssetPaths,
35
38
  type ChunkSize,
36
39
  detectNodeBuiltinsInClient,
37
40
  detectServerOnlyInClient,
38
41
  formatNodeBuiltinLeak,
39
42
  formatServerOnlyLeak,
40
43
  parseBuildTarget,
44
+ parseManifestBuildAssets,
41
45
  parseManifestClientEntry,
42
46
  parseManifestCopyChunks,
43
47
  parseManifestCssLoading,
@@ -51,10 +55,12 @@ import { sanitizeOutputNames } from "./chunk-names.ts"
51
55
  import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
52
56
  import { discoverRoutes } from "./fs.ts"
53
57
  import { generateClientEntry, generateServerManifest } from "./index.ts"
58
+ import { missingClientEntry } from "./internal/codegen.ts"
54
59
  import { loadCopySplit } from "./internal/copy-split.ts"
55
60
  import { assertDisposableDir } from "./internal/disposable-dir.ts"
56
61
  import { entryChunks } from "./internal/entry-chunks.ts"
57
62
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
63
+ import { nodeAdapterRefusal } from "./internal/node-adapter.ts"
58
64
  import {
59
65
  assertDevelopmentProductionParity,
60
66
  assertIdentityParity,
@@ -65,7 +71,7 @@ import {
65
71
  servablePrerenderedPages,
66
72
  } from "./internal/prerendered-pages.ts"
67
73
  import { privateEnvCheck } from "./internal/private-env.ts"
68
- import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.ts"
74
+ import { publicUrlPath } from "./internal/public-url.ts"
69
75
  import {
70
76
  assertNoSecrets,
71
77
  buildEnvironment,
@@ -100,7 +106,7 @@ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
100
106
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
101
107
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
102
108
  import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
103
- import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
109
+ import { IMMUTABLE, ONE_DAY } from "./public-dir.ts"
104
110
  import { createZoneClassifier } from "./zones.ts"
105
111
 
106
112
  export * from "./build-plan.ts"
@@ -360,7 +366,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
360
366
  const sourceReal = await realpath(source)
361
367
  const copied: string[] = []
362
368
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
363
- if (isHiddenPublicPath(rel)) continue
369
+ if (isHiddenPath(rel)) continue
364
370
  const candidate = join(source, rel)
365
371
  const candidateStat = await lstat(candidate)
366
372
  if (candidateStat.isSymbolicLink()) {
@@ -394,6 +400,7 @@ export function resyncServerManifestSource(
394
400
  ): string {
395
401
  const cssLoading = parseManifestCssLoading(source)
396
402
  const copyChunks = parseManifestCopyChunks(source)
403
+ const buildAssets = parseManifestBuildAssets(source)
397
404
  return generateServerManifest(manifest, {
398
405
  resolve: (file) => `${routesPrefix}${file}`,
399
406
  clientEntry: parseManifestClientEntry(source) ?? "",
@@ -401,6 +408,7 @@ export function resyncServerManifestSource(
401
408
  routeStyles: parseManifestRouteStyles(source),
402
409
  ...(cssLoading !== undefined ? { cssLoading } : {}),
403
410
  ...(copyChunks !== undefined ? { copyChunks } : {}),
411
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
404
412
  lazy: MANIFEST_IS_LAZY.test(source),
405
413
  })
406
414
  }
@@ -567,7 +575,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
567
575
  if (verdict !== undefined) throw new Error(verdict)
568
576
  if (refused.size > 0) throw new Error(formatRefusedFiles(refused, root))
569
577
  throw new Error(
570
- `[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
578
+ `[nifra/web] client build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`,
571
579
  )
572
580
  }
573
581
 
@@ -814,9 +822,16 @@ export interface BuildServerOptions {
814
822
  readonly serverEntry: string
815
823
  /** Output directory for the bundled worker (absolute path). */
816
824
  readonly outDir: string
825
+ /**
826
+ * The manifest `buildClient` returned. Supplies {@link clientEntry}, {@link styles},
827
+ * {@link routeStyles}, {@link copyChunks} and {@link buildAssets} at once, so none can be forgotten
828
+ * (a forgotten `styles` ships unstyled pages); an option given explicitly still wins.
829
+ */
830
+ readonly client?: BuildManifest
817
831
  /** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
818
- * server manifest, since a disk-less worker can't read `manifest.json` at runtime. */
819
- readonly clientEntry: string
832
+ * server manifest, since a disk-less worker can't read `manifest.json` at runtime. Required unless
833
+ * {@link client} is given. */
834
+ readonly clientEntry?: string
820
835
  /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
821
836
  * manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
822
837
  * link (the built SSR page would otherwise render unstyled). */
@@ -825,6 +840,12 @@ export interface BuildServerOptions {
825
840
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
826
841
  /** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
827
842
  readonly copyChunks?: Readonly<Record<string, string>> | undefined
843
+ /**
844
+ * Every file the build emitted, by request path (`buildClient`'s `assets` plus its `publicFiles`):
845
+ * baked as `buildAssets` and `isBuildAsset(pathname)` for a host policy or a hand-written static
846
+ * handler. Defaults from {@link client}; without either, the manifest exports neither name.
847
+ */
848
+ readonly buildAssets?: readonly string[] | undefined
828
849
  /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
829
850
  readonly cssLoading?: CssLoadingMode
830
851
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
@@ -1096,6 +1117,12 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
1096
1117
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
1097
1118
  }
1098
1119
 
1120
+ export {
1121
+ checkHostReach,
1122
+ type HostReachOptions,
1123
+ type HostReachReport,
1124
+ type HostReachResult,
1125
+ } from "./host-reach.ts"
1099
1126
  export { importGlobPlugin } from "./plugins/import-glob.ts"
1100
1127
  export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
1101
1128
 
@@ -1151,7 +1178,26 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
1151
1178
  * silently ships a broken worker.
1152
1179
  */
1153
1180
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
1154
- const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles, cssLoading } = options
1181
+ const { routesDir, serverEntry, outDir, client } = options
1182
+ if (
1183
+ client !== undefined &&
1184
+ (typeof client !== "object" ||
1185
+ client === null ||
1186
+ typeof client.entry !== "string" ||
1187
+ !Array.isArray(client.assets))
1188
+ ) {
1189
+ throw new TypeError(
1190
+ "[nifra/web] buildServer `client` must be the manifest buildClient returned ({ entry, assets, ... })",
1191
+ )
1192
+ }
1193
+ const clientEntry = options.clientEntry ?? client?.entry
1194
+ if (clientEntry === undefined) throw missingClientEntry(clientEntry)
1195
+ const styles = options.styles ?? client?.css
1196
+ const routeStyles = options.routeStyles ?? client?.routeStyles
1197
+ const copyChunks = options.copyChunks ?? client?.copyChunks
1198
+ const cssLoading = options.cssLoading ?? client?.cssLoading
1199
+ const buildAssets =
1200
+ options.buildAssets ?? (client === undefined ? undefined : buildAssetPaths(client))
1155
1201
  const entryDir = dirname(serverEntry)
1156
1202
  const manifestFile = options.manifestFile ?? "server-manifest.ts"
1157
1203
  // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
@@ -1176,7 +1222,8 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1176
1222
  styles,
1177
1223
  routeStyles,
1178
1224
  ...(cssLoading !== undefined ? { cssLoading } : {}),
1179
- ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
1225
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
1226
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
1180
1227
  lazy,
1181
1228
  }),
1182
1229
  )
@@ -1212,8 +1259,9 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1212
1259
  ],
1213
1260
  })
1214
1261
  if (!result.success) {
1262
+ const root = resolvePath(options.root ?? dirname(routesDir))
1215
1263
  throw new Error(
1216
- `[nifra/web] server build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
1264
+ `[nifra/web] server build failed:\n${result.logs.map((l) => formatBuildLog(l, root)).join("\n")}`,
1217
1265
  )
1218
1266
  }
1219
1267
  // Named as the metafile names outputs: relative to `outdir`.
@@ -1282,6 +1330,7 @@ export const SERVER_ENTRY_OPTIONS = [
1282
1330
  "csp",
1283
1331
  "nonce",
1284
1332
  "i18n",
1333
+ "hostPolicy",
1285
1334
  ] as const
1286
1335
  export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1287
1336
  /** Each importable option mapped to the specifier of the module that exports it. */
@@ -1306,6 +1355,13 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
1306
1355
  vercel: "x-real-ip",
1307
1356
  }
1308
1357
 
1358
+ // A static path a host's policy refuses is not answered from disk: it falls through to the app, whose
1359
+ // own copy of the policy refuses it the same way it refuses a page.
1360
+ const HOST_ALLOWS_STATIC =
1361
+ ' && hostPolicy.allows(request.headers.get("host") ?? "", pathname, buildAssets)'
1362
+ const NODE_STATIC_ALLOW =
1363
+ " allow: (host: string, pathname: string) => hostPolicy.allows(host, pathname, buildAssets),"
1364
+
1309
1365
  /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
1310
1366
  const PRERENDERED_DIR = "_prerendered"
1311
1367
 
@@ -1379,8 +1435,9 @@ export function generateServerEntry(options: {
1379
1435
  if (target === "static") {
1380
1436
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1381
1437
  }
1382
- // Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
1383
- // condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
1438
+ // Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
1439
+ // both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
1440
+ // stub, so its entry runs the same checks on Deno's own file API.
1384
1441
  const nodeFs = target === "bun" || target === "node"
1385
1442
  const prerendered = Object.entries(options.prerenderedPages ?? {})
1386
1443
  if (prerendered.length > 0 && !nodeFs) {
@@ -1388,13 +1445,10 @@ export function generateServerEntry(options: {
1388
1445
  `[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
1389
1446
  )
1390
1447
  }
1391
- const lines: string[] = [
1392
- !nodeFs
1393
- ? 'import { createWebApp } from "@nifrajs/web"'
1394
- : prerendered.length > 0
1395
- ? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
1396
- : 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
1397
- ]
1448
+ const webImports = ["createWebApp"]
1449
+ if (prerendered.length > 0) webImports.push("prerenderedPageReader")
1450
+ if (target === "bun") webImports.push("servePublicDir")
1451
+ const lines: string[] = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`]
1398
1452
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
1399
1453
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
1400
1454
  if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
@@ -1404,16 +1458,29 @@ export function generateServerEntry(options: {
1404
1458
  if (backendImport !== undefined) {
1405
1459
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
1406
1460
  }
1407
- lines.push(
1408
- options.copyChunks === true
1409
- ? 'import { clientEntry, copyChunks, cssLoading, manifest, styles, routeStyles } from "./server-manifest"'
1410
- : 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1411
- )
1461
+ // A host policy is checked before the static files too, which takes the build's own file set.
1462
+ const hostPolicy = importedOptions.includes("hostPolicy")
1463
+ const manifestImports = [
1464
+ ...(hostPolicy ? ["buildAssets"] : []),
1465
+ "clientEntry",
1466
+ ...(options.copyChunks === true ? ["copyChunks"] : []),
1467
+ "cssLoading",
1468
+ "manifest",
1469
+ "styles",
1470
+ "routeStyles",
1471
+ ]
1472
+ lines.push(`import { ${manifestImports.join(", ")} } from "./server-manifest"`)
1412
1473
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1413
1474
  if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1414
1475
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1415
1476
  if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
1416
- if (target === "deno") lines.push('import { parseByteRange } from "@nifrajs/core/range"')
1477
+ if (target === "deno") {
1478
+ lines.push(
1479
+ 'import { parseByteRange } from "@nifrajs/core/range"',
1480
+ 'import { CONTENT_SNAPSHOT_MAX, contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf, streamedLastModifiedOf } from "@nifrajs/core/static-file"',
1481
+ 'import { createHash } from "node:crypto"',
1482
+ )
1483
+ }
1417
1484
  if (prerendered.length > 0) {
1418
1485
  const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
1419
1486
  const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
@@ -1436,6 +1503,7 @@ export function generateServerEntry(options: {
1436
1503
  ...(useImport !== undefined ? [" use,"] : []),
1437
1504
  ...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
1438
1505
  ...importedOptions.map((name) => ` ${name},`),
1506
+ ...(hostPolicy ? [" buildAssets,"] : []),
1439
1507
  " manifest,",
1440
1508
  " clientEntry,",
1441
1509
  " styles,",
@@ -1468,7 +1536,7 @@ export function generateServerEntry(options: {
1468
1536
  " fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
1469
1537
  " const { pathname } = new URL(request.url)",
1470
1538
  " // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
1471
- ' if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))) {',
1539
+ ` if (env?.ASSETS !== undefined && (pathname.startsWith("/assets/") || PUBLIC_FILES.has(pathname))${hostPolicy ? HOST_ALLOWS_STATIC : ""}) {`,
1472
1540
  " return env.ASSETS.fetch(request)",
1473
1541
  " }",
1474
1542
  " return handler.fetch(request, env as never, ctx)",
@@ -1494,6 +1562,36 @@ export function generateServerEntry(options: {
1494
1562
  ...(hashedFiles !== undefined
1495
1563
  ? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
1496
1564
  : []),
1565
+ )
1566
+ if (target === "node") {
1567
+ // The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
1568
+ // wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
1569
+ // and every file went through one too. The root mount serves only the files the build listed.
1570
+ lines.push(
1571
+ "await serve(app, {",
1572
+ " port: Number(process.env.PORT ?? 3000),",
1573
+ " static: [",
1574
+ " {",
1575
+ ' dir: fileURLToPath(new URL("./assets/", import.meta.url)),',
1576
+ ' prefix: "/assets",',
1577
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1578
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1579
+ ...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
1580
+ " },",
1581
+ " {",
1582
+ " dir: fileURLToPath(STATIC_ROOT),",
1583
+ ' prefix: "/",',
1584
+ " files: PUBLIC_FILES,",
1585
+ " immutable: false,",
1586
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1587
+ ...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
1588
+ " },",
1589
+ " ],",
1590
+ "})",
1591
+ )
1592
+ return `${lines.join("\n")}\n`
1593
+ }
1594
+ lines.push(
1497
1595
  "const staticPath = (pathname: string): string | undefined => {",
1498
1596
  " if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
1499
1597
  ' if (!pathname.startsWith("/assets/")) return undefined',
@@ -1505,11 +1603,10 @@ export function generateServerEntry(options: {
1505
1603
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1506
1604
  " return segments.every(safe) ? '.' + pathname : undefined",
1507
1605
  "}",
1508
- // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
1509
- // answered without reading the file, byte ranges and conditional requests, and a descriptor opened
1510
- // only once the real path is inside the root, so a link planted in the deployed tree cannot reach
1511
- // a file outside it.
1512
- ...(nodeFs
1606
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
1607
+ // tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
1608
+ // inside the root, so a link planted in the deployed tree cannot reach a file outside it.
1609
+ ...(target === "bun"
1513
1610
  ? [
1514
1611
  "const serveStatic = servePublicDir({",
1515
1612
  " dir: fileURLToPath(STATIC_ROOT),",
@@ -1531,7 +1628,7 @@ export function generateServerEntry(options: {
1531
1628
  " async fetch(req, server) {",
1532
1629
  " const { pathname } = new URL(req.url)",
1533
1630
  // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1534
- " if (staticPath(pathname) !== undefined) {",
1631
+ ` if (staticPath(pathname) !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
1535
1632
  " return staticResponse(req)",
1536
1633
  " }",
1537
1634
  ` ${markFramed}`,
@@ -1545,28 +1642,13 @@ export function generateServerEntry(options: {
1545
1642
  )
1546
1643
  return `${lines.join("\n")}\n`
1547
1644
  }
1548
- if (target === "node") {
1549
- lines.push(
1550
- "await serve(",
1551
- " {",
1552
- " async fetch(req, platform) {",
1553
- " const { pathname } = new URL(req.url)",
1554
- " if (staticPath(pathname) !== undefined) return staticResponse(req)",
1555
- " return app.fetch(req, platform)",
1556
- " },",
1557
- " },",
1558
- " { port: Number(process.env.PORT ?? 3000) },",
1559
- ")",
1560
- )
1561
- return `${lines.join("\n")}\n`
1562
- }
1563
1645
  // deno
1564
1646
  lines.push(
1565
1647
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1566
1648
  'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {',
1567
1649
  " const { pathname } = new URL(req.url)",
1568
1650
  " const filePath = staticPath(pathname)",
1569
- " if (filePath !== undefined) {",
1651
+ ` if (filePath !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
1570
1652
  " return staticResponse(req, filePath, pathname)",
1571
1653
  " }",
1572
1654
  ` ${markFramed}`,
@@ -1577,34 +1659,40 @@ export function generateServerEntry(options: {
1577
1659
  }
1578
1660
 
1579
1661
  /**
1580
- * The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
1581
- * inside the root and cross no hidden segment, and the opened file must still be the one that path
1582
- * names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
1583
- * HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
1584
- * whole file.
1662
+ * The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
1663
+ * path must sit inside the root and cross no hidden segment, and the opened file must still be the one
1664
+ * that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
1665
+ * inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
1666
+ * own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
1667
+ * range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
1668
+ * enough is then served from those bytes; otherwise one byte range is served from a seek, several as
1669
+ * the whole file.
1585
1670
  */
1586
1671
  function denoStaticResponse(hashed: boolean): string[] {
1587
1672
  return [
1588
- `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1589
- "const staticHeaders = (pathname: string): Record<string, string> => ({",
1590
- ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1673
+ "const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
1674
+ ' "content-type": contentTypeOf(file),',
1591
1675
  ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1592
1676
  ' "x-content-type-options": "nosniff",',
1593
1677
  ' "accept-ranges": "bytes",',
1594
1678
  "})",
1595
1679
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1596
1680
  "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1597
- "const hiddenSegment = (rel: string): boolean =>",
1598
- ' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
1681
+ "// One per process: the tag of a file is remembered by its real path.",
1682
+ "// A stat costs Deno more than hashing a snapshot of up to 24 KiB, so only a larger one is restatted.",
1683
+ "const STATIC_TAGS = contentTags(createHash, { rehashMax: 24 * 1024 })",
1684
+ "// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
1685
+ "const STATIC_INLINE_MAX = CONTENT_SNAPSHOT_MAX",
1599
1686
  "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1600
1687
  ' const missing = () => new Response("not found", { status: 404 })',
1601
1688
  ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1602
1689
  " let file, info",
1690
+ ' let real = ""',
1603
1691
  " try {",
1604
1692
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1605
- " const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1693
+ " real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1606
1694
  ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1607
- " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1695
+ " if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
1608
1696
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1609
1697
  " file = await Deno.open(real)",
1610
1698
  " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
@@ -1622,12 +1710,60 @@ function denoStaticResponse(hashed: boolean): string[] {
1622
1710
  " file?.close()",
1623
1711
  " return missing()",
1624
1712
  " }",
1713
+ " if (!info.isFile) {",
1714
+ " file.close()",
1715
+ " return missing()",
1716
+ " }",
1625
1717
  " const size: number = info.size",
1626
- " const headers = new Headers(staticHeaders(pathname))",
1627
- ' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
1628
- ' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
1718
+ " const headers = new Headers(staticHeaders(pathname, real))",
1719
+ " // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
1720
+ " const versionOf = (stat: { dev: number; ino: number | null; size: number; mtime: Date | null; ctime: Date | null }) => ({",
1721
+ " dev: stat.dev,",
1722
+ " ino: stat.ino ?? Number.NaN,",
1723
+ " size: stat.size,",
1724
+ " mtimeMs: stat.mtime?.getTime() ?? Number.NaN,",
1725
+ " ctimeMs: stat.ctime?.getTime() ?? Number.NaN,",
1726
+ " })",
1727
+ " const version = versionOf(info)",
1728
+ " const snapshot = size <= STATIC_INLINE_MAX",
1729
+ " const lastModified = snapshot ? lastModifiedOf(version) : streamedLastModifiedOf(version)",
1730
+ ' const ifNoneMatch = req.headers.get("if-none-match")',
1731
+ ' const ifModifiedSince = req.headers.get("if-modified-since")',
1732
+ " let whole: Uint8Array<ArrayBuffer> | undefined",
1733
+ " // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version",
1734
+ " // answers it from the stat alone: a write since that tag would show in the stat.",
1735
+ " let etag: string | undefined = snapshot ? STATIC_TAGS.cached(real, version) : undefined",
1736
+ " try {",
1737
+ ' if (snapshot && (etag === undefined || !(req.method === "HEAD" || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))) {',
1738
+ " whole = new Uint8Array(size)",
1739
+ " let filled = 0",
1740
+ " while (filled < size) {",
1741
+ " const read: number | null = await file.read(whole.subarray(filled))",
1742
+ " if (read === null || read === 0) break",
1743
+ " filled += read",
1744
+ " }",
1745
+ " if (filled < size) whole = whole.subarray(0, filled)",
1746
+ " etag = await STATIC_TAGS.ofSnapshot(real, version, whole, async () => versionOf(await file.stat()))",
1747
+ " }",
1748
+ " } catch {",
1749
+ " file.close()",
1750
+ " return missing()",
1751
+ " }",
1752
+ ' if (etag !== undefined) headers.set("etag", etag)',
1753
+ ' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
1754
+ " if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {",
1755
+ " file.close()",
1756
+ ' headers.delete("content-type")',
1757
+ " return new Response(null, { status: 304, headers })",
1758
+ " }",
1759
+ ' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
1760
+ " const range =",
1761
+ " // A streamed body's date is weak: a write during the stream must never splice two versions.",
1762
+ ' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, snapshot ? lastModified : undefined)',
1763
+ " ? parseByteRange(rangeHeader, size)",
1764
+ ' : { kind: "none" as const }',
1765
+ ' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
1629
1766
  " file.close()",
1630
- " if (!info.isFile) return missing()",
1631
1767
  ' if (range.kind !== "unsatisfiable") {',
1632
1768
  ' headers.set("content-length", String(size))',
1633
1769
  " return new Response(null, { headers })",
@@ -1638,10 +1774,16 @@ function denoStaticResponse(hashed: boolean): string[] {
1638
1774
  " }",
1639
1775
  ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1640
1776
  " const { start, end } = range.ranges[0]!",
1777
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1778
+ " if (whole !== undefined) {",
1779
+ " file.close()",
1780
+ " const part = whole.subarray(start, end + 1)",
1781
+ ' headers.set("content-length", String(part.byteLength))',
1782
+ " return new Response(part, { status: 206, headers })",
1783
+ " }",
1641
1784
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1642
1785
  " await file.seek(start, Deno.SeekMode.Start)",
1643
1786
  " let left = end - start + 1",
1644
- ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1645
1787
  ' headers.set("content-length", String(left))',
1646
1788
  " const body = new ReadableStream<Uint8Array>({",
1647
1789
  " async pull(controller) {",
@@ -1663,6 +1805,11 @@ function denoStaticResponse(hashed: boolean): string[] {
1663
1805
  " })",
1664
1806
  " return new Response(body, { status: 206, headers })",
1665
1807
  " }",
1808
+ " if (whole !== undefined) {",
1809
+ " file.close()",
1810
+ ' headers.set("content-length", String(whole.byteLength))',
1811
+ " return new Response(whole, { headers })",
1812
+ " }",
1666
1813
  ' headers.set("content-length", String(size))',
1667
1814
  " return new Response(file.readable, { headers })",
1668
1815
  "}",
@@ -1814,6 +1961,7 @@ export const bunBundler: Bundler = {
1814
1961
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1815
1962
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1816
1963
  ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1964
+ ...(input.buildAssets !== undefined ? { buildAssets: input.buildAssets } : {}),
1817
1965
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1818
1966
  ...(input.define ? { define: input.define } : {}),
1819
1967
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -1924,6 +2072,11 @@ export async function buildTargetWith(
1924
2072
  ]
1925
2073
  assertDisposableDir("outDir", outDir, sources)
1926
2074
  assertDisposableDir("workDir", workDir, sources)
2075
+ // Before anything is cleared, so a refused build leaves the last deploy dir in place.
2076
+ if (targetPlan.target === "node") {
2077
+ const refusal = nodeAdapterRefusal(workDir)
2078
+ if (refusal !== undefined) throw new Error(refusal)
2079
+ }
1927
2080
  const { rmSync } = await import("node:fs")
1928
2081
  rmSync(outDir, { recursive: true, force: true })
1929
2082
  rmSync(workDir, { recursive: true, force: true })
@@ -2067,6 +2220,7 @@ export async function buildTargetWith(
2067
2220
  ...(client.css !== undefined ? { styles: client.css } : {}),
2068
2221
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
2069
2222
  ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
2223
+ buildAssets: buildAssetPaths(client),
2070
2224
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
2071
2225
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
2072
2226
  ...(client.cssLoading !== undefined
@@ -2085,10 +2239,16 @@ export async function buildTargetWith(
2085
2239
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
2086
2240
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
2087
2241
  // proves nothing can be served beneath it.
2088
- const rules = cloudflareRouteRules(
2089
- publicFiles,
2090
- discoverRoutes(routesDir).routes.map((route) => route.pattern),
2091
- )
2242
+ // A host policy must see every file request, which Pages answers from the CDN before the worker
2243
+ // when the path is excluded, so with one nothing is: each file costs an invocation, and the
2244
+ // worker serves the allowed ones from ASSETS.
2245
+ const rules =
2246
+ options.optionImports?.hostPolicy === undefined
2247
+ ? cloudflareRouteRules(
2248
+ publicFiles,
2249
+ discoverRoutes(routesDir).routes.map((route) => route.pattern),
2250
+ )
2251
+ : { include: ["/*"], exclude: [], omitted: [] }
2092
2252
  writeFileSync(
2093
2253
  `${outDir}/_routes.json`,
2094
2254
  `${JSON.stringify({ version: 1, include: rules.include, exclude: rules.exclude }, null, 2)}\n`,
package/src/deferred.ts CHANGED
@@ -47,6 +47,20 @@ function isDeferred(value: unknown): value is Deferred<unknown> {
47
47
  )
48
48
  }
49
49
 
50
+ /** `target[key] = value` runs the prototype setter for a `__proto__` key; define it as data instead. */
51
+ function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
52
+ if (key === "__proto__") {
53
+ Object.defineProperty(target, key, {
54
+ value,
55
+ enumerable: true,
56
+ writable: true,
57
+ configurable: true,
58
+ })
59
+ } else {
60
+ target[key] = value
61
+ }
62
+ }
63
+
50
64
  /**
51
65
  * Split a loader (or action) result into the **component-facing** data (deferred values carry their
52
66
  * assigned `id` + promise, for `<Await>`) and the **client-serializable** data (deferred values
@@ -113,8 +127,8 @@ export function prepareDeferred(
113
127
  const r = split(v)
114
128
  if (r === undefined) {
115
129
  if (fc !== undefined && cl !== undefined) {
116
- fc[k] = v
117
- cl[k] = v
130
+ setOwn(fc, k, v)
131
+ setOwn(cl, k, v)
118
132
  }
119
133
  continue
120
134
  }
@@ -125,12 +139,12 @@ export function prepareDeferred(
125
139
  if (!Object.hasOwn(input, prev)) continue
126
140
  if (prev === k) break
127
141
  const prevValue = input[prev]
128
- fc[prev] = prevValue
129
- cl[prev] = prevValue
142
+ setOwn(fc, prev, prevValue)
143
+ setOwn(cl, prev, prevValue)
130
144
  }
131
145
  }
132
- fc[k] = r.fc
133
- cl[k] = r.cl
146
+ setOwn(fc, k, r.fc)
147
+ setOwn(cl, k, r.cl)
134
148
  }
135
149
  return fc === undefined || cl === undefined ? undefined : { fc, cl }
136
150
  }