@rangojs/router 0.0.0-experimental.147 → 0.0.0-experimental.149
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 +19 -3
- package/package.json +1 -1
- package/skills/mime-routes/SKILL.md +25 -17
- package/skills/ppr/SKILL.md +20 -10
- package/src/cache/cf/cf-cache-store.ts +37 -2
- package/src/index.rsc.ts +6 -0
- package/src/prerender/build-shell-capture.ts +17 -1
- package/src/router/content-negotiation.ts +47 -5
- package/src/router/metrics.ts +17 -2
- 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/handler.ts +4 -2
- package/src/rsc/rsc-rendering.ts +49 -0
- package/src/rsc/shell-build-manifest.ts +40 -10
- package/src/rsc/shell-capture-constants.ts +27 -0
- package/src/rsc/shell-capture.ts +388 -24
- package/src/rsc/shell-serve.ts +44 -0
- package/src/rsc/ssr-setup.ts +54 -22
- package/src/server/context.ts +1 -0
- package/src/ssr/index.tsx +42 -12
- package/src/urls/pattern-types.ts +29 -0
- package/src/vite/discovery/shell-prerender-phase.ts +2 -0
- package/src/vite/discovery/state.ts +3 -1
- package/src/vite/router-discovery.ts +20 -2
package/dist/vite/index.js
CHANGED
|
@@ -2520,7 +2520,7 @@ import { resolve } from "node:path";
|
|
|
2520
2520
|
// package.json
|
|
2521
2521
|
var package_default = {
|
|
2522
2522
|
name: "@rangojs/router",
|
|
2523
|
-
version: "0.0.0-experimental.
|
|
2523
|
+
version: "0.0.0-experimental.149",
|
|
2524
2524
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2525
2525
|
keywords: [
|
|
2526
2526
|
"react",
|
|
@@ -4671,6 +4671,13 @@ import { readFileSync as readFileSync7 } from "node:fs";
|
|
|
4671
4671
|
import { createRequire as createRequire3, register } from "node:module";
|
|
4672
4672
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
4673
4673
|
|
|
4674
|
+
// src/rsc/shell-serve.ts
|
|
4675
|
+
import React from "react";
|
|
4676
|
+
var DEV_SHELL_PROBE_TIMEOUT_MS = 1e4;
|
|
4677
|
+
function normalizeCaptureTimeout(value) {
|
|
4678
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 1 ? value : void 0;
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4674
4681
|
// src/vite/inject-client-debug.ts
|
|
4675
4682
|
function isRouterInternalDebugId(id) {
|
|
4676
4683
|
if (!id.includes("internal-debug")) return false;
|
|
@@ -6410,6 +6417,8 @@ async function runShellPrerenderPhase(s, builder) {
|
|
|
6410
6417
|
ttl: policy.ttl,
|
|
6411
6418
|
swr: policy.swr,
|
|
6412
6419
|
tags: policy.tags,
|
|
6420
|
+
maxSnapshotBytes: policy.maxSnapshotBytes,
|
|
6421
|
+
captureTimeout: policy.captureTimeout,
|
|
6413
6422
|
buildEnv: s.resolvedBuildEnv,
|
|
6414
6423
|
buildVersion,
|
|
6415
6424
|
captureShellHTML,
|
|
@@ -7701,6 +7710,11 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
|
|
|
7701
7710
|
const swr = swrRaw === null ? void 0 : Number(swrRaw);
|
|
7702
7711
|
const tagsRaw = url.searchParams.get("tags");
|
|
7703
7712
|
const tags = tagsRaw ? tagsRaw.split(",") : void 0;
|
|
7713
|
+
const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
|
|
7714
|
+
const maxSnapshotBytes = maxSnapshotBytesRaw === null ? void 0 : Number(maxSnapshotBytesRaw);
|
|
7715
|
+
const captureTimeout = normalizeCaptureTimeout(
|
|
7716
|
+
Number(url.searchParams.get("captureTimeout"))
|
|
7717
|
+
);
|
|
7704
7718
|
const rscEnvMain = server.environments?.rsc;
|
|
7705
7719
|
let rscRealm = null;
|
|
7706
7720
|
let ssrRealm = null;
|
|
@@ -7750,7 +7764,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
|
|
|
7750
7764
|
res.end("Shell capture registry not available");
|
|
7751
7765
|
return;
|
|
7752
7766
|
}
|
|
7753
|
-
const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|v=${version}`;
|
|
7767
|
+
const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
|
|
7754
7768
|
for (const [, routerInstance] of registry) {
|
|
7755
7769
|
if (typeof routerInstance.match !== "function") continue;
|
|
7756
7770
|
const cached = devPrerenderCache.get(routerInstance, cacheKey);
|
|
@@ -7765,7 +7779,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
|
|
|
7765
7779
|
try {
|
|
7766
7780
|
const probe = await fetch(
|
|
7767
7781
|
`${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
|
|
7768
|
-
{ signal: AbortSignal.timeout(
|
|
7782
|
+
{ signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) }
|
|
7769
7783
|
);
|
|
7770
7784
|
if (!probe.ok) {
|
|
7771
7785
|
res.statusCode = 404;
|
|
@@ -7798,6 +7812,8 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
|
|
|
7798
7812
|
ttl,
|
|
7799
7813
|
swr,
|
|
7800
7814
|
tags,
|
|
7815
|
+
maxSnapshotBytes,
|
|
7816
|
+
captureTimeout,
|
|
7801
7817
|
buildEnv: s.resolvedBuildEnv,
|
|
7802
7818
|
buildVersion: version,
|
|
7803
7819
|
captureShellHTML: ssrModule.captureShellHTML,
|
package/package.json
CHANGED
|
@@ -41,26 +41,33 @@ When an API client requests the same URL (`Accept: application/json`), the JSON
|
|
|
41
41
|
1. **Q-value priority** — higher `q` wins (`Accept: application/json;q=0.9, text/html;q=1.0` serves RSC)
|
|
42
42
|
2. **Client order tiebreaker** — when q-values are equal, the type listed first in Accept wins (matches Express/Hono behavior)
|
|
43
43
|
3. **Specific MIME match** — the variant whose MIME type appears in Accept wins
|
|
44
|
-
4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins)
|
|
44
|
+
4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins); when the RSC route wins this way, it serves the HTML document
|
|
45
45
|
5. **All responses** on a negotiated URL get `Vary: Accept` header, including the RSC side
|
|
46
46
|
|
|
47
|
-
RSC participates as a
|
|
48
|
-
|
|
47
|
+
RSC participates as a candidate alongside response-type variants under two MIME
|
|
48
|
+
types: `text/html` (the document — its canonical representation) and
|
|
49
|
+
`text/x-component` (the RSC flight wire format). There is no special
|
|
50
|
+
short-circuit — RSC follows the same negotiation rules as other types.
|
|
49
51
|
|
|
50
52
|
The MIME mapping used for matching:
|
|
51
53
|
|
|
52
|
-
| Tag | MIME type
|
|
53
|
-
| -------------------- |
|
|
54
|
-
| RSC (plain `path()`) | `text/html`
|
|
55
|
-
| `json` | `application/json`
|
|
56
|
-
| `text` | `text/plain`
|
|
57
|
-
| `xml` | `application/xml`
|
|
58
|
-
| `html` | `text/html`
|
|
59
|
-
| `md` | `text/markdown`
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
the
|
|
54
|
+
| Tag | MIME type |
|
|
55
|
+
| -------------------- | -------------------------------------- |
|
|
56
|
+
| RSC (plain `path()`) | `text/html` **and** `text/x-component` |
|
|
57
|
+
| `json` | `application/json` |
|
|
58
|
+
| `text` | `text/plain` |
|
|
59
|
+
| `xml` | `application/xml` |
|
|
60
|
+
| `html` | `text/html` |
|
|
61
|
+
| `md` | `text/markdown` |
|
|
62
|
+
|
|
63
|
+
Which representation an RSC win renders is decided by the same Accept header:
|
|
64
|
+
the flight wire format is **explicit opt-in only** (`Accept: text/x-component`,
|
|
65
|
+
or the internal `_rsc_*`/`__rsc` transport params the client runtime sends).
|
|
66
|
+
Everything else — browsers, `curl` (`*/*`), a missing Accept header, mismatched
|
|
67
|
+
types like `application/json` on a URL with no JSON variant — gets the HTML
|
|
68
|
+
document. A generic HTTP client never sees the wire format by accident, and an
|
|
69
|
+
explicit `Accept: text/x-component` selects the RSC flight stream even on a
|
|
70
|
+
route where a response variant is defined first.
|
|
64
71
|
|
|
65
72
|
Tags `image`, `stream`, and `any` are pass-through and do not participate in Accept matching.
|
|
66
73
|
|
|
@@ -77,11 +84,12 @@ export const urlpatterns = urls(({ path }) => [
|
|
|
77
84
|
]);
|
|
78
85
|
```
|
|
79
86
|
|
|
80
|
-
- `Accept: text/html` — RSC page
|
|
87
|
+
- `Accept: text/html` — RSC page (HTML document)
|
|
81
88
|
- `Accept: application/json` — JSON handler
|
|
82
89
|
- `Accept: text/plain` — text handler
|
|
83
90
|
- `Accept: application/xml` — XML handler
|
|
84
|
-
- `Accept: */*` — RSC page (the primary, since it was registered first)
|
|
91
|
+
- `Accept: */*` — RSC page as HTML (the primary, since it was registered first)
|
|
92
|
+
- `Accept: text/x-component` — RSC page as the flight wire format
|
|
85
93
|
|
|
86
94
|
## Wildcard Routes
|
|
87
95
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -171,13 +171,12 @@ auth middleware anywhere (global or route DSL) and it guards PPR for free.
|
|
|
171
171
|
|
|
172
172
|
## Verifying it works
|
|
173
173
|
|
|
174
|
-
The header exists on DOCUMENT responses only. A bare `curl`
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
wrong request shape:
|
|
174
|
+
The header exists on DOCUMENT responses only. A bare `curl` gets the HTML
|
|
175
|
+
document (Flight is explicit-opt-in via `Accept: text/x-component`), so it
|
|
176
|
+
sees the header directly; only an explicit Flight request shape lacks it:
|
|
178
177
|
|
|
179
178
|
```
|
|
180
|
-
curl -s -D - -o /dev/null
|
|
179
|
+
curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-shell
|
|
181
180
|
```
|
|
182
181
|
|
|
183
182
|
- First document GET: `MISS`, plus a background capture.
|
|
@@ -193,6 +192,16 @@ curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/product
|
|
|
193
192
|
- A ppr-declared route that CANNOT be honored (missing shell store family,
|
|
194
193
|
per-request nonce) serves plain axis 1 with NO header and warns once per
|
|
195
194
|
key — no header + a declared `ppr` means look for that warning.
|
|
195
|
+
- On Cloudflare, `CFCacheStore` WITHOUT a KV namespace has an inert shell
|
|
196
|
+
family (the shell tier is KV-only): every ppr route stays `MISS` forever.
|
|
197
|
+
The store warns once per isolate — bind KV
|
|
198
|
+
(`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
|
|
199
|
+
- Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
|
|
200
|
+
logs one line per capture attempt/skip (outcome, durations, prelude and
|
|
201
|
+
snapshot bytes, backoff state); pass a function to receive each
|
|
202
|
+
`ShellCaptureDebugEvent` instead. In dev, with `debugPerformance` on, the
|
|
203
|
+
last capture outcome for a key also rides the next document GET's
|
|
204
|
+
`Server-Timing` as `ppr-capture;desc="…"`.
|
|
196
205
|
|
|
197
206
|
## The hole doctrine (encode this in your head)
|
|
198
207
|
|
|
@@ -505,11 +514,12 @@ path(
|
|
|
505
514
|
);
|
|
506
515
|
```
|
|
507
516
|
|
|
508
|
-
| Field
|
|
509
|
-
|
|
|
510
|
-
| `ttl`
|
|
511
|
-
| `swr`
|
|
512
|
-
| `tags`
|
|
517
|
+
| Field | Default | Notes |
|
|
518
|
+
| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
519
|
+
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
|
|
520
|
+
| `swr` | — | stale window: serve the stale shell + background recapture |
|
|
521
|
+
| `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
|
|
522
|
+
| `maxSnapshotBytes` | 8 MiB | cap on the entry's capture data snapshot; over it the snapshot is skipped (shell still stored, warned once per key) so the entry stays under store limits |
|
|
513
523
|
|
|
514
524
|
The shell store is always the app-level `createRouter({ cache })` store; the
|
|
515
525
|
default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
|
|
@@ -137,6 +137,13 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
137
137
|
*/
|
|
138
138
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Stores (by namespace) already warned about the shell family being inert
|
|
142
|
+
* (getShell/putShell no-op without a KV namespace), so a ppr route hitting the
|
|
143
|
+
* silent fail-open warns once per isolate instead of on every request.
|
|
144
|
+
*/
|
|
145
|
+
const warnedShellFamilyInert = new Set<string>();
|
|
146
|
+
|
|
140
147
|
/**
|
|
141
148
|
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
142
149
|
* markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
|
|
@@ -1642,6 +1649,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1642
1649
|
// still applies: shell entries carry tags/taggedAt and are checked against the
|
|
1643
1650
|
// same KV markers isGloballyInvalidated() reads for every other tier.
|
|
1644
1651
|
|
|
1652
|
+
/**
|
|
1653
|
+
* Warn once per isolate that the shell family is inert: getShell/putShell
|
|
1654
|
+
* are ONLY called for routes that declared the `ppr` path option, so firing
|
|
1655
|
+
* here (not in the constructor) scopes the warning to apps that actually
|
|
1656
|
+
* use PPR — a KV-less CFCacheStore is a perfectly fine config otherwise.
|
|
1657
|
+
* Without it, the correctness-first fail-open (issue #651) is invisible:
|
|
1658
|
+
* every ppr route is a permanent MISS with zero diagnostics.
|
|
1659
|
+
* @internal
|
|
1660
|
+
*/
|
|
1661
|
+
private warnShellFamilyInertOnce(): void {
|
|
1662
|
+
this.warnOncePerNamespace(
|
|
1663
|
+
warnedShellFamilyInert,
|
|
1664
|
+
`[CFCacheStore] a ppr route resolved to this store, but no KV namespace ` +
|
|
1665
|
+
`is configured, so the shell family (getShell/putShell) is a no-op: ` +
|
|
1666
|
+
`every ppr route stays a permanent shell MISS (the page still serves ` +
|
|
1667
|
+
`via a full render). Bind a KV namespace and pass it — ` +
|
|
1668
|
+
`new CFCacheStore({ ctx, kv: env.CACHE_KV }) — or use a shell-capable ` +
|
|
1669
|
+
`store via createRouter({ cache }).`,
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1645
1673
|
/**
|
|
1646
1674
|
* Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
|
|
1647
1675
|
* budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
|
|
@@ -1652,7 +1680,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1652
1680
|
async getShell(
|
|
1653
1681
|
key: string,
|
|
1654
1682
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
1655
|
-
if (!this.kv)
|
|
1683
|
+
if (!this.kv) {
|
|
1684
|
+
this.warnShellFamilyInertOnce();
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1656
1687
|
try {
|
|
1657
1688
|
const kvKey = this.toKVKey(`shell:${key}`);
|
|
1658
1689
|
const { value: envelope, timedOut } =
|
|
@@ -1709,7 +1740,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1709
1740
|
tags?: string[],
|
|
1710
1741
|
): Promise<void> {
|
|
1711
1742
|
// KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
|
|
1712
|
-
if (!this.kv
|
|
1743
|
+
if (!this.kv) {
|
|
1744
|
+
this.warnShellFamilyInertOnce();
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (!this.waitUntil) return;
|
|
1713
1748
|
try {
|
|
1714
1749
|
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
1715
1750
|
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
package/src/index.rsc.ts
CHANGED
|
@@ -88,6 +88,12 @@ export type {
|
|
|
88
88
|
OriginCheckPhase,
|
|
89
89
|
} from "./rsc/origin-guard.js";
|
|
90
90
|
|
|
91
|
+
// PPR shell-capture debug sink types (RangoOptions.debugShellCapture)
|
|
92
|
+
export type {
|
|
93
|
+
ShellCaptureDebug,
|
|
94
|
+
ShellCaptureDebugEvent,
|
|
95
|
+
} from "./rsc/shell-capture.js";
|
|
96
|
+
|
|
91
97
|
// Server-side createLoader and redirect
|
|
92
98
|
export {
|
|
93
99
|
createLoader,
|
|
@@ -49,7 +49,9 @@ import {
|
|
|
49
49
|
* drift from the serve-side one.
|
|
50
50
|
*/
|
|
51
51
|
export function resolveBuildPprConfig(
|
|
52
|
-
ppr:
|
|
52
|
+
ppr:
|
|
53
|
+
| true
|
|
54
|
+
| { ttl?: number; swr?: number; tags?: string[]; captureTimeout?: number },
|
|
53
55
|
): ResolvedPprConfig {
|
|
54
56
|
const resolved = resolvePprConfig({ type: "route", ppr } as any);
|
|
55
57
|
// resolvePprConfig returns null only for undefined/false ppr; the collector
|
|
@@ -76,6 +78,18 @@ export interface BuildShellCaptureOptions {
|
|
|
76
78
|
swr?: number;
|
|
77
79
|
/** The route's static ppr.tags (the capture unions render-recorded tags). */
|
|
78
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;
|
|
79
93
|
/** Build-time env bindings (rango plugin buildEnv), if configured. */
|
|
80
94
|
buildEnv?: unknown;
|
|
81
95
|
/**
|
|
@@ -183,8 +197,10 @@ async function attemptBuildCapture(
|
|
|
183
197
|
ttl: opts.ttl,
|
|
184
198
|
swr: opts.swr,
|
|
185
199
|
tags: opts.tags,
|
|
200
|
+
captureTimeout: opts.captureTimeout,
|
|
186
201
|
store: collector as any,
|
|
187
202
|
debug: opts.debug,
|
|
203
|
+
maxSnapshotBytes: opts.maxSnapshotBytes,
|
|
188
204
|
};
|
|
189
205
|
|
|
190
206
|
let mismatchedRouteName: string | undefined;
|
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
|
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) &&
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -31,6 +31,9 @@ import { gateTransitions } from "./transition-gate.js";
|
|
|
31
31
|
import { buildFullPayload } from "./full-payload.js";
|
|
32
32
|
import {
|
|
33
33
|
scheduleShellCapture,
|
|
34
|
+
resolveShellCaptureDebugSink,
|
|
35
|
+
takeCaptureDebugEventForTiming,
|
|
36
|
+
describeShellCaptureEvent,
|
|
34
37
|
type ShellCaptureDescriptor,
|
|
35
38
|
} from "./shell-capture.js";
|
|
36
39
|
import {
|
|
@@ -140,6 +143,29 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
140
143
|
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
141
144
|
const store = reqCtx._cacheStore;
|
|
142
145
|
const key = buildShellKey(url);
|
|
146
|
+
// Dev Server-Timing mirror (issue #651): a capture runs AFTER its
|
|
147
|
+
// triggering response committed, so its outcome can only ride a LATER
|
|
148
|
+
// response's header. When the metrics surface is active
|
|
149
|
+
// (debugPerformance), fold the buffered terminal capture event for this
|
|
150
|
+
// key into THIS request's Server-Timing as `ppr-capture;dur=<attempt
|
|
151
|
+
// ms>;desc="<outcome + sizes + waits>"`. Consuming (read-and-clear)
|
|
152
|
+
// keeps one capture = one report. Dev-only: the buffer is only written
|
|
153
|
+
// in dev (see takeCaptureDebugEventForTiming), and production folds the
|
|
154
|
+
// whole branch away.
|
|
155
|
+
if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
|
|
156
|
+
const lastCapture = takeCaptureDebugEventForTiming(key);
|
|
157
|
+
if (lastCapture) {
|
|
158
|
+
appendMetric(
|
|
159
|
+
reqCtx._metricsStore,
|
|
160
|
+
"ppr:capture",
|
|
161
|
+
performance.now(),
|
|
162
|
+
lastCapture.attemptMs ?? 0,
|
|
163
|
+
undefined,
|
|
164
|
+
// attemptMs already rides as this entry's dur — drop it from desc.
|
|
165
|
+
describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
143
169
|
if (activeNonce !== undefined) {
|
|
144
170
|
// Declared intent that cannot be honored deserves a diagnostic (unlike an
|
|
145
171
|
// undeclared route, which is silent): a ppr route gated off by an active
|
|
@@ -170,8 +196,16 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
170
196
|
ttl: pprConfig.ttl,
|
|
171
197
|
swr: pprConfig.swr,
|
|
172
198
|
tags: pprConfig.tags,
|
|
199
|
+
captureTimeout: pprConfig.captureTimeout,
|
|
173
200
|
store,
|
|
174
201
|
debug: INTERNAL_RANGO_DEBUG,
|
|
202
|
+
maxSnapshotBytes: pprConfig.maxSnapshotBytes,
|
|
203
|
+
// The resolver owns the whole policy: option wins, the
|
|
204
|
+
// INTERNAL_RANGO_DEBUG env flag lights the events up when no
|
|
205
|
+
// option is set, an explicit `false` stays off.
|
|
206
|
+
debugSink: resolveShellCaptureDebugSink(
|
|
207
|
+
ctx.router.debugShellCapture,
|
|
208
|
+
),
|
|
175
209
|
};
|
|
176
210
|
// One serve funnel for BOTH entry sources (runtime store hit below,
|
|
177
211
|
// build-manifest hit further down): schedule the background
|
|
@@ -204,12 +238,25 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
204
238
|
);
|
|
205
239
|
};
|
|
206
240
|
let cached: Awaited<ReturnType<typeof store.getShell>> = null;
|
|
241
|
+
const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
|
|
207
242
|
try {
|
|
208
243
|
cached = await store.getShell(key);
|
|
209
244
|
} catch (error) {
|
|
210
245
|
// A failing store read degrades to axis 1 (MISS), never a 500.
|
|
211
246
|
reportCacheError(error, "cache-read", "[ShellServe] getShell");
|
|
212
247
|
}
|
|
248
|
+
if (reqCtx._metricsStore) {
|
|
249
|
+
// Raw store outcome (pre-validity-gates), so a version-mismatch
|
|
250
|
+
// lifecycle miss is still distinguishable from a store miss.
|
|
251
|
+
appendMetric(
|
|
252
|
+
reqCtx._metricsStore,
|
|
253
|
+
"ppr:shell-read",
|
|
254
|
+
shellReadStart,
|
|
255
|
+
performance.now() - shellReadStart,
|
|
256
|
+
undefined,
|
|
257
|
+
cached ? "hit" : "miss",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
213
260
|
if (cached && isValidShellHit(cached.entry, ctx.version)) {
|
|
214
261
|
if (!hasIntactShellPayload(cached.entry)) {
|
|
215
262
|
// Corrupt stored payload (undecodable prelude / unparseable
|
|
@@ -257,6 +304,8 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
257
304
|
ttl: pprConfig.ttl,
|
|
258
305
|
swr: pprConfig.swr,
|
|
259
306
|
tags: pprConfig.tags,
|
|
307
|
+
maxSnapshotBytes: pprConfig.maxSnapshotBytes,
|
|
308
|
+
captureTimeout: pprConfig.captureTimeout,
|
|
260
309
|
}
|
|
261
310
|
: undefined,
|
|
262
311
|
);
|