@nifrajs/web 5.2.0 → 5.4.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 (102) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +265 -15
  5. package/dist/build.js.map +1 -1
  6. package/dist/csp.d.ts +2 -0
  7. package/dist/csp.d.ts.map +1 -1
  8. package/dist/csp.js +2 -0
  9. package/dist/csp.js.map +1 -1
  10. package/dist/deferred.d.ts.map +1 -1
  11. package/dist/deferred.js +23 -3
  12. package/dist/deferred.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/deferred-free.d.ts +3 -0
  18. package/dist/internal/deferred-free.d.ts.map +1 -0
  19. package/dist/internal/deferred-free.js +15 -0
  20. package/dist/internal/deferred-free.js.map +1 -0
  21. package/dist/internal/module-preparation.d.ts +7 -0
  22. package/dist/internal/module-preparation.d.ts.map +1 -0
  23. package/dist/internal/module-preparation.js +24 -0
  24. package/dist/internal/module-preparation.js.map +1 -0
  25. package/dist/internal/node-adapter.d.ts +19 -0
  26. package/dist/internal/node-adapter.d.ts.map +1 -0
  27. package/dist/internal/node-adapter.js +48 -0
  28. package/dist/internal/node-adapter.js.map +1 -0
  29. package/dist/internal/output-guard.d.ts +2 -0
  30. package/dist/internal/output-guard.d.ts.map +1 -1
  31. package/dist/internal/output-guard.js +51 -9
  32. package/dist/internal/output-guard.js.map +1 -1
  33. package/dist/internal/page-execution.d.ts +1 -0
  34. package/dist/internal/page-execution.d.ts.map +1 -1
  35. package/dist/internal/page-execution.js +53 -43
  36. package/dist/internal/page-execution.js.map +1 -1
  37. package/dist/internal/parity.d.ts.map +1 -1
  38. package/dist/internal/parity.js +3 -0
  39. package/dist/internal/parity.js.map +1 -1
  40. package/dist/internal/prerendered-pages.d.ts +42 -0
  41. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  42. package/dist/internal/prerendered-pages.js +92 -0
  43. package/dist/internal/prerendered-pages.js.map +1 -0
  44. package/dist/internal/render-document.d.ts.map +1 -1
  45. package/dist/internal/render-document.js +93 -35
  46. package/dist/internal/render-document.js.map +1 -1
  47. package/dist/internal/request-url.d.ts +4 -0
  48. package/dist/internal/request-url.d.ts.map +1 -1
  49. package/dist/internal/request-url.js +24 -0
  50. package/dist/internal/request-url.js.map +1 -1
  51. package/dist/internal/secret-scan.d.ts +4 -0
  52. package/dist/internal/secret-scan.d.ts.map +1 -1
  53. package/dist/internal/secret-scan.js +8 -1
  54. package/dist/internal/secret-scan.js.map +1 -1
  55. package/dist/internal/web-app.d.ts +11 -0
  56. package/dist/internal/web-app.d.ts.map +1 -1
  57. package/dist/internal/web-app.js +10 -1
  58. package/dist/internal/web-app.js.map +1 -1
  59. package/dist/isr.d.ts +1 -0
  60. package/dist/isr.d.ts.map +1 -1
  61. package/dist/isr.js +7 -4
  62. package/dist/isr.js.map +1 -1
  63. package/dist/prerender.d.ts +4 -0
  64. package/dist/prerender.d.ts.map +1 -1
  65. package/dist/prerender.js +9 -1
  66. package/dist/prerender.js.map +1 -1
  67. package/dist/public-dir.d.ts +19 -7
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +130 -99
  70. package/dist/public-dir.js.map +1 -1
  71. package/dist/render-seam.d.ts +5 -2
  72. package/dist/render-seam.d.ts.map +1 -1
  73. package/dist/render-seam.js.map +1 -1
  74. package/dist/search.d.ts.map +1 -1
  75. package/dist/search.js +2 -0
  76. package/dist/search.js.map +1 -1
  77. package/dist/vite.d.ts +6 -0
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +127 -54
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +3 -3
  82. package/src/build.ts +330 -49
  83. package/src/csp.ts +4 -0
  84. package/src/deferred.ts +20 -3
  85. package/src/index.ts +3 -0
  86. package/src/internal/deferred-free.ts +15 -0
  87. package/src/internal/module-preparation.ts +30 -0
  88. package/src/internal/node-adapter.ts +57 -0
  89. package/src/internal/output-guard.ts +51 -9
  90. package/src/internal/page-execution.ts +55 -47
  91. package/src/internal/parity.ts +2 -0
  92. package/src/internal/prerendered-pages.ts +124 -0
  93. package/src/internal/render-document.ts +116 -52
  94. package/src/internal/request-url.ts +24 -0
  95. package/src/internal/secret-scan.ts +9 -1
  96. package/src/internal/web-app.ts +21 -1
  97. package/src/isr.ts +7 -4
  98. package/src/prerender.ts +13 -1
  99. package/src/public-dir.ts +158 -97
  100. package/src/render-seam.ts +5 -2
  101. package/src/search.ts +1 -0
  102. package/src/vite.ts +130 -54
