@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
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { isSameOriginPath, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
|
|
2
2
|
import type { BoundaryRegistration, BoundaryStates } from "../boundary.ts"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type CspPolicy,
|
|
5
|
+
cspCandidateNonce,
|
|
6
|
+
cspDocumentNonce,
|
|
7
|
+
cspHeaderValue,
|
|
8
|
+
prepareCspPolicy,
|
|
9
|
+
} from "../csp.ts"
|
|
4
10
|
import { type CssLoadingMode, DEFAULT_CSS_LOADING, normalizeCssLoading } from "../css-contract.ts"
|
|
5
11
|
import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.ts"
|
|
6
12
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
|
|
@@ -11,13 +17,23 @@ import type {
|
|
|
11
17
|
RouteModule,
|
|
12
18
|
UnsafeScriptDescriptor,
|
|
13
19
|
} from "../manifest.ts"
|
|
14
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
DocumentParts,
|
|
22
|
+
MatchChain,
|
|
23
|
+
PageLocale,
|
|
24
|
+
PageLocales,
|
|
25
|
+
RenderAdapter,
|
|
26
|
+
RenderProps,
|
|
27
|
+
} from "../render-seam.ts"
|
|
15
28
|
import {
|
|
16
29
|
ACTION_GLOBAL,
|
|
17
30
|
BOUNDARY_GLOBAL,
|
|
31
|
+
COPY_CHUNKS_GLOBAL,
|
|
32
|
+
COPY_REALM,
|
|
18
33
|
DATA_GLOBAL,
|
|
19
34
|
HANDOVER_ID,
|
|
20
35
|
LAYOUT_DATA_GLOBAL,
|
|
36
|
+
LOCALES_GLOBAL,
|
|
21
37
|
ROOT_ATTRIBUTE,
|
|
22
38
|
ROUTE_GLOBAL,
|
|
23
39
|
} from "../render-seam.ts"
|
|
@@ -44,6 +60,13 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
|
|
|
44
60
|
[LINE_SEP]: "\\u2028",
|
|
45
61
|
[PARA_SEP]: "\\u2029",
|
|
46
62
|
}
|
|
63
|
+
// Pages (the chain's last entry) whose string render suspended, with how many more requests go
|
|
64
|
+
// straight to the stream instead of rendering the whole tree twice. The count runs out so a page
|
|
65
|
+
// that suspended only once (a cold `lazy`) gets its buffered render back.
|
|
66
|
+
const SUSPENDING_PAGES = new WeakMap<object, number>()
|
|
67
|
+
const SUSPENDED_STREAMS = 32
|
|
68
|
+
const isWeakKey = (value: unknown): value is object =>
|
|
69
|
+
(typeof value === "object" && value !== null) || typeof value === "function"
|
|
47
70
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
48
71
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
49
72
|
|
|
@@ -111,6 +134,15 @@ export interface RenderPageOptions {
|
|
|
111
134
|
/** SSG: the URL of a JSON array of the prerendered paths, handed over in place of
|
|
112
135
|
* {@link prerenderedPaths} so a large set is fetched once instead of riding in every page. */
|
|
113
136
|
readonly prerenderedListUrl?: string
|
|
137
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`), handed over to the
|
|
138
|
+
* client; the chunks this page reads its copy from are preloaded. Omit ⇒ none. */
|
|
139
|
+
readonly copyChunks?: Readonly<Record<string, string>>
|
|
140
|
+
/** The page's locale from the app's locale routing: `<html lang dir>` default to it unless `head`
|
|
141
|
+
* sets `lang`, the response carries `Content-Language`, and the adapter reads it as
|
|
142
|
+
* `RenderProps.locale`. Omit ⇒ none of these. */
|
|
143
|
+
readonly locale?: PageLocale
|
|
144
|
+
/** The served locales, handed to the client so it names each page's locale on navigation. */
|
|
145
|
+
readonly locales?: PageLocales
|
|
114
146
|
/** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
|
|
115
147
|
* wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
|
|
116
148
|
readonly revalidate?: number
|
|
@@ -184,7 +216,7 @@ export interface RenderPageOptions {
|
|
|
184
216
|
* prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
|
|
185
217
|
*
|
|
186
218
|
* Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
|
|
187
|
-
* same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
|
|
219
|
+
* same `head` content (a static meta chain - never a `meta(data)` function), `locale`, `title`, `styles`,
|
|
188
220
|
* `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
|
|
189
221
|
* Per-request
|
|
190
222
|
* values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
|
|
@@ -248,6 +280,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
248
280
|
cssLoading: requestedCssLoading,
|
|
249
281
|
prerenderedPaths = [],
|
|
250
282
|
prerenderedListUrl,
|
|
283
|
+
copyChunks,
|
|
284
|
+
locale,
|
|
251
285
|
revalidate,
|
|
252
286
|
revalidateTags = [],
|
|
253
287
|
routeId,
|
|
@@ -276,6 +310,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
276
310
|
: prerenderedPaths.length === 0
|
|
277
311
|
? ""
|
|
278
312
|
: `,"${PRERENDERED_GLOBAL}":${serializeData(prerenderedPaths)}`
|
|
313
|
+
const copyHandover =
|
|
314
|
+
copyChunks === undefined ? "" : `,"${COPY_CHUNKS_GLOBAL}":${serializeData(copyChunks)}`
|
|
315
|
+
const localesHandover =
|
|
316
|
+
options.locales === undefined ? "" : `,"${LOCALES_GLOBAL}":${serializeData(options.locales)}`
|
|
317
|
+
// A route's `lang` wins with its own `dir`; otherwise the page's registered locale names both.
|
|
318
|
+
const lang = head?.lang ?? locale?.lang ?? "en"
|
|
319
|
+
const dir = head?.dir ?? (head?.lang === undefined ? locale?.dir : undefined)
|
|
279
320
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
280
321
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
281
322
|
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
@@ -405,7 +446,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
405
446
|
// `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
|
|
406
447
|
// HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
|
|
407
448
|
// defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
|
|
408
|
-
const htmlAttrs = ` lang="${escapeAttr(
|
|
449
|
+
const htmlAttrs = ` lang="${escapeAttr(lang)}"${dir === undefined ? "" : ` dir="${escapeAttr(dir)}"`}`
|
|
409
450
|
// Marks the container for the client entry when the id is not the one the entry falls back to.
|
|
410
451
|
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
|
|
411
452
|
// A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
|
|
@@ -432,7 +473,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
432
473
|
if (hydrate) {
|
|
433
474
|
// Inert JSON, never executed: the page state needs no nonce and no hash under any CSP.
|
|
434
475
|
slot.tailPre = `<script type="application/json" id="${HANDOVER_ID}">{"${DATA_GLOBAL}":`
|
|
435
|
-
slot.tailPost = `${route}${prerendered}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
|
|
476
|
+
slot.tailPost = `${route}${prerendered}${copyHandover}${localesHandover}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
|
|
436
477
|
} else {
|
|
437
478
|
slot.tailPre = ""
|
|
438
479
|
slot.tailPost = `${islandTags}</body></html>`
|
|
@@ -441,13 +482,18 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
441
482
|
// The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
|
|
442
483
|
// only when something actually deferred - matching the pre-cache emission exactly.
|
|
443
484
|
const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
|
|
444
|
-
|
|
485
|
+
// Per request, so outside the cached shell: one route serves every locale.
|
|
486
|
+
const copyPreloads =
|
|
487
|
+
hydrate && copyChunks !== undefined
|
|
488
|
+
? copyPreloadLinks(copyChunks, options.path ?? "", lang)
|
|
489
|
+
: ""
|
|
490
|
+
const shellHtml = `${slot.shellPre}${copyPreloads}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
|
|
445
491
|
const parts: DocumentParts | undefined =
|
|
446
492
|
adapter.documentParts === true ? { head: "", body: "" } : undefined
|
|
447
493
|
const shellOf = (): string =>
|
|
448
494
|
parts === undefined || (parts.head === "" && parts.body === "")
|
|
449
495
|
? shellHtml
|
|
450
|
-
: `${slot.shellPre}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
|
|
496
|
+
: `${slot.shellPre}${copyPreloads}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
|
|
451
497
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
452
498
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
453
499
|
const closeRootHtml = "</div>"
|
|
@@ -457,6 +503,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
457
503
|
? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
|
|
458
504
|
: (slot.tailPost as string)
|
|
459
505
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
506
|
+
if (locale !== undefined) headers["content-language"] = lang
|
|
460
507
|
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
461
508
|
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
462
509
|
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
@@ -501,6 +548,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
501
548
|
? { boundaries: boundarySplit.forComponent as BoundaryStates }
|
|
502
549
|
: {}),
|
|
503
550
|
...(options.matchChain !== undefined ? { matchChain: options.matchChain } : {}),
|
|
551
|
+
...(locale === undefined ? {} : { locale }),
|
|
504
552
|
}
|
|
505
553
|
|
|
506
554
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
@@ -509,43 +557,77 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
509
557
|
// streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
|
|
510
558
|
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
511
559
|
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
560
|
+
const streamed = (streamNonce: string | undefined): Promise<RenderedPage> =>
|
|
561
|
+
renderStreamedPage(
|
|
562
|
+
adapter,
|
|
563
|
+
chain,
|
|
564
|
+
renderProps,
|
|
565
|
+
shellOf,
|
|
566
|
+
parts,
|
|
567
|
+
closeRootHtml,
|
|
568
|
+
allDeferred,
|
|
569
|
+
tailHtml,
|
|
570
|
+
status,
|
|
571
|
+
headers,
|
|
572
|
+
streamNonce,
|
|
573
|
+
streamNonce === nonce ? nonceAttr : ` nonce="${escapeAttr(streamNonce ?? "")}"`,
|
|
574
|
+
).then((response) => new ResponseRenderedPage(response))
|
|
575
|
+
const last = chain[chain.length - 1]
|
|
576
|
+
const leaf = isWeakKey(last) ? last : undefined
|
|
512
577
|
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
578
|
+
// Under a CSP a render can still write a script for this request alone (Solid's serialized
|
|
579
|
+
// error boundary, React's streamed Suspense reveal): it is handed a nonce, which the document
|
|
580
|
+
// keeps only when the render used it, so every other document stays nonce-free and cacheable.
|
|
581
|
+
const renderNonce =
|
|
582
|
+
nonce ?? (csp === undefined ? undefined : cspCandidateNonce(csp, requestedNonce))
|
|
583
|
+
const adoptNonce = (): void => {
|
|
584
|
+
if (renderNonce === nonce || csp === undefined || renderNonce === undefined) return
|
|
585
|
+
headers["cache-control"] = "private, no-store"
|
|
586
|
+
const policy = cspHeaderValue(csp, adapter, renderNonce)
|
|
587
|
+
if (policy !== undefined) headers["content-security-policy"] = policy
|
|
588
|
+
}
|
|
589
|
+
// A page whose string render suspended before goes straight to the stream: the string pass
|
|
590
|
+
// would render the whole tree only to be thrown away again.
|
|
591
|
+
const skips = leaf === undefined ? undefined : SUSPENDING_PAGES.get(leaf)
|
|
592
|
+
if (leaf !== undefined && skips !== undefined) {
|
|
593
|
+
if (skips > 1) SUSPENDING_PAGES.set(leaf, skips - 1)
|
|
594
|
+
else SUSPENDING_PAGES.delete(leaf)
|
|
595
|
+
adoptNonce()
|
|
596
|
+
return streamed(renderNonce)
|
|
597
|
+
}
|
|
513
598
|
const out =
|
|
514
599
|
parts !== undefined
|
|
515
600
|
? adapter.renderToString(
|
|
516
601
|
chain,
|
|
517
602
|
renderProps,
|
|
518
|
-
|
|
603
|
+
renderNonce === undefined ? { parts } : { nonce: renderNonce, parts },
|
|
519
604
|
)
|
|
520
|
-
:
|
|
605
|
+
: renderNonce === undefined
|
|
521
606
|
? adapter.renderToString(chain, renderProps)
|
|
522
|
-
: adapter.renderToString(chain, renderProps, { nonce })
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
)
|
|
607
|
+
: adapter.renderToString(chain, renderProps, { nonce: renderNonce })
|
|
608
|
+
const page = (bodyHtml: string | undefined): MaybePromise<RenderedPage> => {
|
|
609
|
+
if (bodyHtml === undefined) {
|
|
610
|
+
// The render suspended on something only the streaming renderer waits for (React's `use`
|
|
611
|
+
// in a `<Suspense>`): stream the page, so the boundary resolves on the server.
|
|
612
|
+
if (leaf !== undefined) SUSPENDING_PAGES.set(leaf, SUSPENDED_STREAMS)
|
|
613
|
+
if (parts !== undefined) {
|
|
614
|
+
parts.head = ""
|
|
615
|
+
parts.body = ""
|
|
616
|
+
}
|
|
617
|
+
adoptNonce()
|
|
618
|
+
return streamed(renderNonce)
|
|
619
|
+
}
|
|
620
|
+
if (renderNonce !== undefined && bodyHtml.includes(`nonce="${renderNonce}"`)) adoptNonce()
|
|
621
|
+
return new BufferedRenderedPage(
|
|
622
|
+
shellOf() + bodyHtml + closeRootHtml + tailHtml,
|
|
623
|
+
status,
|
|
624
|
+
headers,
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
return out instanceof Promise ? out.then(page) : page(out)
|
|
533
628
|
}
|
|
534
629
|
|
|
535
|
-
return
|
|
536
|
-
adapter,
|
|
537
|
-
chain,
|
|
538
|
-
renderProps,
|
|
539
|
-
shellOf,
|
|
540
|
-
parts,
|
|
541
|
-
closeRootHtml,
|
|
542
|
-
allDeferred,
|
|
543
|
-
tailHtml,
|
|
544
|
-
status,
|
|
545
|
-
headers,
|
|
546
|
-
nonce,
|
|
547
|
-
nonceAttr,
|
|
548
|
-
).then((response) => new ResponseRenderedPage(response))
|
|
630
|
+
return streamed(nonce)
|
|
549
631
|
}
|
|
550
632
|
|
|
551
633
|
async function renderStreamedPage(
|
|
@@ -638,6 +720,9 @@ function htmlResponse(body: string, init: ResponseInit): Response {
|
|
|
638
720
|
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
639
721
|
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
640
722
|
* the result (the body breaks) rather than silently truncating a 200.
|
|
723
|
+
*
|
|
724
|
+
* The app stream is read one chunk per pull, so a client that stops reading stops the renderer
|
|
725
|
+
* too: a stalled connection holds a chunk, not the whole rendered body.
|
|
641
726
|
*/
|
|
642
727
|
function streamDocument(
|
|
643
728
|
shell: Uint8Array,
|
|
@@ -652,45 +737,59 @@ function streamDocument(
|
|
|
652
737
|
// A client that goes away cancels the body: the renderer stops, and nothing more is written.
|
|
653
738
|
let cancelled = false
|
|
654
739
|
let released = false
|
|
740
|
+
const release = (): void => {
|
|
741
|
+
if (released) return
|
|
742
|
+
released = true
|
|
743
|
+
reader.releaseLock()
|
|
744
|
+
}
|
|
745
|
+
// Settled into scripts now, not when the app stream ends: a rejection a stalled client never
|
|
746
|
+
// reads up to is still handled (and logged) rather than left unhandled.
|
|
747
|
+
const scripts = deferred.map((d) =>
|
|
748
|
+
d.promise
|
|
749
|
+
.then(
|
|
750
|
+
(value) =>
|
|
751
|
+
// `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
|
|
752
|
+
// separators before this framework-owned script is emitted.
|
|
753
|
+
`<script${nonceAttr}>window.__nifraResolve(${d.id},${serializeData(value)})</script>`, // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
|
|
754
|
+
)
|
|
755
|
+
.catch((err: unknown) => {
|
|
756
|
+
// A rejected (or unserializable) deferred streams __nifraReject (the client `<Await>`
|
|
757
|
+
// surfaces it) - it must not break the whole body. Redact: stream a stable opaque code,
|
|
758
|
+
// never the raw error text; log the real reason server-side.
|
|
759
|
+
console.error("[nifra/web] deferred value rejected:", err)
|
|
760
|
+
return `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
|
|
761
|
+
}),
|
|
762
|
+
)
|
|
655
763
|
return new ReadableStream({
|
|
656
|
-
|
|
764
|
+
start(controller) {
|
|
765
|
+
controller.enqueue(shell)
|
|
766
|
+
},
|
|
767
|
+
async pull(controller) {
|
|
657
768
|
try {
|
|
658
|
-
|
|
659
|
-
for (;;) {
|
|
769
|
+
if (!released) {
|
|
660
770
|
const { done, value } = await reader.read()
|
|
661
771
|
if (cancelled) return
|
|
662
|
-
if (done)
|
|
663
|
-
|
|
772
|
+
if (!done) {
|
|
773
|
+
controller.enqueue(value)
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
release()
|
|
777
|
+
controller.enqueue(closeRoot)
|
|
664
778
|
}
|
|
665
|
-
controller.enqueue(closeRoot)
|
|
666
779
|
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
667
780
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
668
781
|
await Promise.all(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const value = serializeData(await d.promise)
|
|
673
|
-
// `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
|
|
674
|
-
// separators before this framework-owned script is emitted.
|
|
675
|
-
script = `<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>` // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
|
|
676
|
-
} catch (err) {
|
|
677
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
678
|
-
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
679
|
-
// text; log the real reason server-side.
|
|
680
|
-
console.error("[nifra/web] deferred value rejected:", err)
|
|
681
|
-
script = `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
|
|
682
|
-
}
|
|
683
|
-
if (!cancelled) controller.enqueue(enc.encode(script))
|
|
782
|
+
scripts.map(async (script) => {
|
|
783
|
+
const html = await script
|
|
784
|
+
if (!cancelled) controller.enqueue(enc.encode(html))
|
|
684
785
|
}),
|
|
685
786
|
)
|
|
686
787
|
if (cancelled) return
|
|
687
788
|
controller.enqueue(tail)
|
|
688
789
|
controller.close()
|
|
689
790
|
} catch (err) {
|
|
791
|
+
release()
|
|
690
792
|
if (!cancelled) controller.error(err)
|
|
691
|
-
} finally {
|
|
692
|
-
released = true
|
|
693
|
-
reader.releaseLock()
|
|
694
793
|
}
|
|
695
794
|
},
|
|
696
795
|
cancel(reason) {
|
|
@@ -1171,3 +1270,25 @@ export function unsafeInlineScript(
|
|
|
1171
1270
|
content,
|
|
1172
1271
|
}
|
|
1173
1272
|
}
|
|
1273
|
+
|
|
1274
|
+
/** The copy chunks a page loads first, as `@nifrajs/i18n/copy` picks them in the browser, so they
|
|
1275
|
+
* download beside the route's code instead of after it. */
|
|
1276
|
+
function copyPreloadLinks(
|
|
1277
|
+
chunks: Readonly<Record<string, string>>,
|
|
1278
|
+
path: string,
|
|
1279
|
+
lang: string,
|
|
1280
|
+
): string {
|
|
1281
|
+
const realm: unknown = Reflect.get(globalThis, Symbol.for(COPY_REALM))
|
|
1282
|
+
if (typeof realm !== "object" || realm === null) return ""
|
|
1283
|
+
const pageLocales: unknown = Reflect.get(realm, "pageLocales")
|
|
1284
|
+
if (typeof pageLocales !== "function") return ""
|
|
1285
|
+
const locales: unknown = Reflect.apply(pageLocales, realm, [path, lang])
|
|
1286
|
+
if (!Array.isArray(locales)) return ""
|
|
1287
|
+
let links = ""
|
|
1288
|
+
for (const locale of locales) {
|
|
1289
|
+
const url =
|
|
1290
|
+
typeof locale === "string" && Object.hasOwn(chunks, locale) ? chunks[locale] : undefined
|
|
1291
|
+
if (url !== undefined) links += `<link rel="modulepreload" href="${escapeAttr(url)}">`
|
|
1292
|
+
}
|
|
1293
|
+
return links
|
|
1294
|
+
}
|
|
@@ -12,3 +12,27 @@ export function urlPartsFor(request: Request): UrlParts {
|
|
|
12
12
|
REQUEST_URL_PARTS.set(request, parts)
|
|
13
13
|
return parts
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
// The origin depends only on the text before the path, which repeats across requests, so each
|
|
17
|
+
// distinct prefix is parsed once instead of every request's whole URL. Cleared when full: a client
|
|
18
|
+
// can vary the Host header without bound.
|
|
19
|
+
const ORIGINS = new Map<string, string>()
|
|
20
|
+
|
|
21
|
+
/** `new URL(request.url).origin`, or `""` for a URL that does not parse. */
|
|
22
|
+
export function originFor(request: { readonly url: string }): string {
|
|
23
|
+
const url = request.url
|
|
24
|
+
const schemeEnd = url.indexOf("://")
|
|
25
|
+
const pathStart = schemeEnd === -1 ? -1 : url.indexOf("/", schemeEnd + 3)
|
|
26
|
+
const prefix = pathStart === -1 ? url : url.slice(0, pathStart)
|
|
27
|
+
let origin = ORIGINS.get(prefix)
|
|
28
|
+
if (origin === undefined) {
|
|
29
|
+
try {
|
|
30
|
+
origin = new URL(prefix).origin
|
|
31
|
+
} catch {
|
|
32
|
+
origin = ""
|
|
33
|
+
}
|
|
34
|
+
if (ORIGINS.size >= 64) ORIGINS.clear()
|
|
35
|
+
ORIGINS.set(prefix, origin)
|
|
36
|
+
}
|
|
37
|
+
return origin
|
|
38
|
+
}
|
|
@@ -694,10 +694,18 @@ export function createRedactor(
|
|
|
694
694
|
export const buildEnvironment = (): Readonly<Record<string, string | undefined>> =>
|
|
695
695
|
(typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
|
|
696
696
|
|
|
697
|
+
/** A build artifact holds a secret. Its own class so a step that tolerates other failures still stops. */
|
|
698
|
+
export class SecretScanError extends Error {
|
|
699
|
+
constructor(message: string) {
|
|
700
|
+
super(message)
|
|
701
|
+
this.name = "SecretScanError"
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
697
705
|
/** Scan and throw the build-failing message on any finding. */
|
|
698
706
|
export function assertNoSecrets(input: SecretScanInput): void {
|
|
699
707
|
const message = formatSecretFindings(scanForSecrets(input))
|
|
700
|
-
if (message !== undefined) throw new
|
|
708
|
+
if (message !== undefined) throw new SecretScanError(message)
|
|
701
709
|
}
|
|
702
710
|
|
|
703
711
|
/** The build-failing message for secret findings, or `undefined` when there are none. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type * as TS from "typescript"
|
|
2
|
+
|
|
3
|
+
/** Whether a loaded `typescript` module carries the in-process compiler API (TypeScript 7's does not). */
|
|
4
|
+
export function isTypeScriptCompiler(value: unknown): value is typeof TS {
|
|
5
|
+
return (
|
|
6
|
+
typeof value === "object" &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
"createSourceFile" in value &&
|
|
9
|
+
typeof value.createSourceFile === "function"
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A Vite `config` hook is a plain function or the object form `{ handler, order }` Vite accepts for hook
|
|
2
|
+
// ordering; plugins are typed structurally (no `vite` type dep) and every other field is kept by spread.
|
|
3
|
+
type ConfigFn = (config: unknown, env: unknown) => unknown
|
|
4
|
+
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Move a plugin's `optimizeDeps.rollupOptions` to `optimizeDeps.rolldownOptions` under rolldown-vite and
|
|
10
|
+
* drop its `jsx` key, so Vite 8 warns about neither on `nifra dev` or a Vite build.
|
|
11
|
+
*
|
|
12
|
+
* Both are version-skew artifacts of plugins such as `@vitejs/plugin-react@4.x`, which still emit
|
|
13
|
+
* `optimizeDeps.rollupOptions.jsx`:
|
|
14
|
+
*
|
|
15
|
+
* - Vite 8 renamed the dep optimizer's options to `rolldownOptions` and moved jsx under `transform.jsx`,
|
|
16
|
+
* so a stale `jsx` key prints `Invalid input options … "jsx" Invalid key`. The route JSX transform runs
|
|
17
|
+
* through the plugin's own `transform` hook and pre-bundled deps almost never hold raw JSX, so the key
|
|
18
|
+
* is dead and dropping it changes no behavior.
|
|
19
|
+
* - Vite 8 reads `rollupOptions` there only as a deprecated alias of `rolldownOptions` and warns on every
|
|
20
|
+
* resolve, even for `{}`. The remaining keys move to `rolldownOptions`; when the same object already
|
|
21
|
+
* sets `rolldownOptions`, Vite ignores the alias, so it is dropped. `ssr.optimizeDeps` gets the same.
|
|
22
|
+
*
|
|
23
|
+
* Only a plugin's `config` hook output is rewritten, never mutated, and only under rolldown-vite.
|
|
24
|
+
* Non-rolldown Vite is passed through verbatim.
|
|
25
|
+
*
|
|
26
|
+
* FLATTEN FIRST: a Vite plugin factory may return an ARRAY of plugins - `@vitejs/plugin-react`'s `react()`
|
|
27
|
+
* returns `[vite:react-babel, vite:react-refresh]`, and it's `react:react-babel`'s `config` hook that emits
|
|
28
|
+
* the offending `optimizeDeps.rollupOptions.jsx`. `nifra.config.ts` writes `vitePlugins = [react()]`, so the
|
|
29
|
+
* plugin list arrives NESTED (`[[babel, refresh]]`). Without flattening, `.map` sees the inner array (which
|
|
30
|
+
* has no `config`), leaves it untouched, and Vite - which flattens plugin arrays itself before running them
|
|
31
|
+
* - then executes the un-stripped babel hook, so the warning survives. Flattening here (Vite accepts a flat
|
|
32
|
+
* list identically) is what lets the strip reach every real plugin.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeRolldownPlugins(
|
|
35
|
+
plugins: readonly unknown[],
|
|
36
|
+
isRolldown: boolean,
|
|
37
|
+
): readonly unknown[] {
|
|
38
|
+
if (!isRolldown) return plugins
|
|
39
|
+
const migrateDepOptions = (deps: unknown): unknown => {
|
|
40
|
+
if (!isRecord(deps)) return deps
|
|
41
|
+
const { rollupOptions, ...rest } = deps
|
|
42
|
+
if (rollupOptions === undefined) return deps
|
|
43
|
+
// Vite skips the alias when the same object sets rolldownOptions (mergeConfigRecursively).
|
|
44
|
+
if (rest.rolldownOptions || rollupOptions === null) return rest
|
|
45
|
+
if (!isRecord(rollupOptions)) return { ...rest, rolldownOptions: rollupOptions }
|
|
46
|
+
const { jsx: _dropped, ...kept } = rollupOptions
|
|
47
|
+
return Object.keys(kept).length === 0 ? rest : { ...rest, rolldownOptions: kept }
|
|
48
|
+
}
|
|
49
|
+
const migrateConfig = (returned: unknown): unknown => {
|
|
50
|
+
if (!isRecord(returned)) return returned
|
|
51
|
+
const ssr = isRecord(returned.ssr) ? returned.ssr : undefined
|
|
52
|
+
const deps = migrateDepOptions(returned.optimizeDeps)
|
|
53
|
+
const ssrDeps = migrateDepOptions(ssr?.optimizeDeps)
|
|
54
|
+
if (deps === returned.optimizeDeps && ssrDeps === ssr?.optimizeDeps) return returned
|
|
55
|
+
const out = { ...returned }
|
|
56
|
+
if (deps !== returned.optimizeDeps) out.optimizeDeps = deps
|
|
57
|
+
if (ssr !== undefined && ssrDeps !== ssr.optimizeDeps)
|
|
58
|
+
out.ssr = { ...ssr, optimizeDeps: ssrDeps }
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
return plugins.flat(Number.POSITIVE_INFINITY).map((plugin) => {
|
|
62
|
+
if (!isRecord(plugin)) return plugin
|
|
63
|
+
// `config` may be a plain function or the object form `{ handler, order }`. Wrap the handler either way.
|
|
64
|
+
const hook = plugin.config
|
|
65
|
+
const objectHook = isRecord(hook) ? hook : undefined
|
|
66
|
+
const handler = typeof hook === "function" ? hook : objectHook?.handler
|
|
67
|
+
if (typeof handler !== "function") return plugin
|
|
68
|
+
const wrappedHandler: ConfigFn = (config, env) => {
|
|
69
|
+
const returned: unknown = handler(config, env)
|
|
70
|
+
// The hook may return a promise - normalize both shapes.
|
|
71
|
+
return returned instanceof Promise ? returned.then(migrateConfig) : migrateConfig(returned)
|
|
72
|
+
}
|
|
73
|
+
// Preserve the ORIGINAL shape: a function stays a function; the object form keeps its `order`
|
|
74
|
+
// (collapsing `{ handler, order }` to a bare function would silently drop the hook ordering).
|
|
75
|
+
return {
|
|
76
|
+
...plugin,
|
|
77
|
+
config:
|
|
78
|
+
objectHook === undefined ? wrappedHandler : { ...objectHook, handler: wrappedHandler },
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
package/src/internal/web-app.ts
CHANGED
|
@@ -21,14 +21,61 @@ import type { RenderAdapter } from "../render-seam.ts"
|
|
|
21
21
|
import { createMatcher, PRERENDERED_LIST_PATH } from "../router.ts"
|
|
22
22
|
import type { EvidenceAncestry } from "./evidence-traversal.ts"
|
|
23
23
|
import { formatShadowedPages, shadowedPages } from "./mount-shadow.ts"
|
|
24
|
-
import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
|
|
24
|
+
import { createPageRequestExecutor, type NonceResolver, type PageI18n } from "./page-execution.ts"
|
|
25
|
+
import { type PrerenderedPages, servePrerenderedPages } from "./prerendered-pages.ts"
|
|
25
26
|
import { requestPattern } from "./route-path.ts"
|
|
26
27
|
|
|
27
28
|
export type { NonceResolver } from "./page-execution.ts"
|
|
29
|
+
|
|
30
|
+
/** An app's locale routing as `createWebApp` reads it: what `defineI18nRouting()` from
|
|
31
|
+
* `@nifrajs/i18n/routing` returns. */
|
|
32
|
+
export interface WebAppI18n {
|
|
33
|
+
readonly locales: {
|
|
34
|
+
readonly default: string
|
|
35
|
+
/** Served locale keys, in registry order. */
|
|
36
|
+
readonly served: readonly string[]
|
|
37
|
+
documentMeta(key: string): { readonly lang: string; readonly dir: "ltr" | "rtl" }
|
|
38
|
+
}
|
|
39
|
+
localeOf(path: string): string
|
|
40
|
+
matchSegment(
|
|
41
|
+
value: string | undefined,
|
|
42
|
+
pathname: string,
|
|
43
|
+
):
|
|
44
|
+
| { readonly kind: "ok" | "not-found" }
|
|
45
|
+
| { readonly kind: "redirect"; readonly location: string }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pageI18n(routing: WebAppI18n): PageI18n {
|
|
49
|
+
const { locales } = routing
|
|
50
|
+
if (
|
|
51
|
+
typeof routing.localeOf !== "function" ||
|
|
52
|
+
typeof routing.matchSegment !== "function" ||
|
|
53
|
+
typeof locales?.documentMeta !== "function" ||
|
|
54
|
+
!Array.isArray(locales.served)
|
|
55
|
+
) {
|
|
56
|
+
throw new TypeError(
|
|
57
|
+
"[nifra/web] createWebApp `i18n` must be the router defineI18nRouting() from @nifrajs/i18n/routing returns",
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
locales: {
|
|
62
|
+
default: locales.default,
|
|
63
|
+
locales: locales.served.map((key) => {
|
|
64
|
+
const { lang, dir } = locales.documentMeta(key)
|
|
65
|
+
return { key, lang, dir }
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
localeOf: (path) => routing.localeOf(path),
|
|
69
|
+
matchSegment: (value, pathname) => routing.matchSegment(value, pathname),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
export interface CreateWebAppOptions<Env = unknown> {
|
|
29
74
|
readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
|
|
30
75
|
readonly adapter: RenderAdapter
|
|
31
76
|
readonly manifest: Manifest
|
|
77
|
+
/** Cache production module imports and layout preparation, never request data. Defaults to NODE_ENV === "production"; disable for a mutable manifest. */
|
|
78
|
+
readonly cacheRouteModules?: boolean
|
|
32
79
|
/** URL of the built client entry (module script) injected into every page. */
|
|
33
80
|
readonly clientEntry: string
|
|
34
81
|
/** Default document title for all pages. */
|
|
@@ -181,6 +228,20 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
181
228
|
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
182
229
|
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
183
230
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>>
|
|
231
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`, the server manifest's
|
|
232
|
+
* `copyChunks`). A production client build moves copy tables (`@nifrajs/i18n/copy`) into one chunk per
|
|
233
|
+
* locale; each page lists them for the browser and preloads its own. Omit ⇒ tables keep their values. */
|
|
234
|
+
readonly copyChunks?: Readonly<Record<string, string>>
|
|
235
|
+
/**
|
|
236
|
+
* The app's locale routing, `defineI18nRouting()` from `@nifrajs/i18n/routing`, registered once:
|
|
237
|
+
* export it as `i18n` from `backend/framework.ts`. Every document then takes its locale from the
|
|
238
|
+
* URL: `<html lang dir>` from the registry unless the route's meta sets `lang`, a `Content-Language`
|
|
239
|
+
* header, and the locale each adapter's `useLocale()` and `<I18nProvider>` read. A route under a
|
|
240
|
+
* `[lang]` or `[[lang]]` directory is checked with its `matchSegment()` before any middleware: an
|
|
241
|
+
* unserved locale, or a page missing from it, is a 404, and a non-canonical prefix (`/en/x` when the
|
|
242
|
+
* default is unprefixed) redirects with a 308.
|
|
243
|
+
*/
|
|
244
|
+
readonly i18n?: WebAppI18n
|
|
184
245
|
/**
|
|
185
246
|
* Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
|
|
186
247
|
* emits `media="print"` links; the generated client waits for them and switches them to `all` before
|
|
@@ -194,6 +255,14 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
194
255
|
* 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
|
|
195
256
|
* pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
|
|
196
257
|
readonly prerenderedPaths?: readonly string[]
|
|
258
|
+
/**
|
|
259
|
+
* Pages a self-hosted build prerendered, answered from their file instead of rendered. `nifra build
|
|
260
|
+
* --target bun|node` wires this from its prerender pass. The page is served from the page route,
|
|
261
|
+
* so `use` middleware and every request hook run first; a request with a query string, a navigation
|
|
262
|
+
* data or component request, or a draft-mode visitor renders on demand as before. Ignored with
|
|
263
|
+
* {@link nonce}, whose documents need a nonce minted for their request.
|
|
264
|
+
*/
|
|
265
|
+
readonly prerenderedPages?: PrerenderedPages
|
|
197
266
|
/** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
|
|
198
267
|
* those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
|
|
199
268
|
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
@@ -417,6 +486,9 @@ export function createWebApp<Env = unknown>(
|
|
|
417
486
|
? undefined
|
|
418
487
|
: servedPrerenderedList(options.prerenderedPaths)
|
|
419
488
|
const pageExecutor = createPageRequestExecutor<Env>({
|
|
489
|
+
...(options.cacheRouteModules === undefined
|
|
490
|
+
? {}
|
|
491
|
+
: { cacheRouteModules: options.cacheRouteModules }),
|
|
420
492
|
...(options.serverComponents === undefined
|
|
421
493
|
? {}
|
|
422
494
|
: { serverComponents: options.serverComponents }),
|
|
@@ -442,6 +514,8 @@ export function createWebApp<Env = unknown>(
|
|
|
442
514
|
...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
|
|
443
515
|
...(options.styles === undefined ? {} : { styles: options.styles }),
|
|
444
516
|
...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
|
|
517
|
+
...(options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }),
|
|
518
|
+
...(options.i18n === undefined ? {} : { i18n: pageI18n(options.i18n) }),
|
|
445
519
|
...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
|
|
446
520
|
...(options.prerenderedPaths === undefined
|
|
447
521
|
? {}
|
|
@@ -460,17 +534,23 @@ export function createWebApp<Env = unknown>(
|
|
|
460
534
|
cacheChannel,
|
|
461
535
|
})
|
|
462
536
|
|
|
537
|
+
// A nonce app's documents authorize their scripts per request, which a file rendered once cannot.
|
|
538
|
+
const prerendered =
|
|
539
|
+
options.prerenderedPages === undefined || options.nonce !== undefined
|
|
540
|
+
? undefined
|
|
541
|
+
: servePrerenderedPages(options.prerenderedPages, options.draftSecret)
|
|
463
542
|
let ownsRootCatchAll = false
|
|
464
543
|
for (const route of manifest.routes) {
|
|
465
544
|
const pattern = requestPattern(route.pattern)
|
|
466
545
|
if (pattern.startsWith("/*")) ownsRootCatchAll = true
|
|
546
|
+
const render = pageExecutor.get(route)
|
|
467
547
|
app.register(
|
|
468
548
|
"GET",
|
|
469
549
|
pattern,
|
|
470
550
|
options.serverComponents?.owns(route.id)
|
|
471
551
|
? { capabilities: options.serverComponents.capabilities(route.id) }
|
|
472
552
|
: undefined,
|
|
473
|
-
|
|
553
|
+
prerendered === undefined ? render : prerendered(render),
|
|
474
554
|
)
|
|
475
555
|
app.register("POST", pattern, undefined, pageExecutor.post(route))
|
|
476
556
|
}
|