@nifrajs/web 5.2.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 +72 -0
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +182 -11
- 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/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 -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.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 +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 +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 +226 -35
- 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/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +55 -47
- 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 +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 +107 -12
- 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) {
|
package/src/public-dir.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
|
|
13
13
|
* The names collide and the concepts do not: `publicPath` never covers user-authored files.
|
|
14
14
|
*/
|
|
15
|
-
import { realpath } from "node:fs/promises"
|
|
15
|
+
import { type FileHandle, realpath } from "node:fs/promises"
|
|
16
16
|
import { extname, normalize, resolve, sep } from "node:path"
|
|
17
17
|
import { Readable } from "node:stream"
|
|
18
18
|
import { parseByteRange } from "@nifrajs/core/range"
|
|
19
19
|
import { pathnameOf } from "@nifrajs/core/server"
|
|
20
|
+
import { isHiddenPublicPath } from "./internal/public-url.ts"
|
|
20
21
|
import { openResolved } from "./open-resolved.ts"
|
|
21
22
|
|
|
22
23
|
/** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
|
|
@@ -96,15 +97,33 @@ export const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
|
-
* Whether a confined path crosses a
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* Whether a confined path crosses a hidden segment ({@link isHiddenPublicPath}). Checked on the
|
|
101
|
+
* resolved path, so `%2e` encodings are covered, and again on the real target, so a visible name
|
|
102
|
+
* linking to `.env` is refused too.
|
|
102
103
|
*/
|
|
103
104
|
function isHiddenPath(root: string, abs: string): boolean {
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
return abs !== root && isHiddenPublicPath(abs.slice(root.length + 1))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Up to this many bytes are read whole through the pinned descriptor, which then closes before the
|
|
109
|
+
// response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
|
|
110
|
+
// stream. A larger body streams, so a video holds 64 KiB per request rather than the file.
|
|
111
|
+
const BUFFERED_BODY_MAX = 256 * 1024
|
|
112
|
+
|
|
113
|
+
/** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
|
|
114
|
+
async function readPinned(
|
|
115
|
+
handle: FileHandle,
|
|
116
|
+
start: number,
|
|
117
|
+
length: number,
|
|
118
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
119
|
+
const bytes = new Uint8Array(length)
|
|
120
|
+
let offset = 0
|
|
121
|
+
while (offset < length) {
|
|
122
|
+
const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset)
|
|
123
|
+
if (bytesRead === 0) break
|
|
124
|
+
offset += bytesRead
|
|
106
125
|
}
|
|
107
|
-
return
|
|
126
|
+
return offset === length ? bytes : bytes.subarray(0, offset)
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
/** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
|
|
@@ -194,7 +213,8 @@ export function servePublicDir(
|
|
|
194
213
|
if (
|
|
195
214
|
resolvedRoot === undefined ||
|
|
196
215
|
resolvedFile === undefined ||
|
|
197
|
-
(resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep))
|
|
216
|
+
(resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep)) ||
|
|
217
|
+
isHiddenPath(resolvedRoot, resolvedFile)
|
|
198
218
|
) {
|
|
199
219
|
return undefined
|
|
200
220
|
}
|
|
@@ -243,7 +263,8 @@ export function servePublicDir(
|
|
|
243
263
|
return new Response(null, { status: 304, headers })
|
|
244
264
|
}
|
|
245
265
|
|
|
246
|
-
|
|
266
|
+
// Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
|
|
267
|
+
const rangeHeader = head ? null : request.headers.get("range")
|
|
247
268
|
const range =
|
|
248
269
|
rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
|
|
249
270
|
? parseByteRange(rangeHeader, size)
|
|
@@ -255,7 +276,16 @@ export function servePublicDir(
|
|
|
255
276
|
return new Response(null, { status: 416, headers })
|
|
256
277
|
}
|
|
257
278
|
|
|
258
|
-
const body = (
|
|
279
|
+
const body = async (
|
|
280
|
+
start: number,
|
|
281
|
+
end: number,
|
|
282
|
+
): Promise<Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>> => {
|
|
283
|
+
const length = end - start + 1
|
|
284
|
+
if (length <= BUFFERED_BODY_MAX) {
|
|
285
|
+
const bytes = await readPinned(handle!, start, length)
|
|
286
|
+
headers.set("content-length", String(bytes.byteLength))
|
|
287
|
+
return bytes
|
|
288
|
+
}
|
|
259
289
|
const stream = handle!.createReadStream({ start, end })
|
|
260
290
|
// FileHandle.createReadStream owns closing the descriptor after EOF/abort.
|
|
261
291
|
handle = undefined
|
|
@@ -266,14 +296,14 @@ export function servePublicDir(
|
|
|
266
296
|
const { start, end } = range.ranges[0]!
|
|
267
297
|
headers.set("content-range", `bytes ${start}-${end}/${size}`)
|
|
268
298
|
headers.set("content-length", String(end - start + 1))
|
|
269
|
-
return new Response(head ? null : body(start, end), { status: 206, headers })
|
|
299
|
+
return new Response(head ? null : await body(start, end), { status: 206, headers })
|
|
270
300
|
}
|
|
271
301
|
|
|
272
302
|
// Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
|
|
273
303
|
// disk means buffering the whole representation to serve a request that asked for less of it.
|
|
274
304
|
// RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
|
|
275
305
|
headers.set("content-length", String(size))
|
|
276
|
-
return new Response(head ? null : body(), { headers })
|
|
306
|
+
return new Response(head || size === 0 ? null : await body(0, size - 1), { headers })
|
|
277
307
|
} finally {
|
|
278
308
|
if (handle !== undefined) {
|
|
279
309
|
try {
|
|
@@ -285,3 +315,68 @@ export function servePublicDir(
|
|
|
285
315
|
}
|
|
286
316
|
}
|
|
287
317
|
}
|
|
318
|
+
|
|
319
|
+
export interface PrerenderedPageReaderOptions {
|
|
320
|
+
/** Absolute path of the directory the build wrote the prerendered pages into. */
|
|
321
|
+
readonly dir: string
|
|
322
|
+
/** Each page path mapped to its file, relative to `dir`. */
|
|
323
|
+
readonly files: Readonly<Record<string, string>>
|
|
324
|
+
/** Bytes of HTML kept in memory once read (default 32 MiB); a page past the budget is read per request. */
|
|
325
|
+
readonly cacheBytes?: number
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Read a self-hosted build's prerendered pages for `createWebApp`'s `prerenderedPages`. Only the build's
|
|
330
|
+
* own files are reachable, each opened only once its real path is inside `dir`, so a link planted in the
|
|
331
|
+
* deployed tree cannot point a page elsewhere. A deploy's files do not change while it runs, so a page
|
|
332
|
+
* is kept after its first read; `undefined` (a missing or refused file) renders the page on demand.
|
|
333
|
+
*/
|
|
334
|
+
export function prerenderedPageReader(
|
|
335
|
+
options: PrerenderedPageReaderOptions,
|
|
336
|
+
): (pathname: string) => Promise<string | undefined> {
|
|
337
|
+
const root = resolve(options.dir)
|
|
338
|
+
const rootReal = realpath(root).catch(() => undefined)
|
|
339
|
+
const files = new Map(Object.entries(options.files))
|
|
340
|
+
const budget = options.cacheBytes ?? 32 * 1024 * 1024
|
|
341
|
+
const cache = new Map<string, string>()
|
|
342
|
+
let cachedBytes = 0
|
|
343
|
+
return async (pathname) => {
|
|
344
|
+
const cached = cache.get(pathname)
|
|
345
|
+
if (cached !== undefined) return cached
|
|
346
|
+
const rel = files.get(pathname)
|
|
347
|
+
if (rel === undefined) return undefined
|
|
348
|
+
const abs = resolve(root, rel)
|
|
349
|
+
if (!abs.startsWith(root + sep) || isHiddenPath(root, abs)) return undefined
|
|
350
|
+
const [resolvedRoot, resolvedFile] = await Promise.all([
|
|
351
|
+
rootReal,
|
|
352
|
+
realpath(abs).catch(() => undefined),
|
|
353
|
+
])
|
|
354
|
+
if (
|
|
355
|
+
resolvedRoot === undefined ||
|
|
356
|
+
resolvedFile === undefined ||
|
|
357
|
+
!resolvedFile.startsWith(resolvedRoot + sep)
|
|
358
|
+
) {
|
|
359
|
+
return undefined
|
|
360
|
+
}
|
|
361
|
+
let handle: FileHandle
|
|
362
|
+
try {
|
|
363
|
+
handle = await openResolved(resolvedFile)
|
|
364
|
+
} catch {
|
|
365
|
+
return undefined
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
if (!(await handle.stat()).isFile()) return undefined
|
|
369
|
+
const bytes = await handle.readFile()
|
|
370
|
+
const html = bytes.toString("utf8")
|
|
371
|
+
if (!cache.has(pathname) && cachedBytes + bytes.byteLength <= budget) {
|
|
372
|
+
cache.set(pathname, html)
|
|
373
|
+
cachedBytes += bytes.byteLength
|
|
374
|
+
}
|
|
375
|
+
return html
|
|
376
|
+
} catch {
|
|
377
|
+
return undefined
|
|
378
|
+
} finally {
|
|
379
|
+
await handle.close().catch(() => undefined)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
package/src/render-seam.ts
CHANGED
|
@@ -155,13 +155,16 @@ export interface RenderAdapter {
|
|
|
155
155
|
): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
|
|
156
156
|
/**
|
|
157
157
|
* Render the chain to a complete HTML string when no content is deferred. `options.nonce` is the
|
|
158
|
-
* document's CSP nonce, for any inline script the renderer emits (Solid's hydration data)
|
|
158
|
+
* document's CSP nonce, for any inline script the renderer emits (Solid's hydration data); a
|
|
159
|
+
* document that sends a policy keeps the nonce only when the returned HTML carries it. Return
|
|
160
|
+
* `undefined` when the render suspended on something only `renderToStream` waits for (React's
|
|
161
|
+
* `use` or `lazy` inside `<Suspense>`): the page is then streamed instead.
|
|
159
162
|
*/
|
|
160
163
|
renderToString?(
|
|
161
164
|
chain: readonly unknown[],
|
|
162
165
|
props: RenderProps,
|
|
163
166
|
options?: RenderStreamOptions,
|
|
164
|
-
): string | Promise<string>
|
|
167
|
+
): string | undefined | Promise<string | undefined>
|
|
165
168
|
/** Per-document bootstrap markup injected into `<head>` for client hydration. */
|
|
166
169
|
hydrationHead(nonce?: string): string
|
|
167
170
|
/**
|
package/src/search.ts
CHANGED
|
@@ -107,6 +107,7 @@ function encodeValue(value: unknown): string {
|
|
|
107
107
|
/** The default JSON-first codec. Repeated keys (`?t=a&t=b`) decode to an array; single keys to a value. */
|
|
108
108
|
const jsonCodec: SearchCodec = {
|
|
109
109
|
parse(raw, limits) {
|
|
110
|
+
if (raw === "" || raw === "?") return {}
|
|
110
111
|
const usp = new URLSearchParams(raw.startsWith("?") ? raw.slice(1) : raw)
|
|
111
112
|
// Two passes instead of the old `Set(keys) + getAll(key)` shape, which rescanned the complete
|
|
112
113
|
// URLSearchParams list once per distinct key (O(keys²)). Admission must be decided BEFORE any
|