@rangojs/router 0.0.0-experimental.149 → 0.0.0-experimental.150
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 +18 -3
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +13 -2
- package/src/router/handler-context.ts +8 -9
- package/src/router/intercept-resolution.ts +30 -1
- package/src/router/segment-resolution/fresh.ts +21 -2
- package/src/router/segment-resolution/revalidation.ts +9 -0
- package/src/rsc/shell-serve.ts +4 -3
- package/src/server/context.ts +125 -7
- package/src/server/request-context.ts +64 -25
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.150",
|
|
2524
2524
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2525
2525
|
keywords: [
|
|
2526
2526
|
"react",
|
|
@@ -4673,6 +4673,21 @@ import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
|
4673
4673
|
|
|
4674
4674
|
// src/rsc/shell-serve.ts
|
|
4675
4675
|
import React from "react";
|
|
4676
|
+
|
|
4677
|
+
// src/server/context.ts
|
|
4678
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4679
|
+
var RSC_CONTEXT_KEY = /* @__PURE__ */ Symbol.for("rangojs-router:rsc-context");
|
|
4680
|
+
var RangoContext = globalThis[RSC_CONTEXT_KEY] ??= new AsyncLocalStorage();
|
|
4681
|
+
var LOADER_SCOPE_KEY = /* @__PURE__ */ Symbol.for("rangojs-router:loader-scope");
|
|
4682
|
+
var loaderScopeALS = globalThis[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage();
|
|
4683
|
+
var LOADER_BODY_SCOPE_KEY = /* @__PURE__ */ Symbol.for("rangojs-router:loader-body-scope");
|
|
4684
|
+
var loaderBodyScopeALS = globalThis[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage();
|
|
4685
|
+
var PUSH_CALLBACK_SCOPE_KEY = /* @__PURE__ */ Symbol.for(
|
|
4686
|
+
"rangojs-router:push-callback-scope"
|
|
4687
|
+
);
|
|
4688
|
+
var pushCallbackScopeALS = globalThis[PUSH_CALLBACK_SCOPE_KEY] ??= new AsyncLocalStorage();
|
|
4689
|
+
|
|
4690
|
+
// src/rsc/shell-serve.ts
|
|
4676
4691
|
var DEV_SHELL_PROBE_TIMEOUT_MS = 1e4;
|
|
4677
4692
|
function normalizeCaptureTimeout(value) {
|
|
4678
4693
|
return typeof value === "number" && Number.isFinite(value) && value >= 1 ? value : void 0;
|
|
@@ -4997,13 +5012,13 @@ function checkSelfGenWrite(state, filePath, consume) {
|
|
|
4997
5012
|
}
|
|
4998
5013
|
|
|
4999
5014
|
// src/router/logging.ts
|
|
5000
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5015
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
|
5001
5016
|
|
|
5002
5017
|
// src/internal-debug.ts
|
|
5003
5018
|
var INTERNAL_RANGO_DEBUG = typeof process !== "undefined" && Boolean(process.env?.INTERNAL_RANGO_DEBUG);
|
|
5004
5019
|
|
|
5005
5020
|
// src/router/logging.ts
|
|
5006
|
-
var routerLogContext = new
|
|
5021
|
+
var routerLogContext = new AsyncLocalStorage2();
|
|
5007
5022
|
|
|
5008
5023
|
// src/router/url-params.ts
|
|
5009
5024
|
var PATH_SAFE_ESCAPES = {
|
package/package.json
CHANGED
package/skills/ppr/SKILL.md
CHANGED
|
@@ -318,8 +318,19 @@ The lane is decided PER TREE NODE, at the entry that REGISTERS the loaders —
|
|
|
318
318
|
`loading()` on a CHILD route does not change a parent layout's lane, and
|
|
319
319
|
`loading()` IS valid on layout and parallel entries, not just routes.
|
|
320
320
|
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
Four hard edges (each e2e/unit-pinned):
|
|
322
|
+
|
|
323
|
+
- **Header writes throw (issue #713).** ppr is a document-scoped `cache()`:
|
|
324
|
+
in any cached scenario ONLY MIDDLEWARE writes response headers. A handler
|
|
325
|
+
or loader on a ppr route calling `ctx.headers.set()`, `cookies().set()`,
|
|
326
|
+
`ctx.header()`, `ctx.setTheme()`, or `setStatus()` throws on EVERY render —
|
|
327
|
+
dev and prod,
|
|
328
|
+
first render, same guard family as the `cache()` boundary guard. Handlers
|
|
329
|
+
are replayed on HITs (the write would silently differ between MISS and
|
|
330
|
+
HIT); loaders are live but settle AFTER the response headers flushed with
|
|
331
|
+
the shell (dead letters). Move the write into route middleware — it runs
|
|
332
|
+
on every request, including HITs, and its headers/cookies merge into every
|
|
333
|
+
response.
|
|
323
334
|
- **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
|
|
324
335
|
throws during capture and the capture REFUSES (deterministic, once-per-key
|
|
325
336
|
warned) — identity can never bake into the shared shell. Give that loader's
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
isNonCacheable,
|
|
15
15
|
type ContextSetOptions,
|
|
16
16
|
} from "../context-var.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
assertCachedHeaderWriteAllowed,
|
|
19
|
+
isInsideCacheScope,
|
|
20
|
+
} from "../server/context.js";
|
|
18
21
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
19
22
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
20
23
|
import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
@@ -214,7 +217,9 @@ export function createHandlerContext<TEnv>(
|
|
|
214
217
|
const stubResponse =
|
|
215
218
|
requestContext?.res ?? new Response(null, { status: 200 });
|
|
216
219
|
|
|
217
|
-
// Guard mutating Headers methods so they throw inside "use cache" or
|
|
220
|
+
// Guard mutating Headers methods so they throw inside "use cache" scope or
|
|
221
|
+
// cached header-write territory (cache() boundary / ppr route render —
|
|
222
|
+
// assertCachedHeaderWriteAllowed, the unified #713 guard).
|
|
218
223
|
// Uses lazy `ctx` reference (assigned below) — only the specific handler ctx
|
|
219
224
|
// is stamped by cache-runtime, not the shared request context.
|
|
220
225
|
// MUTATING_HEADERS_METHODS is hoisted to module scope (constant, read-only).
|
|
@@ -226,13 +231,7 @@ export function createHandlerContext<TEnv>(
|
|
|
226
231
|
if (MUTATING_HEADERS_METHODS.has(prop as string)) {
|
|
227
232
|
return (...args: any[]) => {
|
|
228
233
|
assertNotInsideCacheExec(ctx, "headers");
|
|
229
|
-
|
|
230
|
-
throw new Error(
|
|
231
|
-
`ctx.headers.${String(prop)}() cannot be called inside a cache() boundary. ` +
|
|
232
|
-
`On cache hit the handler is skipped, so this side effect would be lost. ` +
|
|
233
|
-
`Move header mutations to a middleware or layout outside the cache() scope.`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
234
|
+
assertCachedHeaderWriteAllowed("ctx.headers", prop);
|
|
236
235
|
return value.apply(target, args);
|
|
237
236
|
};
|
|
238
237
|
}
|
|
@@ -28,7 +28,32 @@ import {
|
|
|
28
28
|
} from "./segment-resolution.js";
|
|
29
29
|
import type { SegmentResolutionDeps } from "./types.js";
|
|
30
30
|
import { debugLog } from "./logging.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
RangoContext,
|
|
33
|
+
latchPprHeaderScopeForEntries,
|
|
34
|
+
runInsideLoaderScope,
|
|
35
|
+
} from "../server/context.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Header-guard latch for the intercept funnels (issue #713). ppr and
|
|
39
|
+
* intercepts do not compose on the shell path — shells are captured/served
|
|
40
|
+
* only for document requests and withInterceptResolution skips intercepts on
|
|
41
|
+
* full matches — so this is defense in depth: a partial nav CAN render an
|
|
42
|
+
* intercept over a ppr-declared target route, in its own store scope where
|
|
43
|
+
* the main funnel's latch does not apply. Latch by declaration, off the
|
|
44
|
+
* target route's manifest entry (same leaf input as the segment funnels).
|
|
45
|
+
* Called AFTER intercept middleware runs — middleware stays the live lane.
|
|
46
|
+
*/
|
|
47
|
+
function latchPprHeaderScopeForInterceptTarget(
|
|
48
|
+
interceptEntry: InterceptEntry,
|
|
49
|
+
): void {
|
|
50
|
+
const targetEntry = RangoContext.getStore()?.manifest.get(
|
|
51
|
+
interceptEntry.routeName,
|
|
52
|
+
);
|
|
53
|
+
if (targetEntry) {
|
|
54
|
+
latchPprHeaderScopeForEntries([targetEntry], interceptEntry.routeName);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
32
57
|
|
|
33
58
|
/**
|
|
34
59
|
* Check if an intercept's when conditions are satisfied.
|
|
@@ -150,6 +175,8 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
150
175
|
if (middlewareResponse) throw middlewareResponse;
|
|
151
176
|
}
|
|
152
177
|
|
|
178
|
+
latchPprHeaderScopeForInterceptTarget(interceptEntry);
|
|
179
|
+
|
|
153
180
|
const loaderPromises: Promise<any>[] = [];
|
|
154
181
|
const loaderIds: string[] = [];
|
|
155
182
|
|
|
@@ -328,6 +355,8 @@ export async function resolveInterceptLoadersOnly<TEnv>(
|
|
|
328
355
|
return null;
|
|
329
356
|
}
|
|
330
357
|
|
|
358
|
+
latchPprHeaderScopeForInterceptTarget(interceptEntry);
|
|
359
|
+
|
|
331
360
|
const loaderPromises: Promise<any>[] = [];
|
|
332
361
|
const loaderIds: string[] = [];
|
|
333
362
|
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
track,
|
|
41
41
|
RangoContext,
|
|
42
42
|
runInsideLoaderScope,
|
|
43
|
+
latchCachedHeaderScope,
|
|
44
|
+
latchPprHeaderScopeForEntries,
|
|
43
45
|
} from "../../server/context.js";
|
|
44
46
|
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
@@ -650,6 +652,12 @@ export async function resolveAllSegments<TEnv>(
|
|
|
650
652
|
const allSegments: ResolvedSegment[] = [];
|
|
651
653
|
const seenIds = new Set<string>();
|
|
652
654
|
|
|
655
|
+
// ppr routes are document-scoped cached territory: the whole chain (root
|
|
656
|
+
// layout down to the page) bakes into the shared shell, so the header-write
|
|
657
|
+
// guard latches BEFORE any entry resolves (unlike the positional cache()
|
|
658
|
+
// latch below). See assertCachedHeaderWriteAllowed (server/context.ts).
|
|
659
|
+
latchPprHeaderScopeForEntries(entries, routeKey);
|
|
660
|
+
|
|
653
661
|
// Safe request access: during build-time prerendering, context.request
|
|
654
662
|
// is a throwing getter. Use undefined when unavailable.
|
|
655
663
|
let safeRequest: Request | undefined;
|
|
@@ -665,11 +673,13 @@ export async function resolveAllSegments<TEnv>(
|
|
|
665
673
|
|
|
666
674
|
for (const entry of entries) {
|
|
667
675
|
// Set ALS flag when entering a cache() boundary so that ctx.get()
|
|
668
|
-
// can guard non-cacheable variable reads. Also
|
|
669
|
-
// side effects
|
|
676
|
+
// can guard non-cacheable variable reads. Also latch the header-write
|
|
677
|
+
// scope (response-level side effects — headers/cookies/status).
|
|
678
|
+
// Persists for all descendant entries.
|
|
670
679
|
if (entry.type === "cache") {
|
|
671
680
|
const store = RangoContext.getStore();
|
|
672
681
|
if (store) store.insideCacheScope = true;
|
|
682
|
+
latchCachedHeaderScope("cache", routeKey);
|
|
673
683
|
}
|
|
674
684
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
675
685
|
const resolvedSegments = await resolveWithErrorBoundary(
|
|
@@ -718,6 +728,15 @@ export async function resolveLoadersOnly<TEnv>(
|
|
|
718
728
|
const loaderSegments: ResolvedSegment[] = [];
|
|
719
729
|
const seenIds = new Set<string>();
|
|
720
730
|
|
|
731
|
+
// Loader-only serves (cached non-loader segments) still run loaders live —
|
|
732
|
+
// on a ppr route their header writes are dead letters (headers flushed with
|
|
733
|
+
// the shell), so the guard latches here too. cache() needs no latch: loader
|
|
734
|
+
// writes are exempt under cache() (see assertCachedHeaderWriteAllowed).
|
|
735
|
+
latchPprHeaderScopeForEntries(
|
|
736
|
+
entries,
|
|
737
|
+
(context as InternalHandlerContext<any, TEnv>)._routeName,
|
|
738
|
+
);
|
|
739
|
+
|
|
721
740
|
async function collectEntryLoaders(
|
|
722
741
|
entry: EntryData,
|
|
723
742
|
belongsToRoute: boolean,
|
|
@@ -50,6 +50,8 @@ import {
|
|
|
50
50
|
track,
|
|
51
51
|
RangoContext,
|
|
52
52
|
runInsideLoaderScope,
|
|
53
|
+
latchCachedHeaderScope,
|
|
54
|
+
latchPprHeaderScopeForEntries,
|
|
53
55
|
} from "../../server/context.js";
|
|
54
56
|
|
|
55
57
|
/**
|
|
@@ -250,6 +252,9 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
|
|
|
250
252
|
const allMatchedIds: string[] = [];
|
|
251
253
|
const seenIds = new Set<string>();
|
|
252
254
|
|
|
255
|
+
// ppr header-write guard latch — see resolveAllSegments (fresh.ts).
|
|
256
|
+
latchPprHeaderScopeForEntries(entries, routeKey);
|
|
257
|
+
|
|
253
258
|
async function collectEntryLoaders(
|
|
254
259
|
entry: EntryData,
|
|
255
260
|
belongsToRoute: boolean,
|
|
@@ -1260,6 +1265,9 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
|
|
|
1260
1265
|
const seenSegIds = new Set<string>();
|
|
1261
1266
|
const seenMatchIds = new Set<string>();
|
|
1262
1267
|
|
|
1268
|
+
// ppr header-write guard latch — see resolveAllSegments (fresh.ts).
|
|
1269
|
+
latchPprHeaderScopeForEntries(entries, routeKey);
|
|
1270
|
+
|
|
1263
1271
|
const telemetry = getRouterContext()?.telemetry;
|
|
1264
1272
|
|
|
1265
1273
|
for (const entry of entries) {
|
|
@@ -1283,6 +1291,7 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
|
|
|
1283
1291
|
if (entry.type === "cache") {
|
|
1284
1292
|
const store = RangoContext.getStore();
|
|
1285
1293
|
if (store) store.insideCacheScope = true;
|
|
1294
|
+
latchCachedHeaderScope("cache", routeKey);
|
|
1286
1295
|
}
|
|
1287
1296
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
1288
1297
|
const resolved = await resolveWithErrorBoundary(
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import React from "react";
|
|
20
|
-
import type
|
|
20
|
+
import { isPprEntry, type EntryData } from "../server/context.js";
|
|
21
21
|
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
22
22
|
import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
|
|
23
23
|
|
|
@@ -90,9 +90,10 @@ export function normalizeCaptureTimeout(value: unknown): number | undefined {
|
|
|
90
90
|
export function resolvePprConfig(
|
|
91
91
|
entry: EntryData | undefined | null,
|
|
92
92
|
): ResolvedPprConfig | null {
|
|
93
|
-
|
|
93
|
+
// isPprEntry (server/context.ts) is the ONE opt-in predicate — shared with
|
|
94
|
+
// the header-write latch so serve and guard can never drift.
|
|
95
|
+
if (!entry || !isPprEntry(entry)) return null;
|
|
94
96
|
const ppr = entry.ppr;
|
|
95
|
-
if (ppr === undefined || ppr === false) return null;
|
|
96
97
|
if (ppr === true) return { ttl: DEFAULT_PPR_TTL_SECONDS };
|
|
97
98
|
return {
|
|
98
99
|
ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
|
package/src/server/context.ts
CHANGED
|
@@ -277,6 +277,17 @@ export interface TrackedInclude {
|
|
|
277
277
|
lazy: boolean;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Cached response-header write scope (issue #713). `kind` selects the error
|
|
282
|
+
* wording; `routeKey` names the route in the error.
|
|
283
|
+
*
|
|
284
|
+
* @internal This type is an implementation detail and may change without notice.
|
|
285
|
+
*/
|
|
286
|
+
export type CachedHeaderScope = {
|
|
287
|
+
kind: "cache" | "ppr";
|
|
288
|
+
routeKey?: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
280
291
|
/**
|
|
281
292
|
* Context stored in AsyncLocalStorage
|
|
282
293
|
*/
|
|
@@ -317,6 +328,13 @@ interface HelperContext {
|
|
|
317
328
|
/** True when resolving handlers inside a cache() DSL boundary.
|
|
318
329
|
* Read by ctx.get() to guard non-cacheable variable reads. */
|
|
319
330
|
insideCacheScope?: boolean;
|
|
331
|
+
/**
|
|
332
|
+
* RULE (issue #713): in any cached scenario ONLY MIDDLEWARE writes response
|
|
333
|
+
* headers. Latched by the segment funnels, consulted by
|
|
334
|
+
* assertCachedHeaderWriteAllowed(); full doctrine in
|
|
335
|
+
* docs/design/ppr-shell-resume.md "The header doctrine".
|
|
336
|
+
*/
|
|
337
|
+
cachedHeaderScope?: CachedHeaderScope;
|
|
320
338
|
/**
|
|
321
339
|
* Include scope string applied to direct-descendant shortCodes.
|
|
322
340
|
*
|
|
@@ -484,6 +502,9 @@ export const getContext = (): {
|
|
|
484
502
|
trackedIncludes: store.trackedIncludes,
|
|
485
503
|
cacheProfiles: store.cacheProfiles,
|
|
486
504
|
includeScope: store.includeScope,
|
|
505
|
+
// cachedHeaderScope (and insideCacheScope) deliberately NOT copied —
|
|
506
|
+
// the header guard's middleware exemption depends on the latch dying
|
|
507
|
+
// with the funnel scope (see assertCachedHeaderWriteAllowed).
|
|
487
508
|
},
|
|
488
509
|
callback,
|
|
489
510
|
);
|
|
@@ -797,13 +818,9 @@ const loaderBodyScopeALS: AsyncLocalStorage<{
|
|
|
797
818
|
export function isInsideCacheScope(): boolean {
|
|
798
819
|
if (RangoContext.getStore()?.insideCacheScope !== true) return false;
|
|
799
820
|
// Loaders are always fresh — even inside a cache() boundary, the loader
|
|
800
|
-
// function re-executes on every request
|
|
801
|
-
// inside
|
|
802
|
-
if (
|
|
803
|
-
// Also exempt handler-invoked loaders: their bodies run in a loader-body
|
|
804
|
-
// scope (not the DSL loader scope above), so request-scoped reads inside any
|
|
805
|
-
// loader — however invoked — are safe (loaders always re-run fresh).
|
|
806
|
-
if (loaderBodyScopeALS.getStore()?.active) return false;
|
|
821
|
+
// function re-executes on every request (DSL loaders AND handler-invoked
|
|
822
|
+
// loader bodies alike), so request-scoped reads inside any loader are safe.
|
|
823
|
+
if (isInsideAnyLoaderScope()) return false;
|
|
807
824
|
return true;
|
|
808
825
|
}
|
|
809
826
|
|
|
@@ -816,6 +833,107 @@ export function isInsideLoaderScope(): boolean {
|
|
|
816
833
|
return loaderScopeALS.getStore()?.active === true;
|
|
817
834
|
}
|
|
818
835
|
|
|
836
|
+
/**
|
|
837
|
+
* Latch the cached header-write scope for the current request. First latch
|
|
838
|
+
* wins: a ppr route latched at the funnel top is not downgraded by a nested
|
|
839
|
+
* cache() entry (the document-scoped wording is the more useful one).
|
|
840
|
+
* Takes scalars so the already-latched path allocates nothing.
|
|
841
|
+
*/
|
|
842
|
+
export function latchCachedHeaderScope(
|
|
843
|
+
kind: CachedHeaderScope["kind"],
|
|
844
|
+
routeKey?: string,
|
|
845
|
+
): void {
|
|
846
|
+
const store = RangoContext.getStore();
|
|
847
|
+
if (store && !store.cachedHeaderScope) {
|
|
848
|
+
store.cachedHeaderScope = { kind, routeKey };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** True inside ANY loader execution — DSL loader scope or a loader body
|
|
853
|
+
* (however invoked). The "loaders always re-run fresh" exemptions key off
|
|
854
|
+
* this. */
|
|
855
|
+
function isInsideAnyLoaderScope(): boolean {
|
|
856
|
+
return (
|
|
857
|
+
loaderScopeALS.getStore()?.active === true ||
|
|
858
|
+
loaderBodyScopeALS.getStore()?.active === true
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The one ppr opt-in predicate: a page route entry that DECLARED `ppr`
|
|
864
|
+
* (`false` and undefined mean plain axis 1). Shared by the serve path
|
|
865
|
+
* (rsc/shell-serve.ts resolvePprConfig) and the header-write latch below so
|
|
866
|
+
* the two layers can never drift on what counts as a ppr route.
|
|
867
|
+
*/
|
|
868
|
+
export function isPprEntry(entry: EntryData): entry is EntryData & {
|
|
869
|
+
type: "route";
|
|
870
|
+
ppr: true | import("../urls/pattern-types.js").PartialPrerenderProps;
|
|
871
|
+
} {
|
|
872
|
+
return (
|
|
873
|
+
entry.type === "route" && entry.ppr !== undefined && entry.ppr !== false
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Latch the ppr header-write scope when the entry chain about to resolve is a
|
|
879
|
+
* `ppr` page route's. Called at the TOP of every segment funnel — unlike
|
|
880
|
+
* cache() (positional: ancestors before the boundary stay writable), ppr is
|
|
881
|
+
* document-scoped: the root layout down to the page bakes into the shared
|
|
882
|
+
* shell, so the whole funnel is cached territory.
|
|
883
|
+
*
|
|
884
|
+
* Checks the LEAF entry only: `entries` is the traverseBack chain
|
|
885
|
+
* [root, ..., manifestEntry], and the serve path reads `ppr` off the same
|
|
886
|
+
* leaf (rsc-rendering.ts: resolvePprConfig(manifestEntry)) — guard and serve
|
|
887
|
+
* share the predicate (isPprEntry) AND the input, so they cannot drift.
|
|
888
|
+
*/
|
|
889
|
+
export function latchPprHeaderScopeForEntries(
|
|
890
|
+
entries: EntryData[],
|
|
891
|
+
routeKey?: string,
|
|
892
|
+
): void {
|
|
893
|
+
const store = RangoContext.getStore();
|
|
894
|
+
if (!store || store.cachedHeaderScope) return;
|
|
895
|
+
const leaf = entries[entries.length - 1];
|
|
896
|
+
if (leaf !== undefined && isPprEntry(leaf)) {
|
|
897
|
+
store.cachedHeaderScope = { kind: "ppr", routeKey };
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* RULE (issue #713): in any cached scenario ONLY MIDDLEWARE writes response
|
|
903
|
+
* headers — handler and loader writes throw while a scope is latched; the one
|
|
904
|
+
* exemption is loaders under plain cache(). Full layer rules and rationale:
|
|
905
|
+
* docs/design/ppr-shell-resume.md "The header doctrine".
|
|
906
|
+
*/
|
|
907
|
+
export function assertCachedHeaderWriteAllowed(
|
|
908
|
+
surface: string,
|
|
909
|
+
surfaceProp?: string | symbol,
|
|
910
|
+
): void {
|
|
911
|
+
const scope = RangoContext.getStore()?.cachedHeaderScope;
|
|
912
|
+
if (!scope) return;
|
|
913
|
+
const insideLoader = isInsideAnyLoaderScope();
|
|
914
|
+
if (scope.kind === "cache" && insideLoader) return;
|
|
915
|
+
// Everything below runs only on the throw path — `surfaceProp` exists so
|
|
916
|
+
// callers pass constants and the success path allocates nothing (the full
|
|
917
|
+
// surface, e.g. "ctx.headers.set()", is assembled here).
|
|
918
|
+
const fullSurface =
|
|
919
|
+
surfaceProp === undefined ? surface : `${surface}.${String(surfaceProp)}()`;
|
|
920
|
+
const layer = insideLoader ? "loader" : "handler";
|
|
921
|
+
const route = scope.routeKey ? ` (route "${scope.routeKey}")` : "";
|
|
922
|
+
const where =
|
|
923
|
+
scope.kind === "ppr"
|
|
924
|
+
? `on a ppr route${route} — the document shell is cached and replayed`
|
|
925
|
+
: `inside a cache() boundary${route}`;
|
|
926
|
+
const why =
|
|
927
|
+
layer === "loader"
|
|
928
|
+
? "The response headers flush with the shell before loaders settle, so this write is dropped on cache hits."
|
|
929
|
+
: "On a cache hit the handler is skipped, so this write would silently vanish.";
|
|
930
|
+
throw new Error(
|
|
931
|
+
`${fullSurface} cannot be called from a ${layer} ${where}. ${why} ` +
|
|
932
|
+
"Set response headers in route middleware instead — middleware runs " +
|
|
933
|
+
"on every request, including cache hits.",
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
819
937
|
/**
|
|
820
938
|
* Run `fn` inside a loader scope. While active, cache-scope guards
|
|
821
939
|
* are bypassed because loaders are always fresh (never cached) and
|
|
@@ -57,7 +57,10 @@ import {
|
|
|
57
57
|
} from "../theme/constants.js";
|
|
58
58
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
59
59
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
assertCachedHeaderWriteAllowed,
|
|
62
|
+
isInsideCacheScope,
|
|
63
|
+
} from "./context.js";
|
|
61
64
|
import {
|
|
62
65
|
createReverseFunction,
|
|
63
66
|
stripInternalParams,
|
|
@@ -817,6 +820,50 @@ export function createRequestContext<TEnv>(
|
|
|
817
820
|
})
|
|
818
821
|
: new Response(null, { status: 200 });
|
|
819
822
|
|
|
823
|
+
// The #713 choke point: `rawStubHeaders` is the stub's REAL Headers; the
|
|
824
|
+
// proxy below is the only view reachable from consumer code, and its
|
|
825
|
+
// mutating methods consult the cached-scope guard first. shadowStubHeaders
|
|
826
|
+
// shadows `headers` on the stub Response INSTANCE (a real Response, not a
|
|
827
|
+
// facade — safe to hand to the platform), so every surface that ends in a
|
|
828
|
+
// stub-header mutation — ctx.header/setCookie/deleteCookie, ctx.setTheme,
|
|
829
|
+
// the handler ctx.headers proxy, and raw `ctx.res.headers.set(...)` — is
|
|
830
|
+
// guarded at the mutation itself, not per enumerated wrapper. Guard-exempt
|
|
831
|
+
// internal writers (_rotateStateCookie, _setKeepCacheDirective — serve
|
|
832
|
+
// machinery, documented callable from loaders/during capture) write to
|
|
833
|
+
// rawStubHeaders directly. The serve/commit path's stub reads and its
|
|
834
|
+
// Set-Cookie drain (rsc/helpers.ts) run outside any latched funnel scope,
|
|
835
|
+
// where assertCachedHeaderWriteAllowed is a no-op.
|
|
836
|
+
let rawStubHeaders = stubResponse.headers;
|
|
837
|
+
const guardedStubHeaders: Headers = new Proxy(new Headers(), {
|
|
838
|
+
get(_target, prop) {
|
|
839
|
+
const raw = rawStubHeaders;
|
|
840
|
+
const value = Reflect.get(raw, prop) as unknown;
|
|
841
|
+
if (typeof value !== "function") return value;
|
|
842
|
+
if (prop === "set" || prop === "append" || prop === "delete") {
|
|
843
|
+
return (...args: unknown[]) => {
|
|
844
|
+
assertCachedHeaderWriteAllowed("response headers", prop);
|
|
845
|
+
return (value as (...a: unknown[]) => unknown).apply(raw, args);
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
return (value as (...a: unknown[]) => unknown).bind(raw);
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
const shadowStubHeaders = (res: Response): void => {
|
|
852
|
+
Object.defineProperty(res, "headers", {
|
|
853
|
+
value: guardedStubHeaders,
|
|
854
|
+
enumerable: true,
|
|
855
|
+
configurable: true,
|
|
856
|
+
});
|
|
857
|
+
};
|
|
858
|
+
shadowStubHeaders(stubResponse);
|
|
859
|
+
// Rebuild the stub with a new status, re-shadowing the fresh instance
|
|
860
|
+
// (the Response constructor copies rawStubHeaders into new raw Headers).
|
|
861
|
+
const replaceStubStatus = (status: number): void => {
|
|
862
|
+
stubResponse = new Response(null, { status, headers: rawStubHeaders });
|
|
863
|
+
rawStubHeaders = stubResponse.headers;
|
|
864
|
+
shadowStubHeaders(stubResponse);
|
|
865
|
+
};
|
|
866
|
+
|
|
820
867
|
const handleStore = createHandleStore();
|
|
821
868
|
const loaderPromises = new Map<string, Promise<any>>();
|
|
822
869
|
|
|
@@ -838,14 +885,12 @@ export function createRequestContext<TEnv>(
|
|
|
838
885
|
responseCookieCache = null;
|
|
839
886
|
};
|
|
840
887
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
);
|
|
848
|
-
}
|
|
888
|
+
// Guard for the two response-write surfaces that are NOT Headers mutations
|
|
889
|
+
// (setStatus rebuilds the stub Response, onResponse registers a callback) —
|
|
890
|
+
// these can't ride the guarded-headers choke point above, so they stay
|
|
891
|
+
// enumerated. Same unified #713 guard, same message family.
|
|
892
|
+
function assertResponseWriteAllowed(methodName: string): void {
|
|
893
|
+
assertCachedHeaderWriteAllowed("ctx", methodName);
|
|
849
894
|
}
|
|
850
895
|
|
|
851
896
|
// Response stub Set-Cookie wins, then original header (source of truth for mutations).
|
|
@@ -956,7 +1001,6 @@ export function createRequestContext<TEnv>(
|
|
|
956
1001
|
|
|
957
1002
|
setCookie(name: string, value: string, options?: CookieOptions): void {
|
|
958
1003
|
assertNotInsideCacheExec(ctx, "setCookie");
|
|
959
|
-
assertNotInsideCacheScopeALS("setCookie");
|
|
960
1004
|
stubResponse.headers.append(
|
|
961
1005
|
"Set-Cookie",
|
|
962
1006
|
serializeCookieValue(name, value, options),
|
|
@@ -969,7 +1013,6 @@ export function createRequestContext<TEnv>(
|
|
|
969
1013
|
options?: Pick<CookieOptions, "domain" | "path">,
|
|
970
1014
|
): void {
|
|
971
1015
|
assertNotInsideCacheExec(ctx, "deleteCookie");
|
|
972
|
-
assertNotInsideCacheScopeALS("deleteCookie");
|
|
973
1016
|
stubResponse.headers.append(
|
|
974
1017
|
"Set-Cookie",
|
|
975
1018
|
serializeCookieValue(name, "", { ...options, maxAge: 0 }),
|
|
@@ -979,7 +1022,6 @@ export function createRequestContext<TEnv>(
|
|
|
979
1022
|
|
|
980
1023
|
header(name: string, value: string): void {
|
|
981
1024
|
assertNotInsideCacheExec(ctx, "header");
|
|
982
|
-
assertNotInsideCacheScopeALS("header");
|
|
983
1025
|
stubResponse.headers.set(name, value);
|
|
984
1026
|
},
|
|
985
1027
|
|
|
@@ -1008,7 +1050,9 @@ export function createRequestContext<TEnv>(
|
|
|
1008
1050
|
(request.headers.get("x-rango-state") || null) ??
|
|
1009
1051
|
getRawCookieValue(cookieHeader, stateCookieName);
|
|
1010
1052
|
const value = mintStateValue(stateVersion ?? "0", prevRaw);
|
|
1011
|
-
|
|
1053
|
+
// rawStubHeaders: guard-exempt internal writer — invalidateClientCache()
|
|
1054
|
+
// is documented callable from loaders and during shell capture.
|
|
1055
|
+
rawStubHeaders.append(
|
|
1012
1056
|
"Set-Cookie",
|
|
1013
1057
|
serializeStateCookie(stateCookieName, value, url.protocol === "https:"),
|
|
1014
1058
|
);
|
|
@@ -1017,25 +1061,20 @@ export function createRequestContext<TEnv>(
|
|
|
1017
1061
|
|
|
1018
1062
|
// Set the keepClientCache() directive header. The action bridge reads it on
|
|
1019
1063
|
// the response and suppresses its automatic invalidation. `.set` makes this
|
|
1020
|
-
// idempotent (one header regardless of call count).
|
|
1064
|
+
// idempotent (one header regardless of call count). rawStubHeaders:
|
|
1065
|
+
// guard-exempt internal writer.
|
|
1021
1066
|
_setKeepCacheDirective(): void {
|
|
1022
|
-
|
|
1067
|
+
rawStubHeaders.set(KEEP_CACHE_HEADER, "1");
|
|
1023
1068
|
},
|
|
1024
1069
|
|
|
1025
1070
|
setStatus(status: number): void {
|
|
1026
1071
|
assertNotInsideCacheExec(ctx, "setStatus");
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
status,
|
|
1030
|
-
headers: stubResponse.headers,
|
|
1031
|
-
});
|
|
1072
|
+
assertResponseWriteAllowed("setStatus");
|
|
1073
|
+
replaceStubStatus(status);
|
|
1032
1074
|
},
|
|
1033
1075
|
|
|
1034
1076
|
_setStatus(status: number): void {
|
|
1035
|
-
|
|
1036
|
-
status,
|
|
1037
|
-
headers: stubResponse.headers,
|
|
1038
|
-
});
|
|
1077
|
+
replaceStubStatus(status);
|
|
1039
1078
|
},
|
|
1040
1079
|
|
|
1041
1080
|
use: null as any,
|
|
@@ -1084,7 +1123,7 @@ export function createRequestContext<TEnv>(
|
|
|
1084
1123
|
|
|
1085
1124
|
onResponse(callback: (response: Response) => Response): void {
|
|
1086
1125
|
assertNotInsideCacheExec(ctx, "onResponse");
|
|
1087
|
-
|
|
1126
|
+
assertResponseWriteAllowed("onResponse");
|
|
1088
1127
|
this._onResponseCallbacks.push(callback);
|
|
1089
1128
|
},
|
|
1090
1129
|
|