@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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer B: build-time PPR shell capture for Prerender+ppr routes (#699).
|
|
3
|
+
*
|
|
4
|
+
* Runs in the RSC realm of the build's temp server, AFTER all bundles are
|
|
5
|
+
* written (the prelude embeds built client asset URLs — bootstrap module,
|
|
6
|
+
* chunk preloads — that only exist post-client-build). The capture core is
|
|
7
|
+
* producer A's, verbatim: deriveShellCaptureContext (mask funnel, liveness,
|
|
8
|
+
* snapshot recording, implicit doc-cache scope) + captureAndStoreShell (gates,
|
|
9
|
+
* quiesce, tags union, putShell barrier). The differences are only the base
|
|
10
|
+
* context (a synthetic build request created via createRequestContext over the
|
|
11
|
+
* build env — no ambient identity, so the identity guard is trivially
|
|
12
|
+
* satisfied) and the sink (an entry collector instead of a runtime store).
|
|
13
|
+
*
|
|
14
|
+
* The capture's match() re-enters withCacheLookup, HITs the in-realm prerender
|
|
15
|
+
* store seeded from the just-collected Flight payloads, and REPLAYS the
|
|
16
|
+
* build-time segments — no handler execution, exactly the runtime composition
|
|
17
|
+
* path (#697). Live-lane loaders mask into holes; bake-lane loaders execute
|
|
18
|
+
* under the build context and refuse the capture if they reject or read
|
|
19
|
+
* identity, the same eligibility rules as at runtime.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ShellCacheEntry } from "../cache/types.js";
|
|
23
|
+
import { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
|
|
24
|
+
import {
|
|
25
|
+
createRequestContext,
|
|
26
|
+
runWithRequestContext,
|
|
27
|
+
setRequestContextParams,
|
|
28
|
+
} from "../server/request-context.js";
|
|
29
|
+
import {
|
|
30
|
+
deriveShellCaptureContext,
|
|
31
|
+
captureAndStoreShell,
|
|
32
|
+
delay,
|
|
33
|
+
SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
34
|
+
type ShellCaptureDescriptor,
|
|
35
|
+
} from "../rsc/shell-capture.js";
|
|
36
|
+
import { buildFullPayload } from "../rsc/full-payload.js";
|
|
37
|
+
import type { RscPayload, SSRModule } from "../rsc/types.js";
|
|
38
|
+
import type { HandlerContext } from "../rsc/handler-context.js";
|
|
39
|
+
import { renderToReadableStream } from "../deps/rsc.js";
|
|
40
|
+
import {
|
|
41
|
+
resolvePprConfig,
|
|
42
|
+
type ResolvedPprConfig,
|
|
43
|
+
} from "../rsc/shell-serve.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a collected truthy `ppr` path option into the SAME concrete
|
|
47
|
+
* policy the runtime serve path derives — through resolvePprConfig itself,
|
|
48
|
+
* over a synthetic route entry — so the build-stamped ttl default can never
|
|
49
|
+
* drift from the serve-side one.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveBuildPprConfig(
|
|
52
|
+
ppr:
|
|
53
|
+
| true
|
|
54
|
+
| { ttl?: number; swr?: number; tags?: string[]; captureTimeout?: number },
|
|
55
|
+
): ResolvedPprConfig {
|
|
56
|
+
const resolved = resolvePprConfig({ type: "route", ppr } as any);
|
|
57
|
+
// resolvePprConfig returns null only for undefined/false ppr; the collector
|
|
58
|
+
// filtered those out. Guard for the type only.
|
|
59
|
+
if (!resolved) throw new Error("[rango] unreachable: ppr option was falsy");
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildShellCaptureOptions {
|
|
64
|
+
/** The router instance (from RouterRegistry in the same realm). */
|
|
65
|
+
router: any;
|
|
66
|
+
/** Concrete URL path to capture (e.g. "/pp/alpha"). */
|
|
67
|
+
urlPath: string;
|
|
68
|
+
/**
|
|
69
|
+
* The candidate's trie route key. The capture's match() must land on THIS
|
|
70
|
+
* route: the phase sweeps every registered router, and a router that does
|
|
71
|
+
* not own the URL matches something else (its catch-all, a 404 shape) —
|
|
72
|
+
* that capture must not be baked.
|
|
73
|
+
*/
|
|
74
|
+
routeName: string;
|
|
75
|
+
/** Shell store key to stamp into the descriptor (host-free at build). */
|
|
76
|
+
key: string;
|
|
77
|
+
ttl?: number;
|
|
78
|
+
swr?: number;
|
|
79
|
+
/** The route's static ppr.tags (the capture unions render-recorded tags). */
|
|
80
|
+
tags?: string[];
|
|
81
|
+
/**
|
|
82
|
+
* The route's resolved snapshot size cap (ResolvedPprConfig.maxSnapshotBytes)
|
|
83
|
+
* — build captures apply the same over-cap skip as runtime captures, so a
|
|
84
|
+
* raised per-route cap behaves identically across both producers.
|
|
85
|
+
*/
|
|
86
|
+
maxSnapshotBytes?: number;
|
|
87
|
+
/**
|
|
88
|
+
* The route's `ppr.captureTimeout` (ms) — producer B honors the same settle
|
|
89
|
+
* budget as the runtime capture. Build has no waitUntil lifetime bound, so
|
|
90
|
+
* the option is the only ceiling here.
|
|
91
|
+
*/
|
|
92
|
+
captureTimeout?: number;
|
|
93
|
+
/** Build-time env bindings (rango plugin buildEnv), if configured. */
|
|
94
|
+
buildEnv?: unknown;
|
|
95
|
+
/**
|
|
96
|
+
* The MAIN build's version (the version plugin's value folded into the
|
|
97
|
+
* shipped worker) — NOT the temp server's own version-plugin value. The
|
|
98
|
+
* serve-side isValidShellHit gate compares entry.buildVersion against the
|
|
99
|
+
* running worker's ctx.version; stamping the temp server's would make every
|
|
100
|
+
* build entry an eternal MISS.
|
|
101
|
+
*/
|
|
102
|
+
buildVersion: string;
|
|
103
|
+
/**
|
|
104
|
+
* The SSR half, composed by the plugin from the temp server's SSR
|
|
105
|
+
* environment runner (react-dom/static prerender + Flight client), with the
|
|
106
|
+
* bootstrap script content overridden to the BUILT client entry URL.
|
|
107
|
+
*/
|
|
108
|
+
captureShellHTML: NonNullable<SSRModule["captureShellHTML"]>;
|
|
109
|
+
/** Verbose per-attempt breadcrumbs (build log). */
|
|
110
|
+
debug?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BuildShellCaptureResult {
|
|
114
|
+
outcome:
|
|
115
|
+
| "stored"
|
|
116
|
+
| "no-shell"
|
|
117
|
+
| "redirect"
|
|
118
|
+
| "refused"
|
|
119
|
+
/** The router swept does not own this URL — try the next one. */
|
|
120
|
+
| "route-mismatch";
|
|
121
|
+
/** Present iff outcome === "stored". */
|
|
122
|
+
entry?: ShellCacheEntry;
|
|
123
|
+
/** The putShell-barrier tag union (static ppr.tags + render-recorded). */
|
|
124
|
+
tags?: string[];
|
|
125
|
+
/** On route-mismatch: what this router's match actually landed on. */
|
|
126
|
+
matchedRouteName?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Capture the PPR shell for one prerendered URL at build time. Retries once
|
|
131
|
+
* in place on `no-shell` (the first attempt warms the temp server's SSR/Flight
|
|
132
|
+
* transform graph, mirroring producer A's cold-start retry — same delay).
|
|
133
|
+
*/
|
|
134
|
+
export async function captureShellForBuild(
|
|
135
|
+
opts: BuildShellCaptureOptions,
|
|
136
|
+
): Promise<BuildShellCaptureResult> {
|
|
137
|
+
const first = await attemptBuildCapture(opts);
|
|
138
|
+
if (first.outcome !== "no-shell") return first;
|
|
139
|
+
if (opts.debug) {
|
|
140
|
+
console.log(
|
|
141
|
+
`[rango] shell capture attempt 1/2 for ${opts.urlPath} produced no shell (cold graph?) — retrying`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
await delay(SHELL_CAPTURE_RETRY_DELAY_MS);
|
|
145
|
+
return attemptBuildCapture(opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** One attempt: fresh base context, fresh derivation, fresh render. */
|
|
149
|
+
async function attemptBuildCapture(
|
|
150
|
+
opts: BuildShellCaptureOptions,
|
|
151
|
+
): Promise<BuildShellCaptureResult> {
|
|
152
|
+
const router = opts.router;
|
|
153
|
+
const url = new URL(opts.urlPath, "http://build.invalid");
|
|
154
|
+
const request = new Request(url, { method: "GET" });
|
|
155
|
+
|
|
156
|
+
// Synthetic build request context: same factory the runtime handler uses,
|
|
157
|
+
// so the capture's ALS surface (cookie machinery, variables, waitUntil,
|
|
158
|
+
// theme resolution) is production-shaped. No cookie header → theme resolves
|
|
159
|
+
// to the app default, exactly like a first anonymous visitor's capture.
|
|
160
|
+
const baseCtx = createRequestContext({
|
|
161
|
+
env: (opts.buildEnv ?? {}) as any,
|
|
162
|
+
request,
|
|
163
|
+
url,
|
|
164
|
+
variables: {},
|
|
165
|
+
// Fresh empty store per attempt: cache()/"use cache" reads MISS, execute,
|
|
166
|
+
// and are recorded into the snapshot by the derivation's RecordingShell
|
|
167
|
+
// wrapper — the entry pins its own generation, nothing preexisting leaks.
|
|
168
|
+
cacheStore: new MemorySegmentCacheStore(),
|
|
169
|
+
themeConfig: router.themeConfig ?? null,
|
|
170
|
+
stateCookieName: router.resolvedStateCookieName,
|
|
171
|
+
version: opts.buildVersion,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(baseCtx, {
|
|
175
|
+
ttl: opts.ttl,
|
|
176
|
+
swr: opts.swr,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Entry collector: captureAndStoreShell's sink. putShell never fails here,
|
|
180
|
+
// so a "stored" outcome always carries the entry.
|
|
181
|
+
let collected: { entry: ShellCacheEntry; tags?: string[] } | null = null;
|
|
182
|
+
const collector = {
|
|
183
|
+
putShell: async (
|
|
184
|
+
_key: string,
|
|
185
|
+
entry: ShellCacheEntry,
|
|
186
|
+
_ttl?: number,
|
|
187
|
+
_swr?: number,
|
|
188
|
+
tags?: string[],
|
|
189
|
+
): Promise<void> => {
|
|
190
|
+
collected = { entry, tags };
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const descriptor: ShellCaptureDescriptor = {
|
|
195
|
+
key: opts.key,
|
|
196
|
+
buildVersion: opts.buildVersion,
|
|
197
|
+
ttl: opts.ttl,
|
|
198
|
+
swr: opts.swr,
|
|
199
|
+
tags: opts.tags,
|
|
200
|
+
captureTimeout: opts.captureTimeout,
|
|
201
|
+
store: collector as any,
|
|
202
|
+
debug: opts.debug,
|
|
203
|
+
maxSnapshotBytes: opts.maxSnapshotBytes,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let mismatchedRouteName: string | undefined;
|
|
207
|
+
const outcome = await runWithRequestContext(derivedCtx, async () => {
|
|
208
|
+
const match = await router.match(request, { env: opts.buildEnv ?? {} });
|
|
209
|
+
if (match.routeName !== opts.routeName) {
|
|
210
|
+
mismatchedRouteName = match.routeName;
|
|
211
|
+
return "route-mismatch" as const;
|
|
212
|
+
}
|
|
213
|
+
if (match.redirect) return "redirect" as const;
|
|
214
|
+
|
|
215
|
+
setRequestContextParams(match.params, match.routeName);
|
|
216
|
+
|
|
217
|
+
const payload = buildFullPayload(
|
|
218
|
+
match,
|
|
219
|
+
// buildFullPayload reads only ctx.router.* and ctx.version.
|
|
220
|
+
{ router, version: opts.buildVersion } as unknown as HandlerContext<any>,
|
|
221
|
+
url,
|
|
222
|
+
derivedCtx,
|
|
223
|
+
freshHandleStore,
|
|
224
|
+
);
|
|
225
|
+
const rscStream = renderToReadableStream<RscPayload>(payload, {
|
|
226
|
+
onError: (error: unknown) => {
|
|
227
|
+
if (opts.debug) {
|
|
228
|
+
console.warn(
|
|
229
|
+
`[rango] shell capture render error for ${opts.urlPath}:`,
|
|
230
|
+
error,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return captureAndStoreShell(
|
|
237
|
+
{ captureShellHTML: opts.captureShellHTML } as SSRModule,
|
|
238
|
+
rscStream,
|
|
239
|
+
freshHandleStore,
|
|
240
|
+
derivedCtx,
|
|
241
|
+
descriptor,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (outcome === "stored" && collected !== null) {
|
|
246
|
+
const hit: { entry: ShellCacheEntry; tags?: string[] } = collected;
|
|
247
|
+
return { outcome, entry: hit.entry, tags: hit.tags };
|
|
248
|
+
}
|
|
249
|
+
if (outcome === "route-mismatch") {
|
|
250
|
+
return { outcome, matchedRouteName: mismatchedRouteName };
|
|
251
|
+
}
|
|
252
|
+
return { outcome };
|
|
253
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build shell manifest key, shared by the producer (the build's shell
|
|
3
|
+
* prerender phase, vite/discovery/shell-prerender-phase.ts) and the consumer
|
|
4
|
+
* (the runtime read-through, rsc/shell-build-manifest.ts) so the format
|
|
5
|
+
* cannot drift. Dependency-free: the producer runs node-side in the plugin,
|
|
6
|
+
* the consumer in the RSC runtime.
|
|
7
|
+
*
|
|
8
|
+
* PATHNAME-ONLY by design. Host-free because the build knows no request
|
|
9
|
+
* host. Router-free because the router id ($$id) is a hash of
|
|
10
|
+
* filePath:lineNumber of the TRANSFORMED source, which differs between the
|
|
11
|
+
* discovery temp server's dev-style transform chain and the main build's —
|
|
12
|
+
* a temp-realm router id can never be looked up by the shipped worker. The
|
|
13
|
+
* producer instead detects pathname collisions across routers at build time
|
|
14
|
+
* and declines both entries (loudly), keeping the key unambiguous. This is
|
|
15
|
+
* a manifest namespace, never a store keyspace: the runtime shell key
|
|
16
|
+
* (host + pathname + search + ":shell") stays untouched.
|
|
17
|
+
*/
|
|
18
|
+
export function buildShellManifestKey(pathname: string): string {
|
|
19
|
+
return pathname;
|
|
20
|
+
}
|
package/src/prerender/store.ts
CHANGED
|
@@ -73,7 +73,16 @@ export function createDevPrerenderStore(devUrl: string): PrerenderStore {
|
|
|
73
73
|
if (isIntercept) url += "&intercept=1";
|
|
74
74
|
if (meta.isPassthroughRoute) url += "&passthrough=1";
|
|
75
75
|
try {
|
|
76
|
-
|
|
76
|
+
// Bounded: this fetch also runs inside the PPR shell capture (a
|
|
77
|
+
// workerd waitUntil task), where an unsettled fetch does not reject —
|
|
78
|
+
// it pends until the task is torn down. Unbounded, one pending fetch
|
|
79
|
+
// wedged the per-isolate capture queue on GH runners (rotating dev
|
|
80
|
+
// capture failures; endpoint instrumentation showed 3-4ms memo HITs,
|
|
81
|
+
// so latency was never the issue — settlement was). On timeout the
|
|
82
|
+
// catch degrades to a store miss and the pipeline falls through to
|
|
83
|
+
// the live handler render (the documented dev fall-through), so the
|
|
84
|
+
// capture still lands with live content.
|
|
85
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
77
86
|
if (!res.ok) return null;
|
|
78
87
|
return res.json();
|
|
79
88
|
} catch {
|
|
@@ -76,6 +76,46 @@ export function parseAcceptTypes(accept: string): AcceptEntry[] {
|
|
|
76
76
|
|
|
77
77
|
export const RSC_RESPONSE_TYPE = "__rsc__";
|
|
78
78
|
|
|
79
|
+
/** RSC wire-format MIME type; explicit-opt-in flight transport. */
|
|
80
|
+
export const RSC_WIRE_MIME = "text/x-component";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The two representations an RSC route serves, in canonical-first order:
|
|
84
|
+
* text/html (the document) and text/x-component (the flight wire format).
|
|
85
|
+
* Both register as negotiation candidates in pickNegotiateVariant; without
|
|
86
|
+
* the wire-format entry, an explicit Accept: text/x-component fell through
|
|
87
|
+
* to the definition-order fallback — a JSON-first route answered a
|
|
88
|
+
* wire-format request with JSON. Which representation an RSC win actually
|
|
89
|
+
* renders is decided by prefersFlightRepresentation below, from the same
|
|
90
|
+
* Accept header (wired in via isRscRequest, rsc/ssr-setup.ts).
|
|
91
|
+
*/
|
|
92
|
+
const RSC_MIMES: readonly string[] = ["text/html", RSC_WIRE_MIME];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rank the RSC route's two representations against a parsed Accept list:
|
|
96
|
+
* true when the flight wire format outranks the HTML document. Wildcard
|
|
97
|
+
* entries count for the HTML side — they express "anything", and the
|
|
98
|
+
* canonical representation of anything is the document. Co-located with
|
|
99
|
+
* RSC_MIMES so the candidate registration and the representation choice
|
|
100
|
+
* cannot drift.
|
|
101
|
+
*/
|
|
102
|
+
export function prefersFlightRepresentation(
|
|
103
|
+
acceptEntries: AcceptEntry[],
|
|
104
|
+
): boolean {
|
|
105
|
+
for (const entry of acceptEntries) {
|
|
106
|
+
if (entry.q === 0) continue;
|
|
107
|
+
if (entry.mime === RSC_WIRE_MIME) return true;
|
|
108
|
+
if (
|
|
109
|
+
entry.mime === "text/html" ||
|
|
110
|
+
entry.mime === "text/*" ||
|
|
111
|
+
entry.mime === "*/*"
|
|
112
|
+
) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
/**
|
|
80
120
|
* Pick the best negotiate variant by walking the client's sorted Accept list.
|
|
81
121
|
* For each accepted MIME type (in q-value/order priority), check if any
|
|
@@ -87,12 +127,14 @@ export function pickNegotiateVariant<
|
|
|
87
127
|
>(acceptEntries: AcceptEntry[], candidates: T[]): T {
|
|
88
128
|
const byCandidateMime = new Map<string, T>();
|
|
89
129
|
for (const c of candidates) {
|
|
90
|
-
const
|
|
130
|
+
const mimes =
|
|
91
131
|
c.responseType === RSC_RESPONSE_TYPE
|
|
92
|
-
?
|
|
93
|
-
: RESPONSE_TYPE_MIME[c.responseType];
|
|
94
|
-
|
|
95
|
-
byCandidateMime.
|
|
132
|
+
? RSC_MIMES
|
|
133
|
+
: [RESPONSE_TYPE_MIME[c.responseType]];
|
|
134
|
+
for (const mime of mimes) {
|
|
135
|
+
if (mime && !byCandidateMime.has(mime)) {
|
|
136
|
+
byCandidateMime.set(mime, c);
|
|
137
|
+
}
|
|
96
138
|
}
|
|
97
139
|
}
|
|
98
140
|
|
|
@@ -112,6 +112,9 @@ let prerenderStoreInstance: PrerenderStore | null | undefined;
|
|
|
112
112
|
let _deserializeSegments:
|
|
113
113
|
| typeof import("../../cache/segment-codec.js").deserializeSegments
|
|
114
114
|
| undefined;
|
|
115
|
+
let _fragmentSegments:
|
|
116
|
+
| typeof import("../../cache/segment-codec.js").fragmentSegments
|
|
117
|
+
| undefined;
|
|
115
118
|
let _restoreHandles:
|
|
116
119
|
| typeof import("../../cache/handle-snapshot.js").restoreHandles
|
|
117
120
|
| undefined;
|
|
@@ -135,6 +138,7 @@ async function ensurePrerenderDeps() {
|
|
|
135
138
|
import("../../prerender/store.js"),
|
|
136
139
|
]);
|
|
137
140
|
_deserializeSegments = codec.deserializeSegments;
|
|
141
|
+
_fragmentSegments = codec.fragmentSegments;
|
|
138
142
|
_restoreHandles = snapshot.restoreHandles;
|
|
139
143
|
_decodeHandles = snapshot.decodeHandles;
|
|
140
144
|
_hashParams = paramHash.hashParams;
|
|
@@ -237,6 +241,7 @@ async function* yieldFromStore<TEnv>(
|
|
|
237
241
|
|
|
238
242
|
if (
|
|
239
243
|
!_deserializeSegments ||
|
|
244
|
+
!_fragmentSegments ||
|
|
240
245
|
!_restoreHandles ||
|
|
241
246
|
!_decodeHandles ||
|
|
242
247
|
!_hashParams ||
|
|
@@ -245,7 +250,13 @@ async function* yieldFromStore<TEnv>(
|
|
|
245
250
|
throw new Error("yieldFromStore called before ensurePrerenderDeps");
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
|
|
253
|
+
// Shell-HIT tail (issue #700): a Prerender+ppr route's tail serves from THIS
|
|
254
|
+
// store (the prerender lookup runs before the cache scope), so the fragment
|
|
255
|
+
// splice must apply here too — otherwise producer B entries re-serialize the
|
|
256
|
+
// whole tree per request while producer A entries do not.
|
|
257
|
+
const segments = _getRequestContext()?._shellFragmentPayload
|
|
258
|
+
? await _fragmentSegments(entry.segments)
|
|
259
|
+
: await _deserializeSegments(entry.segments);
|
|
249
260
|
|
|
250
261
|
// Replay handle data (same as runtime cache hit path). entry.handles is a
|
|
251
262
|
// Flight-encoded string ("" when none) — decode before restore so
|
package/src/router/metrics.ts
CHANGED
|
@@ -79,6 +79,7 @@ export function appendMetric(
|
|
|
79
79
|
start: number,
|
|
80
80
|
duration: number,
|
|
81
81
|
depth?: number,
|
|
82
|
+
desc?: string,
|
|
82
83
|
): void {
|
|
83
84
|
if (!metricsStore) return;
|
|
84
85
|
metricsStore.metrics.push({
|
|
@@ -86,6 +87,7 @@ export function appendMetric(
|
|
|
86
87
|
duration,
|
|
87
88
|
startTime: start - metricsStore.requestStart,
|
|
88
89
|
depth,
|
|
90
|
+
desc,
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
|
|
@@ -104,6 +106,7 @@ interface DisplayRow {
|
|
|
104
106
|
startTime: number;
|
|
105
107
|
duration: number;
|
|
106
108
|
depth: number | undefined;
|
|
109
|
+
desc: string | undefined;
|
|
107
110
|
spans: Span[];
|
|
108
111
|
}
|
|
109
112
|
|
|
@@ -137,6 +140,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
|
|
|
137
140
|
startTime: m.startTime,
|
|
138
141
|
duration: m.duration + post.duration,
|
|
139
142
|
depth: m.depth,
|
|
143
|
+
desc: m.desc,
|
|
140
144
|
spans: [
|
|
141
145
|
{ startTime: m.startTime, duration: m.duration },
|
|
142
146
|
{ startTime: post.startTime, duration: post.duration },
|
|
@@ -151,6 +155,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
|
|
|
151
155
|
startTime: m.startTime,
|
|
152
156
|
duration: m.duration,
|
|
153
157
|
depth: m.depth,
|
|
158
|
+
desc: m.desc,
|
|
154
159
|
spans: [{ startTime: m.startTime, duration: m.duration }],
|
|
155
160
|
});
|
|
156
161
|
continue;
|
|
@@ -169,6 +174,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
|
|
|
169
174
|
startTime: m.startTime,
|
|
170
175
|
duration: m.duration,
|
|
171
176
|
depth: m.depth,
|
|
177
|
+
desc: m.desc,
|
|
172
178
|
spans: [{ startTime: m.startTime, duration: m.duration }],
|
|
173
179
|
});
|
|
174
180
|
continue;
|
|
@@ -180,6 +186,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
|
|
|
180
186
|
startTime: m.startTime,
|
|
181
187
|
duration: m.duration,
|
|
182
188
|
depth: m.depth,
|
|
189
|
+
desc: m.desc,
|
|
183
190
|
spans: [{ startTime: m.startTime, duration: m.duration }],
|
|
184
191
|
});
|
|
185
192
|
}
|
|
@@ -199,7 +206,9 @@ export function logMetrics(
|
|
|
199
206
|
|
|
200
207
|
const labels = displayRows.map(
|
|
201
208
|
(r) =>
|
|
202
|
-
`${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}
|
|
209
|
+
`${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}${
|
|
210
|
+
r.desc ? ` (${r.desc})` : ""
|
|
211
|
+
}`,
|
|
203
212
|
);
|
|
204
213
|
const startValues = displayRows.map((r) => formatMs(r.startTime));
|
|
205
214
|
const durationValues = displayRows.map((r) => formatMs(r.duration));
|
|
@@ -247,7 +256,13 @@ export function generateServerTiming(metricsStore: MetricsStore): string {
|
|
|
247
256
|
.replace(/[^a-zA-Z0-9-]/g, "")
|
|
248
257
|
.toLowerCase();
|
|
249
258
|
const name = m.depth ? `d${m.depth}-${base}` : base;
|
|
250
|
-
|
|
259
|
+
// desc is a quoted-string: backslash-escape the two delimiters; our
|
|
260
|
+
// producers emit plain printable text, so nothing else needs stripping.
|
|
261
|
+
const desc =
|
|
262
|
+
m.desc !== undefined
|
|
263
|
+
? `;desc="${m.desc.replace(/[\\"]/g, "\\$&")}"`
|
|
264
|
+
: "";
|
|
265
|
+
return `${name};dur=${m.duration.toFixed(2)}${desc}`;
|
|
251
266
|
})
|
|
252
267
|
.join(", ");
|
|
253
268
|
}
|
|
@@ -21,6 +21,7 @@ import type { RouterContext } from "./router-context.js";
|
|
|
21
21
|
import type { ResolveSegmentOptions } from "./segment-resolution.js";
|
|
22
22
|
import { runWithRouterContext } from "./router-context.js";
|
|
23
23
|
import type { EntryData, InterceptEntry } from "../server/context";
|
|
24
|
+
import type { PartialPrerenderProps } from "../urls/pattern-types.js";
|
|
24
25
|
import type {
|
|
25
26
|
HandlerContext,
|
|
26
27
|
InternalHandlerContext,
|
|
@@ -74,6 +75,13 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
74
75
|
* the sinks store it as-is (no longer merge raw records). */
|
|
75
76
|
interceptHandles?: string;
|
|
76
77
|
passthrough?: true;
|
|
78
|
+
/**
|
|
79
|
+
* The matched route entry's `ppr` path option, surfaced so the build
|
|
80
|
+
* collection can flag Prerender+ppr routes as build-time shell candidates
|
|
81
|
+
* (issue #699 producer B). Runtime-only today; the build otherwise never
|
|
82
|
+
* sees the option (it lives on EntryData, not the trie).
|
|
83
|
+
*/
|
|
84
|
+
ppr?: boolean | PartialPrerenderProps;
|
|
77
85
|
} | null> {
|
|
78
86
|
// 1. Find the matching route entry
|
|
79
87
|
const matched = await deps.findMatch(pathname);
|
|
@@ -306,6 +314,18 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
306
314
|
// Use the trie-level route key (e.g., "docs", "docs.article")
|
|
307
315
|
const routeName = matched.routeKey;
|
|
308
316
|
|
|
317
|
+
// Surface the matched route entry's ppr option for the build collection
|
|
318
|
+
// (leaf-first: the deepest type:"route" entry in the ancestor chain is
|
|
319
|
+
// the matched page route; ancestors are layouts/includes).
|
|
320
|
+
let routePpr: boolean | PartialPrerenderProps | undefined;
|
|
321
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
322
|
+
const e = entries[i]!;
|
|
323
|
+
if (e.type === "route") {
|
|
324
|
+
routePpr = e.ppr;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
// 14. Resolve intercept segments for this route (if any ancestor defines
|
|
310
330
|
// an intercept targeting this route). At build time we skip when()
|
|
311
331
|
// evaluation -- we pre-render all intercepts unconditionally and let
|
|
@@ -420,6 +440,7 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
420
440
|
params: matchedParams,
|
|
421
441
|
interceptSegments,
|
|
422
442
|
interceptHandles,
|
|
443
|
+
ppr: routePpr,
|
|
423
444
|
};
|
|
424
445
|
});
|
|
425
446
|
});
|
|
@@ -6,6 +6,7 @@ import type { UrlBuilder, EnvCompatible } from "../urls/pattern-types.js";
|
|
|
6
6
|
import type { EntryData } from "../server/context";
|
|
7
7
|
import type { ErrorInfo, MatchResult } from "../types";
|
|
8
8
|
import type { NonceProvider } from "../rsc/types.js";
|
|
9
|
+
import type { ShellCaptureDebug } from "../rsc/shell-capture.js";
|
|
9
10
|
import type { ExecutionContext } from "../server/request-context.js";
|
|
10
11
|
import type { SerializedSegmentData } from "../cache/types.js";
|
|
11
12
|
import type { MiddlewareEntry, MiddlewareFn } from "./middleware.js";
|
|
@@ -344,6 +345,12 @@ export interface RangoInternal<
|
|
|
344
345
|
*/
|
|
345
346
|
readonly debugPerformance?: boolean;
|
|
346
347
|
|
|
348
|
+
/**
|
|
349
|
+
* PPR shell-capture debug sink (createRouter({ debugShellCapture })), read
|
|
350
|
+
* by rsc-rendering when it builds the capture descriptor for a ppr route.
|
|
351
|
+
*/
|
|
352
|
+
readonly debugShellCapture?: ShellCaptureDebug;
|
|
353
|
+
|
|
347
354
|
/**
|
|
348
355
|
* Resolved platform phase-span tracing (Cloudflare custom spans or OTel), or
|
|
349
356
|
* undefined when off. Threaded onto the request context and read at each
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
OnErrorCallback,
|
|
7
7
|
} from "../types";
|
|
8
8
|
import type { NonceProvider } from "../rsc/types.js";
|
|
9
|
+
import type { ShellCaptureDebug } from "../rsc/shell-capture.js";
|
|
9
10
|
import type { ExecutionContext } from "../server/request-context.js";
|
|
10
11
|
import type { UrlPatterns } from "../urls.js";
|
|
11
12
|
import type { UrlBuilder } from "../urls/pattern-types.js";
|
|
@@ -148,6 +149,18 @@ export interface RangoOptions<TEnv = any> {
|
|
|
148
149
|
*/
|
|
149
150
|
debugCacheSignal?: boolean;
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Debug sink for the PPR shell-capture pipeline (routes with the `ppr` path
|
|
154
|
+
* option). `true` logs one structured line per capture attempt/skip to
|
|
155
|
+
* console (visible via `wrangler tail`); a function receives each
|
|
156
|
+
* `ShellCaptureDebugEvent` (outcome per attempt, snapshot bytes,
|
|
157
|
+
* write-barrier wait, backoff state) for programmatic capture. Off by
|
|
158
|
+
* default; the events also mirror into the dev Server-Timing surface when
|
|
159
|
+
* `debugPerformance` is on. Intended for validating capture behavior on a
|
|
160
|
+
* real deployment, not steady-state production.
|
|
161
|
+
*/
|
|
162
|
+
debugShellCapture?: ShellCaptureDebug;
|
|
163
|
+
|
|
151
164
|
/**
|
|
152
165
|
* Document component that wraps the entire application.
|
|
153
166
|
*
|
package/src/router.ts
CHANGED
|
@@ -168,6 +168,7 @@ export function createRouter<TEnv = any>(
|
|
|
168
168
|
originCheck: originCheckOption,
|
|
169
169
|
viewTransition: viewTransitionOption = "auto",
|
|
170
170
|
debugCacheSignal: debugCacheSignalOption = false,
|
|
171
|
+
debugShellCapture: debugShellCaptureOption,
|
|
171
172
|
strictMode: strictModeOption = true,
|
|
172
173
|
} = options;
|
|
173
174
|
|
|
@@ -1007,6 +1008,10 @@ export function createRouter<TEnv = any>(
|
|
|
1007
1008
|
// Expose router-wide performance debugging for request-level metrics setup
|
|
1008
1009
|
debugPerformance,
|
|
1009
1010
|
|
|
1011
|
+
// Expose the PPR shell-capture debug sink for the render layer
|
|
1012
|
+
// (rsc-rendering resolves it into the capture descriptor)
|
|
1013
|
+
debugShellCapture: debugShellCaptureOption,
|
|
1014
|
+
|
|
1010
1015
|
// Expose resolved span tracing for the handler (Cloudflare custom spans)
|
|
1011
1016
|
tracing: resolvedTracing,
|
|
1012
1017
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-isolate shell-capture serialization.
|
|
3
|
+
*
|
|
4
|
+
* Captures are CPU-bound background renders whose quiet detection is
|
|
5
|
+
* task-quantized (FLIGHT_QUIET_HOPS macrotask hops with zero new bytes). Two
|
|
6
|
+
* captures running concurrently starve each other: one grinding capture —
|
|
7
|
+
* e.g. a prerender+ppr route whose capture round-trips the dev
|
|
8
|
+
* /__rsc_prerender endpoint, whose per-request module re-import can peg a
|
|
9
|
+
* slow CI runner for seconds — keeps the sibling's render byte-silent past
|
|
10
|
+
* its abort budget, so the sibling freezes a trivial prelude and stores
|
|
11
|
+
* nothing. Observed on GH runners as ROTATING eternal-MISS victims (the
|
|
12
|
+
* warmup on one shard, a composition probe, then /ppr-shell?probe=stream once
|
|
13
|
+
* the first was quieted) while every local run passed.
|
|
14
|
+
*
|
|
15
|
+
* Serializing capture execution removes the cross-talk: each capture's quiet
|
|
16
|
+
* window observes only its own work. Captures are TTL-scale background work,
|
|
17
|
+
* so queueing costs latency-to-HIT only — never a served response. On workerd
|
|
18
|
+
* a queued capture rides the scheduling request's waitUntil, whose lifetime
|
|
19
|
+
* is bounded; a capture killed mid-queue by that bound simply recaptures on a
|
|
20
|
+
* later request (the existing best-effort contract).
|
|
21
|
+
*
|
|
22
|
+
* The chain link resolves in `finally` and the prior link is awaited with a
|
|
23
|
+
* swallow, so one rejected capture can never wedge every later one.
|
|
24
|
+
*/
|
|
25
|
+
let captureQueue: Promise<void> = Promise.resolve();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Upper bound on how long one queue link may hold the queue. A capture task
|
|
29
|
+
* normally settles well inside this (attempt + in-place retry + writes), but
|
|
30
|
+
* a task wedged on never-settling I/O — a workerd waitUntil fetch that pends
|
|
31
|
+
* instead of rejecting (seen on GH runners with the dev prerender store
|
|
32
|
+
* before its fetch was time-bounded) — must not block every later capture in
|
|
33
|
+
* the isolate. At the cap the QUEUE is released; the wedged task itself stays
|
|
34
|
+
* detached (its own per-key guards clean up when/if it settles).
|
|
35
|
+
*/
|
|
36
|
+
const QUEUE_LINK_CAP_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `task` after every previously enqueued capture has settled. Returns a
|
|
40
|
+
* promise for THIS task's completion (rejections propagate to the caller —
|
|
41
|
+
* the queue itself is insulated).
|
|
42
|
+
*/
|
|
43
|
+
export function enqueueSerializedCapture(
|
|
44
|
+
task: () => Promise<void>,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const prior = captureQueue;
|
|
47
|
+
let releaseQueue!: () => void;
|
|
48
|
+
captureQueue = new Promise<void>((resolve) => {
|
|
49
|
+
releaseQueue = resolve;
|
|
50
|
+
});
|
|
51
|
+
return (async () => {
|
|
52
|
+
await prior.catch(() => {});
|
|
53
|
+
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
try {
|
|
55
|
+
await Promise.race([
|
|
56
|
+
task(),
|
|
57
|
+
new Promise<void>((resolve) => {
|
|
58
|
+
capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
|
|
59
|
+
(capTimer as { unref?: () => void }).unref?.();
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
} finally {
|
|
63
|
+
if (capTimer) clearTimeout(capTimer);
|
|
64
|
+
releaseQueue();
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
}
|
package/src/rsc/handler.ts
CHANGED
|
@@ -885,8 +885,10 @@ export function createRSCHandler<
|
|
|
885
885
|
// submissions always render HTML (handleProgressiveEnhancement renders via
|
|
886
886
|
// getSSRSetup regardless of Accept). For full/partial-render and action,
|
|
887
887
|
// the render-time HTML decision is exactly !isRscRequest — mayNeedSSR is
|
|
888
|
-
// the coarse transport pre-filter, isRscRequest
|
|
889
|
-
//
|
|
888
|
+
// the coarse transport pre-filter, isRscRequest adds the partial/__rsc
|
|
889
|
+
// flags; both share the same Accept rule (acceptsFlightExplicitly in
|
|
890
|
+
// ssr-setup.ts), so the Accept call cannot drift between them. Both must
|
|
891
|
+
// pass.
|
|
890
892
|
const willRenderHtml =
|
|
891
893
|
plan.mode === "pe-render" ||
|
|
892
894
|
(mayNeedSSR(request, url) &&
|