@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.
- package/CHANGELOG.md +72 -0
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +182 -11
- package/dist/build.js.map +1 -1
- package/dist/csp.d.ts +2 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +2 -0
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +23 -3
- package/dist/deferred.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/deferred-free.d.ts +3 -0
- package/dist/internal/deferred-free.d.ts.map +1 -0
- package/dist/internal/deferred-free.js +15 -0
- package/dist/internal/deferred-free.js.map +1 -0
- package/dist/internal/module-preparation.d.ts +7 -0
- package/dist/internal/module-preparation.d.ts.map +1 -0
- package/dist/internal/module-preparation.js +24 -0
- package/dist/internal/module-preparation.js.map +1 -0
- package/dist/internal/output-guard.d.ts +2 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +51 -9
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +1 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +53 -43
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -1
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/prerendered-pages.d.ts +42 -0
- package/dist/internal/prerendered-pages.d.ts.map +1 -0
- package/dist/internal/prerendered-pages.js +92 -0
- package/dist/internal/prerendered-pages.js.map +1 -0
- package/dist/internal/public-url.d.ts +7 -0
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +13 -0
- package/dist/internal/public-url.js.map +1 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +93 -35
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +4 -0
- package/dist/internal/request-url.d.ts.map +1 -1
- package/dist/internal/request-url.js +24 -0
- package/dist/internal/request-url.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +4 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +8 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/web-app.d.ts +11 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +10 -1
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +7 -4
- package/dist/isr.js.map +1 -1
- package/dist/prerender.d.ts +4 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +9 -1
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +15 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +91 -12
- package/dist/public-dir.js.map +1 -1
- package/dist/render-seam.d.ts +5 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js.map +1 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/vite.d.ts +6 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +127 -54
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +226 -35
- package/src/csp.ts +4 -0
- package/src/deferred.ts +20 -3
- package/src/index.ts +3 -0
- package/src/internal/deferred-free.ts +15 -0
- package/src/internal/module-preparation.ts +30 -0
- package/src/internal/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +55 -47
- package/src/internal/parity.ts +2 -1
- package/src/internal/prerendered-pages.ts +124 -0
- package/src/internal/public-url.ts +13 -0
- package/src/internal/render-document.ts +116 -52
- package/src/internal/request-url.ts +24 -0
- package/src/internal/secret-scan.ts +9 -1
- package/src/internal/web-app.ts +21 -1
- package/src/isr.ts +7 -4
- package/src/prerender.ts +13 -1
- package/src/public-dir.ts +107 -12
- package/src/render-seam.ts +5 -2
- package/src/search.ts +1 -0
- 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
|
-
|
|
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
|
|
1460
|
-
//
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1479
|
-
" if (
|
|
1480
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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`)
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
+
}
|