@rangojs/router 0.0.0-experimental.140 → 0.0.0-experimental.141
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/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +229 -362
- package/skills/rango/SKILL.md +2 -2
- package/src/cache/cf/cf-cache-store.ts +8 -0
- package/src/cache/index.ts +0 -5
- package/src/cache/shell-snapshot.ts +368 -0
- package/src/cache/types.ts +66 -0
- package/src/cache/vercel/vercel-cache-store.ts +12 -1
- package/src/index.rsc.ts +1 -5
- package/src/index.ts +1 -17
- package/src/rsc/rsc-rendering.ts +279 -89
- package/src/rsc/shell-capture.ts +523 -65
- package/src/rsc/shell-serve.ts +124 -0
- package/src/server/context.ts +7 -0
- package/src/server/request-context.ts +52 -46
- package/src/ssr/index.tsx +38 -6
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/cache/shell-cache.ts +0 -386
- package/src/server/live.ts +0 -130
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
import {
|
|
10
10
|
getRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
|
+
runWithRequestContext,
|
|
12
13
|
} from "../server/request-context.js";
|
|
14
|
+
import { SeededShellStore } from "../cache/shell-snapshot.js";
|
|
13
15
|
import { appendMetric } from "../router/metrics.js";
|
|
14
16
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
15
17
|
import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
|
|
@@ -24,7 +26,22 @@ import {
|
|
|
24
26
|
import type { HandlerContext } from "./handler-context.js";
|
|
25
27
|
import { gateTransitions } from "./transition-gate.js";
|
|
26
28
|
import { buildFullPayload } from "./full-payload.js";
|
|
27
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
scheduleShellCapture,
|
|
31
|
+
type ShellCaptureDescriptor,
|
|
32
|
+
} from "./shell-capture.js";
|
|
33
|
+
import {
|
|
34
|
+
SHELL_STATUS_HEADER,
|
|
35
|
+
resolvePprConfig,
|
|
36
|
+
buildShellKey,
|
|
37
|
+
isValidShellHit,
|
|
38
|
+
base64ToBytes,
|
|
39
|
+
hasShellFamily,
|
|
40
|
+
warnShellStoreMissingOnce,
|
|
41
|
+
} from "./shell-serve.js";
|
|
42
|
+
import { reportCacheError } from "../cache/cache-error.js";
|
|
43
|
+
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
44
|
+
import type { ShellCacheEntry } from "../cache/types.js";
|
|
28
45
|
|
|
29
46
|
export function handleRscRendering<TEnv>(
|
|
30
47
|
ctx: HandlerContext<TEnv>,
|
|
@@ -67,6 +84,105 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
67
84
|
let payload: RscPayload;
|
|
68
85
|
let hasInterceptSlots = false;
|
|
69
86
|
|
|
87
|
+
// --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
|
|
88
|
+
//
|
|
89
|
+
// COMMIT POINT. This function is the render pass executeRender wraps, so it runs
|
|
90
|
+
// strictly AFTER the whole middleware chain — the global router.use() chain AND
|
|
91
|
+
// route DSL middleware() both wrap it. Any middleware rejection/redirect/401 has
|
|
92
|
+
// already returned before this line, which is what makes a shared shell safe:
|
|
93
|
+
// not a single shell byte can precede a guard decision, on MISS or HIT.
|
|
94
|
+
//
|
|
95
|
+
// PPR is opt-in per PAGE ROUTE via the `ppr` path option (read off the classified
|
|
96
|
+
// route snapshot — the same matched entry match() will resolve). No `ppr` option
|
|
97
|
+
// means pure axis 1: no store read, no capture, no logs, zero cost.
|
|
98
|
+
//
|
|
99
|
+
// On a valid HIT the composed response is committed HERE — the stored prelude
|
|
100
|
+
// bytes flush immediately while match()/segment resolution/Flight render/resume
|
|
101
|
+
// run behind them inside the response stream (ring-3 reads and render setup hide
|
|
102
|
+
// behind wire bytes). On a MISS the request continues as plain axis 1 and a
|
|
103
|
+
// background capture is scheduled after the response is built.
|
|
104
|
+
let pprMiss: {
|
|
105
|
+
descriptor: ShellCaptureDescriptor;
|
|
106
|
+
ssrModule: SSRModule;
|
|
107
|
+
} | null = null;
|
|
108
|
+
if (
|
|
109
|
+
!isPartial &&
|
|
110
|
+
nonce === undefined &&
|
|
111
|
+
request.method === "GET" &&
|
|
112
|
+
!url.searchParams.has("__prerender_collect") &&
|
|
113
|
+
!isRscRequest(request, url, false)
|
|
114
|
+
) {
|
|
115
|
+
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
116
|
+
if (pprConfig) {
|
|
117
|
+
const store = reqCtx._cacheStore;
|
|
118
|
+
const key = buildShellKey(url);
|
|
119
|
+
if (!hasShellFamily(store)) {
|
|
120
|
+
// Declared intent that cannot be honored deserves a diagnostic (unlike an
|
|
121
|
+
// undeclared route, which is silent). Axis 1 after the warning.
|
|
122
|
+
warnShellStoreMissingOnce(key);
|
|
123
|
+
} else {
|
|
124
|
+
// allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
|
|
125
|
+
// streaming, so bots/SEO crawlers get one complete axis-1 document.
|
|
126
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
127
|
+
ctx,
|
|
128
|
+
request,
|
|
129
|
+
env,
|
|
130
|
+
url,
|
|
131
|
+
reqCtx._metricsStore,
|
|
132
|
+
);
|
|
133
|
+
if (
|
|
134
|
+
streamMode !== "allReady" &&
|
|
135
|
+
ssrModule.resumeShellHTML &&
|
|
136
|
+
ssrModule.captureShellHTML
|
|
137
|
+
) {
|
|
138
|
+
const descriptor: ShellCaptureDescriptor = {
|
|
139
|
+
key,
|
|
140
|
+
ttl: pprConfig.ttl,
|
|
141
|
+
swr: pprConfig.swr,
|
|
142
|
+
tags: pprConfig.tags,
|
|
143
|
+
store,
|
|
144
|
+
debug: INTERNAL_RANGO_DEBUG,
|
|
145
|
+
};
|
|
146
|
+
let cached: Awaited<ReturnType<typeof store.getShell>> = null;
|
|
147
|
+
try {
|
|
148
|
+
cached = await store.getShell(key);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// A failing store read degrades to axis 1 (MISS), never a 500.
|
|
151
|
+
reportCacheError(error, "cache-read", "[ShellServe] getShell");
|
|
152
|
+
}
|
|
153
|
+
if (cached && isValidShellHit(cached.entry)) {
|
|
154
|
+
// Stale (SWR) hit: serve the stale shell now, recapture in the
|
|
155
|
+
// background (stampede-guarded + backoff inside scheduleShellCapture).
|
|
156
|
+
if (cached.shouldRevalidate) {
|
|
157
|
+
scheduleShellCapture(
|
|
158
|
+
ctx,
|
|
159
|
+
request,
|
|
160
|
+
env,
|
|
161
|
+
url,
|
|
162
|
+
reqCtx,
|
|
163
|
+
ssrModule,
|
|
164
|
+
descriptor,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return serveShellHit(
|
|
168
|
+
ctx,
|
|
169
|
+
request,
|
|
170
|
+
env,
|
|
171
|
+
url,
|
|
172
|
+
reqCtx,
|
|
173
|
+
handleStore,
|
|
174
|
+
ssrModule,
|
|
175
|
+
cached.entry,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
// MISS (no entry, invalid reactVersion, or store read failure): axis 1
|
|
179
|
+
// + a background capture scheduled once the response is known servable.
|
|
180
|
+
pprMiss = { descriptor, ssrModule };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
70
186
|
if (isPartial) {
|
|
71
187
|
// Partial render (navigation)
|
|
72
188
|
const result = await ctx.router.matchPartial(request, { env });
|
|
@@ -233,105 +349,179 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
233
349
|
metricsStore,
|
|
234
350
|
);
|
|
235
351
|
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// MUST mark the response with x-rango-shell-resumed so the middleware prepends
|
|
244
|
-
// the cached prelude; if any guard fails we fall through to a normal renderHTML
|
|
245
|
-
// with no marker and the middleware fails open to axis 1.
|
|
246
|
-
const shellResume = reqCtx._shellResume;
|
|
247
|
-
let response: Response;
|
|
248
|
-
if (
|
|
249
|
-
shellResume &&
|
|
250
|
-
!isPartial &&
|
|
251
|
-
nonce === undefined &&
|
|
252
|
-
streamMode !== "allReady" &&
|
|
253
|
-
ssrModule.resumeShellHTML
|
|
254
|
-
) {
|
|
255
|
-
const resumedStream = await observePhase(PHASES.ssr, () =>
|
|
256
|
-
ssrModule.resumeShellHTML!(rscStream, {
|
|
257
|
-
postponed: shellResume.postponed,
|
|
258
|
-
nonce,
|
|
259
|
-
}),
|
|
260
|
-
);
|
|
261
|
-
response = createResponseWithMergedHeaders(resumedStream, {
|
|
262
|
-
headers: {
|
|
263
|
-
"content-type": "text/html;charset=utf-8",
|
|
264
|
-
"x-rango-shell-resumed": "1",
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
} else {
|
|
268
|
-
// ssr-render-html metric + rango.ssr span from one boundary. render:total is
|
|
269
|
-
// recorded by the observePhase wrapper around this function.
|
|
270
|
-
const htmlStream = await observePhase(PHASES.ssr, () =>
|
|
271
|
-
ssrModule.renderHTML(rscStream, {
|
|
272
|
-
nonce,
|
|
273
|
-
streamMode,
|
|
274
|
-
}),
|
|
275
|
-
);
|
|
276
|
-
response = createResponseWithMergedHeaders(htmlStream, {
|
|
277
|
-
headers: { "content-type": "text/html;charset=utf-8" },
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// --- Axis 2: PPR shell CAPTURE (background task; see design doc) ---
|
|
282
|
-
// The middleware set reqCtx._shellCapture (the "capture wanted" descriptor)
|
|
283
|
-
// before its single next(). Capture does NOT flow through the HTTP pipeline: we
|
|
284
|
-
// schedule a background task that re-derives the shell via router.match() under
|
|
285
|
-
// its own derived context (fresh handle store, _shellCaptureRun: true), so the
|
|
286
|
-
// middleware chain never re-runs. Eligibility mirrors resume plus a servable
|
|
287
|
-
// 200-HTML gate; the descriptor is read now (still set — the middleware clears
|
|
288
|
-
// it in a finally after next() returns, which is after this synchronous point).
|
|
289
|
-
maybeScheduleShellCapture(
|
|
290
|
-
ctx,
|
|
291
|
-
request,
|
|
292
|
-
env,
|
|
293
|
-
url,
|
|
294
|
-
reqCtx,
|
|
295
|
-
ssrModule,
|
|
296
|
-
nonce,
|
|
297
|
-
streamMode,
|
|
298
|
-
isPartial,
|
|
299
|
-
response,
|
|
352
|
+
// ssr-render-html metric + rango.ssr span from one boundary. render:total is
|
|
353
|
+
// recorded by the observePhase wrapper around this function.
|
|
354
|
+
const htmlStream = await observePhase(PHASES.ssr, () =>
|
|
355
|
+
ssrModule.renderHTML(rscStream, {
|
|
356
|
+
nonce,
|
|
357
|
+
streamMode,
|
|
358
|
+
}),
|
|
300
359
|
);
|
|
360
|
+
const response = createResponseWithMergedHeaders(htmlStream, {
|
|
361
|
+
headers: { "content-type": "text/html;charset=utf-8" },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
|
|
365
|
+
// The ppr route missed its shell above. Schedule the background capture only
|
|
366
|
+
// when the served response is a 200 HTML document (a 404/error render is not a
|
|
367
|
+
// cacheable shell), and tag the response for observability either way. Capture
|
|
368
|
+
// does NOT flow through the HTTP pipeline: scheduleShellCapture re-derives the
|
|
369
|
+
// page via router.match() under a derived context (fresh handle store,
|
|
370
|
+
// _shellCaptureRun: true) — middleware never re-runs; it already ran for this
|
|
371
|
+
// request and guarding is serve-time.
|
|
372
|
+
if (pprMiss) {
|
|
373
|
+
if (
|
|
374
|
+
response.status === 200 &&
|
|
375
|
+
(response.headers.get("content-type") ?? "").includes("text/html")
|
|
376
|
+
) {
|
|
377
|
+
scheduleShellCapture(
|
|
378
|
+
ctx,
|
|
379
|
+
request,
|
|
380
|
+
env,
|
|
381
|
+
url,
|
|
382
|
+
reqCtx,
|
|
383
|
+
pprMiss.ssrModule,
|
|
384
|
+
pprMiss.descriptor,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
response.headers.set(SHELL_STATUS_HEADER, "MISS");
|
|
388
|
+
}
|
|
301
389
|
|
|
302
390
|
return response;
|
|
303
391
|
}
|
|
304
392
|
|
|
305
393
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
394
|
+
* Serve a validated shell HIT: commit the composed response NOW — the stored
|
|
395
|
+
* prelude bytes are the first thing on the wire — and run the live tail
|
|
396
|
+
* (match(), fresh loaders, full Flight render for hydration, fizz resume of just
|
|
397
|
+
* the holes) BEHIND them inside the response stream. React relies on HTML-parser
|
|
398
|
+
* foster-parenting for content streamed after the prelude's closing
|
|
399
|
+
* `</body></html>`, so plain byte concatenation is the correct composition.
|
|
400
|
+
*
|
|
401
|
+
* Status and headers are committed at the flush: middleware already ran (their
|
|
402
|
+
* ctx.res headers merge in via createResponseWithMergedHeaders), and route
|
|
403
|
+
* middleware code after its next() can still adjust headers on the returned
|
|
404
|
+
* Response object. A failing hole cannot become a 500/redirect after this point —
|
|
405
|
+
* error UI renders inline via Suspense/error boundaries, the documented PPR
|
|
406
|
+
* constraint.
|
|
407
|
+
*
|
|
408
|
+
* The tail promise is kicked off SYNCHRONOUSLY so match/Flight/resume run inside
|
|
409
|
+
* the current ALS request-context frame (the stream may be pulled by the server
|
|
410
|
+
* adapter outside it).
|
|
313
411
|
*/
|
|
314
|
-
function
|
|
412
|
+
function serveShellHit(
|
|
315
413
|
ctx: HandlerContext<any>,
|
|
316
414
|
request: Request,
|
|
317
415
|
env: any,
|
|
318
416
|
url: URL,
|
|
319
417
|
reqCtx: RequestContext<any>,
|
|
418
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
320
419
|
ssrModule: SSRModule,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
420
|
+
entry: ShellCacheEntry,
|
|
421
|
+
): Response {
|
|
422
|
+
const preludeBytes = base64ToBytes(entry.prelude);
|
|
423
|
+
|
|
424
|
+
const renderTail = async (
|
|
425
|
+
activeCtx: RequestContext<any>,
|
|
426
|
+
): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
|
|
427
|
+
const match = await ctx.router.match(request, { env });
|
|
428
|
+
if (match.redirect) return { redirect: match.redirect };
|
|
429
|
+
setRequestContextParams(match.params, match.routeName);
|
|
430
|
+
const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
|
|
431
|
+
// Theme fidelity for resume: initialTheme is per-request METADATA (the
|
|
432
|
+
// visitor's cookie), but React resume requires the tree above the holes to
|
|
433
|
+
// match the frozen prelude, which was rendered with the CAPTURE's
|
|
434
|
+
// initialTheme. Replay the captured value into the payload (the SSR resume
|
|
435
|
+
// tree AND client hydration both read it) so the trees agree by
|
|
436
|
+
// construction. The visitor still sees THEIR theme: the FOUC script in the
|
|
437
|
+
// prelude applies it pre-paint from the cookie, and ThemeProvider re-syncs
|
|
438
|
+
// its state from the cookie post-mount.
|
|
439
|
+
if (payload.metadata) {
|
|
440
|
+
payload.metadata.initialTheme = entry.initialTheme as
|
|
441
|
+
| import("../theme/types.js").Theme
|
|
442
|
+
| undefined;
|
|
443
|
+
}
|
|
444
|
+
// Full Flight render per request: hydration needs the whole payload (there
|
|
445
|
+
// is no Flight-side resume — a React limitation, not ours).
|
|
446
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
447
|
+
onError: (error: unknown) => {
|
|
448
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
return observePhase(PHASES.ssr, () =>
|
|
452
|
+
ssrModule.resumeShellHTML!(rscStream, {
|
|
453
|
+
postponed: entry.postponed,
|
|
454
|
+
nonce: undefined,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const tailPromise: Promise<
|
|
460
|
+
ReadableStream<Uint8Array> | { redirect: string }
|
|
461
|
+
> = (async () => {
|
|
462
|
+
// Capture data snapshot seeding (docs/design/ppr-shell-resume.md): the tail
|
|
463
|
+
// is a FULL FRESH render whose payload must match the frozen prelude. If the
|
|
464
|
+
// capture recorded a snapshot, run the tail through a SeededShellStore
|
|
465
|
+
// overlay so every cache-store read the capture pinned returns its
|
|
466
|
+
// capture-time value AS FRESH — the shell region reproduces byte-identically
|
|
467
|
+
// even after the underlying cache entries drifted (expired/recomputed/
|
|
468
|
+
// tag-invalidated). Everything not pinned (the holes — masked loaders were
|
|
469
|
+
// never recorded) falls through to the real store and stays LIVE. The
|
|
470
|
+
// overlay lives on a DERIVED context (own _cacheStore), so the shared reqCtx
|
|
471
|
+
// is untouched; an entry without a snapshot keeps the pre-snapshot behavior.
|
|
472
|
+
if (entry.snapshot && entry.snapshot.length > 0 && reqCtx._cacheStore) {
|
|
473
|
+
const seededCtx: RequestContext<any> = Object.create(reqCtx);
|
|
474
|
+
seededCtx._cacheStore = new SeededShellStore(
|
|
475
|
+
reqCtx._cacheStore,
|
|
476
|
+
entry.snapshot,
|
|
477
|
+
);
|
|
478
|
+
return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
|
|
479
|
+
}
|
|
480
|
+
return renderTail(reqCtx);
|
|
481
|
+
})();
|
|
482
|
+
// The stream below is the only consumer; pre-attach a no-op catch so a tail
|
|
483
|
+
// failure before the stream is pulled never surfaces as an unhandled rejection.
|
|
484
|
+
tailPromise.catch(() => {});
|
|
485
|
+
|
|
486
|
+
const body = new ReadableStream<Uint8Array>({
|
|
487
|
+
async start(controller) {
|
|
488
|
+
controller.enqueue(preludeBytes);
|
|
489
|
+
try {
|
|
490
|
+
const tail = await tailPromise;
|
|
491
|
+
if (tail instanceof ReadableStream) {
|
|
492
|
+
const reader = tail.getReader();
|
|
493
|
+
try {
|
|
494
|
+
for (;;) {
|
|
495
|
+
const { done, value } = await reader.read();
|
|
496
|
+
if (done) break;
|
|
497
|
+
controller.enqueue(value);
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
reader.releaseLock();
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
// Defensive, near-unreachable: a redirecting match cannot have captured
|
|
504
|
+
// a shell (capture bails on redirects), so a HIT on a redirecting URL
|
|
505
|
+
// requires the route to have BECOME redirecting within the shell TTL.
|
|
506
|
+
// The 200 + prelude are already committed; degrade to a client-side
|
|
507
|
+
// replace so the user still lands on the target.
|
|
508
|
+
controller.enqueue(
|
|
509
|
+
new TextEncoder().encode(
|
|
510
|
+
`<script>location.replace(${JSON.stringify(tail.redirect)})</script>`,
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
controller.close();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
controller.error(error);
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return createResponseWithMergedHeaders(body, {
|
|
522
|
+
headers: {
|
|
523
|
+
"content-type": "text/html;charset=utf-8",
|
|
524
|
+
[SHELL_STATUS_HEADER]: "HIT",
|
|
525
|
+
},
|
|
526
|
+
});
|
|
337
527
|
}
|