@nifrajs/web 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +182 -11
  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/output-guard.d.ts +2 -0
  26. package/dist/internal/output-guard.d.ts.map +1 -1
  27. package/dist/internal/output-guard.js +51 -9
  28. package/dist/internal/output-guard.js.map +1 -1
  29. package/dist/internal/page-execution.d.ts +1 -0
  30. package/dist/internal/page-execution.d.ts.map +1 -1
  31. package/dist/internal/page-execution.js +53 -43
  32. package/dist/internal/page-execution.js.map +1 -1
  33. package/dist/internal/parity.d.ts.map +1 -1
  34. package/dist/internal/parity.js +3 -1
  35. package/dist/internal/parity.js.map +1 -1
  36. package/dist/internal/prerendered-pages.d.ts +42 -0
  37. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  38. package/dist/internal/prerendered-pages.js +92 -0
  39. package/dist/internal/prerendered-pages.js.map +1 -0
  40. package/dist/internal/public-url.d.ts +7 -0
  41. package/dist/internal/public-url.d.ts.map +1 -1
  42. package/dist/internal/public-url.js +13 -0
  43. package/dist/internal/public-url.js.map +1 -1
  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 +15 -0
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +91 -12
  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 +226 -35
  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/output-guard.ts +51 -9
  89. package/src/internal/page-execution.ts +55 -47
  90. package/src/internal/parity.ts +2 -1
  91. package/src/internal/prerendered-pages.ts +124 -0
  92. package/src/internal/public-url.ts +13 -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 +107 -12
  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
@@ -60,8 +60,12 @@ import {
60
60
  assertIdentityParity,
61
61
  collectDevelopmentParityInput,
62
62
  } from "./internal/parity.ts"
63
+ import {
64
+ type ServerPrerenderedPage,
65
+ servablePrerenderedPages,
66
+ } from "./internal/prerendered-pages.ts"
63
67
  import { privateEnvCheck } from "./internal/private-env.ts"
