@nifrajs/web 5.1.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 +3042 -0
- package/dist/build-plan.d.ts +7 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +19 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +51 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +23 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +224 -16
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +14 -7
- package/dist/client.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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +6 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +23 -5
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/copy-split.d.ts +27 -0
- package/dist/internal/copy-split.d.ts.map +1 -0
- package/dist/internal/copy-split.js +173 -0
- package/dist/internal/copy-split.js.map +1 -0
- 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 +20 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +115 -50
- 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 +11 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +131 -41
- 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/typescript-api.d.ts +4 -0
- package/dist/internal/typescript-api.d.ts.map +1 -0
- package/dist/internal/typescript-api.js +8 -0
- package/dist/internal/typescript-api.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +28 -0
- package/dist/internal/vite-plugin-compat.d.ts.map +1 -0
- package/dist/internal/vite-plugin-compat.js +82 -0
- package/dist/internal/vite-plugin-compat.js.map +1 -0
- package/dist/internal/web-app.d.ts +45 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +32 -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/plugins/import-glob.d.ts +20 -0
- package/dist/plugins/import-glob.d.ts.map +1 -0
- package/dist/plugins/import-glob.js +401 -0
- package/dist/plugins/import-glob.js.map +1 -0
- package/dist/plugins/kit.d.ts +11 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +32 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/stylex.d.ts.map +1 -1
- package/dist/plugins/stylex.js +2 -2
- package/dist/plugins/stylex.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 +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +33 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +3 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -4
- package/dist/router.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/server-components/typescript.d.ts.map +1 -1
- package/dist/server-components/typescript.js +2 -7
- package/dist/server-components/typescript.js.map +1 -1
- package/dist/vite.d.ts +7 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +130 -123
- package/dist/vite.js.map +1 -1
- package/package.json +5 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +283 -39
- package/src/client.ts +15 -7
- package/src/csp.ts +4 -0
- package/src/deferred.ts +20 -3
- package/src/index.ts +9 -0
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -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 +137 -53
- 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 +179 -58
- package/src/internal/request-url.ts +24 -0
- package/src/internal/secret-scan.ts +9 -1
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +82 -2
- package/src/isr.ts +7 -4
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/prerender.ts +13 -1
- package/src/public-dir.ts +107 -12
- package/src/render-seam.ts +65 -2
- package/src/router.ts +7 -6
- package/src/search.ts +1 -0
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +133 -135
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,
|
|
@@ -121,6 +122,7 @@ export {
|
|
|
121
122
|
type CreateWebAppOptions,
|
|
122
123
|
createWebApp,
|
|
123
124
|
type NonceResolver,
|
|
125
|
+
type WebAppI18n,
|
|
124
126
|
webProjectEvidence,
|
|
125
127
|
} from "./internal/web-app.ts"
|
|
126
128
|
// ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
|
|
@@ -223,7 +225,9 @@ export {
|
|
|
223
225
|
} from "./nonce.ts"
|
|
224
226
|
// public/ - user-authored static files, served identically in dev and production by one handler.
|
|
225
227
|
export {
|
|
228
|
+
type PrerenderedPageReaderOptions,
|
|
226
229
|
type PublicDirCache,
|
|
230
|
+
prerenderedPageReader,
|
|
227
231
|
resolvePublicPath,
|
|
228
232
|
type ServePublicDirOptions,
|
|
229
233
|
servePublicDir,
|
|
@@ -256,7 +260,12 @@ export {
|
|
|
256
260
|
type DocumentParts,
|
|
257
261
|
HANDOVER_ID,
|
|
258
262
|
LAYOUT_DATA_GLOBAL,
|
|
263
|
+
LOCALES_GLOBAL,
|
|
264
|
+
localeOfProps,
|
|
259
265
|
type MatchChain,
|
|
266
|
+
type PageLocale,
|
|
267
|
+
type PageLocales,
|
|
268
|
+
pageLocaleIn,
|
|
260
269
|
type RenderAdapter,
|
|
261
270
|
type RenderProps,
|
|
262
271
|
type RenderStreamOptions,
|
package/src/internal/codegen.ts
CHANGED
|
@@ -5,9 +5,12 @@ import {
|
|
|
5
5
|
ACTION_GLOBAL,
|
|
6
6
|
BOUNDARY_GLOBAL,
|
|
7
7
|
COMPONENT_GLOBAL,
|
|
8
|
+
COPY_CHUNKS_GLOBAL,
|
|
9
|
+
COPY_REALM,
|
|
8
10
|
DATA_GLOBAL,
|
|
9
11
|
HANDOVER_ID,
|
|
10
12
|
LAYOUT_DATA_GLOBAL,
|
|
13
|
+
LOCALES_GLOBAL,
|
|
11
14
|
ROOT_ATTRIBUTE,
|
|
12
15
|
ROUTE_GLOBAL,
|
|
13
16
|
} from "../render-seam.ts"
|
|
@@ -23,6 +26,7 @@ const HANDOVER_GLOBALS = [
|
|
|
23
26
|
BOUNDARY_GLOBAL,
|
|
24
27
|
COMPONENT_GLOBAL,
|
|
25
28
|
PRERENDERED_GLOBAL,
|
|
29
|
+
LOCALES_GLOBAL,
|
|
26
30
|
]
|
|
27
31
|
export interface GenerateClientEntryOptions {
|
|
28
32
|
/**
|
|
@@ -39,6 +43,9 @@ export interface GenerateClientEntryOptions {
|
|
|
39
43
|
/** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
|
|
40
44
|
readonly resolve: (file: string) => string
|
|
41
45
|
readonly serverComponents?: boolean
|
|
46
|
+
/** The build moved copy tables into per-locale chunks: the handover carries the chunk URLs, and a
|
|
47
|
+
* navigation loads the copy of the URL it goes to beside the route's code. */
|
|
48
|
+
readonly copyChunks?: boolean
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -222,7 +229,7 @@ export function generateClientEntry(
|
|
|
222
229
|
// routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
|
|
223
230
|
`const layoutIdsOf = ${jsValueLiteral(layoutIdsOf)}`,
|
|
224
231
|
"const matchChains = {}",
|
|
225
|
-
|
|
232
|
+
`const ${options.copyChunks === true ? "loadRoute" : "loadModule"} = async (id) => {`,
|
|
226
233
|
" if (chains[id]) return",
|
|
227
234
|
" const mods = await loaders[id]()",
|
|
228
235
|
// For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
|
|
@@ -260,6 +267,17 @@ export function generateClientEntry(
|
|
|
260
267
|
" routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
|
|
261
268
|
" if (page.ssr === false) holds[id] = [...chains[id].slice(0, -1), page.HydrateFallback ?? (() => null)]",
|
|
262
269
|
"}",
|
|
270
|
+
...(options.copyChunks === true
|
|
271
|
+
? [
|
|
272
|
+
`const copyRealm = () => globalThis[Symbol.for(${jsStringLiteral(COPY_REALM)})]`,
|
|
273
|
+
// A second prepare covers a copy definition the route's own code brought in.
|
|
274
|
+
"const loadModule = async (id, path) => {",
|
|
275
|
+
" if (path === undefined) return loadRoute(id)",
|
|
276
|
+
" await Promise.all([loadRoute(id), copyRealm()?.prepare(path)])",
|
|
277
|
+
" await copyRealm()?.prepare(path)",
|
|
278
|
+
"}",
|
|
279
|
+
]
|
|
280
|
+
: []),
|
|
263
281
|
// The server hands page state over as one inert JSON script; lift it onto the globals the
|
|
264
282
|
// router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
|
|
265
283
|
// matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
|
|
@@ -268,7 +286,7 @@ export function generateClientEntry(
|
|
|
268
286
|
"const handover = handovers[handovers.length - 1]",
|
|
269
287
|
"if (handover !== undefined) {",
|
|
270
288
|
' const state = JSON.parse(handover.textContent || "{}")',
|
|
271
|
-
` for (const key of ${jsValueLiteral(HANDOVER_GLOBALS)}) {`,
|
|
289
|
+
` for (const key of ${jsValueLiteral(options.copyChunks === true ? [...HANDOVER_GLOBALS, COPY_CHUNKS_GLOBAL] : HANDOVER_GLOBALS)}) {`,
|
|
272
290
|
" if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
|
|
273
291
|
" }",
|
|
274
292
|
"}",
|
|
@@ -383,7 +401,7 @@ export function generateClientEntry(
|
|
|
383
401
|
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
|
|
384
402
|
" const nonce = currentDocumentNonce()",
|
|
385
403
|
" const args = { data: s.data, pageData: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
|
|
386
|
-
" applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)))",
|
|
404
|
+
" applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)), s.path)",
|
|
387
405
|
" }",
|
|
388
406
|
" })",
|
|
389
407
|
"}).catch((error) => console.error('[nifra/web] hydration failed:', error))",
|
|
@@ -453,6 +471,9 @@ export interface GenerateServerManifestOptions {
|
|
|
453
471
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
454
472
|
/** How framework-owned stylesheet links should activate before the generated client mounts. */
|
|
455
473
|
readonly cssLoading?: CssLoadingMode
|
|
474
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`) - baked as
|
|
475
|
+
* `export const copyChunks` when the client build split copy tables, for `createWebApp`. */
|
|
476
|
+
readonly copyChunks?: Readonly<Record<string, string>> | undefined
|
|
456
477
|
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
457
478
|
* eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
|
|
458
479
|
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
@@ -500,6 +521,7 @@ export function generateServerManifest(
|
|
|
500
521
|
styles,
|
|
501
522
|
routeStyles,
|
|
502
523
|
cssLoading: requestedCssLoading,
|
|
524
|
+
copyChunks,
|
|
503
525
|
lazy = false,
|
|
504
526
|
} = options
|
|
505
527
|
// A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
|
|
@@ -541,6 +563,11 @@ export function generateServerManifest(
|
|
|
541
563
|
const stylesLine = `export const styles = ${jsValueLiteral(styles ?? [])}`
|
|
542
564
|
const routeStylesLine = `export const routeStyles = ${jsValueLiteral(routeStyles ?? {})}`
|
|
543
565
|
const cssLoadingLine = `export const cssLoading = ${jsStringLiteral(cssLoading)}`
|
|
566
|
+
// Only a build that split copy tables has the line; its server entry is the one that imports it.
|
|
567
|
+
const copyChunksLines =
|
|
568
|
+
copyChunks === undefined || Object.keys(copyChunks).length === 0
|
|
569
|
+
? []
|
|
570
|
+
: [`export const copyChunks = ${jsValueLiteral(copyChunks)}`]
|
|
544
571
|
if (lazy) {
|
|
545
572
|
// Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
|
|
546
573
|
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
@@ -558,6 +585,7 @@ export function generateServerManifest(
|
|
|
558
585
|
stylesLine,
|
|
559
586
|
routeStylesLine,
|
|
560
587
|
cssLoadingLine,
|
|
588
|
+
...copyChunksLines,
|
|
561
589
|
`export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
|
|
562
590
|
].join("\n")}\n`
|
|
563
591
|
}
|
|
@@ -577,6 +605,7 @@ export function generateServerManifest(
|
|
|
577
605
|
stylesLine,
|
|
578
606
|
routeStylesLine,
|
|
579
607
|
cssLoadingLine,
|
|
608
|
+
...copyChunksLines,
|
|
580
609
|
`export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
|
|
581
610
|
].join("\n")}\n`
|
|
582
611
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy tables (`@nifrajs/i18n/copy`) in a production client build. Each table a module declares is
|
|
3
|
+
* replaced by its chunk id, and its values move into one chunk per locale, so a page's JavaScript carries
|
|
4
|
+
* the copy of the locale it renders in and none of the others. The server bundle keeps every table whole.
|
|
5
|
+
*
|
|
6
|
+
* The tables are found by `@nifrajs/i18n/extract` with the app's own TypeScript, both resolved from the
|
|
7
|
+
* app root: `@nifrajs/web` depends on neither. An app with no `defineCopy` pays one source scan.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto"
|
|
10
|
+
import { type Dirent, readdirSync, readFileSync } from "node:fs"
|
|
11
|
+
import { createRequire } from "node:module"
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path"
|
|
13
|
+
import { pathToFileURL } from "node:url"
|
|
14
|
+
import type { BunPlugin } from "bun"
|
|
15
|
+
import type * as TS from "typescript"
|
|
16
|
+
import { addSourceTransform, normalizeFilePath } from "../plugins/kit.ts"
|
|
17
|
+
import { isTypeScriptCompiler } from "./typescript-api.ts"
|
|
18
|
+
|
|
19
|
+
const COPY_MODULE = "@nifrajs/i18n/copy"
|
|
20
|
+
const EXTRACT_MODULE = "@nifrajs/i18n/extract"
|
|
21
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/
|
|
22
|
+
const REWRITABLE = /\.m?[jt]sx?(\?|$)/
|
|
23
|
+
const DEPENDENCY = /[\\/]node_modules[\\/]/
|
|
24
|
+
const MAX_DEPTH = 32
|
|
25
|
+
|
|
26
|
+
/** The part of `@nifrajs/i18n/extract` the build reads. */
|
|
27
|
+
interface TableSite {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly values?: Readonly<Record<string, unknown>>
|
|
30
|
+
readonly definition: { readonly file: string; readonly name: string }
|
|
31
|
+
}
|
|
32
|
+
interface Scanner {
|
|
33
|
+
tables(file: string): readonly TableSite[]
|
|
34
|
+
definition(ref: TableSite["definition"]): { readonly locales?: unknown } | undefined
|
|
35
|
+
rewrite(file: string): string | undefined
|
|
36
|
+
}
|
|
37
|
+
interface SplitTable {
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly values: Readonly<Record<string, unknown>>
|
|
40
|
+
readonly locales: unknown
|
|
41
|
+
}
|
|
42
|
+
interface Extract {
|
|
43
|
+
createCopyScanner(
|
|
44
|
+
ts: typeof TS,
|
|
45
|
+
host: {
|
|
46
|
+
readonly root: string
|
|
47
|
+
read(file: string): string | undefined
|
|
48
|
+
resolve(specifier: string, from: string): string | undefined
|
|
49
|
+
},
|
|
50
|
+
): Scanner
|
|
51
|
+
copyChunks(tables: Iterable<SplitTable>): ReadonlyMap<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isExtract = (value: unknown): value is Extract =>
|
|
55
|
+
typeof value === "object" &&
|
|
56
|
+
value !== null &&
|
|
57
|
+
"createCopyScanner" in value &&
|
|
58
|
+
typeof value.createCopyScanner === "function" &&
|
|
59
|
+
"copyChunks" in value &&
|
|
60
|
+
typeof value.copyChunks === "function"
|
|
61
|
+
|
|
62
|
+
/** A chunk file the build wrote. */
|
|
63
|
+
export interface CopyChunkFile {
|
|
64
|
+
/** The file name, served under the build's public path. */
|
|
65
|
+
readonly name: string
|
|
66
|
+
readonly text: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CopySplit {
|
|
70
|
+
/** The client build's plugin, registered after every other one. */
|
|
71
|
+
readonly plugin: BunPlugin
|
|
72
|
+
/** A module with its tables replaced by chunk ids, or `undefined` when it has none to split. */
|
|
73
|
+
rewrite(file: string, source: string): string | undefined
|
|
74
|
+
/** One chunk per locale for the tables this build split, and each one's URL by locale key. */
|
|
75
|
+
chunks(publicPath: string): {
|
|
76
|
+
readonly urls: Readonly<Record<string, string>>
|
|
77
|
+
readonly files: readonly CopyChunkFile[]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const readText = (file: string): string | undefined => {
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(file, "utf8")
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a module under `dir` imports the copy module: the only way an app defines copy tables. */
|
|
90
|
+
function definesCopy(dir: string, skip: ReadonlySet<string>, depth = 0): boolean {
|
|
91
|
+
let entries: Dirent[]
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue
|
|
99
|
+
const path = join(dir, entry.name)
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
if (depth < MAX_DEPTH && !skip.has(path) && definesCopy(path, skip, depth + 1)) return true
|
|
102
|
+
} else if (entry.isFile() && SCRIPT.test(entry.name)) {
|
|
103
|
+
if (readText(path)?.includes(COPY_MODULE) === true) return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const loaderOf = (path: string): "js" | "jsx" | "ts" | "tsx" => {
|
|
110
|
+
if (path.endsWith("x")) return path.endsWith(".tsx") ? "tsx" : "jsx"
|
|
111
|
+
return /\.m?ts$/.test(path) ? "ts" : "js"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The copy split for an app, or `undefined` when it defines no copy tables. An app that does, but whose
|
|
116
|
+
* TypeScript has no compiler API, builds with its tables whole, with a warning.
|
|
117
|
+
*/
|
|
118
|
+
export async function loadCopySplit(
|
|
119
|
+
root: string,
|
|
120
|
+
options: { readonly skip?: readonly string[]; readonly warn?: (message: string) => void } = {},
|
|
121
|
+
): Promise<CopySplit | undefined> {
|
|
122
|
+
const appRoot = resolvePath(root)
|
|
123
|
+
const require = createRequire(join(appRoot, "package.json"))
|
|
124
|
+
let extractPath: string
|
|
125
|
+
try {
|
|
126
|
+
extractPath = require.resolve(EXTRACT_MODULE)
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
if (!definesCopy(appRoot, new Set((options.skip ?? []).map((dir) => resolvePath(dir))))) {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
const warn = options.warn ?? console.warn
|
|
134
|
+
let ts: unknown
|
|
135
|
+
try {
|
|
136
|
+
ts = require("typescript")
|
|
137
|
+
} catch {
|
|
138
|
+
ts = undefined
|
|
139
|
+
}
|
|
140
|
+
if (!isTypeScriptCompiler(ts)) {
|
|
141
|
+
warn(
|
|
142
|
+
"[nifra/web] copy tables ship every locale in the client bundle: splitting them by locale reads the source with the TypeScript compiler API, and the app's TypeScript has none (TypeScript 7 ships none; install typescript@^6 as a dev dependency).",
|
|
143
|
+
)
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
const extract: unknown = await import(pathToFileURL(extractPath).href)
|
|
147
|
+
if (!isExtract(extract)) {
|
|
148
|
+
warn(
|
|
149
|
+
`[nifra/web] copy tables ship every locale in the client bundle: ${EXTRACT_MODULE} at ${extractPath} predates the client split; update @nifrajs/i18n.`,
|
|
150
|
+
)
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
const scanner = extract.createCopyScanner(ts, {
|
|
154
|
+
root: appRoot.replaceAll("\\", "/"),
|
|
155
|
+
read: readText,
|
|
156
|
+
resolve: (specifier, from) => {
|
|
157
|
+
try {
|
|
158
|
+
return createRequire(from).resolve(specifier)
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const split = new Map<string, readonly SplitTable[]>()
|
|
166
|
+
const rewrite = (file: string, source: string): string | undefined => {
|
|
167
|
+
const path = normalizeFilePath(file)
|
|
168
|
+
// The scanner reads the file off disk: text another transform changed first is left alone, since
|
|
169
|
+
// its tables' positions are no longer the file's.
|
|
170
|
+
if (!REWRITABLE.test(path) || DEPENDENCY.test(path) || readText(path) !== source) {
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
const out = scanner.rewrite(path)
|
|
174
|
+
if (out === undefined) return undefined
|
|
175
|
+
split.set(
|
|
176
|
+
path,
|
|
177
|
+
scanner.tables(path).flatMap((site) => {
|
|
178
|
+
const locales =
|
|
179
|
+
site.values === undefined ? undefined : scanner.definition(site.definition)?.locales
|
|
180
|
+
return site.values === undefined || locales === undefined
|
|
181
|
+
? []
|
|
182
|
+
: [{ id: site.id, values: site.values, locales }]
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
plugin: {
|
|
190
|
+
name: "nifra-copy-split",
|
|
191
|
+
setup(build) {
|
|
192
|
+
addSourceTransform(build, rewrite)
|
|
193
|
+
build.onLoad({ filter: REWRITABLE }, async (args) => {
|
|
194
|
+
if (args.namespace !== "file") return undefined
|
|
195
|
+
const path = normalizeFilePath(args.path)
|
|
196
|
+
const contents = rewrite(path, await Bun.file(path).text())
|
|
197
|
+
return contents === undefined ? undefined : { contents, loader: loaderOf(path) }
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
rewrite,
|
|
202
|
+
chunks(publicPath) {
|
|
203
|
+
// Sorted, so a chunk's bytes, and so its name, do not depend on the order modules loaded in.
|
|
204
|
+
const tables = [...split.values()].flat().sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
205
|
+
const urls: Record<string, string> = {}
|
|
206
|
+
const files: CopyChunkFile[] = []
|
|
207
|
+
if (tables.length === 0) return { urls, files }
|
|
208
|
+
const chunks = [...extract.copyChunks(tables)].sort(([a], [b]) => (a < b ? -1 : 1))
|
|
209
|
+
for (const [locale, chunk] of chunks) {
|
|
210
|
+
// A JSON string parses faster than the same object as a literal.
|
|
211
|
+
const text = `export default JSON.parse(${JSON.stringify(JSON.stringify(chunk))})\n`
|
|
212
|
+
const hash = createHash("sha256").update(text).digest("hex").slice(0, 12)
|
|
213
|
+
const name = `copy-${locale.replace(/[^A-Za-z0-9-]/g, "_")}-${hash}.js`
|
|
214
|
+
urls[locale] = `${publicPath}${name}`
|
|
215
|
+
files.push({ name, text })
|
|
216
|
+
}
|
|
217
|
+
return { urls, files }
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* its own `validate` output, as zod and valibot objects do by default.
|
|
15
15
|
*/
|
|
16
16
|
import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
17
|
+
import { markDeferredFree } from "./deferred-free.ts"
|
|
17
18
|
import { wireDate } from "./wire-date.ts"
|
|
18
19
|
|
|
19
20
|
// Registry symbols shared with core and `@nifrajs/schema`, so two copies of either still agree.
|
|
@@ -84,6 +85,8 @@ interface CompiledGuard {
|
|
|
84
85
|
readonly validate: StandardSchemaV1["~standard"]["validate"]
|
|
85
86
|
/** Dev only: name the keys the guard dropped, so a schema missing a field is not a silent blank. */
|
|
86
87
|
readonly warnDropped: boolean
|
|
88
|
+
/** No value this guard passes can hold a `defer()` marker (see internal/deferred-free.ts). */
|
|
89
|
+
readonly deferredFree: boolean
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
interface Deferred {
|
|
@@ -200,7 +203,10 @@ function shapeOf(
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
class Compiler {
|
|
206
|
+
/** Some node may pass a `defer()` marker on: a deferred node, or one that lets any value through. */
|
|
207
|
+
mayPassMarker = false
|
|
203
208
|
private readonly memo = new Map<Node, Project>()
|
|
209
|
+
private readonly issueFree = new WeakSet<Project>([LEAF, LEAF_ARRAY, DATE])
|
|
204
210
|
private readonly ids = new Map<string, Node>()
|
|
205
211
|
readonly sensitive: string[] = []
|
|
206
212
|
|
|
@@ -263,25 +269,33 @@ class Compiler {
|
|
|
263
269
|
case "array":
|
|
264
270
|
return this.array(node, where)
|
|
265
271
|
case "leaf":
|
|
272
|
+
// `not` (or an object `const`) without a `type` can accept an object.
|
|
273
|
+
if (node.type === undefined) this.mayPassMarker = true
|
|
266
274
|
return LEAF
|
|
267
275
|
default:
|
|
268
|
-
return
|
|
276
|
+
return this.unknown()
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
private unknown(): Project {
|
|
281
|
+
this.mayPassMarker = true
|
|
282
|
+
return UNKNOWN
|
|
283
|
+
}
|
|
284
|
+
|
|
272
285
|
private extra(schema: unknown, where: string): Project | undefined {
|
|
273
|
-
if (schema === true || (isNode(schema) && Object.keys(schema).length === 0))
|
|
286
|
+
if (schema === true || (isNode(schema) && Object.keys(schema).length === 0))
|
|
287
|
+
return this.unknown()
|
|
274
288
|
return isNode(schema) ? this.compile(schema, `${where}/*`) : undefined
|
|
275
289
|
}
|
|
276
290
|
|
|
277
291
|
private object(node: Node, where: string): Project {
|
|
278
|
-
const declared
|
|
292
|
+
const declared: Record<string, Project> = Object.create(null)
|
|
279
293
|
for (const [key, sub] of Object.entries(isNode(node.properties) ? node.properties : {})) {
|
|
280
294
|
if (!isNode(sub)) continue
|
|
281
295
|
if (isSensitiveFieldName(key) && typeof sub[DECLASSIFIED_KEY] !== "string") {
|
|
282
296
|
this.sensitive.push(`${where}/${key}`)
|
|
283
297
|
}
|
|
284
|
-
declared
|
|
298
|
+
declared[key] = this.compile(sub, `${where}/${key}`)
|
|
285
299
|
}
|
|
286
300
|
const patterns: Array<readonly [RegExp, Project]> = []
|
|
287
301
|
for (const [pattern, sub] of Object.entries(
|
|
@@ -302,7 +316,7 @@ class Compiler {
|
|
|
302
316
|
}
|
|
303
317
|
// A closed object drops a deferred marker's keys unless it declares them, so only an open one, or
|
|
304
318
|
// one declaring the marker key, could pass a marker on.
|
|
305
|
-
const checksMarker = open || declared.
|
|
319
|
+
const checksMarker = open || declared.__nifra_deferred !== undefined
|
|
306
320
|
const project: Project = (value, state) => {
|
|
307
321
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return value
|
|
308
322
|
if (checksMarker && isDeferred(value)) {
|
|
@@ -325,7 +339,7 @@ class Compiler {
|
|
|
325
339
|
for (const key in input) {
|
|
326
340
|
// An inherited enumerable key (a polluted prototype) is never data.
|
|
327
341
|
if (!Object.hasOwn(input, key)) continue
|
|
328
|
-
const projector = declared
|
|
342
|
+
const projector = declared[key] ?? (open ? undeclared(key) : undefined)
|
|
329
343
|
if (projector === undefined) {
|
|
330
344
|
out ??= copyBefore(input, key)
|
|
331
345
|
continue
|
|
@@ -346,6 +360,8 @@ class Compiler {
|
|
|
346
360
|
}
|
|
347
361
|
return out ?? value
|
|
348
362
|
}
|
|
363
|
+
if (!checksMarker && Object.values(declared).every((child) => this.issueFree.has(child)))
|
|
364
|
+
this.issueFree.add(project)
|
|
349
365
|
return project
|
|
350
366
|
}
|
|
351
367
|
|
|
@@ -356,13 +372,32 @@ class Compiler {
|
|
|
356
372
|
? node.items
|
|
357
373
|
: []
|
|
358
374
|
const head = prefixNodes.map((sub, i) =>
|
|
359
|
-
isNode(sub) ? this.compile(sub, `${where}/${i}`) :
|
|
375
|
+
isNode(sub) ? this.compile(sub, `${where}/${i}`) : this.unknown(),
|
|
360
376
|
)
|
|
361
377
|
const restSchema = Array.isArray(node.items) ? node.additionalItems : node.items
|
|
362
|
-
const rest = this.extra(restSchema, where) ??
|
|
378
|
+
const rest = this.extra(restSchema, where) ?? this.unknown()
|
|
363
379
|
// An array of scalars is the validator's alone: no key to drop, and walking it here would cost
|
|
364
380
|
// every `string[]` a loop. A `Date` inside one reaches the validator as it is.
|
|
365
381
|
if (rest === LEAF && head.every((projector) => projector === LEAF)) return LEAF_ARRAY
|
|
382
|
+
// A uniform subtree that cannot emit deferred-marker issues needs no per-item path bookkeeping.
|
|
383
|
+
if (head.length === 0 && this.issueFree.has(rest)) {
|
|
384
|
+
const project: Project = (value, state) => {
|
|
385
|
+
if (!Array.isArray(value)) return value
|
|
386
|
+
let out: unknown[] | undefined
|
|
387
|
+
for (let i = 0; i < value.length; i++) {
|
|
388
|
+
const before = value[i]
|
|
389
|
+
const after = rest(before, state)
|
|
390
|
+
if (out !== undefined) out[i] = after
|
|
391
|
+
else if (after !== before) {
|
|
392
|
+
out = value.slice(0, i)
|
|
393
|
+
out[i] = after
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return out ?? value
|
|
397
|
+
}
|
|
398
|
+
this.issueFree.add(project)
|
|
399
|
+
return project
|
|
400
|
+
}
|
|
366
401
|
return (value, state) => {
|
|
367
402
|
if (!Array.isArray(value)) return value
|
|
368
403
|
let out: unknown[] | undefined
|
|
@@ -424,7 +459,7 @@ class Compiler {
|
|
|
424
459
|
const branches: Node[] = []
|
|
425
460
|
this.branches(node, where, branches, new Set())
|
|
426
461
|
const kinds = branches.map(shapeOf)
|
|
427
|
-
if (kinds.includes("unknown")) return
|
|
462
|
+
if (kinds.includes("unknown")) return this.unknown()
|
|
428
463
|
const deferreds = branches.filter((_, i) => kinds[i] === "deferred")
|
|
429
464
|
if (deferreds.length > 1) {
|
|
430
465
|
throw new OutputGuardError(
|
|
@@ -463,6 +498,7 @@ class Compiler {
|
|
|
463
498
|
}
|
|
464
499
|
|
|
465
500
|
private deferred(node: Node, where: string): Project {
|
|
501
|
+
this.mayPassMarker = true
|
|
466
502
|
const validate = (node as { readonly [DEFERRED_VALIDATE]?: unknown })[DEFERRED_VALIDATE]
|
|
467
503
|
if (!isNode(node.inner) || typeof validate !== "function") {
|
|
468
504
|
throw new OutputGuardError(
|
|
@@ -473,6 +509,7 @@ class Compiler {
|
|
|
473
509
|
project: this.compile(node.inner, where),
|
|
474
510
|
validate: validate as CompiledGuard["validate"],
|
|
475
511
|
warnDropped: inDevelopment(),
|
|
512
|
+
deferredFree: false,
|
|
476
513
|
}
|
|
477
514
|
return (value, state) => {
|
|
478
515
|
if (!isDeferred(value)) return value
|
|
@@ -591,9 +628,11 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
|
|
|
591
628
|
const standard = schema as StandardSchemaV1
|
|
592
629
|
const json = jsonSchemaOf(standard)
|
|
593
630
|
let project: Project | undefined
|
|
631
|
+
let deferredFree = false
|
|
594
632
|
if (json !== undefined) {
|
|
595
633
|
const compiler = new Compiler(json)
|
|
596
634
|
project = compiler.compile(json, "")
|
|
635
|
+
deferredFree = !compiler.mayPassMarker
|
|
597
636
|
if (compiler.sensitive.length > 0) {
|
|
598
637
|
throw new OutputGuardError(
|
|
599
638
|
`${owner} declares sensitive field(s) ${compiler.sensitive.join(", ")} in ${exportName}. Remove them, or mark a field that must reach the browser with t.declassified("why it may", schema)`,
|
|
@@ -605,6 +644,7 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
|
|
|
605
644
|
project,
|
|
606
645
|
validate: standard["~standard"].validate,
|
|
607
646
|
warnDropped: inDevelopment(),
|
|
647
|
+
deferredFree,
|
|
608
648
|
}
|
|
609
649
|
compiled.set(schema, guard)
|
|
610
650
|
return guard
|
|
@@ -675,6 +715,8 @@ function settle(
|
|
|
675
715
|
// No JSON Schema to project by: the schema's own validate stripped what it does not declare.
|
|
676
716
|
if (guard.warnDropped) warnDropped(label, input, result.value)
|
|
677
717
|
}
|
|
718
|
+
// Only the projected value itself: a validator that returns a new value (a transform) may add a marker.
|
|
719
|
+
if (guard.deferredFree && result.value === input) markDeferredFree(result.value)
|
|
678
720
|
return result.value
|
|
679
721
|
}
|
|
680
722
|
|