@ivogt/rsc-router 0.0.0-experimental.1
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/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router Manifest Loading
|
|
3
|
+
*
|
|
4
|
+
* Handles lazy loading and validation of route manifests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { invariant, RouteNotFoundError } from "../errors";
|
|
8
|
+
import { getContext, type EntryData, type MetricsStore } from "../server/context";
|
|
9
|
+
import type { RouteEntry } from "../types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Module-level cache for manifests per mount index.
|
|
13
|
+
* Only used in production - dev mode skips caching for HMR support.
|
|
14
|
+
*/
|
|
15
|
+
const manifestCache = new Map<number, Map<string, EntryData>>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Load manifest from route entry with AsyncLocalStorage context
|
|
19
|
+
* Handles lazy imports, unwrapping, and validation
|
|
20
|
+
*/
|
|
21
|
+
export async function loadManifest(
|
|
22
|
+
entry: RouteEntry<any>,
|
|
23
|
+
routeKey: string,
|
|
24
|
+
path: string,
|
|
25
|
+
metricsStore?: MetricsStore,
|
|
26
|
+
isSSR?: boolean
|
|
27
|
+
): Promise<EntryData> {
|
|
28
|
+
const mountIndex = entry.mountIndex;
|
|
29
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
30
|
+
|
|
31
|
+
// In production, check cache first
|
|
32
|
+
if (!isDev) {
|
|
33
|
+
const cachedManifest = manifestCache.get(mountIndex);
|
|
34
|
+
if (cachedManifest && cachedManifest.has(routeKey)) {
|
|
35
|
+
return cachedManifest.get(routeKey)!;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const Store = getContext().getOrCreateStore(routeKey);
|
|
40
|
+
|
|
41
|
+
// Set mount index in store for unique shortCode prefixes
|
|
42
|
+
Store.mountIndex = mountIndex;
|
|
43
|
+
|
|
44
|
+
// Set isSSR flag so loading() can check if we're in SSR
|
|
45
|
+
Store.isSSR = isSSR;
|
|
46
|
+
|
|
47
|
+
// Attach metrics store to context if provided
|
|
48
|
+
if (metricsStore) {
|
|
49
|
+
Store.metrics = metricsStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Clear manifest before rebuilding to prevent stale entry mutations
|
|
53
|
+
Store.manifest.clear();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Include mountIndex in namespace to ensure unique cache keys per mount
|
|
57
|
+
const namespaceWithMount = mountIndex !== undefined
|
|
58
|
+
? `#router.M${mountIndex}`
|
|
59
|
+
: "#router";
|
|
60
|
+
|
|
61
|
+
const useItems = await getContext().runWithStore(
|
|
62
|
+
Store,
|
|
63
|
+
Store.namespace || namespaceWithMount,
|
|
64
|
+
Store.parent,
|
|
65
|
+
async () => {
|
|
66
|
+
const load = await entry.handler();
|
|
67
|
+
if (
|
|
68
|
+
load &&
|
|
69
|
+
load !== null &&
|
|
70
|
+
typeof load === "object" &&
|
|
71
|
+
"default" in load
|
|
72
|
+
) {
|
|
73
|
+
return load.default();
|
|
74
|
+
}
|
|
75
|
+
if (typeof load === "function") {
|
|
76
|
+
return load();
|
|
77
|
+
}
|
|
78
|
+
return load;
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
invariant(
|
|
83
|
+
useItems && useItems.length > 0,
|
|
84
|
+
"Did not receive any handler from router.map()"
|
|
85
|
+
);
|
|
86
|
+
invariant(
|
|
87
|
+
useItems.some((item) => item.type === "layout"),
|
|
88
|
+
"Top-level handler must be a layout"
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
invariant(
|
|
92
|
+
Store.manifest.has(routeKey),
|
|
93
|
+
`Route must be registered for ${routeKey}`
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Cache manifest in production after successful build
|
|
97
|
+
if (!isDev) {
|
|
98
|
+
manifestCache.set(mountIndex, new Map(Store.manifest));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Store.manifest.get(routeKey)!;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw new RouteNotFoundError(
|
|
104
|
+
`Failed to load route handlers for ${path}: ${(e as Error).message}`,
|
|
105
|
+
{
|
|
106
|
+
cause: {
|
|
107
|
+
error: e,
|
|
108
|
+
state: {
|
|
109
|
+
path,
|
|
110
|
+
routeKey,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match Context for Router Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates all state needed by the match pipeline middleware.
|
|
5
|
+
* Created once at the start of match()/matchPartial() and passed through the pipeline.
|
|
6
|
+
*
|
|
7
|
+
* DATA FLOW ARCHITECTURE
|
|
8
|
+
* ======================
|
|
9
|
+
*
|
|
10
|
+
* The router uses two complementary data structures:
|
|
11
|
+
*
|
|
12
|
+
* MatchContext (ctx) - Immutable request state
|
|
13
|
+
* MatchPipelineState (state) - Mutable pipeline state
|
|
14
|
+
*
|
|
15
|
+
*
|
|
16
|
+
* Request
|
|
17
|
+
* |
|
|
18
|
+
* v
|
|
19
|
+
* +-------------------+
|
|
20
|
+
* | Create Context | ctx = immutable snapshot of request
|
|
21
|
+
* +-------------------+
|
|
22
|
+
* |
|
|
23
|
+
* v
|
|
24
|
+
* +-------------------+
|
|
25
|
+
* | Create State | state = mutable accumulator
|
|
26
|
+
* +-------------------+
|
|
27
|
+
* |
|
|
28
|
+
* +---> [Pipeline Middleware]
|
|
29
|
+
* | |
|
|
30
|
+
* | ctx: read-only
|
|
31
|
+
* | state: read/write
|
|
32
|
+
* | |
|
|
33
|
+
* +<----------+
|
|
34
|
+
* |
|
|
35
|
+
* v
|
|
36
|
+
* +-------------------+
|
|
37
|
+
* | Build Result | Merge ctx + state into MatchResult
|
|
38
|
+
* +-------------------+
|
|
39
|
+
*
|
|
40
|
+
*
|
|
41
|
+
* MATCHCONTEXT FIELDS
|
|
42
|
+
* ===================
|
|
43
|
+
*
|
|
44
|
+
* Request Info:
|
|
45
|
+
* - request, url, pathname: The incoming HTTP request
|
|
46
|
+
*
|
|
47
|
+
* Environment:
|
|
48
|
+
* - env, bindings: Server environment (Cloudflare bindings, etc.)
|
|
49
|
+
*
|
|
50
|
+
* Client State (from RSC request headers):
|
|
51
|
+
* - clientSegmentIds: Segments the client currently has
|
|
52
|
+
* - clientSegmentSet: Set version for O(1) lookup
|
|
53
|
+
* - stale: Whether client considers its cache stale
|
|
54
|
+
*
|
|
55
|
+
* Navigation State:
|
|
56
|
+
* - prevUrl, prevParams, prevMatch: Previous navigation for comparison
|
|
57
|
+
*
|
|
58
|
+
* Current Match:
|
|
59
|
+
* - matched: Route match result (params, route key)
|
|
60
|
+
* - manifestEntry: Resolved manifest data
|
|
61
|
+
* - entries: All route entries (layouts, loaders, etc.)
|
|
62
|
+
* - routeKey, localRouteName: Route identifiers
|
|
63
|
+
*
|
|
64
|
+
* Handler Context:
|
|
65
|
+
* - handlerContext: Context passed to loaders
|
|
66
|
+
* - loaderPromises: Memoized loader promises
|
|
67
|
+
*
|
|
68
|
+
* Intercepts:
|
|
69
|
+
* - interceptResult: Detected intercept (if soft navigation)
|
|
70
|
+
* - interceptSelectorContext: Context for intercept matching
|
|
71
|
+
*
|
|
72
|
+
* Cache:
|
|
73
|
+
* - cacheScope: Cache configuration and methods
|
|
74
|
+
* - isIntercept: Whether this is an intercept request
|
|
75
|
+
*
|
|
76
|
+
* Flags:
|
|
77
|
+
* - isAction: POST/mutation request
|
|
78
|
+
* - isFullMatch: Document request vs navigation
|
|
79
|
+
*
|
|
80
|
+
*
|
|
81
|
+
* MATCHPIPELINESTATE FIELDS
|
|
82
|
+
* =========================
|
|
83
|
+
*
|
|
84
|
+
* State flags (set by middleware, read by others):
|
|
85
|
+
* - cacheHit: Cache lookup succeeded
|
|
86
|
+
* - shouldRevalidate: SWR revalidation needed
|
|
87
|
+
*
|
|
88
|
+
* Segment accumulation:
|
|
89
|
+
* - segments: Resolved segments from pipeline
|
|
90
|
+
* - matchedIds: All segment IDs in match order
|
|
91
|
+
* - cachedSegments: Segments from cache (if hit)
|
|
92
|
+
*
|
|
93
|
+
* Intercept data:
|
|
94
|
+
* - interceptSegments: Segments for modal slots
|
|
95
|
+
* - slots: Named slot data for client
|
|
96
|
+
*
|
|
97
|
+
*
|
|
98
|
+
* IMMUTABILITY CONTRACT
|
|
99
|
+
* =====================
|
|
100
|
+
*
|
|
101
|
+
* MatchContext is treated as immutable after creation.
|
|
102
|
+
* Middleware should NEVER modify ctx properties.
|
|
103
|
+
*
|
|
104
|
+
* MatchPipelineState is explicitly mutable.
|
|
105
|
+
* Middleware communicate by setting state flags.
|
|
106
|
+
*
|
|
107
|
+
* This separation ensures:
|
|
108
|
+
* - Request data is consistent across all middleware
|
|
109
|
+
* - Pipeline state changes are explicit and trackable
|
|
110
|
+
* - No hidden side effects in request handling
|
|
111
|
+
*/
|
|
112
|
+
import type { CacheScope } from "../cache/cache-scope.js";
|
|
113
|
+
import type {
|
|
114
|
+
EntryData,
|
|
115
|
+
InterceptSelectorContext,
|
|
116
|
+
MetricsStore,
|
|
117
|
+
} from "../server/context.js";
|
|
118
|
+
import type { HandlerContext, ResolvedSegment } from "../types.js";
|
|
119
|
+
import type { RouteMatchResult } from "./pattern-matching.js";
|
|
120
|
+
import type { InterceptResult } from "./router-context.js";
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Action context passed to matchPartial
|
|
124
|
+
*/
|
|
125
|
+
export interface ActionContext {
|
|
126
|
+
actionId?: string;
|
|
127
|
+
actionUrl?: URL;
|
|
128
|
+
actionResult?: any;
|
|
129
|
+
formData?: FormData;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Match context containing all state for the match pipeline
|
|
134
|
+
*/
|
|
135
|
+
export interface MatchContext<TEnv = any> {
|
|
136
|
+
// Request info
|
|
137
|
+
request: Request;
|
|
138
|
+
url: URL;
|
|
139
|
+
pathname: string;
|
|
140
|
+
|
|
141
|
+
// Environment
|
|
142
|
+
env: TEnv;
|
|
143
|
+
bindings: TEnv;
|
|
144
|
+
|
|
145
|
+
// Client state
|
|
146
|
+
clientSegmentIds: string[];
|
|
147
|
+
clientSegmentSet: Set<string>;
|
|
148
|
+
stale: boolean;
|
|
149
|
+
|
|
150
|
+
// Previous navigation state
|
|
151
|
+
prevUrl: URL;
|
|
152
|
+
prevParams: Record<string, string>;
|
|
153
|
+
prevMatch: RouteMatchResult | null;
|
|
154
|
+
|
|
155
|
+
// Current route match
|
|
156
|
+
matched: RouteMatchResult;
|
|
157
|
+
manifestEntry: EntryData;
|
|
158
|
+
entries: EntryData[];
|
|
159
|
+
routeKey: string;
|
|
160
|
+
localRouteName: string;
|
|
161
|
+
|
|
162
|
+
// Handler context (for loaders)
|
|
163
|
+
handlerContext: HandlerContext<any, TEnv>;
|
|
164
|
+
loaderPromises: Map<string, Promise<any>>;
|
|
165
|
+
|
|
166
|
+
// Metrics
|
|
167
|
+
metricsStore: MetricsStore | undefined;
|
|
168
|
+
|
|
169
|
+
// Store for running within context
|
|
170
|
+
Store: any;
|
|
171
|
+
|
|
172
|
+
// Intercept detection
|
|
173
|
+
interceptContextMatch: RouteMatchResult | null;
|
|
174
|
+
interceptSelectorContext: InterceptSelectorContext;
|
|
175
|
+
isSameRouteNavigation: boolean;
|
|
176
|
+
interceptResult: InterceptResult | null;
|
|
177
|
+
|
|
178
|
+
// Cache
|
|
179
|
+
cacheScope: CacheScope | null;
|
|
180
|
+
isIntercept: boolean;
|
|
181
|
+
|
|
182
|
+
// Action context (if this is an action)
|
|
183
|
+
actionContext?: ActionContext;
|
|
184
|
+
isAction: boolean;
|
|
185
|
+
|
|
186
|
+
// Route middleware
|
|
187
|
+
routeMiddleware: Array<{
|
|
188
|
+
handler: any;
|
|
189
|
+
params: Record<string, string>;
|
|
190
|
+
}>;
|
|
191
|
+
|
|
192
|
+
// Full match flag (document requests vs partial/navigation requests)
|
|
193
|
+
// When true, uses simpler resolution without revalidation logic
|
|
194
|
+
isFullMatch: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Mutable state that flows through the pipeline
|
|
199
|
+
*/
|
|
200
|
+
export interface MatchPipelineState {
|
|
201
|
+
// Whether cache was hit
|
|
202
|
+
cacheHit: boolean;
|
|
203
|
+
|
|
204
|
+
// Cached segments (if cache hit)
|
|
205
|
+
cachedSegments?: ResolvedSegment[];
|
|
206
|
+
cachedMatchedIds?: string[];
|
|
207
|
+
|
|
208
|
+
// Whether cache should be revalidated (SWR)
|
|
209
|
+
shouldRevalidate?: boolean;
|
|
210
|
+
|
|
211
|
+
// Resolved segments from pipeline
|
|
212
|
+
segments: ResolvedSegment[];
|
|
213
|
+
matchedIds: string[];
|
|
214
|
+
|
|
215
|
+
// Intercept segments
|
|
216
|
+
interceptSegments: ResolvedSegment[];
|
|
217
|
+
|
|
218
|
+
// Slots state
|
|
219
|
+
slots: Record<
|
|
220
|
+
string,
|
|
221
|
+
{
|
|
222
|
+
active: boolean;
|
|
223
|
+
segments: ResolvedSegment[];
|
|
224
|
+
}
|
|
225
|
+
>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create initial pipeline state
|
|
230
|
+
*/
|
|
231
|
+
export function createPipelineState(): MatchPipelineState {
|
|
232
|
+
return {
|
|
233
|
+
cacheHit: false,
|
|
234
|
+
segments: [],
|
|
235
|
+
matchedIds: [],
|
|
236
|
+
interceptSegments: [],
|
|
237
|
+
slots: {},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Input parameters for createMatchContext
|
|
243
|
+
*/
|
|
244
|
+
export interface CreateMatchContextInput<TEnv = any> {
|
|
245
|
+
request: Request;
|
|
246
|
+
env: TEnv;
|
|
247
|
+
actionContext?: ActionContext;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Result from createMatchContext - either a context or null (fall back to full match)
|
|
252
|
+
*/
|
|
253
|
+
export type CreateMatchContextResult<TEnv = any> =
|
|
254
|
+
| { type: "context"; ctx: MatchContext<TEnv> }
|
|
255
|
+
| { type: "fallback"; reason: string }
|
|
256
|
+
| { type: "error"; error: Error };
|
|
257
|
+
|
|
258
|
+
// Note: createMatchContext() will be implemented in Step J10 when we wire everything together.
|
|
259
|
+
// It requires access to RouterContext (findMatch, loadManifest, etc.) which are closure
|
|
260
|
+
// functions from createRSCRouter(). The implementation will live in router.ts initially
|
|
261
|
+
// and call getRouterContext() to access these dependencies.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Revalidation Middleware
|
|
3
|
+
*
|
|
4
|
+
* Implements SWR (stale-while-revalidate) pattern.
|
|
5
|
+
* Triggers background refresh when cached data is stale.
|
|
6
|
+
*
|
|
7
|
+
* FLOW DIAGRAM
|
|
8
|
+
* ============
|
|
9
|
+
*
|
|
10
|
+
* source (from cache-store)
|
|
11
|
+
* |
|
|
12
|
+
* v
|
|
13
|
+
* +---------------------------+
|
|
14
|
+
* | yield* source | Pure pass-through
|
|
15
|
+
* | (no modifications) |
|
|
16
|
+
* +---------------------------+
|
|
17
|
+
* |
|
|
18
|
+
* v
|
|
19
|
+
* +---------------------+
|
|
20
|
+
* | Should revalidate? |
|
|
21
|
+
* | - shouldRevalidate |──no───> return
|
|
22
|
+
* | - cacheHit |
|
|
23
|
+
* | - cacheScope |
|
|
24
|
+
* +---------------------+
|
|
25
|
+
* | yes
|
|
26
|
+
* v
|
|
27
|
+
* +---------------------------+
|
|
28
|
+
* | requestCtx.waitUntil() | Non-blocking background task
|
|
29
|
+
* +---------------------------+
|
|
30
|
+
* |
|
|
31
|
+
* v (async, doesn't block response)
|
|
32
|
+
* +---------------------------+
|
|
33
|
+
* | Create fresh handleStore | Isolate from response stream
|
|
34
|
+
* +---------------------------+
|
|
35
|
+
* |
|
|
36
|
+
* v
|
|
37
|
+
* +---------------------+
|
|
38
|
+
* | isFullMatch? |
|
|
39
|
+
* +---------------------+
|
|
40
|
+
* |
|
|
41
|
+
* +-----+-----+
|
|
42
|
+
* | |
|
|
43
|
+
* yes no
|
|
44
|
+
* | |
|
|
45
|
+
* v v
|
|
46
|
+
* resolveAll resolveWithRevalidation
|
|
47
|
+
* Segments + resolveIntercepts
|
|
48
|
+
* | |
|
|
49
|
+
* +-----------+
|
|
50
|
+
* |
|
|
51
|
+
* v
|
|
52
|
+
* +---------------------------+
|
|
53
|
+
* | cacheScope.cacheRoute() | Update cache with fresh data
|
|
54
|
+
* +---------------------------+
|
|
55
|
+
*
|
|
56
|
+
*
|
|
57
|
+
* SWR PATTERN
|
|
58
|
+
* ===========
|
|
59
|
+
*
|
|
60
|
+
* Stale-While-Revalidate provides fast responses with eventual consistency:
|
|
61
|
+
*
|
|
62
|
+
* Timeline:
|
|
63
|
+
* ---------
|
|
64
|
+
* T0: Request arrives
|
|
65
|
+
* T1: Cache lookup finds stale entry
|
|
66
|
+
* T2: Return stale data immediately (fast!)
|
|
67
|
+
* T3: Response sent to client
|
|
68
|
+
* T4: waitUntil() triggers background revalidation
|
|
69
|
+
* T5: Fresh data resolved
|
|
70
|
+
* T6: Cache updated with fresh data
|
|
71
|
+
* T7: Next request gets fresh data from cache
|
|
72
|
+
*
|
|
73
|
+
* Benefits:
|
|
74
|
+
* - Fast initial response (cached data)
|
|
75
|
+
* - Eventually consistent (background refresh)
|
|
76
|
+
* - No blocking on revalidation
|
|
77
|
+
*
|
|
78
|
+
*
|
|
79
|
+
* WHEN IS CACHE STALE?
|
|
80
|
+
* ====================
|
|
81
|
+
*
|
|
82
|
+
* The cache-lookup middleware sets state.shouldRevalidate based on:
|
|
83
|
+
* - TTL (time-to-live) expiration
|
|
84
|
+
* - Cache entry metadata
|
|
85
|
+
* - Configured staleness rules
|
|
86
|
+
*
|
|
87
|
+
* This middleware only acts on the flag, it doesn't determine staleness.
|
|
88
|
+
*
|
|
89
|
+
*
|
|
90
|
+
* ISOLATION FROM RESPONSE
|
|
91
|
+
* =======================
|
|
92
|
+
*
|
|
93
|
+
* The background revalidation creates a fresh handleStore:
|
|
94
|
+
*
|
|
95
|
+
* requestCtx._handleStore = createHandleStore();
|
|
96
|
+
*
|
|
97
|
+
* This prevents background handle.push() calls from:
|
|
98
|
+
* - Polluting the current response stream
|
|
99
|
+
* - Causing duplicate data in the client
|
|
100
|
+
* - Creating race conditions
|
|
101
|
+
*
|
|
102
|
+
*
|
|
103
|
+
* FULL VS PARTIAL REVALIDATION
|
|
104
|
+
* ============================
|
|
105
|
+
*
|
|
106
|
+
* Full Match (document request):
|
|
107
|
+
* - Simple resolveAllSegments()
|
|
108
|
+
* - No need to compare with previous state
|
|
109
|
+
*
|
|
110
|
+
* Partial Match (navigation):
|
|
111
|
+
* - resolveAllSegmentsWithRevalidation()
|
|
112
|
+
* - Also resolves intercept segments if applicable
|
|
113
|
+
* - More complex but handles all scenarios
|
|
114
|
+
*/
|
|
115
|
+
import type { ResolvedSegment } from "../../types.js";
|
|
116
|
+
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
117
|
+
import { getRouterContext } from "../router-context.js";
|
|
118
|
+
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates background revalidation middleware
|
|
122
|
+
*
|
|
123
|
+
* If cache was stale (state.shouldRevalidate === true):
|
|
124
|
+
* - Triggers background resolution via waitUntil
|
|
125
|
+
* - Observes segments but doesn't modify them
|
|
126
|
+
* - Updates cache with fresh segments after revalidation completes
|
|
127
|
+
*/
|
|
128
|
+
export function withBackgroundRevalidation<TEnv>(
|
|
129
|
+
ctx: MatchContext<TEnv>,
|
|
130
|
+
state: MatchPipelineState
|
|
131
|
+
): GeneratorMiddleware<ResolvedSegment> {
|
|
132
|
+
return async function* (
|
|
133
|
+
source: AsyncGenerator<ResolvedSegment>
|
|
134
|
+
): AsyncGenerator<ResolvedSegment> {
|
|
135
|
+
// Pass through all segments unchanged
|
|
136
|
+
for await (const segment of source) {
|
|
137
|
+
yield segment;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only trigger background revalidation if:
|
|
141
|
+
// 1. Cache was hit and stale
|
|
142
|
+
// 2. Cache scope exists
|
|
143
|
+
if (!state.shouldRevalidate || !state.cacheHit || !ctx.cacheScope) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const {
|
|
148
|
+
getRequestContext,
|
|
149
|
+
createHandleStore,
|
|
150
|
+
resolveAllSegmentsWithRevalidation,
|
|
151
|
+
resolveAllSegments,
|
|
152
|
+
resolveInterceptEntry,
|
|
153
|
+
} = getRouterContext<TEnv>();
|
|
154
|
+
|
|
155
|
+
const requestCtx = getRequestContext();
|
|
156
|
+
const cacheScope = ctx.cacheScope;
|
|
157
|
+
|
|
158
|
+
const logPrefix = ctx.isFullMatch ? "[Router.match]" : "[Router.matchPartial]";
|
|
159
|
+
|
|
160
|
+
requestCtx?.waitUntil(async () => {
|
|
161
|
+
console.log(`${logPrefix} Revalidating stale route: ${ctx.pathname}`);
|
|
162
|
+
try {
|
|
163
|
+
// Create a fresh handleStore for background revalidation
|
|
164
|
+
// to avoid polluting the current response's handle stream
|
|
165
|
+
if (requestCtx) {
|
|
166
|
+
requestCtx._handleStore = createHandleStore();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let freshSegments: ResolvedSegment[];
|
|
170
|
+
|
|
171
|
+
if (ctx.isFullMatch) {
|
|
172
|
+
// Full match (document request) - simple resolution
|
|
173
|
+
freshSegments = await resolveAllSegments(
|
|
174
|
+
ctx.entries,
|
|
175
|
+
ctx.routeKey,
|
|
176
|
+
ctx.matched.params,
|
|
177
|
+
ctx.handlerContext,
|
|
178
|
+
ctx.loaderPromises
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
// Partial match (navigation) - resolution with revalidation
|
|
182
|
+
const freshResult = await resolveAllSegmentsWithRevalidation(
|
|
183
|
+
ctx.entries,
|
|
184
|
+
ctx.routeKey,
|
|
185
|
+
ctx.matched.params,
|
|
186
|
+
ctx.handlerContext,
|
|
187
|
+
ctx.clientSegmentSet,
|
|
188
|
+
ctx.prevParams,
|
|
189
|
+
ctx.request,
|
|
190
|
+
ctx.prevUrl,
|
|
191
|
+
ctx.url,
|
|
192
|
+
ctx.loaderPromises,
|
|
193
|
+
ctx.actionContext,
|
|
194
|
+
ctx.interceptResult,
|
|
195
|
+
ctx.localRouteName,
|
|
196
|
+
ctx.pathname
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
freshSegments = freshResult.segments;
|
|
200
|
+
|
|
201
|
+
// For intercept revalidation, also resolve fresh intercept segments
|
|
202
|
+
if (ctx.interceptResult) {
|
|
203
|
+
const freshInterceptSegments = await resolveInterceptEntry(
|
|
204
|
+
ctx.interceptResult.intercept,
|
|
205
|
+
ctx.interceptResult.entry,
|
|
206
|
+
ctx.matched.params,
|
|
207
|
+
ctx.handlerContext,
|
|
208
|
+
true,
|
|
209
|
+
{
|
|
210
|
+
clientSegmentIds: ctx.clientSegmentSet,
|
|
211
|
+
prevParams: ctx.prevParams,
|
|
212
|
+
request: ctx.request,
|
|
213
|
+
prevUrl: ctx.prevUrl,
|
|
214
|
+
nextUrl: ctx.url,
|
|
215
|
+
routeKey: ctx.routeKey,
|
|
216
|
+
actionContext: ctx.actionContext,
|
|
217
|
+
stale: false,
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
freshSegments = [...freshSegments, ...freshInterceptSegments];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await cacheScope.cacheRoute(
|
|
225
|
+
ctx.pathname,
|
|
226
|
+
ctx.matched.params,
|
|
227
|
+
freshSegments,
|
|
228
|
+
ctx.isIntercept
|
|
229
|
+
);
|
|
230
|
+
console.log(`${logPrefix} Revalidation complete: ${ctx.pathname}`);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error(`${logPrefix} Revalidation failed:`, error);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
}
|