@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.
- package/CHANGELOG.md +112 -0
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +265 -15
- 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/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.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 -0
- 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/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 +19 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +130 -99
- 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 +330 -49
- 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/node-adapter.ts +57 -0
- package/src/internal/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +55 -47
- package/src/internal/parity.ts +2 -0
- package/src/internal/prerendered-pages.ts +124 -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 +158 -97
- package/src/render-seam.ts +5 -2
- package/src/search.ts +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
1460
|
-
//
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1479
|
-
" if (
|
|
1480
|
-
"
|
|
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
|
-
"
|
|
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`)
|
|
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
|
-
|
|
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
|
+
}
|