@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
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
ShouldRevalidate,
|
|
35
35
|
} from "../manifest.ts"
|
|
36
36
|
import type { NonceResolver } from "../nonce.ts"
|
|
37
|
-
import type { MatchChain, RenderAdapter } from "../render-seam.ts"
|
|
37
|
+
import type { MatchChain, PageLocale, PageLocales, RenderAdapter } from "../render-seam.ts"
|
|
38
38
|
import {
|
|
39
39
|
createMatcher,
|
|
40
40
|
DATA_HEADER,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from "../router.ts"
|
|
47
47
|
import { searchOf, searchOfChain } from "../search.ts"
|
|
48
48
|
import { mergeHeads, resolveChainMeta } from "./head-merge.ts"
|
|
49
|
+
import { prepareModules } from "./module-preparation.ts"
|
|
49
50
|
import {
|
|
50
51
|
EMPTY_RETAIN,
|
|
51
52
|
type HeadersLike,
|
|
@@ -54,9 +55,12 @@ import {
|
|
|
54
55
|
isStatusSignal,
|
|
55
56
|
type LoadedLayoutModules,
|
|
56
57
|
layoutErrorId,
|
|
58
|
+
notFound,
|
|
57
59
|
type RenderAssemblyCache,
|
|
58
60
|
type RenderedPage,
|
|
61
|
+
type RenderPageOptions,
|
|
59
62
|
type RevalidateResult,
|
|
63
|
+
redirect,
|
|
60
64
|
renderPageResult,
|
|
61
65
|
STATUS_SIGNAL,
|
|
62
66
|
STATUS_TEXT,
|
|
@@ -65,7 +69,7 @@ import {
|
|
|
65
69
|
tagLayoutError,
|
|
66
70
|
withDuplicateInstanceHint,
|
|
67
71
|
} from "./render-document.ts"
|
|
68
|
-
import { urlPartsFor } from "./request-url.ts"
|
|
72
|
+
import { originFor, urlPartsFor } from "./request-url.ts"
|
|
69
73
|
import {
|
|
70
74
|
ACTION_SCOPE,
|
|
71
75
|
type CoreResponseControls,
|
|
@@ -179,6 +183,24 @@ function appTarget(location: string, requestUrl: string): string {
|
|
|
179
183
|
: target.pathname + target.search + target.hash
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
/** The app's locale routing, as page execution reads it. */
|
|
187
|
+
export interface PageI18n {
|
|
188
|
+
readonly locales: PageLocales
|
|
189
|
+
/** The served locale key a `pathname + search` is in. */
|
|
190
|
+
localeOf(path: string): string
|
|
191
|
+
/** The answer for a `[lang]` / `[[lang]]` segment's value (`undefined` when an optional one is
|
|
192
|
+
* absent), as `@nifrajs/i18n/routing`'s `matchSegment` gives it. */
|
|
193
|
+
matchSegment(
|
|
194
|
+
value: string | undefined,
|
|
195
|
+
pathname: string,
|
|
196
|
+
):
|
|
197
|
+
| { readonly kind: "ok" | "not-found" }
|
|
198
|
+
| { readonly kind: "redirect"; readonly location: string }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The route directories whose first segment names the locale.
|
|
202
|
+
const LOCALE_SEGMENTS: ReadonlySet<string> = new Set(["[lang]", "[[lang]]"])
|
|
203
|
+
|
|
182
204
|
export interface PageExecutionOptions<Env = unknown> {
|
|
183
205
|
readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
|
|
184
206
|
readonly rerenderOwnerGET?: (
|
|
@@ -187,6 +209,7 @@ export interface PageExecutionOptions<Env = unknown> {
|
|
|
187
209
|
) => Promise<Response>
|
|
188
210
|
readonly adapter: RenderAdapter
|
|
189
211
|
readonly manifest: Manifest
|
|
212
|
+
readonly cacheRouteModules?: boolean
|
|
190
213
|
readonly clientEntry: string
|
|
191
214
|
readonly title?: string
|
|
192
215
|
readonly api?: unknown
|
|
@@ -198,6 +221,10 @@ export interface PageExecutionOptions<Env = unknown> {
|
|
|
198
221
|
readonly prerenderedPaths?: readonly string[]
|
|
199
222
|
/** Where pages fetch {@link prerenderedPaths} from when the list is too large to inline. */
|
|
200
223
|
readonly prerenderedListUrl?: string
|
|
224
|
+
/** Locale key -> copy chunk URL, from a build that split copy tables by locale. */
|
|
225
|
+
readonly copyChunks?: Readonly<Record<string, string>>
|
|
226
|
+
/** The app's locale routing: each document's locale, and the check on a `[lang]` segment. */
|
|
227
|
+
readonly i18n?: PageI18n
|
|
201
228
|
readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
|
|
202
229
|
readonly staticBoundaryCache?: StaticBoundaryCache
|
|
203
230
|
/** Per-request CSP nonce for framework-owned executable document scripts. */
|
|
@@ -272,6 +299,12 @@ type LayoutChain = {
|
|
|
272
299
|
*/
|
|
273
300
|
type LayoutChainSource = (page: RouteModule, count: number) => Promise<LayoutChain | undefined>
|
|
274
301
|
|
|
302
|
+
/** A document's render input, filled in field by field. */
|
|
303
|
+
type DocumentInput = { -readonly [K in keyof RenderPageOptions]: RenderPageOptions[K] } & {
|
|
304
|
+
readonly clientEntry: string
|
|
305
|
+
readonly hydrate: boolean
|
|
306
|
+
}
|
|
307
|
+
|
|
275
308
|
/**
|
|
276
309
|
* Own the page request program behind one private seam.
|
|
277
310
|
*
|
|
@@ -285,6 +318,11 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
285
318
|
options: PageExecutionOptions<Env>,
|
|
286
319
|
): PageRequestExecutor<Env> {
|
|
287
320
|
const { adapter, manifest, clientEntry, api } = options
|
|
321
|
+
const cacheModules =
|
|
322
|
+
options.cacheRouteModules ??
|
|
323
|
+
(typeof process !== "undefined" && process.env?.NODE_ENV === "production")
|
|
324
|
+
const loadModule = prepareModules(cacheModules)
|
|
325
|
+
const preparedLayouts = new WeakMap<RouteEntry, LoadedLayoutModules>()
|
|
288
326
|
// The action result is request-local; a browser cannot manufacture this handoff.
|
|
289
327
|
const progressiveActionData = new WeakMap<Request, unknown>()
|
|
290
328
|
// Bound once per render, so every loader, action and boundary in that render shares one view of the
|
|
@@ -301,6 +339,24 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
301
339
|
options.prerenderedListUrl === undefined
|
|
302
340
|
? { prerenderedPaths: options.prerenderedPaths ?? [] }
|
|
303
341
|
: { prerenderedListUrl: options.prerenderedListUrl }
|
|
342
|
+
const copyOption = options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }
|
|
343
|
+
const appDocumentOptions = { ...prerenderedHandover, ...copyOption, ...cspOption, ...titleOption }
|
|
344
|
+
const { i18n } = options
|
|
345
|
+
const pageLocales = new Map(i18n?.locales.locales.map((locale) => [locale.key, locale]))
|
|
346
|
+
/** The request's locale for a document, with the served locales the client names locales from. */
|
|
347
|
+
const localeOption = (req: Request): { locale?: PageLocale; locales?: PageLocales } => {
|
|
348
|
+
if (i18n === undefined) return {}
|
|
349
|
+
const locale = pageLocales.get(i18n.localeOf(pathOf(req)))
|
|
350
|
+
return locale === undefined ? { locales: i18n.locales } : { locale, locales: i18n.locales }
|
|
351
|
+
}
|
|
352
|
+
/** Whether a route sits under a `[lang]` or `[[lang]]` directory, which the locale routing checks. */
|
|
353
|
+
const localeGuarded = (route: RouteEntry): boolean => {
|
|
354
|
+
if (i18n === undefined) return false
|
|
355
|
+
for (const segment of route.id.split("/")) {
|
|
356
|
+
if (!(segment.startsWith("(") && segment.endsWith(")"))) return LOCALE_SEGMENTS.has(segment)
|
|
357
|
+
}
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
304
360
|
const matchManifestRoute = createMatcher(
|
|
305
361
|
manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
|
|
306
362
|
)
|
|
@@ -379,8 +435,10 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
379
435
|
// Load a route's layout modules once for the current execution. The render path needs each whole
|
|
380
436
|
// module for its component + meta, while the data path also needs loader/gate/boundary declarations.
|
|
381
437
|
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
438
|
+
const known = cacheModules ? preparedLayouts.get(route) : undefined
|
|
439
|
+
if (known !== undefined) return known
|
|
382
440
|
const modules = await Promise.all(
|
|
383
|
-
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry)
|
|
441
|
+
route.layoutIds.map((id) => loadModule(manifest.layouts[id] as LayoutEntry)),
|
|
384
442
|
)
|
|
385
443
|
for (let i = 0; i < modules.length; i++) {
|
|
386
444
|
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
@@ -389,6 +447,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
389
447
|
`[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
|
|
390
448
|
)
|
|
391
449
|
}
|
|
450
|
+
if (cacheModules) preparedLayouts.set(route, modules)
|
|
392
451
|
return modules
|
|
393
452
|
}
|
|
394
453
|
|
|
@@ -553,6 +612,21 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
553
612
|
ctx: LoaderContext,
|
|
554
613
|
controls: PageResponseControls,
|
|
555
614
|
): Promise<void> => {
|
|
615
|
+
if (i18n !== undefined && localeGuarded(route)) {
|
|
616
|
+
// Before any app middleware, as a root `_middleware` would run: an unserved locale, or a page
|
|
617
|
+
// missing from it, never reaches the app's code.
|
|
618
|
+
const match = i18n.matchSegment(ctx.params.lang, pathOf(ctx.request))
|
|
619
|
+
if (match.kind === "redirect") {
|
|
620
|
+
throw tagLayoutError(redirect(match.location, { status: 308 }), "_layout")
|
|
621
|
+
}
|
|
622
|
+
if (match.kind === "not-found") {
|
|
623
|
+
try {
|
|
624
|
+
notFound()
|
|
625
|
+
} catch (signal) {
|
|
626
|
+
throw tagLayoutError(signal, "_layout")
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
556
630
|
const ids = route.middlewareIds ?? []
|
|
557
631
|
for (let i = 0; i < ids.length; i++) {
|
|
558
632
|
const id = ids[i] as string
|
|
@@ -560,7 +634,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
560
634
|
const dirTag = `${id.slice(0, id.length - "_middleware".length)}_layout`
|
|
561
635
|
let outcome: unknown
|
|
562
636
|
try {
|
|
563
|
-
const middleware = (await entry
|
|
637
|
+
const middleware = (await loadModule(entry)).default
|
|
564
638
|
// A `_layout.backend.ts` that exports no middleware is a layout data module, nothing more.
|
|
565
639
|
if (middleware === undefined) continue
|
|
566
640
|
if (typeof middleware !== "function") {
|
|
@@ -718,14 +792,6 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
718
792
|
return undefined
|
|
719
793
|
}
|
|
720
794
|
|
|
721
|
-
const originOf = (req: Request): string => {
|
|
722
|
-
try {
|
|
723
|
-
return new URL(req.url).origin
|
|
724
|
-
} catch {
|
|
725
|
-
return ""
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
795
|
const pathOf = (req: Request): string => {
|
|
730
796
|
try {
|
|
731
797
|
const parts = urlPartsFor(req)
|
|
@@ -738,8 +804,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
738
804
|
/** What a status or error page's `meta` receives: no route params, and its own data. */
|
|
739
805
|
const statusMetaArgs = (req: Request, data: unknown, nonce: string | undefined): MetaArgs =>
|
|
740
806
|
nonce === undefined
|
|
741
|
-
? { data, pageData: data, params: {}, origin:
|
|
742
|
-
: { data, pageData: data, params: {}, origin:
|
|
807
|
+
? { data, pageData: data, params: {}, origin: originFor(req) }
|
|
808
|
+
: { data, pageData: data, params: {}, origin: originFor(req), nonce }
|
|
743
809
|
|
|
744
810
|
const renderError = async (
|
|
745
811
|
req: Request,
|
|
@@ -776,6 +842,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
776
842
|
chain: components,
|
|
777
843
|
data,
|
|
778
844
|
...(wrapped === undefined ? {} : { head: wrapped.head }),
|
|
845
|
+
...localeOption(req),
|
|
779
846
|
clientEntry,
|
|
780
847
|
routeId: errorId,
|
|
781
848
|
status: 500,
|
|
@@ -939,6 +1006,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
939
1006
|
// The build keys stylesheets by route, so a page inside the root layout links the whole-app one.
|
|
940
1007
|
...(chain === undefined ? stylesOf(routeId) : stylesFrom(options.styles)),
|
|
941
1008
|
...prerenderedHandover,
|
|
1009
|
+
...copyOption,
|
|
1010
|
+
...localeOption(req),
|
|
942
1011
|
...titleOption,
|
|
943
1012
|
}
|
|
944
1013
|
if (chain === undefined) return renderPageResult(statusPage)
|
|
@@ -1062,13 +1131,15 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1062
1131
|
data,
|
|
1063
1132
|
pageData: data,
|
|
1064
1133
|
params,
|
|
1065
|
-
origin:
|
|
1134
|
+
origin: originFor(req),
|
|
1066
1135
|
...(nonce === undefined ? {} : { nonce }),
|
|
1067
1136
|
},
|
|
1068
1137
|
serverLeafOf(route, mod),
|
|
1069
1138
|
)
|
|
1070
1139
|
try {
|
|
1071
|
-
|
|
1140
|
+
// Optional fields are assigned, not spread in: a conditional spread per field cost every
|
|
1141
|
+
// document request about 1us on Bun.
|
|
1142
|
+
const page: DocumentInput = {
|
|
1072
1143
|
adapter,
|
|
1073
1144
|
chain,
|
|
1074
1145
|
data,
|
|
@@ -1079,36 +1150,32 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1079
1150
|
path: pathOf(req),
|
|
1080
1151
|
search: searchChainOf(layoutModules, mod, req),
|
|
1081
1152
|
matchChain: matchChainOf(route.layoutIds, layoutModules, route.id, mod),
|
|
1082
|
-
...(serverComponents === undefined ? {} : { serverComponents }),
|
|
1083
|
-
...(progressiveActionData.has(req) ? { actionData: progressiveActionData.get(req) } : {}),
|
|
1084
1153
|
hydrate: mod.hydrate !== false,
|
|
1085
|
-
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1086
|
-
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
1087
1154
|
...preloadOf(route.id),
|
|
1088
1155
|
...stylesOf(route.id),
|
|
1089
|
-
...
|
|
1090
|
-
...(
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1156
|
+
...appDocumentOptions,
|
|
1157
|
+
...localeOption(req),
|
|
1158
|
+
}
|
|
1159
|
+
if (serverComponents !== undefined) page.serverComponents = serverComponents
|
|
1160
|
+
if (progressiveActionData.has(req)) page.actionData = progressiveActionData.get(req)
|
|
1161
|
+
if (layoutData !== undefined) page.layoutData = layoutData
|
|
1162
|
+
if (boundaryStates !== undefined) page.boundaries = boundaryStates
|
|
1163
|
+
if (responseHeaders !== undefined) page.headers = responseHeaders
|
|
1164
|
+
// A response that sets a cookie is one visitor's: it never advertises ISR freshness.
|
|
1165
|
+
if (!personalized && options.cacheChannel?.enabled === true) {
|
|
1166
|
+
if (mod.revalidate !== undefined) page.revalidate = mod.revalidate
|
|
1167
|
+
if (mod.revalidateTags !== undefined) {
|
|
1168
|
+
page.revalidateTags = routeTags(
|
|
1169
|
+
mod.revalidateTags,
|
|
1170
|
+
{ params, url: new URL(req.url) },
|
|
1171
|
+
route.id,
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (mod.islandScripts !== undefined) page.islandScripts = mod.islandScripts
|
|
1176
|
+
if (nonce !== undefined) page.nonce = nonce
|
|
1177
|
+
if (assemblyCache !== undefined) page.assemblyCache = assemblyCache
|
|
1178
|
+
return await renderPageResult(page)
|
|
1112
1179
|
} catch (err) {
|
|
1113
1180
|
if (isStatusSignal(err)) return renderStatusSignal(req, env, err, personalized, settled)
|
|
1114
1181
|
if (isControlFlow(err)) throw err
|
|
@@ -1191,7 +1258,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1191
1258
|
data: null,
|
|
1192
1259
|
pageData: null,
|
|
1193
1260
|
params,
|
|
1194
|
-
origin:
|
|
1261
|
+
origin: originFor(req),
|
|
1195
1262
|
...(nonce === undefined ? {} : { nonce }),
|
|
1196
1263
|
})
|
|
1197
1264
|
let headers: Headers | undefined
|
|
@@ -1213,6 +1280,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1213
1280
|
matchChain: matchChainOf(page.layoutIds, layoutModules, id, mod),
|
|
1214
1281
|
status: 404,
|
|
1215
1282
|
hydrate: false,
|
|
1283
|
+
...localeOption(req),
|
|
1216
1284
|
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1217
1285
|
...stylesOf(id),
|
|
1218
1286
|
...(headers === undefined ? {} : { headers }),
|
|
@@ -1304,12 +1372,14 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1304
1372
|
return {
|
|
1305
1373
|
get: (route) => {
|
|
1306
1374
|
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
|
|
1307
|
-
|
|
1375
|
+
// One shell per locale: `<html lang>` is in it, and one route serves every locale.
|
|
1376
|
+
let assemblySlots = new Map<string, RenderAssemblyCache>()
|
|
1308
1377
|
let assemblySlotMod: unknown
|
|
1309
1378
|
let assemblySlotLayouts: LoadedLayoutModules | undefined
|
|
1310
1379
|
const assemblyCacheFor = (
|
|
1311
1380
|
mod: RouteModule,
|
|
1312
1381
|
layouts: LoadedLayoutModules,
|
|
1382
|
+
req: Request,
|
|
1313
1383
|
): RenderAssemblyCache | undefined => {
|
|
1314
1384
|
if (typeof mod.meta === "function") return undefined
|
|
1315
1385
|
for (const layout of layouts) if (typeof layout.meta === "function") return undefined
|
|
@@ -1318,11 +1388,17 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1318
1388
|
assemblySlotLayouts.length === layouts.length &&
|
|
1319
1389
|
assemblySlotLayouts.every((layout, index) => layout === layouts[index])
|
|
1320
1390
|
if (assemblySlotMod !== mod || !layoutsUnchanged) {
|
|
1321
|
-
|
|
1391
|
+
assemblySlots = new Map()
|
|
1322
1392
|
assemblySlotMod = mod
|
|
1323
1393
|
assemblySlotLayouts = layouts
|
|
1324
1394
|
}
|
|
1325
|
-
|
|
1395
|
+
const key = i18n === undefined ? "" : i18n.localeOf(pathOf(req))
|
|
1396
|
+
let slot = assemblySlots.get(key)
|
|
1397
|
+
if (slot === undefined) {
|
|
1398
|
+
slot = {}
|
|
1399
|
+
assemblySlots.set(key, slot)
|
|
1400
|
+
}
|
|
1401
|
+
return slot
|
|
1326
1402
|
}
|
|
1327
1403
|
|
|
1328
1404
|
return withResponseControls(async (c, controls) => {
|
|
@@ -1340,7 +1416,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1340
1416
|
if (!componentOwner && is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
|
|
1341
1417
|
return renderNotFound(c, controls)
|
|
1342
1418
|
}
|
|
1343
|
-
const
|
|
1419
|
+
const loaded = loadModule(route)
|
|
1420
|
+
const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
|
|
1344
1421
|
const draft = await draftFlag(c.req)
|
|
1345
1422
|
// Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
|
|
1346
1423
|
if (draft) controls.personalize()
|
|
@@ -1364,7 +1441,9 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1364
1441
|
search: loaderSearch(mod.searchSchema, c.req),
|
|
1365
1442
|
set: controls.scope(PAGE_SCOPE),
|
|
1366
1443
|
}
|
|
1367
|
-
if (route.middlewareIds !== undefined
|
|
1444
|
+
if (route.middlewareIds !== undefined || localeGuarded(route)) {
|
|
1445
|
+
await runMiddleware(route, ctx, controls)
|
|
1446
|
+
}
|
|
1368
1447
|
run = await runLayoutChain(
|
|
1369
1448
|
route,
|
|
1370
1449
|
ctx,
|
|
@@ -1510,7 +1589,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1510
1589
|
layoutModules ?? (await loadLayoutModules(route)),
|
|
1511
1590
|
layoutRetained,
|
|
1512
1591
|
boundaryStates,
|
|
1513
|
-
layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
|
|
1592
|
+
layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules, c.req),
|
|
1514
1593
|
responseHeaders,
|
|
1515
1594
|
controls.personalized,
|
|
1516
1595
|
componentState,
|
|
@@ -1520,7 +1599,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1520
1599
|
|
|
1521
1600
|
post: (route) =>
|
|
1522
1601
|
withResponseControls(async (c, controls) => {
|
|
1523
|
-
const
|
|
1602
|
+
const loaded = loadModule(route)
|
|
1603
|
+
const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
|
|
1524
1604
|
const draft = await draftFlag(c.req)
|
|
1525
1605
|
if (draft) controls.personalize()
|
|
1526
1606
|
if (mod.action === undefined) {
|
|
@@ -1569,7 +1649,9 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1569
1649
|
let gated: GatedLayouts
|
|
1570
1650
|
let result: unknown
|
|
1571
1651
|
try {
|
|
1572
|
-
if (route.middlewareIds !== undefined
|
|
1652
|
+
if (route.middlewareIds !== undefined || localeGuarded(route)) {
|
|
1653
|
+
await runMiddleware(route, actionContext, controls)
|
|
1654
|
+
}
|
|
1573
1655
|
gated = await runLayoutGates(route, actionContext, controls)
|
|
1574
1656
|
result = await mod.action(actionContext)
|
|
1575
1657
|
} catch (err) {
|
|
@@ -1736,7 +1818,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1736
1818
|
data,
|
|
1737
1819
|
pageData: data,
|
|
1738
1820
|
params: c.params,
|
|
1739
|
-
origin:
|
|
1821
|
+
origin: originFor(c.req),
|
|
1740
1822
|
...(nonce === undefined ? {} : { nonce }),
|
|
1741
1823
|
},
|
|
1742
1824
|
serverLeafOf(route, mod),
|
|
@@ -1758,6 +1840,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1758
1840
|
...preloadOf(route.id),
|
|
1759
1841
|
...stylesOf(route.id),
|
|
1760
1842
|
...prerenderedHandover,
|
|
1843
|
+
...copyOption,
|
|
1844
|
+
...localeOption(c.req),
|
|
1761
1845
|
...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
|
|
1762
1846
|
...(nonce === undefined ? {} : { nonce }),
|
|
1763
1847
|
...cspOption,
|
package/src/internal/parity.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { discoverRoutes } from "../fs.ts"
|
|
|
20
20
|
import { codeUnitOrder } from "./code-unit-order.ts"
|
|
21
21
|
import { withoutComments } from "./html-spans.ts"
|
|
22
22
|
import { isIdentitySensitivePackage } from "./identity-policy.ts"
|
|
23
|
-
import { publicUrlPath } from "./public-url.ts"
|
|
23
|
+
import { isHiddenPublicPath, publicUrlPath } from "./public-url.ts"
|
|
24
24
|
|
|
25
25
|
const DEPENDENCY_FIELDS = [
|
|
26
26
|
"dependencies",
|
|
@@ -1077,6 +1077,7 @@ const publicFilesUnder = (publicDir: string | false | undefined): readonly strin
|
|
|
1077
1077
|
const files: string[] = []
|
|
1078
1078
|
const walk = (current: string): void => {
|
|
1079
1079
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
1080
|
+
if (isHiddenPublicPath(entry.name)) continue // the build skips it too
|
|
1080
1081
|
const path = join(current, entry.name)
|
|
1081
1082
|
if (entry.isDirectory()) walk(path)
|
|
1082
1083
|
else if (entry.isFile()) files.push(publicUrlPath(relative(root, path)))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { pathnameOf } from "@nifrajs/core/server"
|
|
2
|
+
import { isDraftEnabled } from "../draft.ts"
|
|
3
|
+
import { DATA_HEADER } from "../router.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pages a self-hosted build prerendered, which `createWebApp` answers from their file instead of
|
|
7
|
+
* rendering them. The file is served from inside the app, after `use` middleware, so a gate in front of
|
|
8
|
+
* the pages still runs.
|
|
9
|
+
*/
|
|
10
|
+
export interface PrerenderedPages {
|
|
11
|
+
/** Each servable path, spelled as a request URL spells it, mapped to the document headers its
|
|
12
|
+
* build-time render answered with (a prerender entry's `serveHeaders`). */
|
|
13
|
+
readonly pages: Readonly<Record<string, Readonly<Record<string, string>>>>
|
|
14
|
+
/** The page's HTML, or `undefined` to render it on demand instead. */
|
|
15
|
+
read(pathname: string): Promise<string | undefined>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The document headers a render produces from the route alone, so the build-time value is the value
|
|
19
|
+
// every request would get. Loader headers are left out: a prerendered page is a static file.
|
|
20
|
+
const REPLAYED = [
|
|
21
|
+
"content-language",
|
|
22
|
+
"content-security-policy",
|
|
23
|
+
"content-security-policy-report-only",
|
|
24
|
+
]
|
|
25
|
+
// Vary values a file still answers correctly: compression is negotiated by the host, and a data request
|
|
26
|
+
// never reaches the file.
|
|
27
|
+
const STATIC_VARY = new Set(["accept-encoding", DATA_HEADER])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The headers a self-hosted server replays with a prerendered page, or `undefined` when its build-time
|
|
31
|
+
* render was specific to that request and the page must keep rendering on demand: it set a cookie, was
|
|
32
|
+
* marked private or no-store (a nonce page always is), or varied on a request header.
|
|
33
|
+
*/
|
|
34
|
+
export function prerenderedDocumentHeaders(
|
|
35
|
+
status: number,
|
|
36
|
+
headers: Headers,
|
|
37
|
+
): Record<string, string> | undefined {
|
|
38
|
+
if (status !== 200 || headers.has("set-cookie")) return undefined
|
|
39
|
+
const cacheControl = (headers.get("cache-control") ?? "").toLowerCase()
|
|
40
|
+
if (/\b(?:private|no-store)\b/.test(cacheControl)) return undefined
|
|
41
|
+
for (const name of (headers.get("vary") ?? "").split(",")) {
|
|
42
|
+
const trimmed = name.trim().toLowerCase()
|
|
43
|
+
if (trimmed !== "" && !STATIC_VARY.has(trimmed)) return undefined
|
|
44
|
+
}
|
|
45
|
+
const replayed: Record<string, string> = {}
|
|
46
|
+
for (const name of REPLAYED) {
|
|
47
|
+
const value = headers.get(name)
|
|
48
|
+
if (value !== null) replayed[name] = value
|
|
49
|
+
}
|
|
50
|
+
return replayed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One page a self-hosting build prerendered: its file and the headers it is served with. */
|
|
54
|
+
export interface ServerPrerenderedPage {
|
|
55
|
+
readonly file: string
|
|
56
|
+
readonly headers: Readonly<Record<string, string>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The prerendered pages a server may answer from their file, keyed by the pathname of the request that
|
|
61
|
+
* rendered them. Left out: a render specific to its request, and two paths written to one file (`/x`
|
|
62
|
+
* and `//x`, or `/A` and `/a` on a case-insensitive disk), where only the last render is on disk.
|
|
63
|
+
*/
|
|
64
|
+
export function servablePrerenderedPages(
|
|
65
|
+
entries: readonly {
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly file: string
|
|
68
|
+
readonly serveHeaders?: Readonly<Record<string, string>>
|
|
69
|
+
}[],
|
|
70
|
+
): Record<string, ServerPrerenderedPage> {
|
|
71
|
+
const writes = new Map<string, number>()
|
|
72
|
+
for (const { file } of entries) {
|
|
73
|
+
writes.set(file.toLowerCase(), (writes.get(file.toLowerCase()) ?? 0) + 1)
|
|
74
|
+
}
|
|
75
|
+
const pages: Record<string, ServerPrerenderedPage> = {}
|
|
76
|
+
for (const { path, file, serveHeaders } of entries) {
|
|
77
|
+
if (serveHeaders === undefined || writes.get(file.toLowerCase()) !== 1) continue
|
|
78
|
+
// Concatenated as prerenderRoutes requested it: `new URL("//x", base)` would read a host.
|
|
79
|
+
pages[new URL(`http://localhost${path}`).pathname] = { file, headers: serveHeaders }
|
|
80
|
+
}
|
|
81
|
+
return pages
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wrap a page route's GET handler so a plain document request for a prerendered path is answered from
|
|
86
|
+
* its file. Anything a file cannot answer the way a render would renders on demand: a query string, a
|
|
87
|
+
* navigation data or component request, a draft-mode visitor, or a file the reader cannot return.
|
|
88
|
+
*/
|
|
89
|
+
export function servePrerenderedPages(
|
|
90
|
+
prerendered: PrerenderedPages,
|
|
91
|
+
draftSecret: string | undefined,
|
|
92
|
+
): <C extends { readonly req: Request }, R>(
|
|
93
|
+
render: (c: C) => Promise<R>,
|
|
94
|
+
) => (c: C) => Promise<R | Response> {
|
|
95
|
+
const pages = new Map(Object.entries(prerendered.pages))
|
|
96
|
+
return (render) => async (c) => {
|
|
97
|
+
const req = c.req
|
|
98
|
+
const pathname = pathnameOf(req.url)
|
|
99
|
+
const headers = pages.get(pathname)
|
|
100
|
+
if (
|
|
101
|
+
headers === undefined ||
|
|
102
|
+
req.url.includes("?") ||
|
|
103
|
+
req.headers.has(DATA_HEADER) ||
|
|
104
|
+
req.headers.has("x-nifra-component") ||
|
|
105
|
+
(draftSecret !== undefined && (await isDraftEnabled(req, draftSecret)))
|
|
106
|
+
) {
|
|
107
|
+
return render(c)
|
|
108
|
+
}
|
|
109
|
+
const html = await prerendered.read(pathname)
|
|
110
|
+
if (html === undefined) return render(c)
|
|
111
|
+
if (req.method !== "HEAD") {
|
|
112
|
+
return new Response(html, {
|
|
113
|
+
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return new Response(null, {
|
|
117
|
+
headers: {
|
|
118
|
+
...headers,
|
|
119
|
+
"content-type": "text/html; charset=utf-8",
|
|
120
|
+
"content-length": String(new TextEncoder().encode(html).byteLength),
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -20,3 +20,16 @@ function encodePathSegment(segment: string): string {
|
|
|
20
20
|
export function publicUrlPath(rel: string): string {
|
|
21
21
|
return `/${rel.split(sep).map(encodePathSegment).join("/")}`
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a path under the public directory crosses a dot-segment other than `.well-known`. Serving,
|
|
26
|
+
* the build copy and the parity listing all apply it, so an editor or VCS artifact left in `public/`
|
|
27
|
+
* (`.env`, `.git/`, `.DS_Store`) is neither served in development nor shipped by a build. Both
|
|
28
|
+
* separators split: a request path uses `/`, a relative file path the OS separator.
|
|
29
|
+
*/
|
|
30
|
+
export function isHiddenPublicPath(rel: string): boolean {
|
|
31
|
+
for (const segment of rel.split(/[\\/]/)) {
|
|
32
|
+
if (segment.startsWith(".") && segment !== ".well-known") return true
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|