@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,748 @@
1
+ /// <reference types="vite/types/importMeta.d.ts" />
2
+ /**
3
+ * Middleware Execution
4
+ *
5
+ * True middleware that wraps the entire RSC handler.
6
+ * - `await next()` returns actual Response
7
+ * - Can modify response headers
8
+ * - Can catch errors from RSC rendering
9
+ * - Forgiving API: if middleware doesn't return, original response is used
10
+ */
11
+
12
+ /**
13
+ * Cookie options for setting cookies
14
+ */
15
+ export interface CookieOptions {
16
+ domain?: string;
17
+ path?: string;
18
+ maxAge?: number;
19
+ expires?: Date;
20
+ httpOnly?: boolean;
21
+ secure?: boolean;
22
+ sameSite?: "strict" | "lax" | "none";
23
+ }
24
+
25
+ /**
26
+ * Context passed to middleware
27
+ *
28
+ * @template TEnv - Environment type (bindings, variables)
29
+ * @template TParams - URL params type (typed for route middleware, Record<string, string> for global middleware)
30
+ */
31
+ export interface MiddlewareContext<
32
+ TEnv = any,
33
+ TParams = Record<string, string>,
34
+ > {
35
+ /** Original request */
36
+ request: Request;
37
+
38
+ /** Parsed URL */
39
+ url: URL;
40
+
41
+ /** URL pathname */
42
+ pathname: string;
43
+
44
+ /** URL search params */
45
+ searchParams: URLSearchParams;
46
+
47
+ /** Platform bindings (Cloudflare, etc.) */
48
+ env: TEnv;
49
+
50
+ /** URL params extracted from route/middleware pattern */
51
+ params: TParams;
52
+
53
+ /**
54
+ * Response object - available immediately via stub, real response after `await next()`
55
+ *
56
+ * Headers set before `next()` are merged into the final response.
57
+ * Can be used to modify headers directly like Hono's `c.res`.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * middleware(async (ctx, next) => {
62
+ * // Set headers BEFORE next() - will be merged into final response
63
+ * ctx.res.headers.set('X-Request-Id', generateId());
64
+ *
65
+ * await next();
66
+ *
67
+ * // Set headers AFTER next() - applied directly
68
+ * ctx.res.headers.set('X-Custom', 'value');
69
+ * // No return needed!
70
+ * });
71
+ * ```
72
+ */
73
+ res: Response;
74
+
75
+ /** Get a cookie value */
76
+ cookie(name: string): string | undefined;
77
+
78
+ /** Get all cookies as object */
79
+ cookies(): Record<string, string>;
80
+
81
+ /** Set a cookie on the response */
82
+ setCookie(name: string, value: string, options?: CookieOptions): void;
83
+
84
+ /** Delete a cookie */
85
+ deleteCookie(
86
+ name: string,
87
+ options?: Pick<CookieOptions, "domain" | "path">
88
+ ): void;
89
+
90
+ /** Get a context variable (shared with route handlers) */
91
+ get<K extends string>(key: K): any;
92
+
93
+ /** Set a context variable (shared with route handlers) */
94
+ set<K extends string>(key: K, value: any): void;
95
+
96
+ /**
97
+ * Set a response header - can be called before or after `next()`
98
+ *
99
+ * When called before `next()`, headers are queued and merged into the final response.
100
+ * When called after `next()`, headers are set directly on the response.
101
+ * Shorthand for `ctx.res.headers.set()`.
102
+ */
103
+ header(name: string, value: string): void;
104
+ }
105
+
106
+ /**
107
+ * Middleware function signature
108
+ *
109
+ * @template TEnv - Environment type
110
+ * @template TParams - URL params type (typed for route middleware)
111
+ */
112
+ export type MiddlewareFn<TEnv = any, TParams = Record<string, string>> = (
113
+ ctx: MiddlewareContext<TEnv, TParams>,
114
+ next: () => Promise<Response>
115
+ ) => Response | Promise<Response> | void | Promise<void>;
116
+
117
+ /**
118
+ * Stored middleware entry with pattern matching info
119
+ */
120
+ export interface MiddlewareEntry<TEnv = any> {
121
+ /** Original pattern string */
122
+ pattern: string | null;
123
+
124
+ /** Compiled regex for matching */
125
+ regex: RegExp | null;
126
+
127
+ /** Param names extracted from pattern */
128
+ paramNames: string[];
129
+
130
+ /** The middleware function */
131
+ handler: MiddlewareFn<TEnv>;
132
+
133
+ /** Mount prefix this middleware is scoped to (null = global) */
134
+ mountPrefix: string | null;
135
+ }
136
+
137
+ /**
138
+ * Parse a route pattern into regex and param names
139
+ * Supports: *, /path, /path/*, /path/:param, /path/:param/*
140
+ */
141
+ export function parsePattern(pattern: string): {
142
+ regex: RegExp;
143
+ paramNames: string[];
144
+ } {
145
+ if (pattern === "*") {
146
+ return { regex: /^.*$/, paramNames: [] };
147
+ }
148
+
149
+ const paramNames: string[] = [];
150
+ let regexStr = "^";
151
+
152
+ const parts = pattern.split("/").filter(Boolean);
153
+
154
+ for (let i = 0; i < parts.length; i++) {
155
+ const part = parts[i];
156
+
157
+ if (part === "*") {
158
+ // Wildcard - match rest of path
159
+ regexStr += "(?:/.*)?";
160
+ } else if (part.startsWith(":")) {
161
+ // Param
162
+ const paramName = part.slice(1);
163
+ paramNames.push(paramName);
164
+ regexStr += "/([^/]+)";
165
+ } else {
166
+ // Literal
167
+ regexStr += "/" + escapeRegex(part);
168
+ }
169
+ }
170
+
171
+ // If pattern doesn't end with *, match exact or with trailing segments
172
+ if (!pattern.endsWith("*")) {
173
+ regexStr += "/?$";
174
+ } else {
175
+ regexStr += "$";
176
+ }
177
+
178
+ return { regex: new RegExp(regexStr), paramNames };
179
+ }
180
+
181
+ /**
182
+ * Escape special regex characters
183
+ */
184
+ function escapeRegex(str: string): string {
185
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ }
187
+
188
+ /**
189
+ * Extract params from a pathname using a pattern's regex and param names
190
+ */
191
+ export function extractParams(
192
+ pathname: string,
193
+ regex: RegExp,
194
+ paramNames: string[]
195
+ ): Record<string, string> {
196
+ const match = pathname.match(regex);
197
+ if (!match) return {};
198
+
199
+ const params: Record<string, string> = {};
200
+ for (let i = 0; i < paramNames.length; i++) {
201
+ params[paramNames[i]] = match[i + 1] || "";
202
+ }
203
+ return params;
204
+ }
205
+
206
+ /**
207
+ * Parse cookies from Cookie header
208
+ */
209
+ export function parseCookies(
210
+ cookieHeader: string | null
211
+ ): Record<string, string> {
212
+ if (!cookieHeader) return {};
213
+
214
+ const cookies: Record<string, string> = {};
215
+ const pairs = cookieHeader.split(";");
216
+
217
+ for (const pair of pairs) {
218
+ const [name, ...rest] = pair.trim().split("=");
219
+ if (name) {
220
+ cookies[name] = decodeURIComponent(rest.join("="));
221
+ }
222
+ }
223
+
224
+ return cookies;
225
+ }
226
+
227
+ /**
228
+ * Serialize a cookie for Set-Cookie header
229
+ */
230
+ export function serializeCookie(
231
+ name: string,
232
+ value: string,
233
+ options: CookieOptions = {}
234
+ ): string {
235
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
236
+
237
+ if (options.domain) cookie += `; Domain=${options.domain}`;
238
+ if (options.path) cookie += `; Path=${options.path}`;
239
+ if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`;
240
+ if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`;
241
+ if (options.httpOnly) cookie += "; HttpOnly";
242
+ if (options.secure) cookie += "; Secure";
243
+ if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
244
+
245
+ return cookie;
246
+ }
247
+
248
+ /**
249
+ * Mutable response holder - allows ctx.res to be updated after next() is called
250
+ */
251
+ export interface ResponseHolder {
252
+ response: Response | null;
253
+ }
254
+
255
+ /**
256
+ * Create middleware context
257
+ */
258
+ export function createMiddlewareContext<TEnv>(
259
+ request: Request,
260
+ env: TEnv,
261
+ params: Record<string, string>,
262
+ variables: Record<string, any>,
263
+ responseHolder: ResponseHolder
264
+ ): MiddlewareContext<TEnv> {
265
+ const url = new URL(request.url);
266
+ const cookieHeader = request.headers.get("Cookie");
267
+ let parsedCookies: Record<string, string> | null = null;
268
+
269
+ return {
270
+ request,
271
+ url,
272
+ pathname: url.pathname,
273
+ searchParams: url.searchParams,
274
+ env,
275
+ params,
276
+
277
+ // res getter - returns the stub or real response (always available)
278
+ get res(): Response {
279
+ if (!responseHolder.response) {
280
+ throw new Error(
281
+ "ctx.res is not available - responseHolder was not initialized"
282
+ );
283
+ }
284
+ return responseHolder.response;
285
+ },
286
+
287
+ // res setter - allows middleware to replace the response
288
+ set res(response: Response) {
289
+ responseHolder.response = response;
290
+ },
291
+
292
+ cookie(name: string): string | undefined {
293
+ if (!parsedCookies) {
294
+ parsedCookies = parseCookies(cookieHeader);
295
+ }
296
+ return parsedCookies[name];
297
+ },
298
+
299
+ cookies(): Record<string, string> {
300
+ if (!parsedCookies) {
301
+ parsedCookies = parseCookies(cookieHeader);
302
+ }
303
+ return { ...parsedCookies };
304
+ },
305
+
306
+ setCookie(name: string, value: string, options?: CookieOptions): void {
307
+ if (!responseHolder.response) {
308
+ throw new Error(
309
+ "ctx.setCookie() is not available - responseHolder was not initialized"
310
+ );
311
+ }
312
+ responseHolder.response.headers.append(
313
+ "Set-Cookie",
314
+ serializeCookie(name, value, options)
315
+ );
316
+ },
317
+
318
+ deleteCookie(
319
+ name: string,
320
+ options?: Pick<CookieOptions, "domain" | "path">
321
+ ): void {
322
+ if (!responseHolder.response) {
323
+ throw new Error(
324
+ "ctx.deleteCookie() is not available - responseHolder was not initialized"
325
+ );
326
+ }
327
+ responseHolder.response.headers.append(
328
+ "Set-Cookie",
329
+ serializeCookie(name, "", { ...options, maxAge: 0 })
330
+ );
331
+ },
332
+
333
+ get<K extends string>(key: K): any {
334
+ return variables[key];
335
+ },
336
+
337
+ set<K extends string>(key: K, value: any): void {
338
+ variables[key] = value;
339
+ },
340
+
341
+ header(name: string, value: string): void {
342
+ if (!responseHolder.response) {
343
+ throw new Error(
344
+ "ctx.header() is not available - responseHolder was not initialized"
345
+ );
346
+ }
347
+ responseHolder.response.headers.set(name, value);
348
+ },
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Match middleware entries against a pathname
354
+ * Returns entries that match, with extracted params
355
+ */
356
+ export function matchMiddleware<TEnv>(
357
+ pathname: string,
358
+ entries: MiddlewareEntry<TEnv>[]
359
+ ): Array<{ entry: MiddlewareEntry<TEnv>; params: Record<string, string> }> {
360
+ const matches: Array<{
361
+ entry: MiddlewareEntry<TEnv>;
362
+ params: Record<string, string>;
363
+ }> = [];
364
+
365
+ for (const entry of entries) {
366
+ // No pattern = matches all (global middleware without pattern)
367
+ if (!entry.regex) {
368
+ matches.push({ entry, params: {} });
369
+ continue;
370
+ }
371
+
372
+ // Check if pathname matches
373
+ if (entry.regex.test(pathname)) {
374
+ const params = extractParams(pathname, entry.regex, entry.paramNames);
375
+ matches.push({ entry, params });
376
+ }
377
+ }
378
+
379
+ return matches;
380
+ }
381
+
382
+ /**
383
+ * Execute middleware chain
384
+ *
385
+ * Features:
386
+ * - `await next()` returns actual Response
387
+ * - `ctx.res` available after `await next()` (like Hono's `c.res`)
388
+ * - `ctx.header()` shorthand for setting headers
389
+ * - Forgiving: if middleware doesn't return, uses `ctx.res`
390
+ * - Short-circuit: return Response to stop chain
391
+ * - Error catching: try/catch around `next()` works
392
+ */
393
+ export async function executeMiddleware<TEnv>(
394
+ middlewares: Array<{
395
+ entry: MiddlewareEntry<TEnv>;
396
+ params: Record<string, string>;
397
+ }>,
398
+ request: Request,
399
+ env: TEnv,
400
+ variables: Record<string, any>,
401
+ finalHandler: () => Promise<Response>
402
+ ): Promise<Response> {
403
+ let index = 0;
404
+
405
+ // Create a stub response that's available immediately
406
+ // This allows middleware to set headers/cookies before calling next()
407
+ const stubResponse = new Response(null, { status: 200 });
408
+ const responseHolder: ResponseHolder = { response: stubResponse };
409
+
410
+ const next = async (): Promise<Response> => {
411
+ if (index >= middlewares.length) {
412
+ // End of chain - call actual RSC handler
413
+ const response = await finalHandler();
414
+
415
+ // Merge headers set on stub into the real response
416
+ // Use append for Set-Cookie to preserve multiple cookies
417
+ const mergedHeaders = new Headers(response.headers);
418
+ stubResponse.headers.forEach((value, name) => {
419
+ if (name.toLowerCase() === "set-cookie") {
420
+ mergedHeaders.append(name, value);
421
+ } else {
422
+ mergedHeaders.set(name, value);
423
+ }
424
+ });
425
+
426
+ // Clone response with merged headers (mutable for post-next() modifications)
427
+ responseHolder.response = new Response(response.body, {
428
+ status: response.status,
429
+ statusText: response.statusText,
430
+ headers: mergedHeaders,
431
+ });
432
+
433
+ return responseHolder.response;
434
+ }
435
+
436
+ const { entry, params } = middlewares[index++];
437
+ const ctx = createMiddlewareContext(
438
+ request,
439
+ env,
440
+ params,
441
+ variables,
442
+ responseHolder
443
+ );
444
+
445
+ // Track if next() was called and capture its Promise
446
+ // This handles the case where middleware calls next() synchronously without await
447
+ let nextPromise: Promise<Response> | null = null;
448
+ const wrappedNext = (): Promise<Response> => {
449
+ nextPromise = next();
450
+ return nextPromise;
451
+ };
452
+
453
+ const result = await entry.handler(ctx, wrappedNext);
454
+
455
+ // Explicit return takes precedence
456
+ if (result instanceof Response) {
457
+ responseHolder.response = result;
458
+ return result;
459
+ }
460
+
461
+ // Warn about unexpected return values (non-Response, non-undefined)
462
+ // This catches common mistakes like returning strings or objects
463
+ if (result !== undefined) {
464
+ const fnName = entry.handler.name || "(anonymous)";
465
+ console.warn(
466
+ `[Middleware] "${fnName}" returned ${typeof result} instead of Response or undefined. ` +
467
+ `This return value will be ignored. Did you mean to return a Response?`
468
+ );
469
+ }
470
+
471
+ // If middleware called next(), await it and return the response
472
+ if (nextPromise) {
473
+ await nextPromise;
474
+ return responseHolder.response!;
475
+ }
476
+
477
+ // Middleware didn't call next() and didn't return a Response - that's an error
478
+ // (Note: responseHolder.response is the stub, but we require next() or explicit return)
479
+ const fnName = entry.handler.name || "(anonymous)";
480
+ throw new Error(
481
+ `Middleware must call next() or return a Response. ` +
482
+ `Function: ${fnName}, Pattern: ${entry.pattern ?? "(all)"}
483
+ Source: ${import.meta.env.DEV ? entry.handler.toString().slice(0, 200) : "(source hidden in production)"}`,
484
+ { cause: { url: request.url, fn: entry.handler } }
485
+ );
486
+ };
487
+
488
+ await next();
489
+
490
+ // Use the final response from responseHolder (may have been modified by middleware)
491
+ const finalResponse = responseHolder.response;
492
+ if (!finalResponse) {
493
+ throw new Error("No response generated by middleware chain");
494
+ }
495
+
496
+ return finalResponse;
497
+ }
498
+
499
+ /**
500
+ * Execute middleware for server actions
501
+ *
502
+ * Server actions can't return Response directly, but headers/cookies set
503
+ * on ctx.res (from getRequestContext().res) will be merged into the final response.
504
+ *
505
+ * - Runs middleware for auth checks, variable setting, headers, cookies
506
+ * - Throws if middleware returns Response (can't short-circuit server action)
507
+ */
508
+ export async function executeServerActionMiddleware<TEnv>(
509
+ middlewares: MiddlewareFn<TEnv>[],
510
+ request: Request,
511
+ env: TEnv,
512
+ params: Record<string, string>,
513
+ variables: Record<string, any>,
514
+ stubResponse: Response
515
+ ): Promise<void> {
516
+ if (middlewares.length === 0) {
517
+ return;
518
+ }
519
+
520
+ let index = 0;
521
+ const responseHolder: ResponseHolder = { response: stubResponse };
522
+
523
+ const next = async (): Promise<Response> => {
524
+ if (index >= middlewares.length) {
525
+ return stubResponse;
526
+ }
527
+
528
+ const middleware = middlewares[index++];
529
+ const ctx = createMiddlewareContext(
530
+ request,
531
+ env,
532
+ params,
533
+ variables,
534
+ responseHolder
535
+ );
536
+
537
+ const result = await middleware(ctx, next);
538
+
539
+ // If middleware returned a Response, throw an error
540
+ // Server actions can't short-circuit with a Response
541
+ if (result instanceof Response) {
542
+ throw new Error(
543
+ `Loader middleware returned a Response (status: ${result.status}). ` +
544
+ `Server actions cannot return Response. ` +
545
+ `Use GET-based loader fetching for redirects, or throw an error instead.`
546
+ );
547
+ }
548
+
549
+ return stubResponse;
550
+ };
551
+
552
+ await next();
553
+ // Headers/cookies set on stubResponse will be merged by the caller
554
+ }
555
+
556
+ /**
557
+ * Execute middleware for intercepts (simplified execution)
558
+ *
559
+ * Intercepts use a shared stubResponse from the request context. This function:
560
+ * - Runs middleware in sequence with a simple next() chain
561
+ * - Returns Response if any middleware short-circuits (returns Response or redirects BEFORE next())
562
+ * - Returns null if all middleware calls next() - headers set after next() remain on stubResponse
563
+ *
564
+ * @param middlewares - Array of middleware functions
565
+ * @param request - Original request
566
+ * @param env - Environment bindings
567
+ * @param params - Route params
568
+ * @param variables - Shared variables object
569
+ * @param stubResponse - Response from request context for collecting headers/cookies
570
+ */
571
+ export async function executeInterceptMiddleware<TEnv>(
572
+ middlewares: MiddlewareFn<TEnv>[],
573
+ request: Request,
574
+ env: TEnv,
575
+ params: Record<string, string>,
576
+ variables: Record<string, any>,
577
+ stubResponse: Response
578
+ ): Promise<Response | null> {
579
+ if (middlewares.length === 0) {
580
+ return null;
581
+ }
582
+
583
+ let index = 0;
584
+ let earlyResponse: Response | null = null;
585
+
586
+ // Use provided stubResponse - headers/cookies set here will be merged by the caller
587
+ const responseHolder: ResponseHolder = { response: stubResponse };
588
+
589
+ const next = async (): Promise<Response> => {
590
+ if (index >= middlewares.length || earlyResponse) {
591
+ return stubResponse;
592
+ }
593
+
594
+ const middleware = middlewares[index++];
595
+ const ctx = createMiddlewareContext(
596
+ request,
597
+ env,
598
+ params,
599
+ variables,
600
+ responseHolder
601
+ );
602
+
603
+ const result = await middleware(ctx, next);
604
+
605
+ if (result instanceof Response) {
606
+ earlyResponse = result;
607
+ return result;
608
+ }
609
+
610
+ // Check if middleware replaced ctx.res with a different response
611
+ if (responseHolder.response && responseHolder.response !== stubResponse) {
612
+ earlyResponse = responseHolder.response;
613
+ return earlyResponse;
614
+ }
615
+
616
+ return stubResponse;
617
+ };
618
+
619
+ await next();
620
+
621
+ // Return early response if middleware short-circuited (returned Response BEFORE next())
622
+ if (earlyResponse) {
623
+ // Capture in const for TypeScript narrowing (earlyResponse is `let` which loses narrowing in callbacks)
624
+ const response: Response = earlyResponse;
625
+
626
+ // Merge any headers/cookies set on stub into the early response
627
+ let hasStubHeaders = false;
628
+ stubResponse.headers.forEach(() => {
629
+ hasStubHeaders = true;
630
+ });
631
+
632
+ if (hasStubHeaders) {
633
+ // Clone and merge headers from stub into early response
634
+ const mergedHeaders = new Headers(response.headers);
635
+ stubResponse.headers.forEach((value, name) => {
636
+ if (name.toLowerCase() === "set-cookie") {
637
+ mergedHeaders.append(name, value);
638
+ } else {
639
+ mergedHeaders.set(name, value);
640
+ }
641
+ });
642
+ return new Response(response.body, {
643
+ status: response.status,
644
+ statusText: response.statusText,
645
+ headers: mergedHeaders,
646
+ });
647
+ }
648
+ return response;
649
+ }
650
+
651
+ // All middleware completed without short-circuit
652
+ // Headers/cookies set on stubResponse will be merged into the final response by the caller
653
+ return null;
654
+ }
655
+
656
+ /**
657
+ * Execute middleware chain for loaders (simpler signature)
658
+ *
659
+ * Takes an array of MiddlewareFn directly (no entry wrapper needed).
660
+ * Used for fetchable loader middleware execution.
661
+ */
662
+ export async function executeLoaderMiddleware<TEnv>(
663
+ middlewares: MiddlewareFn<TEnv>[],
664
+ request: Request,
665
+ env: TEnv,
666
+ params: Record<string, string>,
667
+ variables: Record<string, any>,
668
+ finalHandler: () => Promise<Response>
669
+ ): Promise<Response> {
670
+ if (middlewares.length === 0) {
671
+ return finalHandler();
672
+ }
673
+
674
+ // Convert to the format executeMiddleware expects
675
+ const middlewareEntries = middlewares.map((handler) => ({
676
+ entry: {
677
+ pattern: null,
678
+ regex: null,
679
+ paramNames: [],
680
+ handler,
681
+ mountPrefix: null,
682
+ } as MiddlewareEntry<TEnv>,
683
+ params,
684
+ }));
685
+
686
+ return executeMiddleware(
687
+ middlewareEntries,
688
+ request,
689
+ env,
690
+ variables,
691
+ finalHandler
692
+ );
693
+ }
694
+
695
+ /**
696
+ * Entry type for middleware collection
697
+ * Matches the shape of EntryData used in router.ts
698
+ */
699
+ export interface MiddlewareCollectableEntry {
700
+ middleware?: MiddlewareFn<any, any>[];
701
+ layout?: MiddlewareCollectableEntry[];
702
+ }
703
+
704
+ /**
705
+ * Collected route middleware with params
706
+ */
707
+ export interface CollectedMiddleware {
708
+ handler: MiddlewareFn<any, any>;
709
+ params: Record<string, string>;
710
+ }
711
+
712
+ /**
713
+ * Collect route-level middleware from an entry tree
714
+ *
715
+ * Recursively collects middleware from entries and their orphan layouts.
716
+ * Used by match(), matchPartial(), and previewMatch() to gather route middleware.
717
+ *
718
+ * @param entries - Iterable of entries to collect middleware from (typically from traverseBack)
719
+ * @param params - Route params to attach to each middleware entry
720
+ * @returns Array of collected middleware with params
721
+ */
722
+ export function collectRouteMiddleware(
723
+ entries: Iterable<MiddlewareCollectableEntry>,
724
+ params: Record<string, string>
725
+ ): CollectedMiddleware[] {
726
+ const result: CollectedMiddleware[] = [];
727
+
728
+ const collect = (entry: MiddlewareCollectableEntry): void => {
729
+ // Collect entry's own middleware
730
+ if (entry.middleware && entry.middleware.length > 0) {
731
+ for (const mw of entry.middleware) {
732
+ result.push({ handler: mw, params });
733
+ }
734
+ }
735
+ // Collect middleware from orphan layouts (recursive)
736
+ if (entry.layout && entry.layout.length > 0) {
737
+ for (const orphan of entry.layout) {
738
+ collect(orphan);
739
+ }
740
+ }
741
+ };
742
+
743
+ for (const entry of entries) {
744
+ collect(entry);
745
+ }
746
+
747
+ return result;
748
+ }