@nifrajs/web 5.4.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 (100) hide show
  1. package/CHANGELOG.md +46 -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 +81 -40
  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/output-guard.d.ts +6 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +118 -16
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +2 -0
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +16 -11
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/render-document.d.ts +3 -1
  54. package/dist/internal/render-document.d.ts.map +1 -1
  55. package/dist/internal/render-document.js +10 -4
  56. package/dist/internal/render-document.js.map +1 -1
  57. package/dist/internal/web-app.d.ts +15 -1
  58. package/dist/internal/web-app.d.ts.map +1 -1
  59. package/dist/internal/web-app.js +8 -0
  60. package/dist/internal/web-app.js.map +1 -1
  61. package/dist/public-dir.d.ts +4 -3
  62. package/dist/public-dir.d.ts.map +1 -1
  63. package/dist/public-dir.js +24 -18
  64. package/dist/public-dir.js.map +1 -1
  65. package/dist/router.d.ts +2 -1
  66. package/dist/router.d.ts.map +1 -1
  67. package/dist/router.js +9 -8
  68. package/dist/router.js.map +1 -1
  69. package/dist/server-components/vite.d.ts.map +1 -1
  70. package/dist/server-components/vite.js +8 -6
  71. package/dist/server-components/vite.js.map +1 -1
  72. package/dist/service-worker.d.ts +3 -1
  73. package/dist/service-worker.d.ts.map +1 -1
  74. package/dist/service-worker.js +29 -8
  75. package/dist/service-worker.js.map +1 -1
  76. package/dist/zones.d.ts +5 -1
  77. package/dist/zones.d.ts.map +1 -1
  78. package/dist/zones.js +17 -4
  79. package/dist/zones.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/build-plan.ts +26 -0
  82. package/src/build-vite.ts +4 -0
  83. package/src/build.ts +114 -44
  84. package/src/deferred.ts +20 -6
  85. package/src/dev.ts +7 -6
  86. package/src/diagnostic.ts +43 -0
  87. package/src/host-policy.ts +219 -0
  88. package/src/host-reach.ts +177 -0
  89. package/src/index.ts +10 -2
  90. package/src/internal/certified-components.ts +7 -0
  91. package/src/internal/codegen.ts +24 -5
  92. package/src/internal/output-guard.ts +166 -15
  93. package/src/internal/page-execution.ts +23 -9
  94. package/src/internal/render-document.ts +11 -4
  95. package/src/internal/web-app.ts +27 -1
  96. package/src/public-dir.ts +31 -24
  97. package/src/router.ts +9 -8
  98. package/src/server-components/vite.ts +11 -6
  99. package/src/service-worker.ts +32 -9
  100. 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"
