@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.
@@ -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.149",
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 AsyncLocalStorage();
5021
+ var routerLogContext = new AsyncLocalStorage2();
5007
5022
 
5008
5023
  // src/router/url-params.ts
5009
5024
  var PATH_SAFE_ESCAPES = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.149",
3
+ "version": "0.0.0-experimental.150",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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
- Three hard edges (each e2e/unit-pinned):
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 { isInsideCacheScope } from "../server/context.js";
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 cache() scope.
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
- if (isInsideCacheScope()) {
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 { runInsideLoaderScope } from "../server/context.js";
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 guards response-level
669
- // side effects (headers.set). Persists for all descendant entries.
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(
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
- import type { EntryData } from "../server/context.js";
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
- if (!entry || entry.type !== "route") return null;
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,
@@ -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. Skip the guard when running
801
- // inside a loader.
802
- if (loaderScopeALS.getStore()?.active) return false;
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 { isInsideCacheScope } from "./context.js";
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
- function assertNotInsideCacheScopeALS(methodName: string): void {
842
- if (isInsideCacheScope()) {
843
- throw new Error(
844
- `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
845
- `On cache hit the handler is skipped, so this side effect would be lost. ` +
846
- `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
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
- stubResponse.headers.append(
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
- stubResponse.headers.set(KEEP_CACHE_HEADER, "1");
1067
+ rawStubHeaders.set(KEEP_CACHE_HEADER, "1");
1023
1068
  },
1024
1069
 
1025
1070
  setStatus(status: number): void {
1026
1071
  assertNotInsideCacheExec(ctx, "setStatus");
1027
- assertNotInsideCacheScopeALS("setStatus");
1028
- stubResponse = new Response(null, {
1029
- status,
1030
- headers: stubResponse.headers,
1031
- });
1072
+ assertResponseWriteAllowed("setStatus");
1073
+ replaceStubStatus(status);
1032
1074
  },
1033
1075
 
1034
1076
  _setStatus(status: number): void {
1035
- stubResponse = new Response(null, {
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
- assertNotInsideCacheScopeALS("onResponse");
1126
+ assertResponseWriteAllowed("onResponse");
1088
1127
  this._onResponseCallbacks.push(callback);
1089
1128
  },
1090
1129