package/src/build.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  singleCopyPlugin as declaredSingleCopyPlugin,
26
26
  readSingleCopyDeclaration,
27
27
  } from "@nifrajs/core/single-copy"
28
+ import { isHiddenPath } from "@nifrajs/core/static-file"
28
29
  import { type BunPlugin, Glob } from "bun"
29
30
  import {
30
31
  aggregateSizeReport,
@@ -55,11 +56,16 @@ import { loadCopySplit } from "./internal/copy-split.ts"
55
56
  import { assertDisposableDir } from "./internal/disposable-dir.ts"
56
57
  import { entryChunks } from "./internal/entry-chunks.ts"
57
58
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
59
+ import { nodeAdapterRefusal } from "./internal/node-adapter.ts"
58
60
  import {
59
61
  assertDevelopmentProductionParity,
60
62
  assertIdentityParity,
61
63
  collectDevelopmentParityInput,
62
64
  } from "./internal/parity.ts"
65
+ import {
66
+ type ServerPrerenderedPage,
67
+ servablePrerenderedPages,
68
+ } from "./internal/prerendered-pages.ts"
63
69
  import { privateEnvCheck } from "./internal/private-env.ts"
64
70
  import { publicUrlPath } from "./internal/public-url.ts"
65
71
  import {
@@ -71,6 +77,7 @@ import {
71
77
  originName,
72
78
  publicScanFiles,
73
79
  type SecretExemption,
80
+ SecretScanError,
74
81
  scanForSecrets,
75
82
  textOf,
76
83
  } from "./internal/secret-scan.ts"
@@ -94,8 +101,8 @@ import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
94
101
  import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
95
102
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
96
103
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
97
- import { prerenderRoutes } from "./prerender.ts"
98
- import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
104
+ import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
105
+ import { IMMUTABLE, ONE_DAY } from "./public-dir.ts"
99
106
  import { createZoneClassifier } from "./zones.ts"
100
107
 
101
108
  export * from "./build-plan.ts"
@@ -128,6 +135,8 @@ interface BunMetafile {
128
135
  >
129
136
  }
130
137
 
138
+ // A page a bun or node build answers from its file (`generateServerEntry`'s `prerenderedPages`).
139
+ export type { ServerPrerenderedPage } from "./internal/prerendered-pages.ts"
131
140
  // The secret scan every client build, `public/` copy and prerender runs.
132
141
  export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
133
142
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
@@ -340,7 +349,9 @@ export function cloudflareRouteRules(
340
349
 
341
350
  /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
342
351
  /**
343
- * Copy `from` into `to`, returning the URL paths copied (sorted).
352
+ * Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
353
+ * `.well-known` are skipped, as development never serves them: every target ships this list, and a
354
+ * CDN-hosted one serves whatever was copied.
344
355
  *
345
356
  * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
346
357
  * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
@@ -351,6 +362,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
351
362
  const sourceReal = await realpath(source)
352
363
  const copied: string[] = []
353
364
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
365
+ if (isHiddenPath(rel)) continue
354
366
  const candidate = join(source, rel)
355
367
  const candidateStat = await lstat(candidate)
356
368
  if (candidateStat.isSymbolicLink()) {
@@ -1296,6 +1308,9 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
1296
1308
  vercel: "x-real-ip",
1297
1309
  }
1298
1310
 
1311
+ /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
1312
+ const PRERENDERED_DIR = "_prerendered"
1313
+
1299
1314
  /**
1300
1315
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1301
1316
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -1332,6 +1347,9 @@ export function generateServerEntry(options: {
1332
1347
  readonly clientIp?: ServerEntryClientIp
1333
1348
  /** The server manifest exports `copyChunks` (the client build split copy tables by locale). */
1334
1349
  readonly copyChunks?: boolean
1350
+ /** `bun` / `node` only: the pages the build prerendered into `_prerendered/`, by request path, which
1351
+ * the app answers from their file (see `createWebApp`'s `prerenderedPages`). */
1352
+ readonly prerenderedPages?: Readonly<Record<string, ServerPrerenderedPage>>
1335
1353
  }): string {
1336
1354
  const {
1337
1355
  target,
@@ -1363,7 +1381,20 @@ export function generateServerEntry(options: {
1363
1381
  if (target === "static") {
1364
1382
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1365
1383
  }
1366
- const lines: string[] = ['import { createWebApp } from "@nifrajs/web"']
1384
+ // Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
1385
+ // both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
1386
+ // stub, so its entry runs the same checks on Deno's own file API.
1387
+ const nodeFs = target === "bun" || target === "node"
1388
+ const prerendered = Object.entries(options.prerenderedPages ?? {})
1389
+ if (prerendered.length > 0 && !nodeFs) {
1390
+ throw new Error(
1391
+ `[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
1392
+ )
1393
+ }
1394
+ const webImports = ["createWebApp"]
1395
+ if (prerendered.length > 0) webImports.push("prerenderedPageReader")
1396
+ if (target === "bun") webImports.push("servePublicDir")
1397
+ const lines: string[] = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`]
1367
1398
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
1368
1399
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
1369
1400
  if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
@@ -1381,11 +1412,35 @@ export function generateServerEntry(options: {
1381
1412
  // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1382
1413
  if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1383
1414
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1415
+ if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
1416
+ if (target === "deno") {
1417
+ lines.push(
1418
+ 'import { parseByteRange } from "@nifrajs/core/range"',
1419
+ 'import { contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf } from "@nifrajs/core/static-file"',
1420
+ 'import { createHash } from "node:crypto"',
1421
+ )
1422
+ }
1423
+ if (prerendered.length > 0) {
1424
+ const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
1425
+ const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
1426
+ lines.push(
1427
+ "",
1428
+ `// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`,
1429
+ "const prerenderedPages = {",
1430
+ ` pages: ${JSON.stringify(headers)},`,
1431
+ " read: prerenderedPageReader({",
1432
+ ` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`,
1433
+ ` files: ${JSON.stringify(files)},`,
1434
+ " }),",
1435
+ "}",
1436
+ )
1437
+ }
1384
1438
  lines.push(
1385
1439
  "",
1386
1440
  "const app = createWebApp({",
1387
1441
  " adapter,",
1388
1442
  ...(useImport !== undefined ? [" use,"] : []),
1443
+ ...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
1389
1444
  ...importedOptions.map((name) => ` ${name},`),
1390
1445
  " manifest,",
1391
1446
  " clientEntry,",
@@ -1445,6 +1500,34 @@ export function generateServerEntry(options: {
1445
1500
  ...(hashedFiles !== undefined
1446
1501
  ? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
1447
1502
  : []),
1503
+ )
1504
+ if (target === "node") {
1505
+ // The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
1506
+ // wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
1507
+ // and every file went through one too. The root mount serves only the files the build listed.
1508
+ lines.push(
1509
+ "await serve(app, {",
1510
+ " port: Number(process.env.PORT ?? 3000),",
1511
+ " static: [",
1512
+ " {",
1513
+ ' dir: fileURLToPath(new URL("./assets/", import.meta.url)),',
1514
+ ' prefix: "/assets",',
1515
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1516
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1517
+ " },",
1518
+ " {",
1519
+ " dir: fileURLToPath(STATIC_ROOT),",
1520
+ ' prefix: "/",',
1521
+ " files: PUBLIC_FILES,",
1522
+ " immutable: false,",
1523
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1524
+ " },",
1525
+ " ],",
1526
+ "})",
1527
+ )
1528
+ return `${lines.join("\n")}\n`
1529
+ }
1530
+ lines.push(
1448
1531
  "const staticPath = (pathname: string): string | undefined => {",
1449
1532
  " if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
1450
1533
  ' if (!pathname.startsWith("/assets/")) return undefined',
@@ -1456,14 +1539,19 @@ export function generateServerEntry(options: {
1456
1539
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1457
1540
  " return segments.every(safe) ? '.' + pathname : undefined",
1458
1541
  "}",
1459
- // The type table and cache policy `servePublicDir` uses. Without a known type, Bun answers a file
1460
- // as an `application/octet-stream` attachment, which a browser downloads instead of showing.
1461
- `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1462
- "const staticHeaders = (pathname: string): Record<string, string> => ({",
1463
- ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1464
- ` "cache-control": ${hashedFiles !== undefined ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1465
- ' "x-content-type-options": "nosniff",',
1466
- "})",
1542
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
1543
+ // tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
1544
+ // inside the root, so a link planted in the deployed tree cannot reach a file outside it.
1545
+ ...(target === "bun"
1546
+ ? [
1547
+ "const serveStatic = servePublicDir({",
1548
+ " dir: fileURLToPath(STATIC_ROOT),",
1549
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1550
+ "})",
1551
+ "const staticResponse = async (req: Request): Promise<Response> =>",
1552
+ ' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
1553
+ ]
1554
+ : denoStaticResponse(hashedFiles !== undefined)),
1467
1555
  )
1468
1556
  // Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
1469
1557
  // frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
@@ -1475,11 +1563,9 @@ export function generateServerEntry(options: {
1475
1563
  " port: Number(Bun.env.PORT ?? 3000),",
1476
1564
  " async fetch(req, server) {",
1477
1565
  " const { pathname } = new URL(req.url)",
1478
- " const filePath = staticPath(pathname)",
1479
- " if (filePath !== undefined) {",
1480
- " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1481
- ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1482
- " return new Response(file, { headers: staticHeaders(pathname) })",
1566
+ // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1567
+ " if (staticPath(pathname) !== undefined) {",
1568
+ " return staticResponse(req)",
1483
1569
  " }",
1484
1570
  ` ${markFramed}`,
1485
1571
  " // The socket peer, looked up only when something reads it (as core's own listen() does).",
@@ -1492,30 +1578,6 @@ export function generateServerEntry(options: {
1492
1578
  )
1493
1579
  return `${lines.join("\n")}\n`
1494
1580
  }
1495
- if (target === "node") {
1496
- lines.push(
1497
- 'import { readFile } from "node:fs/promises"',
1498
- "await serve(",
1499
- " {",
1500
- " async fetch(req, platform) {",
1501
- " const { pathname } = new URL(req.url)",
1502
- " const filePath = staticPath(pathname)",
1503
- " if (filePath !== undefined) {",
1504
- " try {",
1505
- " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1506
- " return new Response(body, { headers: staticHeaders(pathname) })",
1507
- " } catch {",
1508
- ' return new Response("not found", { status: 404 })',
1509
- " }",
1510
- " }",
1511
- " return app.fetch(req, platform)",
1512
- " },",
1513
- " },",
1514
- " { port: Number(process.env.PORT ?? 3000) },",
1515
- ")",
1516
- )
1517
- return `${lines.join("\n")}\n`
1518
- }
1519
1581
  // deno
1520
1582
  lines.push(
1521
1583
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
@@ -1523,13 +1585,7 @@ export function generateServerEntry(options: {
1523
1585
  " const { pathname } = new URL(req.url)",
1524
1586
  " const filePath = staticPath(pathname)",
1525
1587
  " if (filePath !== undefined) {",
1526
- " try {",
1527
- " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1528
- " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1529
- " return new Response(body, { headers: staticHeaders(pathname) })",
1530
- " } catch {",
1531
- ' return new Response("not found", { status: 404 })',
1532
- " }",
1588
+ " return staticResponse(req, filePath, pathname)",
1533
1589
  " }",
1534
1590
  ` ${markFramed}`,
1535
1591
  " return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
@@ -1538,6 +1594,166 @@ export function generateServerEntry(options: {
1538
1594
  return `${lines.join("\n")}\n`
1539
1595
  }
1540
1596
 
1597
+ /**
1598
+ * The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
1599
+ * path must sit inside the root and cross no hidden segment, and the opened file must still be the one
1600
+ * that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
1601
+ * inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
1602
+ * own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
1603
+ * range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
1604
+ * enough is then served from those bytes; otherwise one byte range is served from a seek, several as
1605
+ * the whole file.
1606
+ */
1607
+ function denoStaticResponse(hashed: boolean): string[] {
1608
+ return [
1609
+ "const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
1610
+ ' "content-type": contentTypeOf(file),',
1611
+ ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1612
+ ' "x-content-type-options": "nosniff",',
1613
+ ' "accept-ranges": "bytes",',
1614
+ "})",
1615
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1616
+ "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1617
+ "// One per process: the tag of a file is remembered by its real path.",
1618
+ "const STATIC_TAGS = contentTags(createHash)",
1619
+ "// 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",
1621
+ "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1622
+ ' const missing = () => new Response("not found", { status: 404 })',
1623
+ ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1624
+ " let file, info",
1625
+ ' let real = ""',
1626
+ " try {",
1627
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1628
+ " real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1629
+ ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1630
+ " if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
1631
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1632
+ " file = await Deno.open(real)",
1633
+ " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
1634
+ " // The opened file must be the one at `real`, which must still resolve to itself.",
1635
+ " const opened = await file.stat()",
1636
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1637
+ " const named = await Deno.stat(real)",
1638
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1639
+ " if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
1640
+ " file.close()",
1641
+ " return missing()",
1642
+ " }",
1643
+ " info = opened",
1644
+ " } catch {",
1645
+ " file?.close()",
1646
+ " return missing()",
1647
+ " }",
1648
+ " if (!info.isFile) {",
1649
+ " file.close()",
1650
+ " return missing()",
1651
+ " }",
1652
+ " const size: number = info.size",
1653
+ " const headers = new Headers(staticHeaders(pathname, real))",
1654
+ " // 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
+ " }",
1662
+ " let whole: Uint8Array<ArrayBuffer> | undefined",
1663
+ " let moved = false",
1664
+ " let etag: string | undefined",
1665
+ " try {",
1666
+ " etag = STATIC_TAGS.cached(real, version)",
1667
+ " if (etag === undefined && size <= STATIC_INLINE_MAX) {",
1668
+ " whole = new Uint8Array(size)",
1669
+ " let filled = 0",
1670
+ " while (filled < size) {",
1671
+ " const read: number | null = await file.read(whole.subarray(filled))",
1672
+ " if (read === null || read === 0) break",
1673
+ " filled += read",
1674
+ " }",
1675
+ " if (filled < size) whole = whole.subarray(0, filled)",
1676
+ " etag = STATIC_TAGS.ofBytes(real, version, whole)",
1677
+ " }",
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
+ " } catch {",
1685
+ " file.close()",
1686
+ " return missing()",
1687
+ " }",
1688
+ ' if (etag !== undefined) headers.set("etag", etag)',
1689
+ " const lastModified = lastModifiedOf(version)",
1690
+ ' 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)) {',
1692
+ " file.close()",
1693
+ ' headers.delete("content-type")',
1694
+ " return new Response(null, { status: 304, headers })",
1695
+ " }",
1696
+ ' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
1697
+ " const range =",
1698
+ ' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, lastModified)',
1699
+ " ? parseByteRange(rangeHeader, size)",
1700
+ ' : { kind: "none" as const }',
1701
+ ' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
1702
+ " file.close()",
1703
+ ' if (range.kind !== "unsatisfiable") {',
1704
+ ' headers.set("content-length", String(size))',
1705
+ " return new Response(null, { headers })",
1706
+ " }",
1707
+ ' headers.delete("content-type")',
1708
+ ' headers.set("content-range", "bytes */" + size)',
1709
+ " return new Response(null, { status: 416, headers })",
1710
+ " }",
1711
+ ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1712
+ " const { start, end } = range.ranges[0]!",
1713
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1714
+ " if (whole !== undefined) {",
1715
+ " file.close()",
1716
+ " const part = whole.subarray(start, end + 1)",
1717
+ ' headers.set("content-length", String(part.byteLength))',
1718
+ " return new Response(part, { status: 206, headers })",
1719
+ " }",
1720
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1721
+ " await file.seek(start, Deno.SeekMode.Start)",
1722
+ " let left = end - start + 1",
1723
+ ' headers.set("content-length", String(left))',
1724
+ " const body = new ReadableStream<Uint8Array>({",
1725
+ " async pull(controller) {",
1726
+ " const chunk = new Uint8Array(Math.min(left, 65_536))",
1727
+ " const read: number | null = await file.read(chunk)",
1728
+ " if (read === null || read === 0) {",
1729
+ " file.close()",
1730
+ " controller.close()",
1731
+ " return",
1732
+ " }",
1733
+ " controller.enqueue(chunk.subarray(0, read))",
1734
+ " left -= read",
1735
+ " if (left <= 0) {",
1736
+ " file.close()",
1737
+ " controller.close()",
1738
+ " }",
1739
+ " },",
1740
+ " cancel: () => file.close(),",
1741
+ " })",
1742
+ " return new Response(body, { status: 206, headers })",
1743
+ " }",
1744
+ " if (whole !== undefined) {",
1745
+ " file.close()",
1746
+ ' headers.set("content-length", String(whole.byteLength))',
1747
+ " return new Response(whole, { headers })",
1748
+ " }",
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
+ ' headers.set("content-length", String(size))',
1752
+ " return new Response(file.readable, { headers })",
1753
+ "}",
1754
+ ]
1755
+ }
1756
+
1541
1757
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1542
1758
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1543
1759
  * pure {@link aggregateSizeReport}. */
@@ -1581,7 +1797,9 @@ export interface BuildTargetOptions {
1581
1797
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1582
1798
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
1583
1799
  * until `buildClient` runs inside `buildTarget`, so a hardcoded entry 404s → pages render but never
1584
- * hydrate. Required for `target: "static"` (SSG drives `app.fetch`); ignored otherwise. */
1800
+ * hydrate. Required for `target: "static"` (SSG drives `app.fetch`). For `bun` and `node` it is
1801
+ * optional: given, the opted-in routes are prerendered into `_prerendered/` and the server answers
1802
+ * them from their file; the other targets ignore it. */
1585
1803
  readonly prerenderApp?: (client: BuildManifest) => PrerenderAppLike | Promise<PrerenderAppLike>
1586
1804
  /** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */
1587
1805
  readonly clientPlugins?: readonly BunPlugin[]
@@ -1710,6 +1928,48 @@ function assertNoPublicCollision(
1710
1928
  }
1711
1929
  }
1712
1930
 
1931
+ /** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
1932
+ * answer from their file: those whose render was not specific to its request. */
1933
+ async function prerenderForServer(
1934
+ prerenderApp: NonNullable<BuildTargetOptions["prerenderApp"]>,
1935
+ client: BuildManifest,
1936
+ routesDir: string,
1937
+ dir: string,
1938
+ publicFiles: readonly string[],
1939
+ secrets: NonNullable<PrerenderOptions["secrets"]>,
1940
+ ): Promise<Record<string, ServerPrerenderedPage>> {
1941
+ const owned = `/${PRERENDERED_DIR}/`
1942
+ const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned))
1943
+ if (clashes.length > 0) {
1944
+ throw new Error(
1945
+ `[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
1946
+ "prerendered pages. Rename or move them out of the public directory.",
1947
+ )
1948
+ }
1949
+ let result: Awaited<ReturnType<typeof prerenderRoutes>>
1950
+ try {
1951
+ result = await prerenderRoutes({
1952
+ app: await prerenderApp(client),
1953
+ routes: discoverRoutes(routesDir).routes,
1954
+ outDir: dir,
1955
+ secrets,
1956
+ })
1957
+ } catch (error) {
1958
+ // Prerendering loads every route module at build time, which a server build never needed before:
1959
+ // one that cannot load there (a connection opened on import, an env var only production sets) costs
1960
+ // the speedup, not the build. A secret in a page still fails it.
1961
+ if (error instanceof SecretScanError) throw error
1962
+ rmSync(dir, { recursive: true, force: true })
1963
+ console.warn(
1964
+ `[nifra/web] prerendering failed, so the server renders every page on demand: ${
1965
+ error instanceof Error ? error.message : String(error)
1966
+ }`,
1967
+ )
1968
+ return {}
1969
+ }
1970
+ return servablePrerenderedPages(result.prerendered)
1971
+ }
1972
+
1713
1973
  /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1714
1974
  export async function buildTarget(
1715
1975
  target: BuildTarget,
@@ -1749,6 +2009,11 @@ export async function buildTargetWith(
1749
2009
  ]
1750
2010
  assertDisposableDir("outDir", outDir, sources)
1751
2011
  assertDisposableDir("workDir", workDir, sources)
2012
+ // Before anything is cleared, so a refused build leaves the last deploy dir in place.
2013
+ if (targetPlan.target === "node") {
2014
+ const refusal = nodeAdapterRefusal(workDir)
2015
+ if (refusal !== undefined) throw new Error(refusal)
2016
+ }
1752
2017
  const { rmSync } = await import("node:fs")
1753
2018
  rmSync(outDir, { recursive: true, force: true })
1754
2019
  rmSync(workDir, { recursive: true, force: true })
@@ -1810,6 +2075,21 @@ export async function buildTargetWith(
1810
2075
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1811
2076
  }
1812
2077
 
2078
+ // A self-hosting server answers its prerendered pages from their file. The static target is all
2079
+ // prerendered pages; the edge targets keep rendering them on demand.
2080
+ const prerenderedPages =
2081
+ options.prerenderApp !== undefined &&
2082
+ (targetPlan.target === "bun" || targetPlan.target === "node")
2083
+ ? await prerenderForServer(
2084
+ options.prerenderApp,
2085
+ client,
2086
+ routesDir,
2087
+ `${staticRoot}/${PRERENDERED_DIR}`,
2088
+ publicFiles,
2089
+ secretScan,
2090
+ )
2091
+ : undefined
2092
+
1813
2093
  if (targetPlan.kind === "static") {
1814
2094
  if (options.prerenderApp === undefined) {
1815
2095
  throw new Error(
@@ -1865,6 +2145,7 @@ export async function buildTargetWith(
1865
2145
  hashedFiles: client.assets,
1866
2146
  ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1867
2147
  ...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
2148
+ ...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
1868
2149
  }),
1869
2150
  )
1870
2151
  const { worker } = await bundler.buildServer({
package/src/csp.ts CHANGED
@@ -175,6 +175,10 @@ export function cspDocumentNonce(
175
175
  return requested ?? policy[CSP_POLICY].generate()
176
176
  }
177
177
 
178
+ /** A nonce for a render that may write a script of its own; the document carries it only if used. */
179
+ export const cspCandidateNonce = (policy: CspPolicy, requested: string | undefined): string =>
180
+ requested ?? policy[CSP_POLICY].generate()
181
+
178
182
  /** The Content-Security-Policy value for one document, or `undefined` when the policy sends none. */
179
183
  export function cspHeaderValue(
180
184
  policy: CspPolicy,
package/src/deferred.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
7
  */
8
8
  import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
9
+ import { isDeferredFree } from "./internal/deferred-free.ts"
9
10
  import { parsePageData } from "./internal/revive-dates.ts"
10
11
 
11
12
  /**
@@ -65,6 +66,7 @@ export function prepareDeferred(
65
66
  readonly deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>
66
67
  } {
67
68
  const deferred: Array<{ id: number; promise: Promise<unknown> }> = []
69
+ if (isDeferredFree(data)) return { forComponent: data, forClient: data, deferred }
68
70
  // Structural sharing: a subtree with no deferred value returns the ORIGINAL node for
69
71
  // both sides (`fc`/`cl` identical to the input), so a deferred-free result - the common case - is
70
72
  // returned by reference with no persisted clone, no downstream copy, and no throwaway clone
@@ -321,6 +323,14 @@ export async function parseNdjsonData(
321
323
  // accumulate listeners across navigations (a per-nav signal would be GC'd, but don't rely on it).
322
324
  const onAbort = (): void => void reader.cancel()
323
325
  if (signal) signal.addEventListener("abort", onAbort, { once: true })
326
+ let ended = false
327
+ // Every way out, a malformed line included, detaches from the signal and the body: a source left
328
+ // streaming into a reader nobody reads, or still locked to it, would hold its response open.
329
+ const release = (): void => {
330
+ signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
331
+ if (!ended) reader.cancel().catch(() => {})
332
+ reader.releaseLock()
333
+ }
324
334
 
325
335
  const readLine = async (): Promise<string | null> => {
326
336
  for (;;) {
@@ -332,6 +342,7 @@ export async function parseNdjsonData(
332
342
  }
333
343
  const { done, value } = await reader.read()
334
344
  if (done) {
345
+ ended = true
335
346
  const rest = buffer
336
347
  buffer = ""
337
348
  return rest === "" ? null : rest
@@ -344,8 +355,14 @@ export async function parseNdjsonData(
344
355
  // Only plain JSON carries t.date() tags; a richer codec decodes its own dates.
345
356
  const decode = (line: string): unknown =>
346
357
  codec === plainJsonCodec ? parsePageData(line) : codec.decode(line)
347
- const first = await readLine()
348
- const data = markersFromPlaceholders(first === null ? null : decode(first), pending)
358
+ let data: unknown
359
+ try {
360
+ const first = await readLine()
361
+ data = markersFromPlaceholders(first === null ? null : decode(first), pending)
362
+ } catch (error) {
363
+ release()
364
+ throw error
365
+ }
349
366
 
350
367
  void (async () => {
351
368
  try {
@@ -363,7 +380,7 @@ export async function parseNdjsonData(
363
380
  } catch {
364
381
  // stream errored - leftovers are rejected below (unless this was an abort)
365
382
  } finally {
366
- signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
383
+ release()
367
384
  // A natural stream end with markers unsettled = truncation → reject (so `<Await>` shows its
368
385
  // error). An `abort` = a superseded navigation → leave them pending: the markers are detached
369
386
  // (their data was never applied), so rejecting would only risk an error flash mid-transition.
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export {
87
87
  // Generated client entries intentionally import the browser router from the client subpath:
88
88
  // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
89
89
  export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
90
+ export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
90
91
  export {
91
92
  gone,
92
93
  notFound,
@@ -224,7 +225,9 @@ export {
224
225
  } from "./nonce.ts"
225
226
  // public/ - user-authored static files, served identically in dev and production by one handler.
226
227
  export {
228
+ type PrerenderedPageReaderOptions,
227
229
  type PublicDirCache,
230
+ prerenderedPageReader,
228
231
  resolvePublicPath,
229
232
  type ServePublicDirOptions,
230
233
  servePublicDir,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Values an output guard settled under a schema that cannot let a `defer()` marker through: no
3
+ * `t.deferred`, and no open or untyped node. `prepareDeferred` returns them without walking them.
4
+ * A marker the schema does declare is projected into a new object, so the value that holds it is
5
+ * a fresh copy, never one marked here.
6
+ */
7
+ const deferredFree = new WeakSet<object>()
8
+
9
+ export function markDeferredFree(value: unknown): void {
10
+ if (typeof value === "object" && value !== null) deferredFree.add(value)
11
+ }
12
+
13
+ export function isDeferredFree(value: unknown): boolean {
14
+ return typeof value === "object" && value !== null && deferredFree.has(value)
15
+ }