@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.
Files changed (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Intercept Resolution Middleware
3
+ *
4
+ * Resolves intercept (modal slot) segments for soft navigation.
5
+ * Yields intercept segments after main route segments.
6
+ *
7
+ * FLOW DIAGRAM
8
+ * ============
9
+ *
10
+ * source (from segment-resolution)
11
+ * |
12
+ * v
13
+ * +---------------------------+
14
+ * | Collect + yield source | Pass through main segments
15
+ * | segments[] |
16
+ * +---------------------------+
17
+ * |
18
+ * v
19
+ * +---------------------+
20
+ * | isFullMatch? |──yes──> return (no intercepts on doc requests)
21
+ * +---------------------+
22
+ * | no
23
+ * v
24
+ * +---------------------+
25
+ * | Has interceptResult |──no───> return
26
+ * | AND not cached? |
27
+ * +---------------------+
28
+ * | yes
29
+ * v
30
+ * +----------------------+ +----------------------------+
31
+ * | Fresh intercept? |yes>| resolveInterceptEntry() |
32
+ * | (!cacheHit or | | - middleware, loaders, UI |
33
+ * | no intercept segs) | +----------------------------+
34
+ * +----------------------+ |
35
+ * | no v
36
+ * v yield intercept segments
37
+ * +----------------------------+ |
38
+ * | Cache hit with intercept | |
39
+ * | handleCacheHitIntercept() | |
40
+ * | - Extract from cache | |
41
+ * | - Re-resolve loaders only | |
42
+ * +----------------------------+ |
43
+ * | |
44
+ * +-------------------------------+
45
+ * |
46
+ * v
47
+ * +---------------------------+
48
+ * | Update state: |
49
+ * | - interceptSegments |
50
+ * | - slots[slotName] |
51
+ * +---------------------------+
52
+ * |
53
+ * v
54
+ * next middleware
55
+ *
56
+ *
57
+ * INTERCEPT SCENARIOS
58
+ * ===================
59
+ *
60
+ * 1. Fresh intercept (no cache):
61
+ * - Full resolution of intercept entry
62
+ * - Resolves middleware, loaders, and component
63
+ * - Yields all intercept segments
64
+ *
65
+ * 2. Cache hit with intercept:
66
+ * - Extracts intercept segments from cached data
67
+ * - Re-resolves ONLY loaders for fresh data
68
+ * - Keeps cached component/layout
69
+ *
70
+ * 3. No intercept:
71
+ * - Passes through unchanged
72
+ * - No intercept segments yielded
73
+ *
74
+ *
75
+ * WHAT ARE INTERCEPTS?
76
+ * ====================
77
+ *
78
+ * Intercepts enable "soft navigation" patterns like modals:
79
+ *
80
+ * 1. User clicks a link (e.g., /photos/123)
81
+ * 2. Instead of full navigation, content renders in a modal slot
82
+ * 3. Background page remains visible and interactive
83
+ * 4. Hard navigation (direct URL) shows full page
84
+ *
85
+ * Configuration:
86
+ * intercept("@modal", "photos", <PhotoModal />, () => [...])
87
+ *
88
+ * The intercept resolves to segments that render in the named slot
89
+ * instead of replacing the main content.
90
+ *
91
+ *
92
+ * SLOT STRUCTURE
93
+ * ==============
94
+ *
95
+ * state.slots[slotName] = {
96
+ * active: true,
97
+ * segments: [...intercept segments]
98
+ * }
99
+ *
100
+ * The client uses this to:
101
+ * 1. Keep current page segments
102
+ * 2. Render intercept segments in named <Outlet name="@modal" />
103
+ */
104
+ import type { ResolvedSegment } from "../../types.js";
105
+ import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
+ import { getRouterContext } from "../router-context.js";
107
+ import type { GeneratorMiddleware } from "./cache-lookup.js";
108
+
109
+ /**
110
+ * Creates intercept resolution middleware
111
+ *
112
+ * If ctx.interceptResult exists and we're not in a cache-hit-with-intercept scenario:
113
+ * - Resolves intercept segments
114
+ * - Updates state.interceptSegments
115
+ * - Updates state.slots with the intercept slot
116
+ * - Yields intercept segments after main segments
117
+ */
118
+ export function withInterceptResolution<TEnv>(
119
+ ctx: MatchContext<TEnv>,
120
+ state: MatchPipelineState
121
+ ): GeneratorMiddleware<ResolvedSegment> {
122
+ return async function* (
123
+ source: AsyncGenerator<ResolvedSegment>
124
+ ): AsyncGenerator<ResolvedSegment> {
125
+ // First, yield all segments from the source (main segment resolution or cache)
126
+ const segments: ResolvedSegment[] = [];
127
+ for await (const segment of source) {
128
+ segments.push(segment);
129
+ yield segment;
130
+ }
131
+
132
+ // Skip intercept resolution for full match (document requests don't have intercepts)
133
+ if (ctx.isFullMatch) {
134
+ return;
135
+ }
136
+
137
+ // Skip intercept resolution if:
138
+ // 1. No intercept result
139
+ // 2. Already have intercept segments (from cache hit with intercept key)
140
+ // 3. Cache hit with intercept key
141
+ const skipInterceptResolution =
142
+ !ctx.interceptResult ||
143
+ state.interceptSegments.length > 0 ||
144
+ (state.cacheHit && ctx.isIntercept);
145
+
146
+ if (skipInterceptResolution) {
147
+ // For cache hit with intercept, extract intercept segments from cached data for slots
148
+ // and re-resolve loaders for fresh data
149
+ if (ctx.interceptResult && state.cacheHit && ctx.isIntercept) {
150
+ await handleCacheHitIntercept(ctx, state, segments);
151
+ }
152
+ return;
153
+ }
154
+
155
+ // Resolve intercept segments
156
+ const { resolveInterceptEntry } = getRouterContext<TEnv>();
157
+
158
+ const slotName = ctx.interceptResult!.intercept.slotName;
159
+ console.log(
160
+ `[Router.matchPartial] Found intercept for "${ctx.localRouteName}" -> slot "${slotName}"`
161
+ );
162
+
163
+ // Resolve intercept entry (middleware, loaders, handler)
164
+ const Store = ctx.Store;
165
+ const interceptSegments = await Store.run(() =>
166
+ resolveInterceptEntry(
167
+ ctx.interceptResult!.intercept,
168
+ ctx.interceptResult!.entry,
169
+ ctx.matched.params,
170
+ ctx.handlerContext,
171
+ true, // belongsToRoute
172
+ {
173
+ clientSegmentIds: ctx.clientSegmentSet,
174
+ prevParams: ctx.prevParams,
175
+ request: ctx.request,
176
+ prevUrl: ctx.prevUrl,
177
+ nextUrl: ctx.url,
178
+ routeKey: ctx.routeKey,
179
+ actionContext: ctx.actionContext,
180
+ stale: ctx.stale,
181
+ }
182
+ )
183
+ );
184
+
185
+ // Update state
186
+ state.interceptSegments = interceptSegments;
187
+ state.slots[slotName] = {
188
+ active: true,
189
+ segments: interceptSegments,
190
+ };
191
+
192
+ // Yield intercept segments
193
+ for (const segment of interceptSegments) {
194
+ yield segment;
195
+ }
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Handle cache hit with intercept scenario
201
+ *
202
+ * Extract intercept segments from cached data and re-resolve loaders for fresh data.
203
+ */
204
+ async function handleCacheHitIntercept<TEnv>(
205
+ ctx: MatchContext<TEnv>,
206
+ state: MatchPipelineState,
207
+ segments: ResolvedSegment[]
208
+ ): Promise<void> {
209
+ if (!ctx.interceptResult) return;
210
+
211
+ const { resolveInterceptLoadersOnly } = getRouterContext<TEnv>();
212
+
213
+ const slotName = ctx.interceptResult.intercept.slotName;
214
+
215
+ // Find intercept segments from cached segments (namespace starts with "intercept:")
216
+ const interceptSegments = segments.filter((s) =>
217
+ s.namespace?.startsWith("intercept:")
218
+ );
219
+ state.interceptSegments = interceptSegments;
220
+
221
+ // Re-resolve intercept loaders for fresh data on cache hit
222
+ // This keeps cached component/layout but fetches fresh loader data
223
+ if (resolveInterceptLoadersOnly) {
224
+ const Store = ctx.Store;
225
+ const freshLoaderResult = await Store.run(() =>
226
+ resolveInterceptLoadersOnly(
227
+ ctx.interceptResult!.intercept,
228
+ ctx.interceptResult!.entry,
229
+ ctx.matched.params,
230
+ ctx.handlerContext,
231
+ true, // belongsToRoute
232
+ {
233
+ clientSegmentIds: ctx.clientSegmentSet,
234
+ prevParams: ctx.prevParams,
235
+ request: ctx.request,
236
+ prevUrl: ctx.prevUrl,
237
+ nextUrl: ctx.url,
238
+ routeKey: ctx.routeKey,
239
+ actionContext: ctx.actionContext,
240
+ stale: ctx.stale,
241
+ }
242
+ )
243
+ );
244
+
245
+ // Update intercept segment's loaderDataPromise with fresh data
246
+ if (freshLoaderResult) {
247
+ const interceptMainSegment = interceptSegments.find(
248
+ (s) => s.type === "parallel" && s.slot
249
+ );
250
+ if (interceptMainSegment) {
251
+ interceptMainSegment.loaderDataPromise = freshLoaderResult.loaderDataPromise;
252
+ interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
253
+ console.log(
254
+ `[Router.matchPartial] Cache HIT + fresh loaders for intercept "${ctx.localRouteName}" -> slot "${slotName}"`
255
+ );
256
+ }
257
+ } else {
258
+ console.log(
259
+ `[Router.matchPartial] Cache HIT for intercept "${ctx.localRouteName}" -> slot "${slotName}" (no loader revalidation)`
260
+ );
261
+ }
262
+ }
263
+
264
+ state.slots[slotName] = {
265
+ active: true,
266
+ segments: interceptSegments,
267
+ };
268
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Segment Resolution Middleware
3
+ *
4
+ * Resolves route segments when cache misses. Skips if cache hit.
5
+ *
6
+ * FLOW DIAGRAM
7
+ * ============
8
+ *
9
+ * source (from cache-lookup)
10
+ * |
11
+ * v
12
+ * +---------------------------+
13
+ * | Iterate source first! | <-- CRITICAL: Must drain source
14
+ * | yield* source | to let cache-lookup run
15
+ * +---------------------------+
16
+ * |
17
+ * v
18
+ * +---------------------+
19
+ * | state.cacheHit? |──yes──> return (cache already yielded)
20
+ * +---------------------+
21
+ * | no
22
+ * v
23
+ * +---------------------+
24
+ * | isFullMatch? |
25
+ * +---------------------+
26
+ * |
27
+ * +-----+-----+
28
+ * | |
29
+ * yes no
30
+ * | |
31
+ * v v
32
+ * resolveAll resolveAllWithRevalidation
33
+ * Segments Segments
34
+ * | |
35
+ * | | (compares with prev state)
36
+ * | | (handles null components)
37
+ * | |
38
+ * +-----------+
39
+ * |
40
+ * v
41
+ * +---------------------------+
42
+ * | Update state: |
43
+ * | - state.segments |
44
+ * | - state.matchedIds |
45
+ * +---------------------------+
46
+ * |
47
+ * v
48
+ * yield all resolved segments
49
+ * |
50
+ * v
51
+ * next middleware
52
+ *
53
+ *
54
+ * RESOLUTION MODES
55
+ * ================
56
+ *
57
+ * Full Match (document request):
58
+ * - Uses resolveAllSegments()
59
+ * - No revalidation logic (nothing to compare against)
60
+ * - Simple resolution of all route entries
61
+ *
62
+ * Partial Match (navigation):
63
+ * - Uses resolveAllSegmentsWithRevalidation()
64
+ * - Compares current vs previous params/URL
65
+ * - Sets component = null for segments client already has
66
+ * - Respects custom revalidation rules
67
+ *
68
+ *
69
+ * CRITICAL: SOURCE ITERATION
70
+ * ==========================
71
+ *
72
+ * The middleware MUST iterate the source generator before checking cacheHit:
73
+ *
74
+ * for await (const segment of source) { yield segment; }
75
+ *
76
+ * This is because:
77
+ * 1. Generator middleware are lazy (don't execute until iterated)
78
+ * 2. cache-lookup sets state.cacheHit during iteration
79
+ * 3. Without draining source first, cache-lookup never runs
80
+ *
81
+ * Incorrect pattern:
82
+ * if (!state.cacheHit) { ... } // cacheHit still false!
83
+ * yield* source; // Too late, already resolved
84
+ *
85
+ * Correct pattern:
86
+ * yield* source; // Let cache-lookup set cacheHit
87
+ * if (state.cacheHit) return; // Now we can check
88
+ */
89
+ import type { ResolvedSegment } from "../../types.js";
90
+ import type { MatchContext, MatchPipelineState } from "../match-context.js";
91
+ import { getRouterContext } from "../router-context.js";
92
+ import type { GeneratorMiddleware } from "./cache-lookup.js";
93
+
94
+ /**
95
+ * Creates segment resolution middleware
96
+ *
97
+ * Only runs on cache miss (state.cacheHit === false).
98
+ * Uses resolveAllSegmentsWithRevalidation from RouterContext to resolve segments.
99
+ */
100
+ export function withSegmentResolution<TEnv>(
101
+ ctx: MatchContext<TEnv>,
102
+ state: MatchPipelineState
103
+ ): GeneratorMiddleware<ResolvedSegment> {
104
+ return async function* (
105
+ source: AsyncGenerator<ResolvedSegment>
106
+ ): AsyncGenerator<ResolvedSegment> {
107
+ // IMPORTANT: Always iterate source first to give cache-lookup a chance
108
+ // to run and set state.cacheHit. Without this, cache-lookup never executes!
109
+ for await (const segment of source) {
110
+ yield segment;
111
+ }
112
+
113
+ // If cache hit, segments were already yielded by cache lookup
114
+ if (state.cacheHit) {
115
+ return;
116
+ }
117
+
118
+ const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
119
+ getRouterContext<TEnv>();
120
+
121
+ const Store = ctx.Store;
122
+
123
+ if (ctx.isFullMatch) {
124
+ // Full match (document request) - simple resolution without revalidation
125
+ const segments = await Store.run(() =>
126
+ resolveAllSegments(
127
+ ctx.entries,
128
+ ctx.routeKey,
129
+ ctx.matched.params,
130
+ ctx.handlerContext,
131
+ ctx.loaderPromises
132
+ )
133
+ );
134
+
135
+ // Update state with resolved segments
136
+ state.segments = segments;
137
+ state.matchedIds = segments.map((s) => s.id);
138
+
139
+ // Yield all resolved segments
140
+ for (const segment of segments) {
141
+ yield segment;
142
+ }
143
+ } else {
144
+ // Partial match (navigation) - resolution with revalidation logic
145
+ const result = await Store.run(() =>
146
+ resolveAllSegmentsWithRevalidation(
147
+ ctx.entries,
148
+ ctx.routeKey,
149
+ ctx.matched.params,
150
+ ctx.handlerContext,
151
+ ctx.clientSegmentSet,
152
+ ctx.prevParams,
153
+ ctx.request,
154
+ ctx.prevUrl,
155
+ ctx.url,
156
+ ctx.loaderPromises,
157
+ ctx.actionContext,
158
+ ctx.interceptResult,
159
+ ctx.localRouteName,
160
+ ctx.pathname
161
+ )
162
+ );
163
+
164
+ // Update state with resolved segments
165
+ state.segments = result.segments;
166
+ state.matchedIds = result.matchedIds;
167
+
168
+ // Yield all resolved segments
169
+ for (const segment of result.segments) {
170
+ yield segment;
171
+ }
172
+ }
173
+ };
174
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Match Pipelines
3
+ *
4
+ * Composes async generator middleware into pipelines for route matching.
5
+ * The pipeline transforms navigation requests into resolved UI segments.
6
+ *
7
+ * PIPELINE ARCHITECTURE OVERVIEW
8
+ * ==============================
9
+ *
10
+ * The router uses a pipeline of async generator middleware to process requests.
11
+ * Each middleware can:
12
+ * 1. Produce segments (yield)
13
+ * 2. Transform segments from upstream
14
+ * 3. Observe segments without modifying them
15
+ * 4. Trigger side effects (caching, background revalidation)
16
+ *
17
+ * REQUEST FLOW DIAGRAM
18
+ * ====================
19
+ *
20
+ * Navigation Request
21
+ * |
22
+ * v
23
+ * +------------------+
24
+ * | Create Context | MatchContext: routes, params, client state
25
+ * +------------------+
26
+ * |
27
+ * v
28
+ * +------------------+
29
+ * | Select Pipeline | Full (document) vs Partial (navigation)
30
+ * +------------------+
31
+ * |
32
+ * v
33
+ * ==================== PIPELINE EXECUTION ====================
34
+ * | |
35
+ * | empty() ─────> [1] ─────> [2] ─────> [3] ─────> [4] ───>|───> segments
36
+ * | | | | | | |
37
+ * | | cache | segment |intercept | cache | bg |
38
+ * | | lookup | resolve | resolve | store | reval |
39
+ * | |
40
+ * ============================================================
41
+ * |
42
+ * v
43
+ * +------------------+
44
+ * | Collect Result | Filter segments, build MatchResult
45
+ * +------------------+
46
+ * |
47
+ * v
48
+ * RSC Stream Response
49
+ *
50
+ *
51
+ * MIDDLEWARE EXECUTION ORDER
52
+ * ==========================
53
+ *
54
+ * Middleware compose in reverse order (rightmost = innermost, runs first):
55
+ *
56
+ * compose(A, B, C)(source) => source -> C -> B -> A -> output
57
+ *
58
+ * For the partial match pipeline:
59
+ *
60
+ * compose(
61
+ * withBackgroundRevalidation, // [5] Outermost - triggers SWR
62
+ * withCacheStore, // [4] Stores segments in cache
63
+ * withInterceptResolution, // [3] Resolves intercept segments
64
+ * withSegmentResolution, // [2] Resolves on cache miss
65
+ * withCacheLookup // [1] Innermost - checks cache first
66
+ * )
67
+ *
68
+ * Execution flow for cache MISS:
69
+ *
70
+ * empty() yields nothing
71
+ * -> [1] cache-lookup: no cache, passes through
72
+ * -> [2] segment-resolution: resolves segments, yields them
73
+ * -> [3] intercept-resolution: resolves intercepts, yields them
74
+ * -> [4] cache-store: observes all, stores in cache
75
+ * -> [5] bg-revalidation: no-op (wasn't stale)
76
+ * -> output: all segments
77
+ *
78
+ * Execution flow for cache HIT (stale):
79
+ *
80
+ * empty() yields nothing
81
+ * -> [1] cache-lookup: HIT! yields cached segments + fresh loaders
82
+ * -> [2] segment-resolution: sees cacheHit=true, skips
83
+ * -> [3] intercept-resolution: extracts intercepts from cache
84
+ * -> [4] cache-store: sees cacheHit=true, skips
85
+ * -> [5] bg-revalidation: triggers waitUntil() to revalidate
86
+ * -> output: cached segments + fresh loader data
87
+ *
88
+ *
89
+ * TWO PIPELINE VARIANTS
90
+ * =====================
91
+ *
92
+ * 1. createMatchPipeline (Full Match)
93
+ * - Used for document requests (initial page load)
94
+ * - No revalidation logic (no previous state to compare)
95
+ * - Simpler segment resolution
96
+ *
97
+ * 2. createMatchPartialPipeline (Partial Match)
98
+ * - Used for client-side navigation
99
+ * - Includes revalidation for SWR
100
+ * - Compares with previous params/URL
101
+ * - Supports intercepts (soft navigation modals)
102
+ */
103
+ import type { ResolvedSegment } from "../types.js";
104
+ import type { MatchContext, MatchPipelineState } from "./match-context.js";
105
+ import type { GeneratorMiddleware } from "./match-middleware/index.js";
106
+ import {
107
+ withBackgroundRevalidation,
108
+ withCacheLookup,
109
+ withCacheStore,
110
+ withInterceptResolution,
111
+ withSegmentResolution,
112
+ } from "./match-middleware/index.js";
113
+
114
+ /**
115
+ * Compose multiple async generator middleware into a single middleware
116
+ *
117
+ * Middleware are applied in reverse order (rightmost runs first, innermost).
118
+ * For the pipeline:
119
+ * compose(A, B, C)(source)
120
+ *
121
+ * The flow is: source -> C -> B -> A -> output
122
+ * Where C is the innermost (runs first on input) and A is outermost (runs last).
123
+ */
124
+ export function compose<T>(
125
+ ...middleware: GeneratorMiddleware<T>[]
126
+ ): GeneratorMiddleware<T> {
127
+ if (middleware.length === 0) {
128
+ return (source) => source;
129
+ }
130
+ if (middleware.length === 1) {
131
+ return middleware[0];
132
+ }
133
+ return (source) => {
134
+ // Apply middleware in reverse order (rightmost first)
135
+ return middleware.reduceRight((prev, fn) => fn(prev), source);
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Create an empty async generator (source for pipeline)
141
+ */
142
+ export async function* empty<T>(): AsyncGenerator<T> {
143
+ // Yields nothing - used as the initial source for the pipeline
144
+ }
145
+
146
+ /**
147
+ * Create the match partial pipeline
148
+ *
149
+ * Pipeline order (innermost to outermost):
150
+ * 1. cache-lookup - Check cache first, yield cached segments if hit
151
+ * 2. segment-resolution - Resolve segments if cache miss
152
+ * 3. intercept-resolution - Resolve intercept segments
153
+ * 4. cache-store - Store segments in cache
154
+ * 5. background-revalidation - Trigger SWR if cache was stale
155
+ *
156
+ * Data flow:
157
+ * - empty() produces no segments
158
+ * - cache-lookup either yields cached segments OR passes through to segment-resolution
159
+ * - segment-resolution resolves fresh segments on cache miss
160
+ * - intercept-resolution adds intercept segments
161
+ * - cache-store observes and caches segments
162
+ * - background-revalidation triggers SWR revalidation if needed
163
+ */
164
+ export function createMatchPartialPipeline<TEnv>(
165
+ ctx: MatchContext<TEnv>,
166
+ state: MatchPipelineState
167
+ ): AsyncGenerator<ResolvedSegment> {
168
+ // Build the middleware chain
169
+ const pipeline = compose<ResolvedSegment>(
170
+ // Outermost - observes segments and triggers background revalidation
171
+ withBackgroundRevalidation(ctx, state),
172
+ // Observes and stores segments in cache
173
+ withCacheStore(ctx, state),
174
+ // Adds intercept segments after main segments
175
+ withInterceptResolution(ctx, state),
176
+ // Resolves segments on cache miss
177
+ withSegmentResolution(ctx, state),
178
+ // Innermost - checks cache first
179
+ withCacheLookup(ctx, state)
180
+ );
181
+
182
+ // Start with empty source - cache lookup or segment resolution will produce segments
183
+ return pipeline(empty());
184
+ }
185
+
186
+ /**
187
+ * Create the full match pipeline (simpler, no revalidation)
188
+ *
189
+ * Used for document requests (initial page load) where we don't need
190
+ * revalidation logic since there's no previous state to compare against.
191
+ */
192
+ export function createMatchPipeline<TEnv>(
193
+ ctx: MatchContext<TEnv>,
194
+ state: MatchPipelineState
195
+ ): AsyncGenerator<ResolvedSegment> {
196
+ // For full match, we only need:
197
+ // 1. Cache lookup
198
+ // 2. Segment resolution (without revalidation)
199
+ // 3. Intercept resolution
200
+ // 4. Cache store
201
+
202
+ // Note: Full match uses different resolution logic (resolveAllSegments instead of
203
+ // resolveAllSegmentsWithRevalidation). This will be handled by the segment resolution
204
+ // middleware checking ctx.isFullMatch or similar flag.
205
+
206
+ const pipeline = compose<ResolvedSegment>(
207
+ withCacheStore(ctx, state),
208
+ withInterceptResolution(ctx, state),
209
+ withSegmentResolution(ctx, state),
210
+ withCacheLookup(ctx, state)
211
+ );
212
+
213
+ return pipeline(empty());
214
+ }