@rangojs/router 0.23.0 → 0.24.0
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/bin/rango.js +4 -3
- package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
- package/dist/types/browser/partial-update.d.ts +6 -4
- package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
- package/dist/types/browser/react/context.d.ts +3 -2
- package/dist/types/browser/types.d.ts +5 -1
- package/dist/types/build/route-types/scan-filter.d.ts +7 -1
- package/dist/types/cache/document-cache-policy.d.ts +24 -0
- package/dist/types/cache/document-cache.d.ts +2 -10
- package/dist/types/context-var.d.ts +12 -0
- package/dist/types/errors.d.ts +10 -0
- package/dist/types/handles/pushed-by.d.ts +8 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.rsc.d.ts +2 -0
- package/dist/types/response-utils.d.ts +7 -0
- package/dist/types/reverse.d.ts +8 -8
- package/dist/types/route-definition/rewrite.d.ts +56 -0
- package/dist/types/route-map-builder.d.ts +7 -2
- package/dist/types/router/basename.d.ts +7 -0
- package/dist/types/router/handler-context.d.ts +1 -5
- package/dist/types/router/internal-params.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +15 -0
- package/dist/types/router/revalidation.d.ts +1 -2
- package/dist/types/router/route-trie-builder.d.ts +10 -0
- package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
- package/dist/types/rsc/full-payload.d.ts +11 -0
- package/dist/types/rsc/handler.d.ts +33 -0
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/loader-fetch.d.ts +16 -0
- package/dist/types/rsc/rewrite-passes.d.ts +74 -0
- package/dist/types/rsc/ssr-setup.d.ts +3 -1
- package/dist/types/server/request-context.d.ts +39 -2
- package/dist/types/server/rewrite-trail.d.ts +49 -0
- package/dist/types/server/slot-accessors.d.ts +29 -0
- package/dist/types/testing/index.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +8 -0
- package/dist/types/testing/run-middleware.d.ts +20 -0
- package/dist/types/testing/serve-shell-request.d.ts +2 -1
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/request-scope.d.ts +7 -0
- package/dist/types/vite/discovery/state.d.ts +12 -9
- package/dist/vite/index.js +164 -103
- package/package.json +1 -1
- package/skills/caching/SKILL.md +12 -0
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +84 -0
- package/skills/hooks/SKILL.md +11 -6
- package/skills/hooks/data.md +3 -1
- package/skills/loader/SKILL.md +7 -1
- package/skills/middleware/SKILL.md +183 -2
- package/skills/ppr/SKILL.md +9 -0
- package/skills/rango/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +27 -0
- package/skills/testing/client-components.md +1 -0
- package/skills/testing/middleware.md +31 -10
- package/skills/testing/response-routes.md +1 -0
- package/skills/testing/serve-shell-request.md +19 -1
- package/skills/use-cache/SKILL.md +4 -2
- package/src/__augment-tests__/augmented.check.ts +20 -0
- package/src/browser/event-controller.ts +3 -0
- package/src/browser/merge-segment-loaders.ts +31 -18
- package/src/browser/navigation-bridge.ts +19 -3
- package/src/browser/partial-update.ts +27 -12
- package/src/browser/react/Link.tsx +7 -9
- package/src/browser/react/NavigationProvider.tsx +46 -26
- package/src/browser/react/context.ts +3 -2
- package/src/browser/react/use-router.ts +2 -4
- package/src/browser/server-action-bridge.ts +6 -0
- package/src/browser/types.ts +5 -1
- package/src/build/route-types/scan-filter.ts +11 -4
- package/src/cache/document-cache-policy.ts +131 -0
- package/src/cache/document-cache.ts +12 -119
- package/src/context-var.ts +32 -2
- package/src/errors.ts +19 -0
- package/src/handles/pushed-by.ts +26 -0
- package/src/index.rsc.ts +8 -0
- package/src/index.ts +7 -0
- package/src/response-utils.ts +19 -0
- package/src/reverse.ts +17 -14
- package/src/route-definition/redirect.ts +2 -15
- package/src/route-definition/rewrite.ts +146 -0
- package/src/route-map-builder.ts +14 -11
- package/src/router/basename.ts +20 -0
- package/src/router/handler-context.ts +3 -13
- package/src/router/intercept-resolution.ts +29 -12
- package/src/router/internal-params.ts +16 -0
- package/src/router/loader-resolution.ts +2 -0
- package/src/router/match-middleware/background-revalidation.ts +11 -9
- package/src/router/match-middleware/cache-lookup.ts +20 -15
- package/src/router/match-middleware/cache-store.ts +9 -11
- package/src/router/match-middleware/intercept-resolution.ts +4 -2
- package/src/router/middleware-types.ts +16 -0
- package/src/router/middleware.ts +127 -49
- package/src/router/revalidation.ts +1 -1
- package/src/router/route-trie-builder.ts +28 -1
- package/src/router/segment-resolution/loader-snapshot.ts +18 -4
- package/src/router/segment-resolution/mask-nested.ts +8 -4
- package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
- package/src/router/segment-resolution/revalidation.ts +57 -33
- package/src/rsc/full-payload.ts +37 -5
- package/src/rsc/handler.ts +82 -55
- package/src/rsc/helpers.ts +24 -0
- package/src/rsc/loader-fetch.ts +79 -24
- package/src/rsc/response-cache-serve.ts +18 -2
- package/src/rsc/response-route-handler.ts +4 -7
- package/src/rsc/rewrite-passes.ts +203 -0
- package/src/rsc/rsc-rendering.ts +4 -2
- package/src/rsc/ssr-setup.ts +11 -5
- package/src/server/cookie-parse.ts +36 -12
- package/src/server/cookie-store.ts +7 -3
- package/src/server/handle-store.ts +48 -17
- package/src/server/request-context.ts +154 -63
- package/src/server/request-headers.ts +4 -14
- package/src/server/rewrite-trail.ts +190 -0
- package/src/server/slot-accessors.ts +57 -0
- package/src/ssr/index.tsx +118 -16
- package/src/testing/dispatch.ts +138 -67
- package/src/testing/index.ts +1 -0
- package/src/testing/render-route.tsx +16 -3
- package/src/testing/run-loader.ts +2 -0
- package/src/testing/run-middleware.ts +56 -11
- package/src/testing/serve-shell-request.ts +15 -2
- package/src/thenable-status.ts +11 -0
- package/src/types/request-scope.ts +8 -0
- package/src/vite/discovery/state.ts +21 -13
- package/src/vite/plugins/expose-internal-ids.ts +2 -3
- package/src/vite/router-discovery.ts +29 -27
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from "../server/handle-store.js";
|
|
56
56
|
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
57
57
|
import { _getRequestContext } from "../server/request-context.js";
|
|
58
|
+
import { defineRewrittenFrom } from "../server/rewrite-trail.js";
|
|
58
59
|
import {
|
|
59
60
|
firstUnitCandidate,
|
|
60
61
|
loaderOwner,
|
|
@@ -777,6 +778,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
777
778
|
return renderedPromise;
|
|
778
779
|
},
|
|
779
780
|
};
|
|
781
|
+
defineRewrittenFrom(loaderCtx, reqCtxRef ?? _getRequestContext());
|
|
780
782
|
(loaderCtx as any)[NOCACHE_SYMBOL] = true;
|
|
781
783
|
|
|
782
784
|
// Meter this loader once via observePhase (loader:<id> perf metric +
|
|
@@ -109,11 +109,12 @@ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
|
|
|
109
109
|
import { getContext } from "../../server/context.js";
|
|
110
110
|
import { executionStart } from "../../cache/tag-invalidation.js";
|
|
111
111
|
import {
|
|
112
|
+
bindRes,
|
|
112
113
|
createRequestContext,
|
|
113
114
|
runWithRequestContext,
|
|
114
115
|
type RequestContext,
|
|
115
116
|
} from "../../server/request-context.js";
|
|
116
|
-
import {
|
|
117
|
+
import { recordUse } from "../segment-resolution/prefetch-deferral.js";
|
|
117
118
|
|
|
118
119
|
/**
|
|
119
120
|
* Re-render a cached route in the background and write it with
|
|
@@ -166,9 +167,7 @@ export async function rerenderAndCacheRoute<TEnv>(
|
|
|
166
167
|
themeConfig: requestCtx._themeConfig,
|
|
167
168
|
});
|
|
168
169
|
const renderCtx: RequestContext<TEnv> = Object.assign(
|
|
169
|
-
Object.create(requestCtx,
|
|
170
|
-
res: Object.getOwnPropertyDescriptor(sink, "res")!,
|
|
171
|
-
}),
|
|
170
|
+
Object.create(requestCtx),
|
|
172
171
|
{
|
|
173
172
|
_handleStore: handleStore,
|
|
174
173
|
_metricsStore: undefined,
|
|
@@ -185,6 +184,8 @@ export async function rerenderAndCacheRoute<TEnv>(
|
|
|
185
184
|
_setKeepCacheDirective: sink._setKeepCacheDirective,
|
|
186
185
|
},
|
|
187
186
|
);
|
|
187
|
+
// ctx.res reads the sink's stub, not the live request's.
|
|
188
|
+
bindRes(renderCtx, () => sink.res);
|
|
188
189
|
const store = Object.assign(Object.create(ctx.Store), { metrics: undefined });
|
|
189
190
|
const runInStore = <T>(fn: () => T): T =>
|
|
190
191
|
getContext().runWithStore(
|
|
@@ -282,15 +283,16 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
282
283
|
// Only trigger background revalidation if:
|
|
283
284
|
// 1. Cache was hit and stale
|
|
284
285
|
// 2. Cache scope exists
|
|
285
|
-
// 3. The request
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
286
|
+
// 3. The request may refresh the record (RecordUse.refresh): a fill may
|
|
287
|
+
// not (prefetch: false). It runs no handler of a segment the client
|
|
288
|
+
// holds, and the re-render runs every handler of the route. The record
|
|
289
|
+
// stays stale until a request that reads it arrives: the prefetch
|
|
290
|
+
// before the fill already was one.
|
|
289
291
|
if (
|
|
290
292
|
!state.shouldRevalidate ||
|
|
291
293
|
!state.cacheHit ||
|
|
292
294
|
!ctx.cacheScope ||
|
|
293
|
-
|
|
295
|
+
!recordUse(ctx.handlerContext).refresh
|
|
294
296
|
) {
|
|
295
297
|
return;
|
|
296
298
|
}
|
|
@@ -100,8 +100,8 @@ import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
|
|
|
100
100
|
import { treeHasStreaming } from "./segment-resolution.js";
|
|
101
101
|
import { loaderPins } from "../segment-resolution/loader-cache.js";
|
|
102
102
|
import {
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
fillSends,
|
|
104
|
+
recordUse,
|
|
105
105
|
} from "../segment-resolution/prefetch-deferral.js";
|
|
106
106
|
import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
|
|
107
107
|
import {
|
|
@@ -346,20 +346,23 @@ async function* yieldFromStore<TEnv>(
|
|
|
346
346
|
const paramsChanged =
|
|
347
347
|
!ctx.isFullMatch && !paramsEqual(ctx.matched.params, ctx.prevParams);
|
|
348
348
|
const resendRouteSegments = replaceable === true && ctx.isAction;
|
|
349
|
-
// A fill (prefetch-deferral.ts) keeps every segment the client holds.
|
|
350
|
-
const fill = isFillRequest(ctx.handlerContext);
|
|
351
349
|
for (const segment of segments) {
|
|
352
350
|
if (
|
|
353
351
|
!ctx.isFullMatch &&
|
|
354
352
|
ctx.clientSegmentSet.has(segment.id) &&
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
(segment.type === "route" || segment.belongsToRoute === true)
|
|
360
|
-
)))
|
|
353
|
+
// A stored intercept segment is re-sent, as on the runtime cache path:
|
|
354
|
+
// its loaders run (handleCacheHitIntercept), and a kept (null) one would
|
|
355
|
+
// be left out of the response with its loaders' new data.
|
|
356
|
+
!segment.namespace?.startsWith("intercept:")
|
|
361
357
|
) {
|
|
362
|
-
|
|
358
|
+
// A fill keeps it (fillSends); otherwise it is sent again when the
|
|
359
|
+
// params changed or an action re-sends the route's own segments.
|
|
360
|
+
const sends =
|
|
361
|
+
fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ??
|
|
362
|
+
(paramsChanged ||
|
|
363
|
+
(resendRouteSegments &&
|
|
364
|
+
(segment.type === "route" || segment.belongsToRoute === true)));
|
|
365
|
+
if (!sends) keepClientSegment(segment);
|
|
363
366
|
}
|
|
364
367
|
yield segment;
|
|
365
368
|
}
|
|
@@ -739,7 +742,7 @@ export function withCacheLookup<TEnv>(
|
|
|
739
742
|
if (
|
|
740
743
|
ctx.isAction ||
|
|
741
744
|
!ctx.cacheScope?.enabled ||
|
|
742
|
-
|
|
745
|
+
!recordUse(ctx.handlerContext).read
|
|
743
746
|
) {
|
|
744
747
|
yield* source;
|
|
745
748
|
if (ms) {
|
|
@@ -912,7 +915,6 @@ export function withCacheLookup<TEnv>(
|
|
|
912
915
|
|
|
913
916
|
yield* liveSegments;
|
|
914
917
|
|
|
915
|
-
const fill = isFillRequest(ctx.handlerContext);
|
|
916
918
|
for (const segment of cacheResult.segments) {
|
|
917
919
|
if (!ctx.clientSegmentSet.has(segment.id)) {
|
|
918
920
|
if (isTraceActive()) {
|
|
@@ -935,8 +937,11 @@ export function withCacheLookup<TEnv>(
|
|
|
935
937
|
continue;
|
|
936
938
|
}
|
|
937
939
|
|
|
938
|
-
// A fill
|
|
939
|
-
if (
|
|
940
|
+
// A fill keeps a held segment outright (fillSends).
|
|
941
|
+
if (
|
|
942
|
+
fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ===
|
|
943
|
+
false
|
|
944
|
+
) {
|
|
940
945
|
keepClientSegment(segment);
|
|
941
946
|
yield segment;
|
|
942
947
|
continue;
|
|
@@ -114,10 +114,7 @@ import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
|
|
|
114
114
|
import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
|
|
115
115
|
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
116
116
|
import { rerenderAndCacheRoute } from "./background-revalidation.js";
|
|
117
|
-
import {
|
|
118
|
-
defersAboveRecord,
|
|
119
|
-
isFillRequest,
|
|
120
|
-
} from "../segment-resolution/prefetch-deferral.js";
|
|
117
|
+
import { recordUse } from "../segment-resolution/prefetch-deferral.js";
|
|
121
118
|
|
|
122
119
|
/**
|
|
123
120
|
* Creates cache store middleware
|
|
@@ -161,7 +158,7 @@ export function withCacheStore<TEnv>(
|
|
|
161
158
|
ctx.isAction ||
|
|
162
159
|
state.cacheHit ||
|
|
163
160
|
ctx.request.method !== "GET" ||
|
|
164
|
-
|
|
161
|
+
!recordUse(ctx.handlerContext).write
|
|
165
162
|
) {
|
|
166
163
|
if (ms) {
|
|
167
164
|
ms.metrics.push({
|
|
@@ -200,13 +197,14 @@ export function withCacheStore<TEnv>(
|
|
|
200
197
|
cacheScope.covers(s.id, s.namespace),
|
|
201
198
|
);
|
|
202
199
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
200
|
+
// The client holds part of what the record covers, so writing it takes
|
|
201
|
+
// the proactive re-render below, which a request that may not refresh
|
|
202
|
+
// the record (RecordUse.refresh) does not start. That is a fill: it
|
|
203
|
+
// renders what the client is missing and nothing else, and writes like
|
|
204
|
+
// any other request only when that is everything the record covers. The
|
|
205
|
+
// stale-hit refresh skips it for the same reason
|
|
208
206
|
// (withBackgroundRevalidation).
|
|
209
|
-
if (hasNullComponents &&
|
|
207
|
+
if (hasNullComponents && !recordUse(ctx.handlerContext).refresh) return;
|
|
210
208
|
|
|
211
209
|
const requestCtx = getRequestContext();
|
|
212
210
|
if (!requestCtx) return;
|
|
@@ -260,7 +260,9 @@ async function handleCacheHitIntercept<TEnv>(
|
|
|
260
260
|
interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
|
|
261
261
|
debugLog(
|
|
262
262
|
"matchPartial.intercept",
|
|
263
|
-
|
|
263
|
+
freshLoaderResult.loaderIds.length > 0
|
|
264
|
+
? "cache hit with fresh intercept loaders"
|
|
265
|
+
: "cache hit, every intercept loader held",
|
|
264
266
|
{
|
|
265
267
|
routeName: ctx.localRouteName,
|
|
266
268
|
slotName,
|
|
@@ -270,7 +272,7 @@ async function handleCacheHitIntercept<TEnv>(
|
|
|
270
272
|
} else {
|
|
271
273
|
debugLog(
|
|
272
274
|
"matchPartial.intercept",
|
|
273
|
-
"cache hit
|
|
275
|
+
"cache hit, intercept has no loaders",
|
|
274
276
|
{
|
|
275
277
|
routeName: ctx.localRouteName,
|
|
276
278
|
slotName,
|
|
@@ -8,6 +8,7 @@ import type { GlobalReverseFunction } from "../reverse.js";
|
|
|
8
8
|
import type { Theme } from "../theme/types.js";
|
|
9
9
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
10
10
|
import type { RequestScope } from "../types/request-scope.js";
|
|
11
|
+
import type { Rewrite } from "../route-definition/rewrite.js";
|
|
11
12
|
|
|
12
13
|
type GetVariableFn = {
|
|
13
14
|
<T>(contextVar: ContextVar<T>): T | undefined;
|
|
@@ -81,6 +82,21 @@ export interface MiddlewareContext<
|
|
|
81
82
|
|
|
82
83
|
/** Global route names only; see {@link GlobalReverseFunction}. */
|
|
83
84
|
reverse: GlobalReverseFunction<DefaultReverseRouteMap>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether `value` is a `rewrite()` thrown downstream. A middleware that
|
|
88
|
+
* wraps `next()` in try/catch rethrows it, so the request re-dispatches:
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* try {
|
|
92
|
+
* return await next();
|
|
93
|
+
* } catch (error) {
|
|
94
|
+
* if (ctx.isRewrite(error)) throw error;
|
|
95
|
+
* return new Response("Error", { status: 500 });
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
isRewrite(value: unknown): value is Rewrite;
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
export type MiddlewareFn<
|
package/src/router/middleware.ts
CHANGED
|
@@ -32,7 +32,14 @@ import {
|
|
|
32
32
|
import { getCacheExecScope } from "../cache/cache-exec-scope.js";
|
|
33
33
|
import { assertNonCacheableReadAllowed } from "../server/context.js";
|
|
34
34
|
import { readGuardedTheme } from "../server/cookie-store.js";
|
|
35
|
+
import { defineSlot, slotAccessor } from "../server/slot-accessors.js";
|
|
35
36
|
import type { Theme } from "../theme/types.js";
|
|
37
|
+
import {
|
|
38
|
+
isRewrite,
|
|
39
|
+
rewriteNotSupported,
|
|
40
|
+
type Rewrite,
|
|
41
|
+
} from "../route-definition/rewrite.js";
|
|
42
|
+
import { defineRewrittenFrom } from "../server/rewrite-trail.js";
|
|
36
43
|
|
|
37
44
|
// Re-export types consumed through this module's path.
|
|
38
45
|
export type {
|
|
@@ -43,19 +50,54 @@ export type {
|
|
|
43
50
|
} from "./middleware-types.js";
|
|
44
51
|
|
|
45
52
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* request-context getters: a spread of the ctx (inside a "use cache"
|
|
50
|
-
* function, say) does not read it. One shared descriptor, so every ctx keeps
|
|
51
|
-
* the same property shape.
|
|
53
|
+
* The middleware ctx's accessors: shared descriptors (slot-accessors.ts).
|
|
54
|
+
* routeName, _routeName, theme and setTheme read only the request context;
|
|
55
|
+
* headers calls the per-ctx closure in HEADERS_SLOT.
|
|
52
56
|
*/
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
const HEADERS_SLOT: unique symbol = Symbol("rango.middlewareContext.headers");
|
|
58
|
+
|
|
59
|
+
const MIDDLEWARE_ACCESSORS: Record<
|
|
60
|
+
"routeName" | "_routeName" | "headers" | "setTheme" | "theme",
|
|
61
|
+
PropertyDescriptor
|
|
62
|
+
> = {
|
|
63
|
+
// Re-derived from the request context on each access, so global middleware
|
|
64
|
+
// sees the matched route name after await next().
|
|
65
|
+
routeName: {
|
|
66
|
+
get(): string | undefined {
|
|
67
|
+
const raw = _getRequestContext()?._routeName;
|
|
68
|
+
return raw && !isAutoGeneratedRouteName(raw) ? raw : undefined;
|
|
69
|
+
},
|
|
70
|
+
enumerable: true,
|
|
71
|
+
configurable: true,
|
|
72
|
+
},
|
|
73
|
+
// The matched route key, auto-generated names included (the handler ctx's
|
|
74
|
+
// _routeName); undefined before routing.
|
|
75
|
+
_routeName: {
|
|
76
|
+
get(): string | undefined {
|
|
77
|
+
return _getRequestContext()?._routeName;
|
|
78
|
+
},
|
|
79
|
+
enumerable: true,
|
|
80
|
+
configurable: true,
|
|
81
|
+
},
|
|
82
|
+
headers: slotAccessor(HEADERS_SLOT, true),
|
|
83
|
+
setTheme: {
|
|
84
|
+
get(): MiddlewareContext<unknown>["setTheme"] {
|
|
85
|
+
return _getRequestContext()?.setTheme;
|
|
86
|
+
},
|
|
87
|
+
enumerable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
},
|
|
90
|
+
// The visitor's theme cookie, guarded like the handler ctx.theme and
|
|
91
|
+
// cookies() (#971, #946), so a read inside a "use cache" function the
|
|
92
|
+
// middleware calls throws. Non-enumerable like the handler and
|
|
93
|
+
// request-context getters: a spread of the ctx does not read it.
|
|
94
|
+
theme: {
|
|
95
|
+
get(): Theme | undefined {
|
|
96
|
+
return readGuardedTheme(_getRequestContext(), "ctx.theme");
|
|
97
|
+
},
|
|
98
|
+
enumerable: false,
|
|
99
|
+
configurable: true,
|
|
56
100
|
},
|
|
57
|
-
enumerable: false,
|
|
58
|
-
configurable: true,
|
|
59
101
|
};
|
|
60
102
|
|
|
61
103
|
const MIDDLEWARE_METRIC_DEPTH = 1;
|
|
@@ -252,15 +294,20 @@ export function createMiddlewareContext<TEnv>(
|
|
|
252
294
|
|
|
253
295
|
// reqCtx captured once above: the request-scoped platform fields
|
|
254
296
|
// (originalUrl, executionContext, waitUntil) are immutable per request,
|
|
255
|
-
// so snapshotting beats re-reading ALS on every access. The
|
|
256
|
-
//
|
|
257
|
-
//
|
|
297
|
+
// so snapshotting beats re-reading ALS on every access. The accessors
|
|
298
|
+
// (routeName, _routeName, theme, setTheme: MIDDLEWARE_ACCESSORS) read the
|
|
299
|
+
// request context on each access because those can change during
|
|
300
|
+
// `await next()`.
|
|
258
301
|
//
|
|
259
302
|
// Request-scoped like the handler ctx (issue #940): the NOCACHE_SYMBOL brand
|
|
260
303
|
// below makes a "use cache" function taking it key by host, _routeName,
|
|
261
304
|
// pathname, params and search; set/header/headers throw inside that
|
|
262
305
|
// function, and non-cacheable get reads run the shared guard.
|
|
263
|
-
|
|
306
|
+
// Data members only: the accessors are MIDDLEWARE_ACCESSORS.
|
|
307
|
+
const fields: Omit<
|
|
308
|
+
MiddlewareContext<TEnv>,
|
|
309
|
+
"routeName" | "headers" | "setTheme"
|
|
310
|
+
> = {
|
|
264
311
|
request,
|
|
265
312
|
url,
|
|
266
313
|
originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
|
|
@@ -269,39 +316,12 @@ export function createMiddlewareContext<TEnv>(
|
|
|
269
316
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
270
317
|
params,
|
|
271
318
|
build: reqCtx?.build ?? false,
|
|
319
|
+
isRewrite,
|
|
272
320
|
dynamic(): void {
|
|
273
321
|
reqCtx?.dynamic();
|
|
274
322
|
},
|
|
275
323
|
executionContext: reqCtx?.executionContext,
|
|
276
324
|
waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
|
|
277
|
-
// Getter: re-derives from request context on each access so that global
|
|
278
|
-
// middleware sees the matched route name after await next().
|
|
279
|
-
get routeName(): MiddlewareContext<TEnv>["routeName"] {
|
|
280
|
-
const reqCtx = _getRequestContext();
|
|
281
|
-
const raw = reqCtx?._routeName;
|
|
282
|
-
return (
|
|
283
|
-
raw && !isAutoGeneratedRouteName(raw) ? raw : undefined
|
|
284
|
-
) as MiddlewareContext<TEnv>["routeName"];
|
|
285
|
-
},
|
|
286
|
-
|
|
287
|
-
// The matched route key, auto-generated names included (the handler
|
|
288
|
-
// ctx's _routeName); undefined before routing.
|
|
289
|
-
get _routeName(): string | undefined {
|
|
290
|
-
return _getRequestContext()?._routeName;
|
|
291
|
-
},
|
|
292
|
-
|
|
293
|
-
// Guarded only inside a "use cache" body: the stamp covers a miss that
|
|
294
|
-
// took this ctx as an argument, the exec scope a stale refresh (which does
|
|
295
|
-
// not stamp its args, cache-runtime.ts). A write there is lost on a hit.
|
|
296
|
-
get headers(): Headers {
|
|
297
|
-
const headers = getResponse().headers;
|
|
298
|
-
return (INSIDE_CACHE_EXEC as symbol) in ctx ||
|
|
299
|
-
getCacheExecScope() !== undefined
|
|
300
|
-
? guardHeaderWrites(headers, () =>
|
|
301
|
-
assertNotInsideCacheExec(ctx, "headers"),
|
|
302
|
-
)
|
|
303
|
-
: headers;
|
|
304
|
-
},
|
|
305
325
|
|
|
306
326
|
get: ((keyOrVar: any) => {
|
|
307
327
|
// "use cache" only: intercept middleware runs inside a cache()
|
|
@@ -338,11 +358,6 @@ export function createMiddlewareContext<TEnv>(
|
|
|
338
358
|
responseHolder.response.headers.set(name, value);
|
|
339
359
|
},
|
|
340
360
|
|
|
341
|
-
// theme: MIDDLEWARE_THEME_DESCRIPTOR, defined after this literal.
|
|
342
|
-
get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
|
|
343
|
-
return _getRequestContext()?.setTheme;
|
|
344
|
-
},
|
|
345
|
-
|
|
346
361
|
setLocationState(entries) {
|
|
347
362
|
const reqCtx = _getRequestContext();
|
|
348
363
|
if (!reqCtx) {
|
|
@@ -376,11 +391,46 @@ export function createMiddlewareContext<TEnv>(
|
|
|
376
391
|
}
|
|
377
392
|
},
|
|
378
393
|
};
|
|
379
|
-
|
|
394
|
+
const ctx = fields as MiddlewareContext<TEnv>;
|
|
395
|
+
// Guarded only inside a "use cache" body: the stamp covers a miss that
|
|
396
|
+
// took this ctx as an argument, the exec scope a stale refresh (which does
|
|
397
|
+
// not stamp its args, cache-runtime.ts). A write there is lost on a hit.
|
|
398
|
+
defineSlot(ctx, HEADERS_SLOT, (): Headers => {
|
|
399
|
+
const headers = getResponse().headers;
|
|
400
|
+
return (INSIDE_CACHE_EXEC as symbol) in ctx ||
|
|
401
|
+
getCacheExecScope() !== undefined
|
|
402
|
+
? guardHeaderWrites(headers, () =>
|
|
403
|
+
assertNotInsideCacheExec(ctx, "headers"),
|
|
404
|
+
)
|
|
405
|
+
: headers;
|
|
406
|
+
});
|
|
407
|
+
Object.defineProperty(ctx, "routeName", MIDDLEWARE_ACCESSORS.routeName);
|
|
408
|
+
Object.defineProperty(ctx, "_routeName", MIDDLEWARE_ACCESSORS._routeName);
|
|
409
|
+
Object.defineProperty(ctx, "headers", MIDDLEWARE_ACCESSORS.headers);
|
|
410
|
+
Object.defineProperty(ctx, "setTheme", MIDDLEWARE_ACCESSORS.setTheme);
|
|
411
|
+
Object.defineProperty(ctx, "theme", MIDDLEWARE_ACCESSORS.theme);
|
|
412
|
+
defineRewrittenFrom(ctx, reqCtx, "middleware");
|
|
380
413
|
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
381
414
|
return ctx;
|
|
382
415
|
}
|
|
383
416
|
|
|
417
|
+
/** A middleware threw a Rewrite after its next() started the downstream. */
|
|
418
|
+
function rewriteAfterNextError(handler: Function, rewrite: Rewrite): Error {
|
|
419
|
+
return new Error(
|
|
420
|
+
`Middleware "${handler.name || "(anonymous)"}" threw rewrite() after ` +
|
|
421
|
+
`calling next(). Throw rewrite() before await next().`,
|
|
422
|
+
{ cause: rewrite },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A middleware returned a Rewrite: only a thrown one re-dispatches. */
|
|
427
|
+
function returnedRewriteError(handler: Function): Error {
|
|
428
|
+
return new Error(
|
|
429
|
+
`Middleware "${handler.name || "(anonymous)"}" returned a rewrite(). ` +
|
|
430
|
+
`Throw it instead: \`throw rewrite(...)\`, don't return it.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
384
434
|
/**
|
|
385
435
|
* Match middleware entries against a pathname
|
|
386
436
|
* Returns entries that match, with extracted params
|
|
@@ -584,6 +634,12 @@ export async function executeMiddleware<TEnv>(
|
|
|
584
634
|
// downstream chain and overwrite responseHolder.response.
|
|
585
635
|
let nextPromise: Promise<Response> | null = null;
|
|
586
636
|
let nextResolvedAt: number | undefined;
|
|
637
|
+
// Whether next() rejected with a Rewrite: only then did nothing render
|
|
638
|
+
// (a downstream middleware rewrote before its own next()). Any other
|
|
639
|
+
// rejection can come after the route rendered (a route middleware that
|
|
640
|
+
// throws after its next(), the render's own error), so a rewrite thrown
|
|
641
|
+
// after it would re-dispatch onto the used handle store.
|
|
642
|
+
let nextRejectedWithRewrite = false;
|
|
587
643
|
const wrappedNext = (): Promise<Response> => {
|
|
588
644
|
if (nextPromise) {
|
|
589
645
|
throw new Error(
|
|
@@ -599,6 +655,7 @@ export async function executeMiddleware<TEnv>(
|
|
|
599
655
|
},
|
|
600
656
|
(err) => {
|
|
601
657
|
nextResolvedAt = performance.now();
|
|
658
|
+
nextRejectedWithRewrite = isRewrite(err);
|
|
602
659
|
throw err;
|
|
603
660
|
},
|
|
604
661
|
);
|
|
@@ -612,6 +669,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
612
669
|
// middleware-over-middleware and the core handler underneath. Pass-through
|
|
613
670
|
// when neither surface is active.
|
|
614
671
|
let result: Response | void;
|
|
672
|
+
// A thrown rewrite() is control flow like a thrown Response: held past
|
|
673
|
+
// the span (which settles as success) and rethrown below, so it unwinds
|
|
674
|
+
// to the dispatcher (rsc/handler.ts) with no further code of this
|
|
675
|
+
// middleware run.
|
|
676
|
+
let rewrite: Rewrite | undefined;
|
|
615
677
|
try {
|
|
616
678
|
result = await observePhase(PHASES.middleware(metricLabel), async () => {
|
|
617
679
|
try {
|
|
@@ -627,6 +689,10 @@ export async function executeMiddleware<TEnv>(
|
|
|
627
689
|
// this convention (segment-resolution/helpers.ts keeps result handling
|
|
628
690
|
// outside the span). Real errors propagate past the span.
|
|
629
691
|
if (error instanceof Response) return error;
|
|
692
|
+
if (isRewrite(error)) {
|
|
693
|
+
rewrite = error;
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
630
696
|
throw error;
|
|
631
697
|
}
|
|
632
698
|
});
|
|
@@ -635,6 +701,16 @@ export async function executeMiddleware<TEnv>(
|
|
|
635
701
|
// error paths (idempotent guard in finishMiddleware).
|
|
636
702
|
finishMiddleware();
|
|
637
703
|
}
|
|
704
|
+
// A rewrite after this middleware's next() started the downstream chain
|
|
705
|
+
// (resolved, still running, or failed) would leave that render running
|
|
706
|
+
// against the context the next pass moves, or re-dispatch onto what it
|
|
707
|
+
// already pushed, and drop the response it built. Only a rewrite after
|
|
708
|
+
// next() rejected with a Rewrite (nothing rendered) unwinds.
|
|
709
|
+
if (rewrite && nextPromise && !nextRejectedWithRewrite) {
|
|
710
|
+
throw rewriteAfterNextError(entry.handler, rewrite);
|
|
711
|
+
}
|
|
712
|
+
if (rewrite) throw rewrite;
|
|
713
|
+
if (isRewrite(result)) throw returnedRewriteError(entry.handler);
|
|
638
714
|
|
|
639
715
|
// Record post-next() processing time when middleware did work after
|
|
640
716
|
// the downstream chain resolved (e.g. adding headers, logging).
|
|
@@ -805,10 +881,12 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
805
881
|
// runner settles rango.middleware as success, not STATUS_ERROR (same
|
|
806
882
|
// reasoning as executeMiddleware's main chain). Real errors propagate.
|
|
807
883
|
if (error instanceof Response) return error;
|
|
884
|
+
if (isRewrite(error)) throw rewriteNotSupported(error, "intercept");
|
|
808
885
|
throw error;
|
|
809
886
|
}
|
|
810
887
|
},
|
|
811
888
|
);
|
|
889
|
+
if (isRewrite(result)) throw returnedRewriteError(middleware);
|
|
812
890
|
|
|
813
891
|
if (result instanceof Response) {
|
|
814
892
|
earlyResponse = result;
|
|
@@ -20,7 +20,7 @@ import { makeIsAction } from "./is-action.js";
|
|
|
20
20
|
/**
|
|
21
21
|
* Options for revalidation evaluation
|
|
22
22
|
*/
|
|
23
|
-
interface EvaluateRevalidationOptions<TEnv> {
|
|
23
|
+
export interface EvaluateRevalidationOptions<TEnv> {
|
|
24
24
|
/** Current segment to evaluate */
|
|
25
25
|
segment: ResolvedSegment;
|
|
26
26
|
/** Previous route params (from route match, not segment) */
|
|
@@ -153,6 +153,31 @@ function sortSuffixParams(node: TrieNode): void {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Give every static-children map in the trie a null prototype, in place, and
|
|
158
|
+
* return the trie (#1109). The walker indexes `node.s` with raw URL segments,
|
|
159
|
+
* so a map with Object.prototype would answer `/constructor` or `/__proto__`
|
|
160
|
+
* with an inherited member. The builder creates null-prototype maps; a trie
|
|
161
|
+
* that arrives as JSON (the build manifest) or across the dev runner boundary
|
|
162
|
+
* gets them here. Object.setPrototypeOf keeps a JSON-parsed map's fast
|
|
163
|
+
* properties, where Object.create(null) starts in dictionary mode.
|
|
164
|
+
*/
|
|
165
|
+
export function nullPrototypeStaticChildren(node: TrieNode): TrieNode {
|
|
166
|
+
if (node.s) {
|
|
167
|
+
Object.setPrototypeOf(node.s, null);
|
|
168
|
+
for (const child of Object.values(node.s)) {
|
|
169
|
+
nullPrototypeStaticChildren(child);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (node.xp) {
|
|
173
|
+
for (const child of Object.values(node.xp)) {
|
|
174
|
+
nullPrototypeStaticChildren(child.c);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (node.p) nullPrototypeStaticChildren(node.p.c);
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
156
181
|
/**
|
|
157
182
|
* Insert a route into the trie. Optional params expand into two branches at
|
|
158
183
|
* registration time (skip-first, then present), so each terminal lives at the
|
|
@@ -271,7 +296,9 @@ function insertSegments(
|
|
|
271
296
|
const segment = segments[index];
|
|
272
297
|
|
|
273
298
|
if (segment.type === "static") {
|
|
274
|
-
|
|
299
|
+
// Null prototype: a segment named `constructor` or `__proto__` is an own
|
|
300
|
+
// key, never an inherited member to descend into and write a leaf on.
|
|
301
|
+
if (!node.s) node.s = Object.create(null) as Record<string, TrieNode>;
|
|
275
302
|
if (!node.s[segment.value]) node.s[segment.value] = {};
|
|
276
303
|
insertSegments(
|
|
277
304
|
node.s[segment.value],
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { isValidElement, type ReactElement } from "react";
|
|
28
28
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
29
|
+
import { assignOwn } from "../../context-var.js";
|
|
29
30
|
import {
|
|
30
31
|
cloneElementWithProps,
|
|
31
32
|
isPlainDataObject,
|
|
@@ -178,7 +179,7 @@ async function elideNested(
|
|
|
178
179
|
for (const key of Object.keys(source)) {
|
|
179
180
|
const r = await elideNested(source[key], seen);
|
|
180
181
|
if (r.state === "rejected") return r;
|
|
181
|
-
out
|
|
182
|
+
assignOwn(out, key, r.value);
|
|
182
183
|
if (r.value !== source[key]) changed = true;
|
|
183
184
|
result.hasHole ||= r.hasHole;
|
|
184
185
|
}
|
|
@@ -301,13 +302,21 @@ export function overlayLoaderContainer(
|
|
|
301
302
|
// Shape drift (fresh not a plain object): markers fall back to undefined.
|
|
302
303
|
const freshObj = isPlainDataObject(fresh) ? fresh : undefined;
|
|
303
304
|
let out: Record<string, unknown> | undefined;
|
|
305
|
+
// Own keys only: a key named like an Object.prototype member is data.
|
|
306
|
+
// The spread copies an own `__proto__` as data, so assigning a recorded
|
|
307
|
+
// key writes it; a fresh-only key goes through assignOwn.
|
|
304
308
|
for (const key of Object.keys(recorded)) {
|
|
305
|
-
const next = overlayLoaderContainer(
|
|
309
|
+
const next = overlayLoaderContainer(
|
|
310
|
+
freshObj && Object.hasOwn(freshObj, key) ? freshObj[key] : undefined,
|
|
311
|
+
recorded[key],
|
|
312
|
+
);
|
|
306
313
|
if (next !== recorded[key]) (out ??= { ...recorded })[key] = next;
|
|
307
314
|
}
|
|
308
315
|
if (freshObj) {
|
|
309
316
|
for (const key of Object.keys(freshObj)) {
|
|
310
|
-
if (!(key
|
|
317
|
+
if (!Object.hasOwn(recorded, key)) {
|
|
318
|
+
assignOwn((out ??= { ...recorded }), key, freshObj[key]);
|
|
319
|
+
}
|
|
311
320
|
}
|
|
312
321
|
}
|
|
313
322
|
return out ?? recorded;
|
|
@@ -341,7 +350,12 @@ function overlayProps(
|
|
|
341
350
|
const props = recorded.props;
|
|
342
351
|
let changed: Record<string, unknown> | undefined;
|
|
343
352
|
for (const key of Object.keys(props)) {
|
|
344
|
-
const next = overlayLoaderContainer(
|
|
353
|
+
const next = overlayLoaderContainer(
|
|
354
|
+
freshProps && Object.hasOwn(freshProps, key)
|
|
355
|
+
? freshProps[key]
|
|
356
|
+
: undefined,
|
|
357
|
+
props[key],
|
|
358
|
+
);
|
|
345
359
|
if (next !== props[key]) (changed ??= {})[key] = next;
|
|
346
360
|
}
|
|
347
361
|
return changed ? cloneElementWithProps(recorded, changed) : recorded;
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Deliberately a LEAF module: loader-mask (the other natural home) imports
|
|
10
10
|
* request-context, so any funnel importing the mask from there inherits that
|
|
11
|
-
* graph. This module imports only is-thenable and
|
|
11
|
+
* graph. This module imports only react and two leaves, is-thenable and
|
|
12
|
+
* context-var.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { cloneElement, isValidElement, type ReactElement } from "react";
|
|
16
|
+
import { assignOwn } from "../../context-var.js";
|
|
15
17
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -52,7 +54,8 @@ type KeyValidated = { _store?: { validated?: unknown } };
|
|
|
52
54
|
* keyless child of a static children list (validated at creation) would warn
|
|
53
55
|
* "Each child in a list should have a unique key" once cloned. Pass only the
|
|
54
56
|
* props that change: cloneElement re-parents the element (owner) when its
|
|
55
|
-
* config carries `ref`.
|
|
57
|
+
* config carries `ref`. cloneElement copies props by assignment, so a prop
|
|
58
|
+
* key named `__proto__` can't survive a clone; prop copies don't use assignOwn.
|
|
56
59
|
*/
|
|
57
60
|
export function cloneElementWithProps(
|
|
58
61
|
element: ReactElement,
|
|
@@ -206,8 +209,9 @@ export function maskNestedContainerThenables(
|
|
|
206
209
|
seen.set(value, out);
|
|
207
210
|
let changed = false;
|
|
208
211
|
for (const key of Object.keys(source)) {
|
|
209
|
-
|
|
210
|
-
|
|
212
|
+
const masked = maskNestedContainerThenables(source[key], seen, report);
|
|
213
|
+
assignOwn(out, key, masked);
|
|
214
|
+
if (masked !== source[key]) changed = true;
|
|
211
215
|
}
|
|
212
216
|
if (changed) return out;
|
|
213
217
|
seen.set(value, value);
|