@@ -33,12 +34,14 @@ import {
33
34
  type BuildTarget,
34
35
  type BuildTargetPlan,
35
36
  type Bundler,
37
+ buildAssetPaths,
36
38
  type ChunkSize,
37
39
  detectNodeBuiltinsInClient,
38
40
  detectServerOnlyInClient,
39
41
  formatNodeBuiltinLeak,
40
42
  formatServerOnlyLeak,
41
43
  parseBuildTarget,
44
+ parseManifestBuildAssets,
42
45
  parseManifestClientEntry,
43
46
  parseManifestCopyChunks,
44
47
  parseManifestCssLoading,
@@ -52,6 +55,7 @@ import { sanitizeOutputNames } from "./chunk-names.ts"
52
55
  import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
53
56
  import { discoverRoutes } from "./fs.ts"
54
57
  import { generateClientEntry, generateServerManifest } from "./index.ts"
58
+ import { missingClientEntry } from "./internal/codegen.ts"
55
59
  import { loadCopySplit } from "./internal/copy-split.ts"
56
60
  import { assertDisposableDir } from "./internal/disposable-dir.ts"
57
61
  import { entryChunks } from "./internal/entry-chunks.ts"
@@ -396,6 +400,7 @@ export function resyncServerManifestSource(
396
400
  ): string {
397
401
  const cssLoading = parseManifestCssLoading(source)
398
402
  const copyChunks = parseManifestCopyChunks(source)
403
+ const buildAssets = parseManifestBuildAssets(source)
399
404
  return generateServerManifest(manifest, {
400
405
  resolve: (file) => `${routesPrefix}${file}`,
401
406
  clientEntry: parseManifestClientEntry(source) ?? "",
@@ -403,6 +408,7 @@ export function resyncServerManifestSource(
403
408
  routeStyles: parseManifestRouteStyles(source),
404
409
  ...(cssLoading !== undefined ? { cssLoading } : {}),
405
410
  ...(copyChunks !== undefined ? { copyChunks } : {}),
411
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
406
412
  lazy: MANIFEST_IS_LAZY.test(source),
407
413
  })
408
414
  }
@@ -569,7 +575,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
569
575
  if (verdict !== undefined) throw new Error(verdict)
570
576
  if (refused.size > 0) throw new Error(formatRefusedFiles(refused, root))
571
577
  throw new Error(
572
- `[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")}`,
573
579
  )
574
580
  }
575
581
 
@@ -816,9 +822,16 @@ export interface BuildServerOptions {
816
822
  readonly serverEntry: string
817
823
  /** Output directory for the bundled worker (absolute path). */
818
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
819
831
  /** The content-hashed client entry URL (from `buildClient`'s manifest) - **baked** into the generated
820
- * server manifest, since a disk-less worker can't read `manifest.json` at runtime. */
821
- 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
822
835
  /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked into the generated
823
836
  * manifest so the server entry hands them to `createWebApp` (→ `<link rel="stylesheet">`). Omit ⇒ no CSS
824
837
  * link (the built SSR page would otherwise render unstyled). */
@@ -827,6 +840,12 @@ export interface BuildServerOptions {
827
840
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
828
841
  /** Copy chunk URLs by locale (`buildClient`'s `BuildManifest.copyChunks`) - baked as `copyChunks`. */
829
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
830
849
  /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
831
850
  readonly cssLoading?: CssLoadingMode
832
851
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
@@ -1098,6 +1117,12 @@ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string):
1098
1117
  return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
1099
1118
  }
1100
1119
 
1120
+ export {
1121
+ checkHostReach,
1122
+ type HostReachOptions,
1123
+ type HostReachReport,
1124
+ type HostReachResult,
1125
+ } from "./host-reach.ts"
1101
1126
  export { importGlobPlugin } from "./plugins/import-glob.ts"
1102
1127
  export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
1103
1128
 
@@ -1153,7 +1178,26 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
1153
1178
  * silently ships a broken worker.
1154
1179
  */
1155
1180
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
1156
- 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))
1157
1201
  const entryDir = dirname(serverEntry)
1158
1202
  const manifestFile = options.manifestFile ?? "server-manifest.ts"
1159
1203
  // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
@@ -1178,7 +1222,8 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1178
1222
  styles,
1179
1223
  routeStyles,
1180
1224
  ...(cssLoading !== undefined ? { cssLoading } : {}),
1181
- ...(options.copyChunks !== undefined ? { copyChunks: options.copyChunks } : {}),
1225
+ ...(copyChunks !== undefined ? { copyChunks } : {}),
1226
+ ...(buildAssets !== undefined ? { buildAssets } : {}),
1182
1227
  lazy,
1183
1228
  }),
1184
1229
  )
@@ -1214,8 +1259,9 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
1214
1259
  ],
1215
1260
  })
1216
1261
  if (!result.success) {
1262
+ const root = resolvePath(options.root ?? dirname(routesDir))
1217
1263
  throw new Error(
1218
- `[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")}`,
1219
1265
  )
1220
1266
  }
1221
1267
  // Named as the metafile names outputs: relative to `outdir`.
@@ -1284,6 +1330,7 @@ export const SERVER_ENTRY_OPTIONS = [
1284
1330
  "csp",
1285
1331
  "nonce",
1286
1332
  "i18n",
1333
+ "hostPolicy",
1287
1334
  ] as const
1288
1335
  export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1289
1336
  /** Each importable option mapped to the specifier of the module that exports it. */
@@ -1308,6 +1355,13 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
1308
1355
  vercel: "x-real-ip",
1309
1356
  }
1310
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
+
1311
1365
  /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
1312
1366
  const PRERENDERED_DIR = "_prerendered"
1313
1367
 
