@rangojs/router 0.0.0-experimental.147 → 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/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.ts +386 -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/urls/pattern-types.ts +27 -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/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
|
}
|
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
|
/**
|
|
@@ -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<
|
|
@@ -243,6 +243,8 @@ export async function runShellPrerenderPhase(
|
|
|
243
243
|
ttl: policy.ttl,
|
|
244
244
|
swr: policy.swr,
|
|
245
245
|
tags: policy.tags,
|
|
246
|
+
maxSnapshotBytes: policy.maxSnapshotBytes,
|
|
247
|
+
captureTimeout: policy.captureTimeout,
|
|
246
248
|
buildEnv: s.resolvedBuildEnv,
|
|
247
249
|
buildVersion,
|
|
248
250
|
captureShellHTML,
|
|
@@ -73,7 +73,9 @@ export interface ShellPrerenderCandidate {
|
|
|
73
73
|
urlPath: string;
|
|
74
74
|
routeName: string;
|
|
75
75
|
paramHash: string;
|
|
76
|
-
ppr:
|
|
76
|
+
ppr:
|
|
77
|
+
| true
|
|
78
|
+
| { ttl?: number; swr?: number; tags?: string[]; captureTimeout?: number };
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export interface DiscoveryState {
|
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
createScanFilter,
|
|
20
20
|
} from "../build/generate-route-types.js";
|
|
21
21
|
import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
|
|
22
|
+
import {
|
|
23
|
+
DEV_SHELL_PROBE_TIMEOUT_MS,
|
|
24
|
+
normalizeCaptureTimeout,
|
|
25
|
+
} from "../rsc/shell-serve.js";
|
|
22
26
|
import {
|
|
23
27
|
injectClientDebugFlag,
|
|
24
28
|
internalDebugNoCacheMiddleware,
|
|
@@ -1144,6 +1148,18 @@ export function createRouterDiscoveryPlugin(
|
|
|
1144
1148
|
const swr = swrRaw === null ? undefined : Number(swrRaw);
|
|
1145
1149
|
const tagsRaw = url.searchParams.get("tags");
|
|
1146
1150
|
const tags = tagsRaw ? tagsRaw.split(",") : undefined;
|
|
1151
|
+
const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
|
|
1152
|
+
const maxSnapshotBytes =
|
|
1153
|
+
maxSnapshotBytesRaw === null
|
|
1154
|
+
? undefined
|
|
1155
|
+
: Number(maxSnapshotBytesRaw);
|
|
1156
|
+
// Boundary revalidation via the SHARED normalizer (shell-serve.ts):
|
|
1157
|
+
// the param crossed an HTTP query string, and a garbage value must
|
|
1158
|
+
// fall back to the capture default, never reach setTimeout as NaN
|
|
1159
|
+
// (which Node clamps to ~1ms — an instant abort).
|
|
1160
|
+
const captureTimeout = normalizeCaptureTimeout(
|
|
1161
|
+
Number(url.searchParams.get("captureTimeout")),
|
|
1162
|
+
);
|
|
1147
1163
|
|
|
1148
1164
|
// Resolve the capture realms: main-server envs (Node preset) or the
|
|
1149
1165
|
// shared temp Node server (Cloudflare preset — no main RSC runner).
|
|
@@ -1205,7 +1221,7 @@ export function createRouterDiscoveryPlugin(
|
|
|
1205
1221
|
// (this fetch blocks a foreground document request), and the memoized
|
|
1206
1222
|
// body needs neither the pre-flight round-trip nor a capture. Keyed
|
|
1207
1223
|
// per router instance (= HMR generation) like the prerender memo.
|
|
1208
|
-
const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|v=${version}`;
|
|
1224
|
+
const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
|
|
1209
1225
|
for (const [, routerInstance] of registry) {
|
|
1210
1226
|
if (typeof routerInstance.match !== "function") continue;
|
|
1211
1227
|
const cached = devPrerenderCache.get(routerInstance, cacheKey);
|
|
@@ -1224,7 +1240,7 @@ export function createRouterDiscoveryPlugin(
|
|
|
1224
1240
|
try {
|
|
1225
1241
|
const probe = await fetch(
|
|
1226
1242
|
`${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
|
|
1227
|
-
{ signal: AbortSignal.timeout(
|
|
1243
|
+
{ signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) },
|
|
1228
1244
|
);
|
|
1229
1245
|
if (!probe.ok) {
|
|
1230
1246
|
res.statusCode = 404;
|
|
@@ -1258,6 +1274,8 @@ export function createRouterDiscoveryPlugin(
|
|
|
1258
1274
|
ttl,
|
|
1259
1275
|
swr,
|
|
1260
1276
|
tags,
|
|
1277
|
+
maxSnapshotBytes,
|
|
1278
|
+
captureTimeout,
|
|
1261
1279
|
buildEnv: s.resolvedBuildEnv,
|
|
1262
1280
|
buildVersion: version,
|
|
1263
1281
|
captureShellHTML: ssrModule.captureShellHTML,
|