@nifrajs/web 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +265 -15
- package/dist/build.js.map +1 -1
- package/dist/csp.d.ts +2 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +2 -0
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +23 -3
- package/dist/deferred.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/deferred-free.d.ts +3 -0
- package/dist/internal/deferred-free.d.ts.map +1 -0
- package/dist/internal/deferred-free.js +15 -0
- package/dist/internal/deferred-free.js.map +1 -0
- package/dist/internal/module-preparation.d.ts +7 -0
- package/dist/internal/module-preparation.d.ts.map +1 -0
- package/dist/internal/module-preparation.js +24 -0
- package/dist/internal/module-preparation.js.map +1 -0
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/output-guard.d.ts +2 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +51 -9
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +1 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +53 -43
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -0
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/prerendered-pages.d.ts +42 -0
- package/dist/internal/prerendered-pages.d.ts.map +1 -0
- package/dist/internal/prerendered-pages.js +92 -0
- package/dist/internal/prerendered-pages.js.map +1 -0
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +93 -35
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +4 -0
- package/dist/internal/request-url.d.ts.map +1 -1
- package/dist/internal/request-url.js +24 -0
- package/dist/internal/request-url.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +4 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +8 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/web-app.d.ts +11 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +10 -1
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +7 -4
- package/dist/isr.js.map +1 -1
- package/dist/prerender.d.ts +4 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +9 -1
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +19 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +130 -99
- package/dist/public-dir.js.map +1 -1
- package/dist/render-seam.d.ts +5 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js.map +1 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/vite.d.ts +6 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +127 -54
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +330 -49
- package/src/csp.ts +4 -0
- package/src/deferred.ts +20 -3
- package/src/index.ts +3 -0
- package/src/internal/deferred-free.ts +15 -0
- package/src/internal/module-preparation.ts +30 -0
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +55 -47
- package/src/internal/parity.ts +2 -0
- package/src/internal/prerendered-pages.ts +124 -0
- package/src/internal/render-document.ts +116 -52
- package/src/internal/request-url.ts +24 -0
- package/src/internal/secret-scan.ts +9 -1
- package/src/internal/web-app.ts +21 -1
- package/src/isr.ts +7 -4
- package/src/prerender.ts +13 -1
- package/src/public-dir.ts +158 -97
- package/src/render-seam.ts +5 -2
- package/src/search.ts +1 -0
- package/src/vite.ts +130 -54
|
@@ -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"
|
|
@@ -54,6 +60,13 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
|
|
|
54
60
|
[LINE_SEP]: "\\u2028",
|
|
55
61
|
[PARA_SEP]: "\\u2029",
|
|
56
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"
|
|
57
70
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
58
71
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
59
72
|
|
|
@@ -544,43 +557,77 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
544
557
|
// streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
|
|
545
558
|
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
546
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
|
|
547
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
|
+
}
|
|
548
598
|
const out =
|
|
549
599
|
parts !== undefined
|
|
550
600
|
? adapter.renderToString(
|
|
551
601
|
chain,
|
|
552
602
|
renderProps,
|
|
553
|
-
|
|
603
|
+
renderNonce === undefined ? { parts } : { nonce: renderNonce, parts },
|
|
554
604
|
)
|
|
555
|
-
:
|
|
605
|
+
: renderNonce === undefined
|
|
556
606
|
? adapter.renderToString(chain, renderProps)
|
|
557
|
-
: adapter.renderToString(chain, renderProps, { nonce })
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
)
|
|
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)
|
|
568
628
|
}
|
|
569
629
|
|
|
570
|
-
return
|
|
571
|
-
adapter,
|
|
572
|
-
chain,
|
|
573
|
-
renderProps,
|
|
574
|
-
shellOf,
|
|
575
|
-
parts,
|
|
576
|
-
closeRootHtml,
|
|
577
|
-
allDeferred,
|
|
578
|
-
tailHtml,
|
|
579
|
-
status,
|
|
580
|
-
headers,
|
|
581
|
-
nonce,
|
|
582
|
-
nonceAttr,
|
|
583
|
-
).then((response) => new ResponseRenderedPage(response))
|
|
630
|
+
return streamed(nonce)
|
|
584
631
|
}
|
|
585
632
|
|
|
586
633
|
async function renderStreamedPage(
|
|
@@ -673,6 +720,9 @@ function htmlResponse(body: string, init: ResponseInit): Response {
|
|
|
673
720
|
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
674
721
|
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
675
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.
|
|
676
726
|
*/
|
|
677
727
|
function streamDocument(
|
|
678
728
|
shell: Uint8Array,
|
|
@@ -687,45 +737,59 @@ function streamDocument(
|
|
|
687
737
|
// A client that goes away cancels the body: the renderer stops, and nothing more is written.
|
|
688
738
|
let cancelled = false
|
|
689
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
|
+
)
|
|
690
763
|
return new ReadableStream({
|
|
691
|
-
|
|
764
|
+
start(controller) {
|
|
765
|
+
controller.enqueue(shell)
|
|
766
|
+
},
|
|
767
|
+
async pull(controller) {
|
|
692
768
|
try {
|
|
693
|
-
|
|
694
|
-
for (;;) {
|
|
769
|
+
if (!released) {
|
|
695
770
|
const { done, value } = await reader.read()
|
|
696
771
|
if (cancelled) return
|
|
697
|
-
if (done)
|
|
698
|
-
|
|
772
|
+
if (!done) {
|
|
773
|
+
controller.enqueue(value)
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
release()
|
|
777
|
+
controller.enqueue(closeRoot)
|
|
699
778
|
}
|
|
700
|
-
controller.enqueue(closeRoot)
|
|
701
779
|
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
702
780
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
703
781
|
await Promise.all(
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const value = serializeData(await d.promise)
|
|
708
|
-
// `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
|
|
709
|
-
// separators before this framework-owned script is emitted.
|
|
710
|
-
script = `<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>` // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
|
|
711
|
-
} catch (err) {
|
|
712
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
713
|
-
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
714
|
-
// text; log the real reason server-side.
|
|
715
|
-
console.error("[nifra/web] deferred value rejected:", err)
|
|
716
|
-
script = `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`
|
|
717
|
-
}
|
|
718
|
-
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))
|
|
719
785
|
}),
|
|
720
786
|
)
|
|
721
787
|
if (cancelled) return
|
|
722
788
|
controller.enqueue(tail)
|
|
723
789
|
controller.close()
|
|
724
790
|
} catch (err) {
|
|
791
|
+
release()
|
|
725
792
|
if (!cancelled) controller.error(err)
|
|
726
|
-
} finally {
|
|
727
|
-
released = true
|
|
728
|
-
reader.releaseLock()
|
|
729
793
|
}
|
|
730
794
|
},
|
|
731
795
|
cancel(reason) {
|
|
@@ -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. */
|
package/src/internal/web-app.ts
CHANGED
|
@@ -22,6 +22,7 @@ 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
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"
|
|
@@ -73,6 +74,8 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
73
74
|
readonly serverComponents?: import("../server-components/types.ts").ServerComponentRuntime
|
|
74
75
|
readonly adapter: RenderAdapter
|
|
75
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
|
|
76
79
|
/** URL of the built client entry (module script) injected into every page. */
|
|
77
80
|
readonly clientEntry: string
|
|
78
81
|
/** Default document title for all pages. */
|
|
@@ -252,6 +255,14 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
252
255
|
* 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
|
|
253
256
|
* pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
|
|
254
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
|
|
255
266
|
/** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
|
|
256
267
|
* those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
|
|
257
268
|
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
@@ -475,6 +486,9 @@ export function createWebApp<Env = unknown>(
|
|
|
475
486
|
? undefined
|
|
476
487
|
: servedPrerenderedList(options.prerenderedPaths)
|
|
477
488
|
const pageExecutor = createPageRequestExecutor<Env>({
|
|
489
|
+
...(options.cacheRouteModules === undefined
|
|
490
|
+
? {}
|
|
491
|
+
: { cacheRouteModules: options.cacheRouteModules }),
|
|
478
492
|
...(options.serverComponents === undefined
|
|
479
493
|
? {}
|
|
480
494
|
: { serverComponents: options.serverComponents }),
|
|
@@ -520,17 +534,23 @@ export function createWebApp<Env = unknown>(
|
|
|
520
534
|
cacheChannel,
|
|
521
535
|
})
|
|
522
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)
|
|
523
542
|
let ownsRootCatchAll = false
|
|
524
543
|
for (const route of manifest.routes) {
|
|
525
544
|
const pattern = requestPattern(route.pattern)
|
|
526
545
|
if (pattern.startsWith("/*")) ownsRootCatchAll = true
|
|
546
|
+
const render = pageExecutor.get(route)
|
|
527
547
|
app.register(
|
|
528
548
|
"GET",
|
|
529
549
|
pattern,
|
|
530
550
|
options.serverComponents?.owns(route.id)
|
|
531
551
|
? { capabilities: options.serverComponents.capabilities(route.id) }
|
|
532
552
|
: undefined,
|
|
533
|
-
|
|
553
|
+
prerendered === undefined ? render : prerendered(render),
|
|
534
554
|
)
|
|
535
555
|
app.register("POST", pattern, undefined, pageExecutor.post(route))
|
|
536
556
|
}
|
package/src/isr.ts
CHANGED
|
@@ -161,6 +161,8 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
161
161
|
private order = 0
|
|
162
162
|
private readonly purgedKeys = new Map<string, number>()
|
|
163
163
|
private readonly purgedTags = new Map<string, number>()
|
|
164
|
+
// The newest purge order whose mark was evicted. A render fenced before it may predate that purge.
|
|
165
|
+
private purgeFloor = 0
|
|
164
166
|
|
|
165
167
|
constructor(options: MemoryCacheStoreOptions = {}) {
|
|
166
168
|
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
|
|
@@ -246,8 +248,8 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
|
|
249
|
-
if ((this.purgedKeys.get(key) ??
|
|
250
|
-
return tags.some((tag) => (this.purgedTags.get(tag) ??
|
|
251
|
+
if ((this.purgedKeys.get(key) ?? this.purgeFloor) > fence) return true
|
|
252
|
+
return tags.some((tag) => (this.purgedTags.get(tag) ?? this.purgeFloor) > fence)
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
private markPurged(marks: Map<string, number>, name: string): void {
|
|
@@ -255,9 +257,10 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
255
257
|
marks.set(name, ++this.order)
|
|
256
258
|
// Bounded like the entries: a mark matters only to a render still in flight.
|
|
257
259
|
while (marks.size > this.max) {
|
|
258
|
-
const oldest = marks.
|
|
260
|
+
const oldest = marks.entries().next().value
|
|
259
261
|
if (oldest === undefined) break
|
|
260
|
-
marks.delete(oldest)
|
|
262
|
+
marks.delete(oldest[0])
|
|
263
|
+
this.purgeFloor = Math.max(this.purgeFloor, oldest[1])
|
|
261
264
|
}
|
|
262
265
|
}
|
|
263
266
|
|
package/src/prerender.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
12
12
|
import { dirname, join, resolve, sep } from "node:path"
|
|
13
|
+
import { prerenderedDocumentHeaders } from "./internal/prerendered-pages.ts"
|
|
13
14
|
import { assertNoSecrets, buildEnvironment, type SecretExemption } from "./internal/secret-scan.ts"
|
|
14
15
|
import { fillRoutePattern, type RouteEntry } from "./manifest.ts"
|
|
15
16
|
import { DATA_HEADER, PRERENDERED_GLOBAL, PRERENDERED_LIST_PATH } from "./router.ts"
|
|
@@ -52,6 +53,10 @@ export interface PrerenderEntry {
|
|
|
52
53
|
/** The loader-data file written alongside (`<path>/_data.json`) so a client soft-nav INTO this
|
|
53
54
|
* route skips the worker. Absent when the data-mode response is NDJSON (a deferred loader). */
|
|
54
55
|
readonly dataFile?: string
|
|
56
|
+
/** The document headers a self-hosted server sends with this file (its `content-language` and CSP).
|
|
57
|
+
* Absent when the render was specific to its request - it set a cookie, was private or no-store, or
|
|
58
|
+
* varied on a request header - so a server keeps rendering this path on demand. */
|
|
59
|
+
readonly serveHeaders?: Readonly<Record<string, string>>
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
export interface PrerenderResult {
|
|
@@ -184,7 +189,14 @@ export async function prerenderRoutes(options: PrerenderOptions): Promise<Preren
|
|
|
184
189
|
await dataRes.body?.cancel() // not emitting (non-OK or NDJSON) - release the stream
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
|
|
192
|
+
const serveHeaders = prerenderedDocumentHeaders(res.status, res.headers)
|
|
193
|
+
prerendered.push({
|
|
194
|
+
path,
|
|
195
|
+
file,
|
|
196
|
+
bytes: html.length,
|
|
197
|
+
...(dataFile ? { dataFile } : {}),
|
|
198
|
+
...(serveHeaders ? { serveHeaders } : {}),
|
|
199
|
+
})
|
|
188
200
|
}
|
|
189
201
|
|
|
190
202
|
for (const route of options.routes) {
|