@@ -1404,11 +1458,18 @@ 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"')
@@ -1416,7 +1477,7 @@ export function generateServerEntry(options: {
1416
1477
  if (target === "deno") {
1417
1478
  lines.push(
1418
1479
  'import { parseByteRange } from "@nifrajs/core/range"',
1419
- 'import { contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf } from "@nifrajs/core/static-file"',
1480
+ 'import { CONTENT_SNAPSHOT_MAX, contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf, streamedLastModifiedOf } from "@nifrajs/core/static-file"',
1420
1481
  'import { createHash } from "node:crypto"',
1421
1482
  )
1422
1483
  }
@@ -1442,6 +1503,7 @@ export function generateServerEntry(options: {
1442
1503
  ...(useImport !== undefined ? [" use,"] : []),
1443
1504
  ...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
1444
1505
  ...importedOptions.map((name) => ` ${name},`),
1506
+ ...(hostPolicy ? [" buildAssets,"] : []),
1445
1507
  " manifest,",
1446
1508
  " clientEntry,",
1447
1509
  " styles,",
@@ -1474,7 +1536,7 @@ export function generateServerEntry(options: {
1474
1536
  " fetch(request: Request, env: Assets, ctx: ExecutionContext) {",
1475
1537
  " const { pathname } = new URL(request.url)",
1476
1538
  " // Only the build's own outputs: the hashed bundle prefix and the files it copied.",
1477
- ' 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 : ""}) {`,
1478
1540
  " return env.ASSETS.fetch(request)",
1479
1541
  " }",
1480
1542
  " return handler.fetch(request, env as never, ctx)",
@@ -1514,6 +1576,7 @@ export function generateServerEntry(options: {
1514
1576
  ' prefix: "/assets",',
1515
1577
  ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1516
1578
  ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1579
+ ...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
1517
1580
  " },",
1518
1581
  " {",
1519
1582
  " dir: fileURLToPath(STATIC_ROOT),",
@@ -1521,6 +1584,7 @@ export function generateServerEntry(options: {
1521
1584
  " files: PUBLIC_FILES,",
1522
1585
  " immutable: false,",
1523
1586
  ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1587
+ ...(hostPolicy ? [NODE_STATIC_ALLOW] : []),
1524
1588
  " },",
1525
1589
  " ],",
1526
1590
  "})",
@@ -1564,7 +1628,7 @@ export function generateServerEntry(options: {
1564
1628
  " async fetch(req, server) {",
1565
1629
  " const { pathname } = new URL(req.url)",
1566
1630
  // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1567
- " if (staticPath(pathname) !== undefined) {",
1631
+ ` if (staticPath(pathname) !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
1568
1632
  " return staticResponse(req)",
1569
1633
  " }",
1570
1634
  ` ${markFramed}`,
@@ -1584,7 +1648,7 @@ export function generateServerEntry(options: {
1584
1648
  'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {',
1585
1649
  " const { pathname } = new URL(req.url)",
1586
1650
  " const filePath = staticPath(pathname)",
1587
- " if (filePath !== undefined) {",
1651
+ ` if (filePath !== undefined${hostPolicy ? HOST_ALLOWS_STATIC.replaceAll("request", "req") : ""}) {`,
1588
1652
  " return staticResponse(req, filePath, pathname)",
1589
1653
  " }",
1590
1654
  ` ${markFramed}`,
@@ -1615,9 +1679,10 @@ function denoStaticResponse(hashed: boolean): string[] {
1615
1679
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1616
1680
  "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1617
1681
  "// One per process: the tag of a file is remembered by its real path.",
1618
- "const STATIC_TAGS = contentTags(createHash)",
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 })",
1619
1684
  "// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
1620
- "const STATIC_INLINE_MAX = 256 * 1024",
1685
+ "const STATIC_INLINE_MAX = CONTENT_SNAPSHOT_MAX",
1621
1686
  "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1622
1687
  ' const missing = () => new Response("not found", { status: 404 })',
1623
1688
  ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
@@ -1652,19 +1717,24 @@ function denoStaticResponse(hashed: boolean): string[] {
1652
1717
  " const size: number = info.size",
1653
1718
  " const headers = new Headers(staticHeaders(pathname, real))",
1654
1719
  " // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
1655
- " const version = {",
1656
- " dev: info.dev,",
1657
- " ino: info.ino ?? Number.NaN,",
1658
- " size,",
1659
- " mtimeMs: info.mtime?.getTime() ?? Number.NaN,",
1660
- " ctimeMs: info.ctime?.getTime() ?? Number.NaN,",
1661
- " }",
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")',
1662
1732
  " let whole: Uint8Array<ArrayBuffer> | undefined",
1663
- " let moved = false",
1664
- " let etag: string | 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",
1665
1736
  " try {",
1666
- " etag = STATIC_TAGS.cached(real, version)",
1667
- " if (etag === undefined && size <= STATIC_INLINE_MAX) {",
1737
+ ' if (snapshot && (etag === undefined || !(req.method === "HEAD" || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))) {',
1668
1738
  " whole = new Uint8Array(size)",
1669
1739
  " let filled = 0",
1670
1740
  " while (filled < size) {",
@@ -1673,29 +1743,23 @@ function denoStaticResponse(hashed: boolean): string[] {
1673
1743
  " filled += read",
1674
1744
  " }",
1675
1745
  " if (filled < size) whole = whole.subarray(0, filled)",
1676
- " etag = STATIC_TAGS.ofBytes(real, version, whole)",
1746
+ " etag = await STATIC_TAGS.ofSnapshot(real, version, whole, async () => versionOf(await file.stat()))",
1677
1747
  " }",
1678
- " etag ??= await STATIC_TAGS.ofFile(real, version, async (bytes: Uint8Array, position: number) => {",
1679
- " moved = true",
1680
- " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1681
- " await file.seek(position, Deno.SeekMode.Start)",
1682
- " return (await file.read(bytes)) ?? 0",
1683
- " })",
1684
1748
  " } catch {",
1685
1749
  " file.close()",
1686
1750
  " return missing()",
1687
1751
  " }",
1688
1752
  ' if (etag !== undefined) headers.set("etag", etag)',
1689
- " const lastModified = lastModifiedOf(version)",
1690
1753
  ' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
1691
- ' if (isNotModified(req.headers.get("if-none-match"), req.headers.get("if-modified-since"), etag, lastModified)) {',
1754
+ " if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {",
1692
1755
  " file.close()",
1693
1756
  ' headers.delete("content-type")',
1694
1757
  " return new Response(null, { status: 304, headers })",
1695
1758
  " }",
1696
1759
  ' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
1697
1760
  " const range =",
1698
- ' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, lastModified)',
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)',
1699
1763
  " ? parseByteRange(rangeHeader, size)",
1700
1764
  ' : { kind: "none" as const }',
1701
1765
  ' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
@@ -1746,8 +1810,6 @@ function denoStaticResponse(hashed: boolean): string[] {
1746
1810
  ' headers.set("content-length", String(whole.byteLength))',
1747
1811
  " return new Response(whole, { headers })",
1748
1812
  " }",
1749
- " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1750
- " if (moved) await file.seek(0, Deno.SeekMode.Start)",
1751
1813
  ' headers.set("content-length", String(size))',
1752
1814
  " return new Response(file.readable, { headers })",
1753
1815
  "}",
@@ -1899,6 +1961,7 @@ export const bunBundler: Bundler = {
1899
1961
  ...(input.styles !== undefined ? { styles: input.styles } : {}),
1900
1962
  ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1901
1963
  ...(input.copyChunks !== undefined ? { copyChunks: input.copyChunks } : {}),
1964
+ ...(input.buildAssets !== undefined ? { buildAssets: input.buildAssets } : {}),
1902
1965
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1903
1966
  ...(input.define ? { define: input.define } : {}),
1904
1967
  ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
@@ -2157,6 +2220,7 @@ export async function buildTargetWith(
2157
2220
  ...(client.css !== undefined ? { styles: client.css } : {}),
2158
2221
  ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
2159
2222
  ...(client.copyChunks !== undefined ? { copyChunks: client.copyChunks } : {}),
2223
+ buildAssets: buildAssetPaths(client),
2160
2224
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
2161
2225
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
2162
2226
  ...(client.cssLoading !== undefined
@@ -2175,10 +2239,16 @@ export async function buildTargetWith(
2175
2239
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
2176
2240
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
2177
2241
  // proves nothing can be served beneath it.
2178
- const rules = cloudflareRouteRules(
2179
- publicFiles,
2180
- discoverRoutes(routesDir).routes.map((route) => route.pattern),
2181
- )
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: [] }
2182
2252
  writeFileSync(
2183
2253
  `${outDir}/_routes.json`,
2184
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
  }
package/src/dev.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  isLoopbackHost,
66
66
  } from "./dev-session.ts"
67
67
  import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
68
+ import { formatBuildLog } from "./diagnostic.ts"
68
69
  import { discoverRoutes } from "./fs.ts"
69
70
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
70
71
  import { DEV_ENTRY_FILE } from "./internal/dev-reserved.ts"
@@ -634,11 +635,6 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
634
635
  * Overlapping runs are collapsed - a save during a bundle queues exactly one re-run, so a burst of edits
635
636
  * cannot pile up builds behind each other.
636
637
  */
637
- /**
638
- * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
639
- * whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
640
- * `.errors`. Surface it, or the guard reports a failure while hiding the reason.
641
- */
642
638
  /** A Host header's name: lowercased, without its port or IPv6 brackets. */
643
639
  function hostName(host: string): string {
644
640
  const name = host.trim().toLowerCase()
@@ -648,9 +644,14 @@ function hostName(host: string): string {
648
644
  return colon === -1 ? name : name.slice(0, colon)
649
645
  }
650
646
 
647
+ /**
648
+ * Format a client-build rejection for the guard's report. Bun.build rejects with an AggregateError
649
+ * whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
650
+ * `.errors`. Surface it, or the guard reports a failure while hiding the reason.
651
+ */
651
652
  export function buildFailureDetail(err: unknown): string {
652
653
  return err instanceof AggregateError && err.errors.length > 0
653
- ? err.errors.map((e) => ` ${e instanceof Error ? e.message : String(e)}`).join("\n")
654
+ ? err.errors.map((e) => ` ${formatBuildLog(e).replaceAll("\n", "\n ")}`).join("\n")
654
655
  : ` ${err instanceof Error ? err.message : String(err)}`
655
656
  }
656
657
 
package/src/diagnostic.ts CHANGED
@@ -34,6 +34,49 @@ export {
34
34
  /** Shared endpoint name used by both dev pipelines and the agent-facing MCP tools. */
35
35
  export const LAST_ERROR_PATH = "/__nifra/last-error"
36
36
 
37
+ /** `file` relative to `root` when it sits inside it (the bundler reports real paths, so a symlinked
38
+ * root is compared by its real path too), else `file` as given. */
39
+ function pathUnder(root: string, file: string): string {
40
+ const inside = (base: string): string | undefined => {
41
+ const rel = relative(base, file).replaceAll("\\", "/")
42
+ return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel
43
+ }
44
+ let real: string | undefined
45
+ try {
46
+ real = realpathSync(root)
47
+ } catch {
48
+ real = undefined
49
+ }
50
+ return inside(root) ?? (real === undefined ? undefined : inside(real)) ?? file
51
+ }
52
+
53
+ /**
54
+ * One bundler log as `file:line:column: message`, the source line it points at underneath. Bun's
55
+ * `BuildMessage`/`ResolveMessage` carry the place in `position`, which their `String()` drops: a
56
+ * failed build said "Could not resolve: ./db" without naming the module that imported it.
57
+ */
58
+ export function formatBuildLog(log: unknown, root: string = process.cwd()): string {
59
+ const message =
60
+ typeof log === "object" && log !== null && "message" in log && typeof log.message === "string"
61
+ ? log.message
62
+ : String(log)
63
+ const position =
64
+ typeof log === "object" && log !== null && "position" in log ? log.position : undefined
65
+ if (typeof position !== "object" || position === null || !("file" in position)) return message
66
+ const file = typeof position.file === "string" ? position.file : ""
67
+ if (file === "") return message
68
+ const where = [
69
+ pathUnder(root, file),
70
+ "line" in position && typeof position.line === "number" ? position.line : undefined,
71
+ "column" in position && typeof position.column === "number" ? position.column : undefined,
72
+ ]
73
+ .filter((part) => part !== undefined)
74
+ .join(":")
75
+ const text =
76
+ "lineText" in position && typeof position.lineText === "string" ? position.lineText.trim() : ""
77
+ return text === "" ? `${where}: ${message}` : `${where}: ${message}\n ${text}`
78
+ }
79
+
37
80
  /** One parsed stack frame. `file`/`line`/`column` are present only when the frame could be located. */
38
81
  export interface DiagnosticFrame {
39
82
  readonly raw: string