@nifrajs/web 5.0.0 → 5.2.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 +2970 -0
- package/README.md +8 -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 +12 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +43 -6
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +7 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -22
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +3 -2
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +2 -6
- package/dist/dev-port.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/package-entries.d.ts +34 -0
- package/dist/internal/package-entries.d.ts.map +1 -0
- package/dist/internal/package-entries.js +324 -0
- package/dist/internal/package-entries.js.map +1 -0
- package/dist/internal/page-execution.d.ts +19 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +123 -14
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +15 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +8 -0
- package/dist/internal/private-env.d.ts.map +1 -1
- package/dist/internal/private-env.js +25 -10
- package/dist/internal/private-env.js.map +1 -1
- package/dist/internal/render-document.d.ts +15 -46
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +59 -62
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/route-path.d.ts +6 -0
- package/dist/internal/route-path.d.ts.map +1 -1
- package/dist/internal/route-path.js +11 -0
- package/dist/internal/route-path.js.map +1 -1
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +103 -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 +38 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +30 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/without-query.d.ts +4 -0
- package/dist/internal/without-query.d.ts.map +1 -0
- package/dist/internal/without-query.js +9 -0
- package/dist/internal/without-query.js.map +1 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +17 -6
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +15 -2
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +9 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +1 -0
- package/dist/manifest.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/plugins/vite-leak-guard.d.ts +6 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +80 -7
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +17 -21
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/plugins/zone-guard.d.ts.map +1 -1
- package/dist/plugins/zone-guard.js +44 -3
- package/dist/plugins/zone-guard.js.map +1 -1
- package/dist/prerender.d.ts +5 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +41 -19
- package/dist/prerender.js.map +1 -1
- package/dist/render-seam.d.ts +60 -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 +13 -9
- package/dist/router.js.map +1 -1
- package/dist/seo.d.ts +49 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +48 -0
- package/dist/seo.js.map +1 -0
- 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 +1 -28
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -69
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +30 -8
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +185 -46
- package/dist/zones.js.map +1 -1
- package/package.json +10 -4
- package/src/build-plan.ts +21 -0
- package/src/build-vite.ts +60 -3
- package/src/build.ts +57 -4
- package/src/client.ts +42 -22
- package/src/dev-port.ts +4 -10
- package/src/index.ts +8 -4
- package/src/internal/codegen.ts +32 -3
- package/src/internal/copy-split.ts +220 -0
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +150 -13
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/render-document.ts +100 -81
- package/src/internal/route-path.ts +16 -0
- package/src/internal/secret-scan.ts +101 -1
- package/src/internal/typescript-api.ts +11 -0
- package/src/internal/vite-plugin-compat.ts +81 -0
- package/src/internal/web-app.ts +71 -6
- package/src/internal/without-query.ts +8 -0
- package/src/internal/zone-graph.ts +19 -6
- package/src/isr.ts +13 -2
- package/src/manifest.ts +10 -0
- package/src/plugins/import-glob.ts +392 -0
- package/src/plugins/kit.ts +36 -0
- package/src/plugins/stylex.ts +2 -1
- package/src/plugins/vite-leak-guard.ts +99 -7
- package/src/plugins/vite-zone-guard.ts +21 -19
- package/src/plugins/zone-guard.ts +41 -2
- package/src/prerender.ts +39 -18
- package/src/render-seam.ts +88 -2
- package/src/router.ts +16 -11
- package/src/seo.ts +68 -0
- package/src/server-components/typescript.ts +2 -10
- package/src/vite.ts +3 -81
- package/src/zones.ts +237 -52
|
@@ -6,21 +6,28 @@ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../defer
|
|
|
6
6
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
|
|
7
7
|
import type {
|
|
8
8
|
Loader as LayoutLoader,
|
|
9
|
-
LinkDescriptor,
|
|
10
9
|
Meta,
|
|
11
|
-
MetaDescriptor,
|
|
12
10
|
MetaInput,
|
|
13
11
|
RouteModule,
|
|
14
|
-
ScriptDescriptor,
|
|
15
12
|
UnsafeScriptDescriptor,
|
|
16
13
|
} from "../manifest.ts"
|
|
17
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
DocumentParts,
|
|
16
|
+
MatchChain,
|
|
17
|
+
PageLocale,
|
|
18
|
+
PageLocales,
|
|
19
|
+
RenderAdapter,
|
|
20
|
+
RenderProps,
|
|
21
|
+
} from "../render-seam.ts"
|
|
18
22
|
import {
|
|
19
23
|
ACTION_GLOBAL,
|
|
20
24
|
BOUNDARY_GLOBAL,
|
|
25
|
+
COPY_CHUNKS_GLOBAL,
|
|
26
|
+
COPY_REALM,
|
|
21
27
|
DATA_GLOBAL,
|
|
22
28
|
HANDOVER_ID,
|
|
23
29
|
LAYOUT_DATA_GLOBAL,
|
|
30
|
+
LOCALES_GLOBAL,
|
|
24
31
|
ROOT_ATTRIBUTE,
|
|
25
32
|
ROUTE_GLOBAL,
|
|
26
33
|
} from "../render-seam.ts"
|
|
@@ -114,6 +121,15 @@ export interface RenderPageOptions {
|
|
|
114
121
|
/** SSG: the URL of a JSON array of the prerendered paths, handed over in place of
|
|
115
122
|
* {@link prerenderedPaths} so a large set is fetched once instead of riding in every page. */
|
|
116
123
|
readonly prerenderedListUrl?: string
|
|
124
|
+
/** Locale key -> copy chunk URL (`buildClient`'s `BuildManifest.copyChunks`), handed over to the
|
|
125
|
+
* client; the chunks this page reads its copy from are preloaded. Omit ⇒ none. */
|
|
126
|
+
readonly copyChunks?: Readonly<Record<string, string>>
|
|
127
|
+
/** The page's locale from the app's locale routing: `<html lang dir>` default to it unless `head`
|
|
128
|
+
* sets `lang`, the response carries `Content-Language`, and the adapter reads it as
|
|
129
|
+
* `RenderProps.locale`. Omit ⇒ none of these. */
|
|
130
|
+
readonly locale?: PageLocale
|
|
131
|
+
/** The served locales, handed to the client so it names each page's locale on navigation. */
|
|
132
|
+
readonly locales?: PageLocales
|
|
117
133
|
/** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
|
|
118
134
|
* wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
|
|
119
135
|
readonly revalidate?: number
|
|
@@ -187,7 +203,7 @@ export interface RenderPageOptions {
|
|
|
187
203
|
* prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
|
|
188
204
|
*
|
|
189
205
|
* Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
|
|
190
|
-
* same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
|
|
206
|
+
* same `head` content (a static meta chain - never a `meta(data)` function), `locale`, `title`, `styles`,
|
|
191
207
|
* `cssLoading`, `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`.
|
|
192
208
|
* Per-request
|
|
193
209
|
* values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
|
|
@@ -201,8 +217,10 @@ export interface RenderPageOptions {
|
|
|
201
217
|
export interface RenderAssemblyCache {
|
|
202
218
|
/** Shell up to (not including) the deferred-runtime insertion point. */
|
|
203
219
|
shellPre?: string
|
|
204
|
-
/** Shell from after the deferred-runtime insertion point through
|
|
220
|
+
/** Shell from after the deferred-runtime insertion point through `<body>`. */
|
|
205
221
|
shellPost?: string
|
|
222
|
+
/** The open `#root` container. */
|
|
223
|
+
rootOpen?: string
|
|
206
224
|
/** Tail opener: the handover script open tag + the data member's key. */
|
|
207
225
|
tailPre?: string
|
|
208
226
|
/** Tail from after the per-request handover members (the route id, prerendered paths, the handover
|
|
@@ -249,6 +267,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
249
267
|
cssLoading: requestedCssLoading,
|
|
250
268
|
prerenderedPaths = [],
|
|
251
269
|
prerenderedListUrl,
|
|
270
|
+
copyChunks,
|
|
271
|
+
locale,
|
|
252
272
|
revalidate,
|
|
253
273
|
revalidateTags = [],
|
|
254
274
|
routeId,
|
|
@@ -277,6 +297,13 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
277
297
|
: prerenderedPaths.length === 0
|
|
278
298
|
? ""
|
|
279
299
|
: `,"${PRERENDERED_GLOBAL}":${serializeData(prerenderedPaths)}`
|
|
300
|
+
const copyHandover =
|
|
301
|
+
copyChunks === undefined ? "" : `,"${COPY_CHUNKS_GLOBAL}":${serializeData(copyChunks)}`
|
|
302
|
+
const localesHandover =
|
|
303
|
+
options.locales === undefined ? "" : `,"${LOCALES_GLOBAL}":${serializeData(options.locales)}`
|
|
304
|
+
// A route's `lang` wins with its own `dir`; otherwise the page's registered locale names both.
|
|
305
|
+
const lang = head?.lang ?? locale?.lang ?? "en"
|
|
306
|
+
const dir = head?.dir ?? (head?.lang === undefined ? locale?.dir : undefined)
|
|
280
307
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
281
308
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
282
309
|
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
@@ -406,7 +433,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
406
433
|
// `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
|
|
407
434
|
// HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
|
|
408
435
|
// defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
|
|
409
|
-
const htmlAttrs = ` lang="${escapeAttr(
|
|
436
|
+
const htmlAttrs = ` lang="${escapeAttr(lang)}"${dir === undefined ? "" : ` dir="${escapeAttr(dir)}"`}`
|
|
410
437
|
// Marks the container for the client entry when the id is not the one the entry falls back to.
|
|
411
438
|
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
|
|
412
439
|
// A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
|
|
@@ -423,7 +450,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
423
450
|
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
|
|
424
451
|
: ""
|
|
425
452
|
slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head, nonce)}${styleLinks}${entryPreloads}`
|
|
426
|
-
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body
|
|
453
|
+
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body>`
|
|
454
|
+
slot.rootOpen = `<div id="${escapeAttr(rootId)}"${rootMarker}>`
|
|
427
455
|
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
428
456
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
429
457
|
let islandTags = ""
|
|
@@ -432,7 +460,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
432
460
|
if (hydrate) {
|
|
433
461
|
// Inert JSON, never executed: the page state needs no nonce and no hash under any CSP.
|
|
434
462
|
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>`
|
|
463
|
+
slot.tailPost = `${route}${prerendered}${copyHandover}${localesHandover}}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
|
|
436
464
|
} else {
|
|
437
465
|
slot.tailPre = ""
|
|
438
466
|
slot.tailPost = `${islandTags}</body></html>`
|
|
@@ -441,7 +469,18 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
441
469
|
// The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
|
|
442
470
|
// only when something actually deferred - matching the pre-cache emission exactly.
|
|
443
471
|
const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
|
|
444
|
-
|
|
472
|
+
// Per request, so outside the cached shell: one route serves every locale.
|
|
473
|
+
const copyPreloads =
|
|
474
|
+
hydrate && copyChunks !== undefined
|
|
475
|
+
? copyPreloadLinks(copyChunks, options.path ?? "", lang)
|
|
476
|
+
: ""
|
|
477
|
+
const shellHtml = `${slot.shellPre}${copyPreloads}${runtimeSeam}${slot.shellPost}${slot.rootOpen}`
|
|
478
|
+
const parts: DocumentParts | undefined =
|
|
479
|
+
adapter.documentParts === true ? { head: "", body: "" } : undefined
|
|
480
|
+
const shellOf = (): string =>
|
|
481
|
+
parts === undefined || (parts.head === "" && parts.body === "")
|
|
482
|
+
? shellHtml
|
|
483
|
+
: `${slot.shellPre}${copyPreloads}${runtimeSeam}${parts.head}${slot.shellPost}${parts.body}${slot.rootOpen}`
|
|
445
484
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
446
485
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
447
486
|
const closeRootHtml = "</div>"
|
|
@@ -451,6 +490,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
451
490
|
? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
|
|
452
491
|
: (slot.tailPost as string)
|
|
453
492
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
493
|
+
if (locale !== undefined) headers["content-language"] = lang
|
|
454
494
|
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
455
495
|
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
456
496
|
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
@@ -495,6 +535,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
495
535
|
? { boundaries: boundarySplit.forComponent as BoundaryStates }
|
|
496
536
|
: {}),
|
|
497
537
|
...(options.matchChain !== undefined ? { matchChain: options.matchChain } : {}),
|
|
538
|
+
...(locale === undefined ? {} : { locale }),
|
|
498
539
|
}
|
|
499
540
|
|
|
500
541
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
@@ -504,13 +545,22 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
504
545
|
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
505
546
|
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
506
547
|
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
507
|
-
const out =
|
|
548
|
+
const out =
|
|
549
|
+
parts !== undefined
|
|
550
|
+
? adapter.renderToString(
|
|
551
|
+
chain,
|
|
552
|
+
renderProps,
|
|
553
|
+
nonce === undefined ? { parts } : { nonce, parts },
|
|
554
|
+
)
|
|
555
|
+
: nonce === undefined
|
|
556
|
+
? adapter.renderToString(chain, renderProps)
|
|
557
|
+
: adapter.renderToString(chain, renderProps, { nonce })
|
|
508
558
|
return typeof out === "string"
|
|
509
|
-
? new BufferedRenderedPage(
|
|
559
|
+
? new BufferedRenderedPage(shellOf() + out + closeRootHtml + tailHtml, status, headers)
|
|
510
560
|
: out.then(
|
|
511
561
|
(bodyHtml) =>
|
|
512
562
|
new BufferedRenderedPage(
|
|
513
|
-
|
|
563
|
+
shellOf() + bodyHtml + closeRootHtml + tailHtml,
|
|
514
564
|
status,
|
|
515
565
|
headers,
|
|
516
566
|
),
|
|
@@ -521,7 +571,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
521
571
|
adapter,
|
|
522
572
|
chain,
|
|
523
573
|
renderProps,
|
|
524
|
-
|
|
574
|
+
shellOf,
|
|
575
|
+
parts,
|
|
525
576
|
closeRootHtml,
|
|
526
577
|
allDeferred,
|
|
527
578
|
tailHtml,
|
|
@@ -536,7 +587,8 @@ async function renderStreamedPage(
|
|
|
536
587
|
adapter: RenderAdapter,
|
|
537
588
|
chain: readonly unknown[],
|
|
538
589
|
renderProps: RenderProps,
|
|
539
|
-
|
|
590
|
+
shellOf: () => string,
|
|
591
|
+
parts: DocumentParts | undefined,
|
|
540
592
|
closeRootHtml: string,
|
|
541
593
|
allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
542
594
|
tailHtml: string,
|
|
@@ -550,13 +602,20 @@ async function renderStreamedPage(
|
|
|
550
602
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
551
603
|
// sent. A *mid*-stream failure errors the body instead.
|
|
552
604
|
const enc = TEXT_ENCODER
|
|
553
|
-
const shell = enc.encode(shellHtml)
|
|
554
605
|
const closeRoot = enc.encode(closeRootHtml)
|
|
555
606
|
const tail = enc.encode(tailHtml)
|
|
556
607
|
const appStream =
|
|
557
|
-
|
|
558
|
-
? await adapter.renderToStream(
|
|
559
|
-
|
|
608
|
+
parts !== undefined
|
|
609
|
+
? await adapter.renderToStream(
|
|
610
|
+
chain,
|
|
611
|
+
renderProps,
|
|
612
|
+
nonce === undefined ? { parts } : { nonce, parts },
|
|
613
|
+
)
|
|
614
|
+
: nonce === undefined
|
|
615
|
+
? await adapter.renderToStream(chain, renderProps)
|
|
616
|
+
: await adapter.renderToStream(chain, renderProps, { nonce })
|
|
617
|
+
// After the render: an adapter fills its document parts before handing back the stream.
|
|
618
|
+
const shell = enc.encode(shellOf())
|
|
560
619
|
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
|
|
561
620
|
return new Response(body, { status, headers })
|
|
562
621
|
}
|
|
@@ -1123,68 +1182,6 @@ function headTags(head: Meta | undefined, documentNonce?: string): string {
|
|
|
1123
1182
|
return out
|
|
1124
1183
|
}
|
|
1125
1184
|
|
|
1126
|
-
/**
|
|
1127
|
-
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
1128
|
-
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
1129
|
-
*
|
|
1130
|
-
* ```ts
|
|
1131
|
-
* export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
|
|
1132
|
-
* ```
|
|
1133
|
-
*/
|
|
1134
|
-
export function canonical(href: string): LinkDescriptor {
|
|
1135
|
-
return { rel: "canonical", href }
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
/** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
|
|
1139
|
-
* ones become tags. `type` defaults to `"website"`. */
|
|
1140
|
-
export interface OpenGraphInput {
|
|
1141
|
-
readonly title?: string
|
|
1142
|
-
readonly description?: string
|
|
1143
|
-
/** Absolute URL of the share image (`og:image`). */
|
|
1144
|
-
readonly image?: string
|
|
1145
|
-
/** Canonical URL of the page (`og:url`). */
|
|
1146
|
-
readonly url?: string
|
|
1147
|
-
/** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
|
|
1148
|
-
readonly type?: string
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/**
|
|
1152
|
-
* Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
|
|
1153
|
-
* properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
|
|
1154
|
-
*
|
|
1155
|
-
* ```ts
|
|
1156
|
-
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
1157
|
-
* ```
|
|
1158
|
-
*/
|
|
1159
|
-
export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
|
|
1160
|
-
const tags: MetaDescriptor[] = []
|
|
1161
|
-
const add = (property: string, content: string | undefined): void => {
|
|
1162
|
-
if (content !== undefined) tags.push({ property, content })
|
|
1163
|
-
}
|
|
1164
|
-
add("og:title", input.title)
|
|
1165
|
-
add("og:description", input.description)
|
|
1166
|
-
add("og:image", input.image)
|
|
1167
|
-
add("og:url", input.url)
|
|
1168
|
-
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
1169
|
-
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
1170
|
-
return tags
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
|
|
1175
|
-
* object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
|
|
1176
|
-
* `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
|
|
1177
|
-
*
|
|
1178
|
-
* ```ts
|
|
1179
|
-
* export const meta = {
|
|
1180
|
-
* script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
|
|
1181
|
-
* }
|
|
1182
|
-
* ```
|
|
1183
|
-
*/
|
|
1184
|
-
export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
1185
|
-
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
1185
|
/**
|
|
1189
1186
|
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
1190
1187
|
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
@@ -1209,3 +1206,25 @@ export function unsafeInlineScript(
|
|
|
1209
1206
|
content,
|
|
1210
1207
|
}
|
|
1211
1208
|
}
|
|
1209
|
+
|
|
1210
|
+
/** The copy chunks a page loads first, as `@nifrajs/i18n/copy` picks them in the browser, so they
|
|
1211
|
+
* download beside the route's code instead of after it. */
|
|
1212
|
+
function copyPreloadLinks(
|
|
1213
|
+
chunks: Readonly<Record<string, string>>,
|
|
1214
|
+
path: string,
|
|
1215
|
+
lang: string,
|
|
1216
|
+
): string {
|
|
1217
|
+
const realm: unknown = Reflect.get(globalThis, Symbol.for(COPY_REALM))
|
|
1218
|
+
if (typeof realm !== "object" || realm === null) return ""
|
|
1219
|
+
const pageLocales: unknown = Reflect.get(realm, "pageLocales")
|
|
1220
|
+
if (typeof pageLocales !== "function") return ""
|
|
1221
|
+
const locales: unknown = Reflect.apply(pageLocales, realm, [path, lang])
|
|
1222
|
+
if (!Array.isArray(locales)) return ""
|
|
1223
|
+
let links = ""
|
|
1224
|
+
for (const locale of locales) {
|
|
1225
|
+
const url =
|
|
1226
|
+
typeof locale === "string" && Object.hasOwn(chunks, locale) ? chunks[locale] : undefined
|
|
1227
|
+
if (url !== undefined) links += `<link rel="modulepreload" href="${escapeAttr(url)}">`
|
|
1228
|
+
}
|
|
1229
|
+
return links
|
|
1230
|
+
}
|
|
@@ -6,3 +6,19 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const requestPattern = (pattern: string): string =>
|
|
8
8
|
pattern.normalize("NFC").replace(/[^!-~]/gu, encodeURIComponent)
|
|
9
|
+
|
|
10
|
+
// This module also compiles without the DOM lib; at runtime the name is the browser's own global.
|
|
11
|
+
declare const location: { readonly origin: string } | undefined
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An app path as a URL `fetch`, `history` and `location` resolve on this document's own origin. A path
|
|
15
|
+
* starting `//` or `/\` is the path of a page here (`https://shop.example//evil.example/x`), but resolved
|
|
16
|
+
* as written it is a protocol-relative URL to another host, so it is anchored to this origin.
|
|
17
|
+
*/
|
|
18
|
+
export const appUrl = (path: string): string =>
|
|
19
|
+
path.charCodeAt(0) === 47 /* / */ &&
|
|
20
|
+
(path.charCodeAt(1) === 47 || path.charCodeAt(1) === 92) /* / or \ */ &&
|
|
21
|
+
typeof location !== "undefined" &&
|
|
22
|
+
location.origin !== "null"
|
|
23
|
+
? location.origin + path
|
|
24
|
+
: path
|
|
@@ -293,6 +293,98 @@ function formatMatches(text: string): Match[] {
|
|
|
293
293
|
return matches
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** Words that mark a whole `_`/`-` segment of a value as a stand-in: `your_signing_secret`. */
|
|
297
|
+
const PLACEHOLDER_SEGMENT =
|
|
298
|
+
/^(?:your|example|placeholder|sample|dummy|changeme|replace|generated|x{3,})$/
|
|
299
|
+
|
|
300
|
+
const placeholderSegments = (value: string): boolean =>
|
|
301
|
+
value
|
|
302
|
+
.toLowerCase()
|
|
303
|
+
.split(/[_-]/)
|
|
304
|
+
.some((segment) => PLACEHOLDER_SEGMENT.test(segment))
|
|
305
|
+
|
|
306
|
+
/** A word-cased label: letters and marks, hyphen-joined words, an acronym. No digits, no runs of
|
|
307
|
+
* mixed case, no credential punctuation. */
|
|
308
|
+
const LABEL = /^(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+)(?:-(?:\p{Lu}{1,4}|\p{Lu}?[\p{Ll}\p{M}]+))*$/u
|
|
309
|
+
|
|
310
|
+
/** How many string values a catalog holds at least, and the share of them that is prose. */
|
|
311
|
+
const CATALOG_VALUES = 10
|
|
312
|
+
|
|
313
|
+
/** Where a `/` starts a regular expression: after anything but a value's end. */
|
|
314
|
+
const VALUE_END = /[\w$)\]}"'`]/
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The spans of top-level object literals that read as message catalogs: at least
|
|
318
|
+
* {@link CATALOG_VALUES} string property values, most of them prose with whitespace. A lexer, not a
|
|
319
|
+
* parser, so a build scans without TypeScript; strings, comments and regular expressions are skipped.
|
|
320
|
+
*/
|
|
321
|
+
function catalogSpans(text: string): Array<readonly [number, number]> {
|
|
322
|
+
const spans: Array<readonly [number, number]> = []
|
|
323
|
+
let depth = 0
|
|
324
|
+
let start = 0
|
|
325
|
+
let values = 0
|
|
326
|
+
let prose = 0
|
|
327
|
+
// Whether the open top-level brace starts an object literal, not a block such as a function body.
|
|
328
|
+
let literal = false
|
|
329
|
+
let last = ""
|
|
330
|
+
let i = 0
|
|
331
|
+
while (i < text.length) {
|
|
332
|
+
const c = text.charAt(i)
|
|
333
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
334
|
+
const end = text.indexOf("\n", i)
|
|
335
|
+
i = end === -1 ? text.length : end
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
339
|
+
const end = text.indexOf("*/", i + 2)
|
|
340
|
+
i = end === -1 ? text.length : end + 2
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (c === '"' || c === "'" || c === "`" || (c === "/" && !VALUE_END.test(last))) {
|
|
344
|
+
let j = i + 1
|
|
345
|
+
let inClass = false
|
|
346
|
+
for (; j < text.length; j++) {
|
|
347
|
+
const d = text[j]
|
|
348
|
+
if (d === "\\") {
|
|
349
|
+
j++
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (c !== "`" && d === "\n") break
|
|
353
|
+
if (c !== "/") {
|
|
354
|
+
if (d === c) break
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (d === "[") inClass = true
|
|
358
|
+
else if (d === "]") inClass = false
|
|
359
|
+
else if (d === "/" && !inClass) break
|
|
360
|
+
}
|
|
361
|
+
// A string after `:` is a property's value.
|
|
362
|
+
if (depth > 0 && c !== "/" && c !== "`" && last === ":") {
|
|
363
|
+
values++
|
|
364
|
+
if (/\s/.test(text.slice(i + 1, j))) prose++
|
|
365
|
+
}
|
|
366
|
+
last = c
|
|
367
|
+
i = j + 1
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
if (c === "{") {
|
|
371
|
+
if (depth === 0) {
|
|
372
|
+
start = i
|
|
373
|
+
values = 0
|
|
374
|
+
prose = 0
|
|
375
|
+
const word = /([\w$]+)\s*$/.exec(text.slice(Math.max(0, i - 16), i))?.[1]
|
|
376
|
+
literal = last === "" || "=(,:[?".includes(last) || word === "default"
|
|
377
|
+
}
|
|
378
|
+
depth++
|
|
379
|
+
} else if (c === "}" && depth > 0 && --depth === 0) {
|
|
380
|
+
if (literal && values >= CATALOG_VALUES && prose * 2 > values) spans.push([start, i + 1])
|
|
381
|
+
}
|
|
382
|
+
if (!/\s/.test(c)) last = c
|
|
383
|
+
i++
|
|
384
|
+
}
|
|
385
|
+
return spans
|
|
386
|
+
}
|
|
387
|
+
|
|
296
388
|
/** The credential a literal given to `name` carries, when the name is secret-like and the literal
|
|
297
389
|
* looks random. */
|
|
298
390
|
function credentialOf(name: string, value: string): string | undefined {
|
|
@@ -304,6 +396,7 @@ function credentialOf(name: string, value: string): string | undefined {
|
|
|
304
396
|
secret === undefined ||
|
|
305
397
|
PUBLIC_VALUE.test(secret) ||
|
|
306
398
|
PLACEHOLDER.test(secret) ||
|
|
399
|
+
placeholderSegments(secret) ||
|
|
307
400
|
!looksRandom(secret, 16)
|
|
308
401
|
)
|
|
309
402
|
return undefined
|
|
@@ -312,9 +405,16 @@ function credentialOf(name: string, value: string): string | undefined {
|
|
|
312
405
|
|
|
313
406
|
function assignedMatches(text: string): Match[] {
|
|
314
407
|
const matches: Match[] = []
|
|
408
|
+
let catalogs: ReadonlyArray<readonly [number, number]> | undefined
|
|
409
|
+
/** A label in a message catalog: `password: "Lykilorð"` names the field in another language. */
|
|
410
|
+
const catalogLabel = (value: string, valueAt: number): boolean => {
|
|
411
|
+
if (!LABEL.test(value)) return false
|
|
412
|
+
catalogs ??= catalogSpans(text)
|
|
413
|
+
return catalogs.some(([start, end]) => valueAt > start && valueAt < end)
|
|
414
|
+
}
|
|
315
415
|
const add = (name: string, value: string, valueAt: number): void => {
|
|
316
416
|
const secret = credentialOf(name, value)
|
|
317
|
-
if (secret === undefined) return
|
|
417
|
+
if (secret === undefined || catalogLabel(value, valueAt)) return
|
|
318
418
|
matches.push({
|
|
319
419
|
rule: "assigned-secret",
|
|
320
420
|
// The credential is the value's tail: a header value's scheme is not part of it.
|
|
@@ -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
|
+
}
|