@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,405 @@
1
+ import { createElement, type ReactNode, type ComponentType } from "react";
2
+ import { OutletProvider } from "./client.js";
3
+ import type {
4
+ ResolvedSegment,
5
+ LoaderDataResult,
6
+ RootLayoutProps,
7
+ } from "./types.js";
8
+ import { isLoaderDataResult } from "./types.js";
9
+ import { invariant } from "./errors.js";
10
+ import {
11
+ RouteContentWrapper,
12
+ LoaderBoundary,
13
+ } from "./route-content-wrapper.js";
14
+ import { RootErrorBoundary } from "./root-error-boundary.js";
15
+
16
+ /**
17
+ * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
18
+ */
19
+ function resolveLoaderData(
20
+ resolvedData: any[],
21
+ loaderIds: string[]
22
+ ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
23
+ const loaderData: Record<string, any> = {};
24
+ let errorFallback: ReactNode = null;
25
+
26
+ for (let i = 0; i < loaderIds.length; i++) {
27
+ const id = loaderIds[i];
28
+ const result = resolvedData[i];
29
+
30
+ if (!isLoaderDataResult(result)) {
31
+ // Legacy format - direct data
32
+ loaderData[id] = result;
33
+ continue;
34
+ }
35
+
36
+ if (result.ok) {
37
+ loaderData[id] = result.data;
38
+ continue;
39
+ }
40
+
41
+ // Error case
42
+ if (result.fallback) {
43
+ errorFallback = result.fallback;
44
+ } else {
45
+ throw new Error(result.error.message);
46
+ }
47
+ }
48
+
49
+ return { loaderData, errorFallback };
50
+ }
51
+
52
+ /**
53
+ * Options for renderSegments
54
+ */
55
+ export interface RenderSegmentsOptions {
56
+ /**
57
+ * If true, this render is for a server action response.
58
+ * In browser during actions, we await component promises to prevent
59
+ * UI flickering/suspense during optimistic updates.
60
+ */
61
+ isAction?: boolean;
62
+
63
+ /**
64
+ * If true, force awaiting all loaders instead of streaming with Suspense.
65
+ * Used for popstate (back/forward) navigation where we want instant rendering
66
+ * from cache without showing loading skeletons.
67
+ */
68
+ forceAwait?: boolean;
69
+
70
+ /**
71
+ * Intercept segments to inject into the tree.
72
+ * These are parallel segments from intercept routes that need to be
73
+ * associated with their parent layout's named outlet.
74
+ *
75
+ * Passed separately for explicit handling - makes the flow clearer
76
+ * and easier to debug than relying on ID pattern matching.
77
+ */
78
+ interceptSegments?: ResolvedSegment[];
79
+
80
+ /**
81
+ * Root layout component that wraps the entire application.
82
+ * When provided, wraps both route content and the error boundary,
83
+ * preventing the app shell from unmounting during errors (avoids FOUC).
84
+ */
85
+ rootLayout?: ComponentType<RootLayoutProps>;
86
+ }
87
+
88
+ /**
89
+ * Render segments into a React tree with proper layout nesting
90
+ *
91
+ * Layouts nest using OutletProvider, while route + parallel + error + notFound segments
92
+ * render as siblings in a Fragment.
93
+ *
94
+ * Error segments are treated like route segments - they render their fallback
95
+ * component in place of the failed segment. When an error occurs in a handler,
96
+ * loader, or middleware, the router creates an error segment with the nearest
97
+ * error boundary's fallback component.
98
+ *
99
+ * NotFound segments are similar to error segments but are triggered by
100
+ * DataNotFoundError (thrown via notFound()). They render the nearest
101
+ * notFoundBoundary's fallback component.
102
+ *
103
+ * @param segments - Array of resolved segments to render
104
+ * @returns ReactNode representing the component tree
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * const segments = [
109
+ * { id: 'L0.0', type: 'layout', component: <RootLayout /> },
110
+ * { id: 'L1.0', type: 'layout', component: <BlogLayout /> },
111
+ * { id: 'R2.0', type: 'route', component: <BlogPost /> },
112
+ * { id: 'P3.0', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
113
+ * ];
114
+ *
115
+ * const tree = renderSegments(segments);
116
+ * // Results in:
117
+ * // <OutletProvider><RootLayout>
118
+ * // <OutletProvider><BlogLayout>
119
+ * // <><BlogPost /><Sidebar /></>
120
+ * // </BlogLayout></OutletProvider>
121
+ * // </RootLayout></OutletProvider>
122
+ *
123
+ * // For server actions, pass isAction to await components:
124
+ * const tree = renderSegments(segments, { isAction: true });
125
+ * ```
126
+ */
127
+ export async function renderSegments(
128
+ segments: ResolvedSegment[],
129
+ options?: RenderSegmentsOptions
130
+ ): Promise<ReactNode> {
131
+ const {
132
+ isAction,
133
+ interceptSegments,
134
+ forceAwait,
135
+ rootLayout: RootLayout,
136
+ } = options || {};
137
+
138
+ // Separate segments by type, passing intercept segments for explicit injection
139
+ const tree = segmentTreeWalk(segments, interceptSegments);
140
+ // Render content segments as siblings
141
+ let content: ReactNode = null;
142
+ for (const node of tree) {
143
+ invariant(
144
+ node.segment.type === "layout" ||
145
+ node.segment.type === "route" ||
146
+ node.segment.type === "error" ||
147
+ node.segment.type === "notFound",
148
+ `Expected layout, route, error, or notFound segment, got ${node.segment.type}`
149
+ );
150
+ const { component, id, params, loading } = node.segment;
151
+
152
+ // Only include params in key for segments that belong to the route
153
+ // - Routes: always include params (they render param-specific content)
154
+ // - Error/notFound segments: always include params (they replace failed route content)
155
+ // - Route's layouts (orphans): include params (children of parameterized route)
156
+ // - Parent chain layouts: exclude params (shared across routes, param-agnostic)
157
+ // This prevents unnecessary unmounting when params change
158
+ const includeParams =
159
+ node.segment.type === "route" ||
160
+ node.segment.type === "error" ||
161
+ node.segment.type === "notFound" ||
162
+ (node.segment.type === "layout" && node.segment.belongsToRoute);
163
+
164
+ const paramStr =
165
+ includeParams && params && Object.keys(params).length > 0
166
+ ? Object.entries(params)
167
+ .sort(([a], [b]) => a.localeCompare(b))
168
+ .map(([k, v]) => `${k}=${v}`)
169
+ .join(",")
170
+ : "";
171
+ const key = `${paramStr ? `${id}-${paramStr}` : id}`;
172
+
173
+ // Get loader entries for this node
174
+ const loaderEntries = node.loaders.filter(
175
+ (loader) => loader.loaderId && loader.loaderData !== undefined
176
+ );
177
+
178
+ // Determine the component content (with or without Suspense wrapper)
179
+ // Wrap when loading skeleton defined OR component is Promise (needs Suspense)
180
+ // During actions, await component Promise to prevent Suspense from triggering
181
+ // This keeps existing content visible instead of showing loading skeleton
182
+ let resolvedComponent = component;
183
+ if (isAction && component instanceof Promise) {
184
+ resolvedComponent = await component;
185
+ }
186
+
187
+ let nodeContent: ReactNode =
188
+ loading || loading === null || resolvedComponent instanceof Promise
189
+ ? createElement(RouteContentWrapper, {
190
+ key: `suspense-loading-${id}`,
191
+ content:
192
+ resolvedComponent instanceof Promise
193
+ ? resolvedComponent
194
+ : Promise.resolve(resolvedComponent),
195
+ fallback: loading,
196
+ })
197
+ : resolvedComponent;
198
+
199
+ // Common props for OutletProvider
200
+ const outletContent: ReactNode =
201
+ node.segment.type === "layout" ? content : null;
202
+
203
+ // Prepare loader data if there are loaders
204
+ const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
205
+ const loaderDataPromise =
206
+ loaderEntries.length > 0
207
+ ? Promise.all(
208
+ loaderEntries.map((loader) =>
209
+ loader.loaderData instanceof Promise
210
+ ? loader.loaderData
211
+ : Promise.resolve(loader.loaderData)
212
+ )
213
+ )
214
+ : Promise.resolve([]);
215
+
216
+ // Use LoaderBoundary when loading is defined to maintain consistent tree structure
217
+ // This ensures cached segments (which may not have loader segments) have the same
218
+ // tree structure as fresh segments, preventing React remounts
219
+ // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
220
+ if (loading !== undefined) {
221
+ content = createElement(LoaderBoundary, {
222
+ key: `loader-boundary-${key}`,
223
+ loaderDataPromise:
224
+ forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
225
+ loaderIds,
226
+ fallback: loading,
227
+ outletKey: key,
228
+ outletContent,
229
+ segment: node.segment,
230
+ parallel: node.parallel,
231
+ children: nodeContent,
232
+ });
233
+ continue;
234
+ }
235
+
236
+ // No loading skeleton defined - use OutletProvider directly
237
+ if (loaderEntries.length === 0) {
238
+ // No loaders, no loading - simple OutletProvider
239
+ content = createElement(OutletProvider, {
240
+ key,
241
+ content: outletContent,
242
+ segment: node.segment,
243
+ parallel: node.parallel,
244
+ children: nodeContent,
245
+ });
246
+ continue;
247
+ }
248
+
249
+ // Has loaders but no loading skeleton - await loaders and render directly
250
+ const resolvedData = await loaderDataPromise;
251
+ const { loaderData, errorFallback } = resolveLoaderData(
252
+ resolvedData,
253
+ loaderIds
254
+ );
255
+
256
+ content = createElement(OutletProvider, {
257
+ key,
258
+ content: outletContent,
259
+ segment: node.segment,
260
+ parallel: node.parallel,
261
+ loaderData: Object.keys(loaderData).length > 0 ? loaderData : undefined,
262
+ children: errorFallback ?? nodeContent,
263
+ });
264
+ }
265
+
266
+ // Always wrap with root error boundary to prevent white screens
267
+ // This catches any unhandled errors that bubble up from the segment tree
268
+ const errorBoundaryWrapped = createElement(RootErrorBoundary, {
269
+ children: content,
270
+ });
271
+
272
+ // If rootLayout is provided, wrap the error boundary with it
273
+ // This ensures the app shell stays mounted even during errors (prevents FOUC)
274
+ if (RootLayout) {
275
+ return createElement(RootLayout, {
276
+ children: errorBoundaryWrapped,
277
+ });
278
+ }
279
+
280
+ return errorBoundaryWrapped;
281
+ }
282
+
283
+ /**
284
+ * Walk segments in bottom-to-top order for React nesting
285
+ *
286
+ * Segments from match() are in top-to-bottom order (root → leaf):
287
+ * Example: [L0, L0L0, L0R1L0.@sidebar, L0R1L0, L0R1]
288
+ *
289
+ * For proper React rendering, we need bottom-to-top (leaf → root):
290
+ * - Innermost content (route) wraps inside layouts
291
+ * - Each layer provides context via OutletProvider
292
+ * - Outer layouts receive inner content via <Outlet />
293
+ *
294
+ * Parallel segments must be matched to their parent by ID prefix:
295
+ * - "L0R1L0.@sidebar" belongs to "L0R1L0"
296
+ * - Pre-grouping prevents parallels from attaching to wrong parents
297
+ * during the reversed iteration (which would cause "L0R1L0.@sidebar"
298
+ * to incorrectly attach to "L0L0" instead of "L0R1L0")
299
+ *
300
+ * Loader segments are also grouped by parent:
301
+ * - "L0D0.cart" belongs to "L0"
302
+ * - Loaders don't render directly, their data is passed to context
303
+ *
304
+ * Intercept segments are passed separately for explicit handling:
305
+ * - They are injected into the correct parent's parallel array
306
+ * - This makes the flow clearer than relying on ID pattern matching
307
+ *
308
+ * @param segments - Main segments from the route tree
309
+ * @param interceptSegments - Optional intercept segments to inject
310
+ */
311
+ function* segmentTreeWalk(
312
+ segments: ResolvedSegment[],
313
+ interceptSegments?: ResolvedSegment[]
314
+ ): Generator<{
315
+ segment: ResolvedSegment;
316
+ parallel: ResolvedSegment[];
317
+ loaders: ResolvedSegment[];
318
+ }> {
319
+ // Pre-group parallel and loader segments by their parent ID using prefix matching
320
+ // This ensures each parallel/loader is associated with the correct parent segment
321
+ // regardless of the iteration order
322
+ const parallelsByParent = new Map<string, ResolvedSegment[]>();
323
+ const loadersByParent = new Map<string, ResolvedSegment[]>();
324
+ const nonParallels: ResolvedSegment[] = [];
325
+
326
+ for (const segment of segments) {
327
+ if (segment.type === "parallel") {
328
+ // Extract parent ID from parallel ID
329
+ // Example: "L0R1L0.@sidebar" → "L0R1L0"
330
+ const parentId = segment.id.split(".")[0];
331
+ if (!parallelsByParent.has(parentId)) {
332
+ parallelsByParent.set(parentId, []);
333
+ }
334
+ parallelsByParent.get(parentId)!.push(segment);
335
+ } else if (segment.type === "loader") {
336
+ // Extract parent ID from loader ID
337
+ // Example: "L0D0.cart" → "L0"
338
+ // Loader ID format: {parentShortCode}D{index}.{loaderId}
339
+ const parentId = segment.id.split("D")[0];
340
+ if (!loadersByParent.has(parentId)) {
341
+ loadersByParent.set(parentId, []);
342
+ }
343
+ loadersByParent.get(parentId)!.push(segment);
344
+ } else {
345
+ // Layout, route, error, and notFound segments are all rendered in the tree
346
+ // Error/notFound segments replace the failed segment with fallback UI
347
+ nonParallels.push(segment);
348
+ }
349
+ }
350
+
351
+ // INTERCEPT SEGMENTS: Explicitly inject into parent's parallel array
352
+ // Intercept segments are passed separately for explicit handling
353
+ if (interceptSegments && interceptSegments.length > 0) {
354
+ for (const intercept of interceptSegments) {
355
+ if (intercept.type === "parallel" && intercept.slot) {
356
+ // Extract parent ID from intercept ID (e.g., "M4L0L0L2.@modal" → "M4L0L0L2")
357
+ const parentId = intercept.id.split(".")[0];
358
+ if (!parallelsByParent.has(parentId)) {
359
+ parallelsByParent.set(parentId, []);
360
+ }
361
+ parallelsByParent.get(parentId)!.push(intercept);
362
+ } else if (intercept.type === "loader") {
363
+ // Intercept loaders - extract parent from loader ID
364
+ const parentId = intercept.id.split("D")[0];
365
+ if (!loadersByParent.has(parentId)) {
366
+ loadersByParent.set(parentId, []);
367
+ }
368
+ loadersByParent.get(parentId)!.push(intercept);
369
+ }
370
+ }
371
+ }
372
+
373
+ // Sort segments by ID to ensure consistent root-to-leaf ordering
374
+ // regardless of the order they arrive in the input array (which can differ
375
+ // between document requests and actions)
376
+ // Shorter IDs come first (closer to root), same length sorted lexicographically
377
+ nonParallels.sort((a, b) => {
378
+ if (a.id.length !== b.id.length) {
379
+ return a.id.length - b.id.length;
380
+ }
381
+ return a.id.localeCompare(b.id);
382
+ });
383
+
384
+ // Iterate bottom-to-top using reverse() to process leaf segments first
385
+ // This processes route/leaf layouts first, then parent layouts
386
+ // Note: We reverse the array to iterate from end to start (bottom-to-top)
387
+ for (let i = nonParallels.length - 1; i >= 0; i--) {
388
+ const segment = nonParallels[i];
389
+
390
+ // Lookup parallels and loaders that belong to this segment by ID prefix
391
+ const parallel = parallelsByParent.get(segment.id) || [];
392
+ const loaders = loadersByParent.get(segment.id) || [];
393
+
394
+ // Also include loaders from parallel segments (e.g., intercept loaders)
395
+ // These have parent IDs like "M9L0L1.@modal" which match the parallel segment ID
396
+ for (const p of parallel) {
397
+ const parallelLoaders = loadersByParent.get(p.id);
398
+ if (parallelLoaders) {
399
+ loaders.push(...parallelLoaders);
400
+ }
401
+ }
402
+
403
+ yield { segment, parallel, loaders };
404
+ }
405
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Tests for RequestContext, specifically the onResponse callback API
3
+ */
4
+ import { describe, it, expect, beforeEach } from "vitest";
5
+ import {
6
+ createRequestContext,
7
+ runWithRequestContext,
8
+ getRequestContext,
9
+ } from "../request-context.js";
10
+
11
+ describe("RequestContext", () => {
12
+ describe("onResponse", () => {
13
+ it("should register callbacks", () => {
14
+ const ctx = createRequestContext({
15
+ env: {},
16
+ request: new Request("https://example.com"),
17
+ url: new URL("https://example.com"),
18
+ variables: {},
19
+ });
20
+
21
+ expect(ctx._onResponseCallbacks).toHaveLength(0);
22
+
23
+ ctx.onResponse((res) => res);
24
+ expect(ctx._onResponseCallbacks).toHaveLength(1);
25
+
26
+ ctx.onResponse((res) => res);
27
+ expect(ctx._onResponseCallbacks).toHaveLength(2);
28
+ });
29
+
30
+ it("should allow callbacks to inspect response status", () => {
31
+ const ctx = createRequestContext({
32
+ env: {},
33
+ request: new Request("https://example.com"),
34
+ url: new URL("https://example.com"),
35
+ variables: {},
36
+ });
37
+
38
+ let capturedStatus: number | undefined;
39
+
40
+ ctx.onResponse((res) => {
41
+ capturedStatus = res.status;
42
+ return res;
43
+ });
44
+
45
+ // Simulate calling the callback
46
+ const response = new Response("OK", { status: 200 });
47
+ for (const callback of ctx._onResponseCallbacks) {
48
+ callback(response);
49
+ }
50
+
51
+ expect(capturedStatus).toBe(200);
52
+ });
53
+
54
+ it("should allow callbacks to modify response", () => {
55
+ const ctx = createRequestContext({
56
+ env: {},
57
+ request: new Request("https://example.com"),
58
+ url: new URL("https://example.com"),
59
+ variables: {},
60
+ });
61
+
62
+ ctx.onResponse((res) => {
63
+ // Return a new response with added header
64
+ const newHeaders = new Headers(res.headers);
65
+ newHeaders.set("X-Modified", "true");
66
+ return new Response(res.body, {
67
+ status: res.status,
68
+ headers: newHeaders,
69
+ });
70
+ });
71
+
72
+ let response = new Response("OK", { status: 200 });
73
+ for (const callback of ctx._onResponseCallbacks) {
74
+ response = callback(response);
75
+ }
76
+
77
+ expect(response.headers.get("X-Modified")).toBe("true");
78
+ });
79
+
80
+ it("should run multiple callbacks in order", () => {
81
+ const ctx = createRequestContext({
82
+ env: {},
83
+ request: new Request("https://example.com"),
84
+ url: new URL("https://example.com"),
85
+ variables: {},
86
+ });
87
+
88
+ const order: number[] = [];
89
+
90
+ ctx.onResponse((res) => {
91
+ order.push(1);
92
+ return res;
93
+ });
94
+
95
+ ctx.onResponse((res) => {
96
+ order.push(2);
97
+ return res;
98
+ });
99
+
100
+ ctx.onResponse((res) => {
101
+ order.push(3);
102
+ return res;
103
+ });
104
+
105
+ let response = new Response("OK");
106
+ for (const callback of ctx._onResponseCallbacks) {
107
+ response = callback(response) ?? response;
108
+ }
109
+
110
+ expect(order).toEqual([1, 2, 3]);
111
+ });
112
+
113
+ it("should allow callbacks to chain modifications", () => {
114
+ const ctx = createRequestContext({
115
+ env: {},
116
+ request: new Request("https://example.com"),
117
+ url: new URL("https://example.com"),
118
+ variables: {},
119
+ });
120
+
121
+ ctx.onResponse((res) => {
122
+ const newHeaders = new Headers(res.headers);
123
+ newHeaders.set("X-First", "1");
124
+ return new Response(res.body, { status: res.status, headers: newHeaders });
125
+ });
126
+
127
+ ctx.onResponse((res) => {
128
+ const newHeaders = new Headers(res.headers);
129
+ newHeaders.set("X-Second", "2");
130
+ return new Response(res.body, { status: res.status, headers: newHeaders });
131
+ });
132
+
133
+ let response = new Response("OK");
134
+ for (const callback of ctx._onResponseCallbacks) {
135
+ response = callback(response) ?? response;
136
+ }
137
+
138
+ expect(response.headers.get("X-First")).toBe("1");
139
+ expect(response.headers.get("X-Second")).toBe("2");
140
+ });
141
+
142
+ it("should be accessible via getRequestContext", () => {
143
+ const ctx = createRequestContext({
144
+ env: {},
145
+ request: new Request("https://example.com"),
146
+ url: new URL("https://example.com"),
147
+ variables: {},
148
+ });
149
+
150
+ let callbackCalled = false;
151
+
152
+ runWithRequestContext(ctx, () => {
153
+ const currentCtx = getRequestContext();
154
+ currentCtx?.onResponse((res) => {
155
+ callbackCalled = true;
156
+ return res;
157
+ });
158
+ });
159
+
160
+ expect(ctx._onResponseCallbacks).toHaveLength(1);
161
+
162
+ // Trigger callback
163
+ const response = new Response("OK");
164
+ for (const callback of ctx._onResponseCallbacks) {
165
+ callback(response);
166
+ }
167
+
168
+ expect(callbackCalled).toBe(true);
169
+ });
170
+ });
171
+ });