@rangojs/router 0.0.0-experimental.146 → 0.0.0-experimental.148
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/dist/bin/rango.js +7 -0
- package/dist/vite/index.js +823 -235
- package/package.json +6 -1
- package/skills/mime-routes/SKILL.md +25 -17
- package/skills/ppr/SKILL.md +20 -10
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +11 -2
- package/src/cache/cf/cf-cache-store.ts +60 -2
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/types.ts +14 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/index.rsc.ts +6 -0
- package/src/prerender/build-shell-capture.ts +253 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/content-negotiation.ts +47 -5
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/metrics.ts +17 -2
- package/src/router/prerender-match.ts +21 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +13 -0
- package/src/router.ts +5 -0
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/handler.ts +4 -2
- package/src/rsc/rsc-rendering.ts +131 -23
- package/src/rsc/shell-build-manifest.ts +274 -0
- package/src/rsc/shell-capture.ts +486 -63
- package/src/rsc/shell-serve.ts +44 -0
- package/src/rsc/ssr-setup.ts +54 -22
- package/src/segment-fragments.ts +124 -0
- package/src/server/context.ts +1 -0
- package/src/server/request-context.ts +65 -11
- package/src/ssr/index.tsx +47 -9
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/urls/pattern-types.ts +27 -0
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +397 -0
- package/src/vite/discovery/state.ts +44 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/rango.ts +1 -0
- package/src/vite/router-discovery.ts +310 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -30,11 +30,47 @@ export const SHELL_STATUS_HEADER = "x-rango-shell";
|
|
|
30
30
|
*/
|
|
31
31
|
export const DEFAULT_PPR_TTL_SECONDS = 300;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Timeout for the dev /__rsc_shell endpoint's sequential /__rsc_prerender
|
|
35
|
+
* pre-flight probe (vite/router-discovery.ts). Hoisted here so the client-side
|
|
36
|
+
* fetch bound (shell-build-manifest.ts devShellFetchTimeoutMs) enumerates the
|
|
37
|
+
* SAME term of the endpoint's worst-case envelope — the two cannot drift.
|
|
38
|
+
*/
|
|
39
|
+
export const DEV_SHELL_PROBE_TIMEOUT_MS: number = 10_000;
|
|
40
|
+
|
|
33
41
|
/** The route's ppr option normalized to a concrete policy. */
|
|
34
42
|
export interface ResolvedPprConfig {
|
|
35
43
|
ttl: number;
|
|
36
44
|
swr?: number;
|
|
37
45
|
tags?: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot size cap, passed through undefaulted (like swr/tags): the single
|
|
48
|
+
* defaulting site is captureAndStoreShell (DEFAULT_PPR_MAX_SNAPSHOT_BYTES in
|
|
49
|
+
* shell-capture.ts), so direct descriptor callers and resolved configs
|
|
50
|
+
* cannot drift.
|
|
51
|
+
*/
|
|
52
|
+
maxSnapshotBytes?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Capture settle budget in ms (`ppr.captureTimeout`). Undefined = the
|
|
55
|
+
* capture default (SHELL_CAPTURE_MAX_WAIT_MS, 5000) — the default's single
|
|
56
|
+
* owner stays shell-capture.ts so build/runtime producers cannot drift.
|
|
57
|
+
*/
|
|
58
|
+
captureTimeout?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate the raw `ppr.captureTimeout` option: a finite number >= 1ms passes
|
|
63
|
+
* through; anything else (including 0/negative/NaN/Infinity/non-number)
|
|
64
|
+
* resolves to undefined, which means "use the capture default" downstream.
|
|
65
|
+
* Mirrors the prefetch-limit option policy: invalid values silently fall back
|
|
66
|
+
* to the default rather than throwing at request time. Also the boundary
|
|
67
|
+
* re-normalizer for the dev /__rsc_shell endpoint (vite/router-discovery.ts),
|
|
68
|
+
* whose param crossed an HTTP query string.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeCaptureTimeout(value: unknown): number | undefined {
|
|
71
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
72
|
+
? value
|
|
73
|
+
: undefined;
|
|
38
74
|
}
|
|
39
75
|
|
|
40
76
|
/**
|
|
@@ -42,6 +78,12 @@ export interface ResolvedPprConfig {
|
|
|
42
78
|
* route does not declare `ppr` (or declares `ppr: false`) — the caller then does
|
|
43
79
|
* NOTHING: no store read, no capture, no logs. Pure axis 1, zero cost.
|
|
44
80
|
*
|
|
81
|
+
* The route's NAME is irrelevant here (and everywhere on the shell lane):
|
|
82
|
+
* nameless `path()` routes register their EntryData under a synthesized
|
|
83
|
+
* `$path_*` manifest key with the `ppr` option intact (urls/path-helper.ts),
|
|
84
|
+
* so a nameless entry resolves exactly like a named one — pinned by the
|
|
85
|
+
* nameless-ppr e2e in both apps (issue #714).
|
|
86
|
+
*
|
|
45
87
|
* PPR is a DOCUMENT-level property of the page route; there is no subtree
|
|
46
88
|
* inheritance (declaring it on a layout is not supported — a follow-up).
|
|
47
89
|
*/
|
|
@@ -56,6 +98,8 @@ export function resolvePprConfig(
|
|
|
56
98
|
ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
|
|
57
99
|
swr: ppr.swr,
|
|
58
100
|
tags: ppr.tags,
|
|
101
|
+
maxSnapshotBytes: ppr.maxSnapshotBytes,
|
|
102
|
+
captureTimeout: normalizeCaptureTimeout(ppr.captureTimeout),
|
|
59
103
|
};
|
|
60
104
|
}
|
|
61
105
|
|
package/src/rsc/ssr-setup.ts
CHANGED
|
@@ -11,6 +11,11 @@ import type { SSRModule } from "./types.js";
|
|
|
11
11
|
import type { SSRStreamMode } from "../router/router-options.js";
|
|
12
12
|
import type { MetricsStore } from "../server/context.js";
|
|
13
13
|
import { appendMetric } from "../router/metrics.js";
|
|
14
|
+
import {
|
|
15
|
+
parseAcceptTypes,
|
|
16
|
+
prefersFlightRepresentation,
|
|
17
|
+
RSC_WIRE_MIME,
|
|
18
|
+
} from "../router/content-negotiation.js";
|
|
14
19
|
import { _getRequestContext } from "../server/request-context.js";
|
|
15
20
|
|
|
16
21
|
export type SSRSetup = readonly [SSRModule, SSRStreamMode];
|
|
@@ -90,12 +95,42 @@ export function getSSRSetup<TEnv>(
|
|
|
90
95
|
);
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Accept-based flight opt-in: the client explicitly listed the RSC wire
|
|
100
|
+
* format (text/x-component) in Accept, ranked above the HTML document, and
|
|
101
|
+
* did not override with __html.
|
|
102
|
+
*
|
|
103
|
+
* The flight stream is an internal transport representation — it is served
|
|
104
|
+
* ONLY on explicit opt-in (this Accept value, or the _rsc_ / __rsc transport
|
|
105
|
+
* params). Everything else (missing Accept, wildcards, application/json,
|
|
106
|
+
* browser Accept strings) gets the HTML document, per RFC 9110: a missing
|
|
107
|
+
* Accept is equivalent to a full wildcard, and a wildcard gets the server's
|
|
108
|
+
* canonical representation. The old rule ("no text/html substring → flight")
|
|
109
|
+
* handed the wire format to every generic client — curl, health checks,
|
|
110
|
+
* link unfurlers.
|
|
111
|
+
*
|
|
112
|
+
* The includes() guard is a parse-skipping fast path: the bulk of traffic
|
|
113
|
+
* (browsers, curl, monitors) never mentions the wire format and pays no
|
|
114
|
+
* parseAcceptTypes allocation. Ranking lives in prefersFlightRepresentation
|
|
115
|
+
* (router/content-negotiation.ts), co-located with the candidate MIME set.
|
|
116
|
+
*/
|
|
117
|
+
function acceptsFlightExplicitly(request: Request, url: URL): boolean {
|
|
118
|
+
if (url.searchParams.has("__html")) return false;
|
|
119
|
+
const accept = request.headers.get("accept");
|
|
120
|
+
if (accept === null || !accept.includes(RSC_WIRE_MIME)) return false;
|
|
121
|
+
return prefersFlightRepresentation(parseAcceptTypes(accept));
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
/**
|
|
94
125
|
* Classify whether a request may require SSR (HTML rendering).
|
|
95
126
|
*
|
|
96
|
-
* Returns false for requests that are definitively RSC-only
|
|
97
|
-
* prerender collection, or
|
|
98
|
-
*
|
|
127
|
+
* Returns false for requests that are definitively RSC-only: transport
|
|
128
|
+
* params (partial/action/loader/__rsc), prerender collection, or an explicit
|
|
129
|
+
* Accept: text/x-component. Must never return false for a request whose
|
|
130
|
+
* render-time decision (isRscRequest) will be HTML — the two share
|
|
131
|
+
* acceptsFlightExplicitly so the Accept rule cannot drift. document-cache.ts
|
|
132
|
+
* keys its HTML/RSC response slots off this function, so any divergence from
|
|
133
|
+
* the render decision poisons a cache slot with the wrong representation.
|
|
99
134
|
*
|
|
100
135
|
* Note: response/mime routes are excluded by the caller — this function
|
|
101
136
|
* runs after classifyRequest() determines the request mode.
|
|
@@ -112,24 +147,21 @@ export function mayNeedSSR(request: Request, url: URL): boolean {
|
|
|
112
147
|
return false;
|
|
113
148
|
}
|
|
114
149
|
|
|
115
|
-
|
|
116
|
-
// if Accept is present and does not include text/html (and no __html override),
|
|
117
|
-
// the response will be RSC, not HTML.
|
|
118
|
-
const accept = request.headers.get("accept");
|
|
119
|
-
if (
|
|
120
|
-
accept &&
|
|
121
|
-
!accept.includes("text/html") &&
|
|
122
|
-
!url.searchParams.has("__html")
|
|
123
|
-
) {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return true;
|
|
150
|
+
return !acceptsFlightExplicitly(request, url);
|
|
128
151
|
}
|
|
129
152
|
|
|
130
|
-
// Final render-time decision: is the response an RSC stream (vs HTML)?
|
|
131
|
-
//
|
|
132
|
-
// Accept
|
|
153
|
+
// Final render-time decision: is the response an RSC stream (vs HTML)?
|
|
154
|
+
// Flight requires explicit opt-in: the partial transport param, __rsc, or
|
|
155
|
+
// Accept: text/x-component. mayNeedSSR is the coarse pre-filter over the
|
|
156
|
+
// transport params; both delegate the Accept call to acceptsFlightExplicitly.
|
|
157
|
+
//
|
|
158
|
+
// _rsc_partial is read from the URL in addition to the plan-derived isPartial
|
|
159
|
+
// flag: the 404 fallback plan hardcodes mode "full-render" even for partial
|
|
160
|
+
// navigations (handler.ts RouteNotFoundError catch), so a partial 404 reaches
|
|
161
|
+
// this decision with isPartial=false. The old Accept rule masked that by
|
|
162
|
+
// classifying */* as flight; without the URL check a client-side navigation
|
|
163
|
+
// to a missing route received an HTML 404 it cannot apply, and the
|
|
164
|
+
// navigation never committed (multi-router soft-404, popstate not-found).
|
|
133
165
|
export function isRscRequest(
|
|
134
166
|
request: Request,
|
|
135
167
|
url: URL,
|
|
@@ -137,8 +169,8 @@ export function isRscRequest(
|
|
|
137
169
|
): boolean {
|
|
138
170
|
return (
|
|
139
171
|
isPartial ||
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
url
|
|
172
|
+
url.searchParams.has("_rsc_partial") ||
|
|
173
|
+
url.searchParams.has("__rsc") ||
|
|
174
|
+
acceptsFlightExplicitly(request, url)
|
|
143
175
|
);
|
|
144
176
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment Flight-fragment envelopes (PPR fast-path payload splice, issue #700).
|
|
3
|
+
*
|
|
4
|
+
* On a shell-HIT tail, replayed (baked) segments do NOT round-trip through the
|
|
5
|
+
* server codec (deserialize -> re-serialize) per request. The stored RSC
|
|
6
|
+
* fragment STRINGS ride the outgoing payload verbatim inside these envelopes
|
|
7
|
+
* — the outer Flight render copies a string instead of re-serializing a whole
|
|
8
|
+
* element tree — and the CONSUMER expands each envelope through its own Flight
|
|
9
|
+
* deserializer: the SSR resume pass (ssr/ssr-root.tsx) and browser hydration
|
|
10
|
+
* (browser/rsc-router.tsx). Each fragment is its own row space, decoded
|
|
11
|
+
* independently, exactly like the segment codec's per-record decode — so
|
|
12
|
+
* there is no Flight row-id collision or shared-row dedupe hazard.
|
|
13
|
+
*
|
|
14
|
+
* Envelopes appear ONLY on fields that hold ReactNodes (component / layout /
|
|
15
|
+
* loading). A plain object is never a valid ReactNode, so the marker cannot
|
|
16
|
+
* collide with legitimate segment content; loader data fields (consumer data,
|
|
17
|
+
* any shape) are never enveloped.
|
|
18
|
+
*
|
|
19
|
+
* This module is shared client/server code (browser + SSR + RSC import it) —
|
|
20
|
+
* it must stay dependency-free: no plugin-rsc, no request-context.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ResolvedSegment } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One RSC-encoded fragment traveling inside an RscPayload segment field.
|
|
27
|
+
* `f` is the stored Flight document (segment-codec output) for that field.
|
|
28
|
+
*/
|
|
29
|
+
export interface SegmentFlightFragment {
|
|
30
|
+
__rangoFragment: 1;
|
|
31
|
+
f: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap a stored fragment string for the wire. */
|
|
35
|
+
export function segmentFragment(encoded: string): SegmentFlightFragment {
|
|
36
|
+
return { __rangoFragment: 1, f: encoded };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True iff `value` is a fragment envelope (see collision note in the header). */
|
|
40
|
+
export function isSegmentFragment(
|
|
41
|
+
value: unknown,
|
|
42
|
+
): value is SegmentFlightFragment {
|
|
43
|
+
return (
|
|
44
|
+
typeof value === "object" &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
(value as { __rangoFragment?: unknown }).__rangoFragment === 1 &&
|
|
47
|
+
typeof (value as { f?: unknown }).f === "string"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A consumer-side Flight deserializer: the browser's or the SSR runtime's
|
|
53
|
+
* createFromReadableStream (each resolves client references through its own
|
|
54
|
+
* module map, exactly as it does for the outer payload stream). Non-generic
|
|
55
|
+
* so both environments' generic createFromReadableStream signatures assign
|
|
56
|
+
* directly.
|
|
57
|
+
*/
|
|
58
|
+
export type FragmentDecoder = (
|
|
59
|
+
stream: ReadableStream<Uint8Array>,
|
|
60
|
+
) => Promise<unknown>;
|
|
61
|
+
|
|
62
|
+
/** One-chunk byte stream over an encoded fragment (local: this module must not
|
|
63
|
+
* import segment-codec, which pulls plugin-rsc into client bundles). */
|
|
64
|
+
function fragmentToStream(encoded: string): ReadableStream<Uint8Array> {
|
|
65
|
+
const bytes = new TextEncoder().encode(encoded);
|
|
66
|
+
return new ReadableStream<Uint8Array>({
|
|
67
|
+
start(controller) {
|
|
68
|
+
controller.enqueue(bytes);
|
|
69
|
+
controller.close();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The segment fields that may carry an envelope (ReactNode fields only). */
|
|
75
|
+
const FRAGMENT_FIELDS = ["component", "layout", "loading"] as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Expand every fragment envelope in `segments` IN PLACE via `decode`,
|
|
79
|
+
* in parallel. A payload with no envelopes (every non-shell-HIT payload) costs
|
|
80
|
+
* one synchronous field scan. A failing fragment decode rejects the whole
|
|
81
|
+
* expansion: the caller's payload promise rejects, which on the SSR side
|
|
82
|
+
* errors the HIT tail (serveShellHit then schedules the healing recapture) —
|
|
83
|
+
* the same posture as a parseable-but-mismatched postponed blob.
|
|
84
|
+
*/
|
|
85
|
+
export async function expandSegmentFragments(
|
|
86
|
+
segments: ResolvedSegment[] | undefined,
|
|
87
|
+
decode: FragmentDecoder,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (!segments || segments.length === 0) return;
|
|
90
|
+
const decodes: Promise<void>[] = [];
|
|
91
|
+
for (const segment of segments) {
|
|
92
|
+
for (const field of FRAGMENT_FIELDS) {
|
|
93
|
+
const value = segment[field];
|
|
94
|
+
if (isSegmentFragment(value)) {
|
|
95
|
+
// Promise.resolve() adoption is load-bearing: some deserializers
|
|
96
|
+
// (react-server-dom's Flight client) return a THENABLE Chunk whose
|
|
97
|
+
// .then returns undefined — chaining directly would push undefined
|
|
98
|
+
// into the barrier and Promise.all would not wait for the decode.
|
|
99
|
+
decodes.push(
|
|
100
|
+
Promise.resolve(decode(fragmentToStream(value.f))).then((node) => {
|
|
101
|
+
segment[field] = node as ResolvedSegment[typeof field];
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (decodes.length > 0) await Promise.all(decodes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True when any segment carries an unexpanded envelope. Cheap scan used by
|
|
112
|
+
* consumers that only need to know whether an expansion pass is required.
|
|
113
|
+
*/
|
|
114
|
+
export function hasSegmentFragments(
|
|
115
|
+
segments: ResolvedSegment[] | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
if (!segments) return false;
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
for (const field of FRAGMENT_FIELDS) {
|
|
120
|
+
if (isSegmentFragment(segment[field])) return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface PerformanceMetric {
|
|
|
27
27
|
duration: number; // milliseconds
|
|
28
28
|
startTime: number; // relative to request start
|
|
29
29
|
depth?: number; // nesting level for hierarchical display (0 = top-level)
|
|
30
|
+
desc?: string; // free-form outcome detail, emitted as Server-Timing desc="..."
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -228,6 +228,19 @@ export interface RequestContext<
|
|
|
228
228
|
store?: SegmentCacheStore;
|
|
229
229
|
};
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* @internal Shell-HIT tail marker: cache/prerender-store hits during THIS
|
|
233
|
+
* render emit stored segment fragments VERBATIM into the payload
|
|
234
|
+
* (segment-codec fragmentSegments) instead of deserialize -> re-serialize
|
|
235
|
+
* per request; the payload consumers (SSR resume + browser hydration)
|
|
236
|
+
* expand them (segment-fragments.ts, issue #700). Own property of
|
|
237
|
+
* serveShellHit's derived tail context ONLY — it must never be visible to a
|
|
238
|
+
* capture render: the capture SSR-prerenders the payload AND serializes
|
|
239
|
+
* segments into records (cacheRoute), and an envelope reaching
|
|
240
|
+
* serializeSegments would store a double-encoded fragment.
|
|
241
|
+
*/
|
|
242
|
+
_shellFragmentPayload?: boolean;
|
|
243
|
+
|
|
231
244
|
/**
|
|
232
245
|
* @internal Handler-layer liveness observed DURING a shell capture, from
|
|
233
246
|
* three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
|
|
@@ -575,6 +588,7 @@ export type PublicRequestContext<
|
|
|
575
588
|
| "_transitionWhen"
|
|
576
589
|
| "_cacheStore"
|
|
577
590
|
| "_shellCaptureRun"
|
|
591
|
+
| "_shellFragmentPayload"
|
|
578
592
|
| "_shellCaptureGuardTrippedLoaderId"
|
|
579
593
|
| "_explicitTaggedStores"
|
|
580
594
|
| "_requestTags"
|
|
@@ -1105,10 +1119,56 @@ export function createRequestContext<TEnv>(
|
|
|
1105
1119
|
reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
|
|
1106
1120
|
};
|
|
1107
1121
|
|
|
1108
|
-
|
|
1122
|
+
wireRenderBarrier(ctx, handleStore);
|
|
1123
|
+
|
|
1124
|
+
ctx.use = createUseFunction({
|
|
1125
|
+
handleStore,
|
|
1126
|
+
loaderPromises,
|
|
1127
|
+
getContext: () => ctx,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
1131
|
+
return ctx;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Wire a fresh render barrier onto `ctx`, closure-bound to THIS ctx and THIS
|
|
1136
|
+
* handle store. Called by createRequestContext for every fresh context, and by
|
|
1137
|
+
* deriveShellCaptureContext (rsc/shell-capture.ts) for the PPR capture's
|
|
1138
|
+
* derived context.
|
|
1139
|
+
*
|
|
1140
|
+
* The derived-context call is load-bearing (issue #684, plan 009): the capture
|
|
1141
|
+
* context is `Object.create(reqCtx)`, so without its own wiring every
|
|
1142
|
+
* `_renderBarrier*` read fell through the prototype to the FOREGROUND
|
|
1143
|
+
* request's barrier — whose getter and resolver are closure-bound to the
|
|
1144
|
+
* foreground ctx and its handle store, and whose resolver no-ops once
|
|
1145
|
+
* resolved. A bake-lane loader's `await ctx.rendered()` during capture then
|
|
1146
|
+
* resolved instantly against the foreground's barrier and `ctx.use(handle)`
|
|
1147
|
+
* read the foreground's handle snapshot; the capture's fresh `_handleStore`
|
|
1148
|
+
* was invisible, so foreground per-request handle data could bake into the
|
|
1149
|
+
* shared shell.
|
|
1150
|
+
*/
|
|
1151
|
+
export function wireRenderBarrier(
|
|
1152
|
+
ctx: RequestContext<any, any>,
|
|
1153
|
+
handleStore: HandleStore,
|
|
1154
|
+
): void {
|
|
1155
|
+
// Reset the whole barrier family as OWN properties. No-op for a fresh
|
|
1156
|
+
// context; for the derived capture context this shadows the foreground's
|
|
1157
|
+
// resolved state so the capture runs its own barrier lifecycle. In
|
|
1158
|
+
// particular _treeHasStreaming must be recomputed for the CAPTURE's tree
|
|
1159
|
+
// (cache-lookup/segment-resolution only set it when undefined): an
|
|
1160
|
+
// inherited `true` made a capture-lane rendered() seal the capture's fresh
|
|
1161
|
+
// store at loader start and pair it with the foreground's segment order.
|
|
1162
|
+
ctx._renderBarrierSegmentOrder = undefined;
|
|
1163
|
+
ctx._renderBarrierWaiters = undefined;
|
|
1164
|
+
ctx._renderBarrierHandleSnapshot = undefined;
|
|
1165
|
+
ctx._renderBarrierGuardClosed = undefined;
|
|
1166
|
+
ctx._handlerLoaderDeps = undefined;
|
|
1167
|
+
ctx._treeHasStreaming = undefined;
|
|
1168
|
+
|
|
1169
|
+
// Lazy allocation: only create the Promise when a loader calls rendered().
|
|
1109
1170
|
let barrierResolved = false;
|
|
1110
1171
|
let resolveBarrier: (() => void) | undefined;
|
|
1111
|
-
ctx._renderBarrier = null as any;
|
|
1112
1172
|
ctx._resolveRenderBarrier = (
|
|
1113
1173
|
segments: Array<{ type: string; id: string }>,
|
|
1114
1174
|
) => {
|
|
@@ -1136,6 +1196,9 @@ export function createRequestContext<TEnv>(
|
|
|
1136
1196
|
}
|
|
1137
1197
|
if (resolveBarrier) resolveBarrier();
|
|
1138
1198
|
};
|
|
1199
|
+
// defineProperty, not assignment: on a derived context the prototype's
|
|
1200
|
+
// _renderBarrier may already be a non-writable data property (the getter
|
|
1201
|
+
// pins it after first access), which would reject a plain assignment.
|
|
1139
1202
|
Object.defineProperty(ctx, "_renderBarrier", {
|
|
1140
1203
|
get() {
|
|
1141
1204
|
const p = barrierResolved
|
|
@@ -1152,15 +1215,6 @@ export function createRequestContext<TEnv>(
|
|
|
1152
1215
|
},
|
|
1153
1216
|
configurable: true,
|
|
1154
1217
|
});
|
|
1155
|
-
|
|
1156
|
-
ctx.use = createUseFunction({
|
|
1157
|
-
handleStore,
|
|
1158
|
-
loaderPromises,
|
|
1159
|
-
getContext: () => ctx,
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
1163
|
-
return ctx;
|
|
1164
1218
|
}
|
|
1165
1219
|
|
|
1166
1220
|
// Capture the Max-Age value so it can be parsed numerically. A leading zero
|
package/src/ssr/index.tsx
CHANGED
|
@@ -201,13 +201,16 @@ const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
|
|
|
201
201
|
* cached segments that are ALREADY serialized, so it emits the whole shell payload
|
|
202
202
|
* in the first tick and the gate declares quiesce almost immediately (~a few ms).
|
|
203
203
|
* On the old fresh-execution path the Flight dribbled out as handlers ran, so
|
|
204
|
-
* Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed.
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
204
|
+
* Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed.
|
|
205
|
+
*
|
|
206
|
+
* Hops alone are NOT render-readiness: fizz cannot emit even <html> until the
|
|
207
|
+
* payload root settles, which waits on every referenced client-module LOAD —
|
|
208
|
+
* real module-runner I/O in dev (100ms+ cold), which no fixed count of near-
|
|
209
|
+
* zero-cost task hops can buy. captureShellHTML therefore awaits the payload-
|
|
210
|
+
* settled signal (SsrRootOptions.onPayloadSettled, deadline-bounded) between
|
|
211
|
+
* quiesce and these hops; the hops then only flush the settled tree and mark
|
|
212
|
+
* pending boundaries POSTPONED. Still task-based (masked loaders never emit,
|
|
213
|
+
* so more hops never lets a hole settle). Bounded by maxWaitMs end to end.
|
|
211
214
|
*/
|
|
212
215
|
const POST_QUIESCE_TASK_HOPS = 16;
|
|
213
216
|
|
|
@@ -517,9 +520,18 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
517
520
|
const deadline = createCancelableTimeout(maxWaitMs);
|
|
518
521
|
try {
|
|
519
522
|
// No nonce (nonce'd requests never reach capture); no formState.
|
|
523
|
+
// payloadSettled: fires when the Flight payload root settles — i.e.
|
|
524
|
+
// every client-module load the payload references completed and fizz
|
|
525
|
+
// can actually emit the tree. The abort below gates on it (bounded by
|
|
526
|
+
// the same deadline): Flight byte-quiet alone is NOT render-readiness.
|
|
527
|
+
let settlePayload!: () => void;
|
|
528
|
+
const payloadSettled = new Promise<void>((resolve) => {
|
|
529
|
+
settlePayload = resolve;
|
|
530
|
+
});
|
|
520
531
|
const SsrRoot = createSsrRootComponent({
|
|
521
532
|
createFromReadableStream,
|
|
522
533
|
rscStream,
|
|
534
|
+
onPayloadSettled: settlePayload,
|
|
523
535
|
});
|
|
524
536
|
|
|
525
537
|
// Bootstrap load raced against the deadline. A load that never resolves
|
|
@@ -588,10 +600,36 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
588
600
|
// wall-clock debounce here — maxWaitMs is only the pathological guard for a
|
|
589
601
|
// shell that never goes quiet (a root postpone / hung handle).
|
|
590
602
|
await Promise.race([opts.quiesce, deadline.promise]);
|
|
603
|
+
// Then wait for fizz RENDER-READINESS, bounded by the same deadline:
|
|
604
|
+
// the payload root settles only after every client-module load the
|
|
605
|
+
// payload references completed (real module-runner I/O in dev; 100ms+
|
|
606
|
+
// on a cold graph). Flight byte-quiet does NOT imply this — a fully
|
|
607
|
+
// REPLAYED (prerendered) route's Flight stream finishes in ~1-3ms and
|
|
608
|
+
// an abort taken on quiet-plus-task-hops alone landed BEFORE fizz could
|
|
609
|
+
// emit <html>, freezing a zero-byte prelude: the eternal-MISS shape
|
|
610
|
+
// this route class showed on every cold graph (dev cold boots, GH
|
|
611
|
+
// runners) while ordinary routes — whose live handler execution keeps
|
|
612
|
+
// Flight noisy long enough — never hit it. Masked-loader holes do not
|
|
613
|
+
// block payload settlement (they postpone below the root), so this
|
|
614
|
+
// await costs a genuinely hole-y shell nothing; a payload that NEVER
|
|
615
|
+
// settles (hung handles) degrades at the deadline exactly as before.
|
|
616
|
+
// A prerender that SETTLES first (early success or a hard rejection)
|
|
617
|
+
// ends the wait immediately — fizz is already done either way.
|
|
618
|
+
const prerenderSettled = prerenderPromise.then(
|
|
619
|
+
() => {},
|
|
620
|
+
() => {},
|
|
621
|
+
);
|
|
622
|
+
await Promise.race([payloadSettled, prerenderSettled, deadline.promise]);
|
|
591
623
|
// Fixed task hops before the abort: give React's fizz worker turns to flush
|
|
592
624
|
// the now-complete shell and mark the still-pending boundaries as POSTPONED
|
|
593
|
-
// rather than errored. Deterministic (the byte set is already frozen
|
|
594
|
-
// fixed count of turns suffices — no
|
|
625
|
+
// rather than errored. Deterministic (the byte set is already frozen and
|
|
626
|
+
// the payload is settled), so a fixed count of turns suffices — no
|
|
627
|
+
// wall-clock. Ready-but-queued fizz render work (however large — a multi-MB
|
|
628
|
+
// outlined boundary) can never lose this race: tracked-postpones pings run
|
|
629
|
+
// on scheduleMicrotask, so every runnable task drains before the FIRST
|
|
630
|
+
// setTimeout hop fires; only tasks parked on genuinely pending promises
|
|
631
|
+
// (masked loaders, real per-request I/O) remain, and those are exactly the
|
|
632
|
+
// holes that must postpone (issue #702).
|
|
595
633
|
for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
|
|
596
634
|
await macrotask();
|
|
597
635
|
}
|
package/src/ssr/ssr-root.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { expandSegmentFragments } from "../segment-fragments.js";
|
|
2
3
|
import { renderSegments } from "../segment-system.js";
|
|
3
4
|
import {
|
|
4
5
|
filterSegmentOrder,
|
|
@@ -128,6 +129,18 @@ export interface SsrRootOptions {
|
|
|
128
129
|
rscStream: ReadableStream<Uint8Array>;
|
|
129
130
|
/** Nonce for CSP; propagated to NonceContext. */
|
|
130
131
|
nonce?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Fires once when the Flight payload root settles (resolve OR reject) — the
|
|
134
|
+
* signal that every client-module load the payload references completed and
|
|
135
|
+
* fizz can start emitting the tree. The capture pass gates its abort on
|
|
136
|
+
* this: a fully REPLAYED (prerendered) route's Flight stream goes
|
|
137
|
+
* byte-quiet in ~1-3ms, but fizz cannot render even <html> until the
|
|
138
|
+
* module loads finish (real module-runner I/O in dev; 100ms+ on a cold
|
|
139
|
+
* graph), so an abort gated on Flight quiet alone fires first and freezes
|
|
140
|
+
* a zero-byte prelude. Masked-loader holes do NOT block this signal —
|
|
141
|
+
* they postpone below the root.
|
|
142
|
+
*/
|
|
143
|
+
onPayloadSettled?: () => void;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/**
|
|
@@ -148,7 +161,7 @@ export interface SsrRootOptions {
|
|
|
148
161
|
* re-running the whole segment-tree build unless the promise is memoized.
|
|
149
162
|
*/
|
|
150
163
|
export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
151
|
-
const { createFromReadableStream, rscStream, nonce } = opts;
|
|
164
|
+
const { createFromReadableStream, rscStream, nonce, onPayloadSettled } = opts;
|
|
152
165
|
|
|
153
166
|
let payload: Promise<RscPayload> | undefined;
|
|
154
167
|
let handlesPromise: Promise<HandleData> | undefined;
|
|
@@ -156,7 +169,27 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
|
156
169
|
let rootPromise: Promise<React.ReactNode> | undefined;
|
|
157
170
|
|
|
158
171
|
return function SsrRoot() {
|
|
159
|
-
payload
|
|
172
|
+
if (payload === undefined) {
|
|
173
|
+
// Shell-HIT tails carry replayed segments as VERBATIM stored fragments
|
|
174
|
+
// (segment-fragments.ts, issue #700); expand them through this
|
|
175
|
+
// environment's deserializer before anything reads the segments. Every
|
|
176
|
+
// other payload (full render, capture, actions) has no envelopes and
|
|
177
|
+
// pays one field scan. onPayloadSettled fires AFTER expansion: the
|
|
178
|
+
// capture's fizz-readiness gate must include fragment module loads.
|
|
179
|
+
// Promise.resolve() adoption is load-bearing: some wirings (the build
|
|
180
|
+
// temp server's vendored Flight client) return a THENABLE Chunk whose
|
|
181
|
+
// .then returns undefined — chaining on it directly yields undefined.
|
|
182
|
+
payload = Promise.resolve(
|
|
183
|
+
createFromReadableStream<RscPayload>(rscStream),
|
|
184
|
+
).then(async (resolvedPayload) => {
|
|
185
|
+
await expandSegmentFragments(
|
|
186
|
+
resolvedPayload.metadata?.segments,
|
|
187
|
+
createFromReadableStream,
|
|
188
|
+
);
|
|
189
|
+
return resolvedPayload;
|
|
190
|
+
});
|
|
191
|
+
if (onPayloadSettled) payload.then(onPayloadSettled, onPayloadSettled);
|
|
192
|
+
}
|
|
160
193
|
const resolved = React.use(payload);
|
|
161
194
|
|
|
162
195
|
const themeConfig = resolved.metadata?.themeConfig ?? null;
|
|
@@ -62,6 +62,33 @@ export interface PartialPrerenderProps {
|
|
|
62
62
|
* capture render auto-collects (the shell's own non-loader request tags).
|
|
63
63
|
*/
|
|
64
64
|
tags?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Upper bound (serialized UTF-8 bytes) on the capture data snapshot riding
|
|
67
|
+
* inside the shell entry. The snapshot duplicates every cache-store value
|
|
68
|
+
* the capture pinned, so a page over a large cache() segment can push the
|
|
69
|
+
* entry toward store value limits (Cloudflare KV caps a value at 25 MiB).
|
|
70
|
+
* Over the cap the snapshot is skipped: the shell is still stored and
|
|
71
|
+
* served, but pinned reads fall back to the live store, so drifted cached
|
|
72
|
+
* content can hydration-mismatch and be repaired client-side (the
|
|
73
|
+
* pre-snapshot behavior). Reported once per key. Defaults to 8 MiB.
|
|
74
|
+
*/
|
|
75
|
+
maxSnapshotBytes?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Capture settle budget in MILLISECONDS (default 5000). Bounds the whole
|
|
78
|
+
* background capture: the wait for deferred shell material — top-level
|
|
79
|
+
* pushed handle promises (`ctx.use(Meta)(promise.then(...))` and friends)
|
|
80
|
+
* are AWAITED and their settled values baked into the stored shell — AND
|
|
81
|
+
* the fizz prerender deadline. Declare it when a route's shell material
|
|
82
|
+
* takes longer than 5s to settle. A budget that expires with pushes still
|
|
83
|
+
* pending REFUSES the capture (the route stays MISS with the once-per-key
|
|
84
|
+
* warning) — a shell with missing head material is never stored. Capture
|
|
85
|
+
* is background work (waitUntil), so a longer budget costs latency-to-HIT
|
|
86
|
+
* only, never a served response; the platform waitUntil lifetime (workerd:
|
|
87
|
+
* ~30s past response completion) is the physical ceiling. Build-time
|
|
88
|
+
* captures (Prerender+ppr, producer B) honor the same budget with no
|
|
89
|
+
* platform ceiling. Non-finite or sub-1ms values fall back to the default.
|
|
90
|
+
*/
|
|
91
|
+
captureTimeout?: number;
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
export interface PathOptions<
|
|
@@ -381,6 +381,33 @@ export async function discoverRouters(
|
|
|
381
381
|
state.perRouterTrieMap = newPerRouterTrieMap;
|
|
382
382
|
state.mergedRouteTrie = newMergedRouteTrie;
|
|
383
383
|
|
|
384
|
+
// Install the route tries into the RSC realm BEFORE prerender collection.
|
|
385
|
+
// matchForPrerender resolves each enumerated URL via findMatch, and without
|
|
386
|
+
// a trie findMatch silently falls back to the insertion-order regex matcher
|
|
387
|
+
// — a root `path("/*")` declared before a nested static route then wins the
|
|
388
|
+
// match, and the artifact bakes the CATCH-ALL page under `catchAll/<hash>`
|
|
389
|
+
// while runtime (trie-ranked: wildcard last) matches the real route and
|
|
390
|
+
// misses the manifest — wrong-content bake for plain Prerender routes, a
|
|
391
|
+
// guaranteed 404 once handler eviction runs. Dev never hits this because
|
|
392
|
+
// propagateDiscoveryState (router-discovery.ts) pushes the same setters on
|
|
393
|
+
// every discovery/HMR pass; configureServer early-returns in build mode, so
|
|
394
|
+
// collection was the one findMatch consumer running trieless. Mirrors the
|
|
395
|
+
// dev perRouterSetters loop; deliberately does NOT markRouterTrieAuthoritative
|
|
396
|
+
// so a genuine trie gap keeps the regex fallback, exactly as in dev.
|
|
397
|
+
if (serverMod.setRouteTrie && newMergedRouteTrie) {
|
|
398
|
+
serverMod.setRouteTrie(newMergedRouteTrie);
|
|
399
|
+
}
|
|
400
|
+
const perRouterSetters: Array<[Map<string, unknown>, string]> = [
|
|
401
|
+
[newPerRouterManifestDataMap, "setRouterManifest"],
|
|
402
|
+
[newPerRouterTrieMap, "setRouterTrie"],
|
|
403
|
+
[newPerRouterPrecomputedMap, "setRouterPrecomputedEntries"],
|
|
404
|
+
];
|
|
405
|
+
for (const [map, fn] of perRouterSetters) {
|
|
406
|
+
const setter = serverMod[fn];
|
|
407
|
+
if (typeof setter !== "function") continue;
|
|
408
|
+
for (const [routerId, value] of map) setter(routerId, value);
|
|
409
|
+
}
|
|
410
|
+
|
|
384
411
|
// Expand prerender routes and render static handlers (build mode only)
|
|
385
412
|
await expandPrerenderRoutes(state, rscEnv, registry, allManifests);
|
|
386
413
|
await renderStaticHandlers(state, rscEnv, registry);
|
|
@@ -281,6 +281,22 @@ export async function expandPrerenderRoutes(
|
|
|
281
281
|
"__pr",
|
|
282
282
|
mainValue,
|
|
283
283
|
);
|
|
284
|
+
// Prerender + ppr composition: flag the URL as a build-time shell
|
|
285
|
+
// candidate for the post-build capture phase (producer B, #699).
|
|
286
|
+
// The payload JSON is retained in memory so that phase can seed an
|
|
287
|
+
// in-realm prerender store the capture's match() will HIT.
|
|
288
|
+
if (result.ppr !== undefined && result.ppr !== false) {
|
|
289
|
+
(state.shellCandidates ??= []).push({
|
|
290
|
+
urlPath: entry.urlPath,
|
|
291
|
+
routeName: result.routeName,
|
|
292
|
+
paramHash,
|
|
293
|
+
ppr: result.ppr === true ? true : result.ppr,
|
|
294
|
+
});
|
|
295
|
+
(state.prerenderPayloadValues ??= new Map()).set(
|
|
296
|
+
mainKey,
|
|
297
|
+
mainValue,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
284
300
|
if (result.interceptSegments?.length) {
|
|
285
301
|
const interceptKey = `${result.routeName}/${paramHash}/i`;
|
|
286
302
|
const interceptValue = JSON.stringify({
|