64
- import { publicUrlPath } from "./internal/public-url.ts"
68
+ import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.ts"
65
69
  import {
66
70
  assertNoSecrets,
67
71
  buildEnvironment,
@@ -71,6 +75,7 @@ import {
71
75
  originName,
72
76
  publicScanFiles,
73
77
  type SecretExemption,
78
+ SecretScanError,
74
79
  scanForSecrets,
75
80
  textOf,
76
81
  } from "./internal/secret-scan.ts"
@@ -94,7 +99,7 @@ import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
94
99
  import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
95
100
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
96
101
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
97
- import { prerenderRoutes } from "./prerender.ts"
102
+ import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
98
103
  import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
99
104
  import { createZoneClassifier } from "./zones.ts"
100
105
 
@@ -128,6 +133,8 @@ interface BunMetafile {
128
133
  >
129
134
  }
130
135
 
136
+ // A page a bun or node build answers from its file (`generateServerEntry`'s `prerenderedPages`).
137
+ export type { ServerPrerenderedPage } from "./internal/prerendered-pages.ts"
131
138
  // The secret scan every client build, `public/` copy and prerender runs.
132
139
  export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
133
140
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
@@ -340,7 +347,9 @@ export function cloudflareRouteRules(
340
347
 
341
348
  /** The built asset map - the server reads `entry` for the client script + serves `assets`. */
342
349
  /**
343
- * Copy `from` into `to`, returning the URL paths copied (sorted).
350
+ * Copy `from` into `to`, returning the URL paths copied (sorted). Hidden entries other than
351
+ * `.well-known` are skipped, as development never serves them: every target ships this list, and a
352
+ * CDN-hosted one serves whatever was copied.
344
353
  *
345
354
  * Lives here, not beside `servePublicDir`, because it is BUILD-time: it reaches for `Bun.Glob` and
346
355
  * `node:fs`, and `public-dir.ts` is reachable from the client bundle graph through the package
@@ -351,6 +360,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
351
360
  const sourceReal = await realpath(source)
352
361
  const copied: string[] = []
353
362
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
363
+ if (isHiddenPublicPath(rel)) continue
354
364
  const candidate = join(source, rel)
355
365
  const candidateStat = await lstat(candidate)
356
366
  if (candidateStat.isSymbolicLink()) {
@@ -1296,6 +1306,9 @@ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>>
1296
1306
  vercel: "x-real-ip",
1297
1307
  }
1298
1308
 
1309
+ /** Where a self-hosting build writes its prerendered pages, beside `server.js`. */
1310
+ const PRERENDERED_DIR = "_prerendered"
1311
+
1299
1312
  /**
1300
1313
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
1301
1314
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
@@ -1332,6 +1345,9 @@ export function generateServerEntry(options: {
1332
1345
  readonly clientIp?: ServerEntryClientIp
1333
1346
  /** The server manifest exports `copyChunks` (the client build split copy tables by locale). */
1334
1347
  readonly copyChunks?: boolean
1348
+ /** `bun` / `node` only: the pages the build prerendered into `_prerendered/`, by request path, which
1349
+ * the app answers from their file (see `createWebApp`'s `prerenderedPages`). */
1350
+ readonly prerenderedPages?: Readonly<Record<string, ServerPrerenderedPage>>
1335
1351
  }): string {
1336
1352
  const {
1337
1353
  target,
@@ -1363,7 +1379,22 @@ export function generateServerEntry(options: {
1363
1379
  if (target === "static") {
1364
1380
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1365
1381
  }
1366
- const lines: string[] = ['import { createWebApp } from "@nifrajs/web"']
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.
1384
+ const nodeFs = target === "bun" || target === "node"
1385
+ const prerendered = Object.entries(options.prerenderedPages ?? {})
1386
+ if (prerendered.length > 0 && !nodeFs) {
1387
+ throw new Error(
1388
+ `[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
1389
+ )
1390
+ }
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
+ ]
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,29 @@ 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") lines.push('import { parseByteRange } from "@nifrajs/core/range"')
1417
+ if (prerendered.length > 0) {
1418
+ const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
1419
+ const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
1420
+ lines.push(
1421
+ "",
1422
+ `// The pages the build prerendered into ${PRERENDERED_DIR}/, answered from their file after \`use\`.`,
1423
+ "const prerenderedPages = {",
1424
+ ` pages: ${JSON.stringify(headers)},`,
1425
+ " read: prerenderedPageReader({",
1426
+ ` dir: fileURLToPath(new URL(${JSON.stringify(`./${PRERENDERED_DIR}/`)}, import.meta.url)),`,
1427
+ ` files: ${JSON.stringify(files)},`,
1428
+ " }),",
1429
+ "}",
1430
+ )
1431
+ }
1384
1432
  lines.push(
1385
1433
  "",
1386
1434
  "const app = createWebApp({",
1387
1435
  " adapter,",
1388
1436
  ...(useImport !== undefined ? [" use,"] : []),
1437
+ ...(prerendered.length > 0 ? [" prerenderedPages,"] : []),
1389
1438
  ...importedOptions.map((name) => ` ${name},`),
1390
1439
  " manifest,",
1391
1440
  " clientEntry,",
@@ -1456,14 +1505,20 @@ export function generateServerEntry(options: {
1456
1505
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1457
1506
  " return segments.every(safe) ? '.' + pathname : undefined",
1458
1507
  "}",
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
- "})",
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
1513
+ ? [
1514
+ "const serveStatic = servePublicDir({",
1515
+ " dir: fileURLToPath(STATIC_ROOT),",
1516
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1517
+ "})",
1518
+ "const staticResponse = async (req: Request): Promise<Response> =>",
1519
+ ' (await serveStatic(req)) ?? new Response("not found", { status: 404 })',
1520
+ ]
1521
+ : denoStaticResponse(hashedFiles !== undefined)),
1467
1522
  )
1468
1523
  // Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
1469
1524
  // frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
@@ -1475,11 +1530,9 @@ export function generateServerEntry(options: {
1475
1530
  " port: Number(Bun.env.PORT ?? 3000),",
1476
1531
  " async fetch(req, server) {",
1477
1532
  " 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) })",
1533
+ // Braced: the next emitted line opens with `(`, which would otherwise call the returned value.
1534
+ " if (staticPath(pathname) !== undefined) {",
1535
+ " return staticResponse(req)",
1483
1536
  " }",
1484
1537
  ` ${markFramed}`,
1485
1538
  " // The socket peer, looked up only when something reads it (as core's own listen() does).",
@@ -1494,20 +1547,11 @@ export function generateServerEntry(options: {
1494
1547
  }
1495
1548
  if (target === "node") {
1496
1549
  lines.push(
1497
- 'import { readFile } from "node:fs/promises"',
1498
1550
  "await serve(",
1499
1551
  " {",
1500
1552
  " async fetch(req, platform) {",
1501
1553
  " 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
- " }",
1554
+ " if (staticPath(pathname) !== undefined) return staticResponse(req)",
1511
1555
  " return app.fetch(req, platform)",
1512
1556
  " },",
1513
1557
  " },",
@@ -1523,13 +1567,7 @@ export function generateServerEntry(options: {
1523
1567
  " const { pathname } = new URL(req.url)",
1524
1568
  " const filePath = staticPath(pathname)",
1525
1569
  " 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
- " }",
1570
+ " return staticResponse(req, filePath, pathname)",
1533
1571
  " }",
1534
1572
  ` ${markFramed}`,
1535
1573
  " return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
@@ -1538,6 +1576,99 @@ export function generateServerEntry(options: {
1538
1576
  return `${lines.join("\n")}\n`
1539
1577
  }
1540
1578
 
1579
+ /**
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.
1585
+ */
1586
+ function denoStaticResponse(hashed: boolean): string[] {
1587
+ 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",',
1591
+ ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1592
+ ' "x-content-type-options": "nosniff",',
1593
+ ' "accept-ranges": "bytes",',
1594
+ "})",
1595
+ "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1596
+ "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")',
1599
+ "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1600
+ ' const missing = () => new Response("not found", { status: 404 })',
1601
+ ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1602
+ " let file, info",
1603
+ " try {",
1604
+ " // @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))",
1606
+ ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1607
+ " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1608
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1609
+ " file = await Deno.open(real)",
1610
+ " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
1611
+ " // The opened file must be the one at `real`, which must still resolve to itself.",
1612
+ " const opened = await file.stat()",
1613
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1614
+ " const named = await Deno.stat(real)",
1615
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1616
+ " if (opened.dev !== named.dev || opened.ino !== named.ino || (await Deno.realPath(real)) !== real) {",
1617
+ " file.close()",
1618
+ " return missing()",
1619
+ " }",
1620
+ " info = opened",
1621
+ " } catch {",
1622
+ " file?.close()",
1623
+ " return missing()",
1624
+ " }",
1625
+ " 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") {',
1629
+ " file.close()",
1630
+ " if (!info.isFile) return missing()",
1631
+ ' if (range.kind !== "unsatisfiable") {',
1632
+ ' headers.set("content-length", String(size))',
1633
+ " return new Response(null, { headers })",
1634
+ " }",
1635
+ ' headers.delete("content-type")',
1636
+ ' headers.set("content-range", "bytes */" + size)',
1637
+ " return new Response(null, { status: 416, headers })",
1638
+ " }",
1639
+ ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1640
+ " const { start, end } = range.ranges[0]!",
1641
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1642
+ " await file.seek(start, Deno.SeekMode.Start)",
1643
+ " let left = end - start + 1",
1644
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1645
+ ' headers.set("content-length", String(left))',
1646
+ " const body = new ReadableStream<Uint8Array>({",
1647
+ " async pull(controller) {",
1648
+ " const chunk = new Uint8Array(Math.min(left, 65_536))",
1649
+ " const read: number | null = await file.read(chunk)",
1650
+ " if (read === null || read === 0) {",
1651
+ " file.close()",
1652
+ " controller.close()",
1653
+ " return",
1654
+ " }",
1655
+ " controller.enqueue(chunk.subarray(0, read))",
1656
+ " left -= read",
1657
+ " if (left <= 0) {",
1658
+ " file.close()",
1659
+ " controller.close()",
1660
+ " }",
1661
+ " },",
1662
+ " cancel: () => file.close(),",
1663
+ " })",
1664
+ " return new Response(body, { status: 206, headers })",
1665
+ " }",
1666
+ ' headers.set("content-length", String(size))',
1667
+ " return new Response(file.readable, { headers })",
1668
+ "}",
1669
+ ]
1670
+ }
1671
+
1541
1672
  /** Measure each emitted output file's raw + gzip size. Reads the file off disk and gzips it with
1542
1673
  * `Bun.gzipSync` (the over-the-wire weight). Async only because it reads files; the aggregation is the
1543
1674
  * pure {@link aggregateSizeReport}. */
@@ -1581,7 +1712,9 @@ export interface BuildTargetOptions {
1581
1712
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1582
1713
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
1583
1714
  * 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. */
1715
+ * hydrate. Required for `target: "static"` (SSG drives `app.fetch`). For `bun` and `node` it is
1716
+ * optional: given, the opted-in routes are prerendered into `_prerendered/` and the server answers
1717
+ * them from their file; the other targets ignore it. */
1585
1718
  readonly prerenderApp?: (client: BuildManifest) => PrerenderAppLike | Promise<PrerenderAppLike>
1586
1719
  /** Client-build plugins (e.g. the MDX/Vue/Solid Bun plugins). */
1587
1720
  readonly clientPlugins?: readonly BunPlugin[]
@@ -1710,6 +1843,48 @@ function assertNoPublicCollision(
1710
1843
  }
1711
1844
  }
1712
1845
 
1846
+ /** Prerender a self-hosting build's opted-in routes into `dir` and return the pages its server may
1847
+ * answer from their file: those whose render was not specific to its request. */
1848
+ async function prerenderForServer(
1849
+ prerenderApp: NonNullable<BuildTargetOptions["prerenderApp"]>,
1850
+ client: BuildManifest,
1851
+ routesDir: string,
1852
+ dir: string,
1853
+ publicFiles: readonly string[],
1854
+ secrets: NonNullable<PrerenderOptions["secrets"]>,
1855
+ ): Promise<Record<string, ServerPrerenderedPage>> {
1856
+ const owned = `/${PRERENDERED_DIR}/`
1857
+ const clashes = publicFiles.filter((file) => file.toLowerCase().startsWith(owned))
1858
+ if (clashes.length > 0) {
1859
+ throw new Error(
1860
+ `[nifra/web] public file(s) ${clashes.join(", ")} sit under ${owned}, where the build writes its ` +
1861
+ "prerendered pages. Rename or move them out of the public directory.",
1862
+ )
1863
+ }
1864
+ let result: Awaited<ReturnType<typeof prerenderRoutes>>
1865
+ try {
1866
+ result = await prerenderRoutes({
1867
+ app: await prerenderApp(client),
1868
+ routes: discoverRoutes(routesDir).routes,
1869
+ outDir: dir,
1870
+ secrets,
1871
+ })
1872
+ } catch (error) {
1873
+ // Prerendering loads every route module at build time, which a server build never needed before:
1874
+ // one that cannot load there (a connection opened on import, an env var only production sets) costs
1875
+ // the speedup, not the build. A secret in a page still fails it.
1876
+ if (error instanceof SecretScanError) throw error
1877
+ rmSync(dir, { recursive: true, force: true })
1878
+ console.warn(
1879
+ `[nifra/web] prerendering failed, so the server renders every page on demand: ${
1880
+ error instanceof Error ? error.message : String(error)
1881
+ }`,
1882
+ )
1883
+ return {}
1884
+ }
1885
+ return servablePrerenderedPages(result.prerendered)
1886
+ }
1887
+
1713
1888
  /** Build a full deploy dir for `target` using the default Bun bundler. See {@link buildTargetWith}. */
1714
1889
  export async function buildTarget(
1715
1890
  target: BuildTarget,
@@ -1810,6 +1985,21 @@ export async function buildTargetWith(
1810
1985
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1811
1986
  }
1812
1987
 
1988
+ // A self-hosting server answers its prerendered pages from their file. The static target is all
1989
+ // prerendered pages; the edge targets keep rendering them on demand.
1990
+ const prerenderedPages =
1991
+ options.prerenderApp !== undefined &&
1992
+ (targetPlan.target === "bun" || targetPlan.target === "node")
1993
+ ? await prerenderForServer(
1994
+ options.prerenderApp,
1995
+ client,
1996
+ routesDir,
1997
+ `${staticRoot}/${PRERENDERED_DIR}`,
1998
+ publicFiles,
1999
+ secretScan,
2000
+ )
2001
+ : undefined
2002
+
1813
2003
  if (targetPlan.kind === "static") {
1814
2004
  if (options.prerenderApp === undefined) {
1815
2005
  throw new Error(
@@ -1865,6 +2055,7 @@ export async function buildTargetWith(
1865
2055
  hashedFiles: client.assets,
1866
2056
  ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1867
2057
  ...(client.copyChunks !== undefined ? { copyChunks: true } : {}),
2058
+ ...(prerenderedPages !== undefined ? { prerenderedPages } : {}),
1868
2059
  }),
1869
2060
  )
1870
2061
  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
+ }
@@ -0,0 +1,30 @@
1
+ interface ModuleEntry<T extends object> {
2
+ readonly load: () => Promise<T>
3
+ }
4
+
5
+ /** Production imports are immutable; failed imports remain retryable. No loader data is cached. */
6
+ export function prepareModules(
7
+ enabled: boolean,
8
+ ): <T extends object>(entry: ModuleEntry<T>) => T | Promise<T> {
9
+ const modules = new WeakMap<object, object | Promise<object>>()
10
+ return <T extends object>(entry: ModuleEntry<T>): T | Promise<T> => {
11
+ if (!enabled) return entry.load()
12
+ const known = modules.get(entry)
13
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this entry.load populates its slot, so the cached value retains that entry's module type.
14
+ if (known !== undefined) return known as T | Promise<T>
15
+ const pending = Promise.resolve()
16
+ .then(() => entry.load())
17
+ .then(
18
+ (module) => {
19
+ modules.set(entry, module)
20
+ return module
21
+ },
22
+ (error) => {
23
+ modules.delete(entry)
24
+ throw error
25
+ },
26
+ )
27
+ modules.set(entry, pending)
28
+ return pending
29
+ }
30
+ }