@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,942 @@
1
+ /// <reference types="@vitejs/plugin-rsc/types" />
2
+ /// <reference path="../vite/version.d.ts" />
3
+ /**
4
+ * RSC Request Handler
5
+ *
6
+ * Main request handler for RSC rendering, server actions, loader fetching,
7
+ * and progressive enhancement (no-JS form submissions).
8
+ */
9
+
10
+ import { renderSegments } from "../segment-system.js";
11
+ import { RouteNotFoundError } from "../errors.js";
12
+ import { getLoaderLazy } from "../server/loader-registry.js";
13
+ import {
14
+ matchMiddleware,
15
+ executeMiddleware,
16
+ executeLoaderMiddleware,
17
+ } from "../router/middleware.js";
18
+ import {
19
+ runWithRequestContext,
20
+ setRequestContextParams,
21
+ requireRequestContext,
22
+ createRequestContext,
23
+ } from "../server/request-context.js";
24
+ import * as rscDeps from "@vitejs/plugin-rsc/rsc";
25
+
26
+
27
+ import type {
28
+ RscPayload,
29
+ ReactFormState,
30
+ CreateRSCHandlerOptions,
31
+ } from "./types.js";
32
+ import { hasBodyContent, createResponseWithMergedHeaders } from "./helpers.js";
33
+ import { generateNonce } from "./nonce.js";
34
+ import { VERSION } from "rsc-router:version";
35
+ import type { ErrorPhase } from "../types.js";
36
+ import { invokeOnError } from "../router/error-handling.js";
37
+
38
+ /**
39
+ * Create an RSC request handler.
40
+ *
41
+ * @example Basic usage (deps and loadSSRModule have sensible defaults)
42
+ * ```tsx
43
+ * import { createRSCHandler } from "rsc-router/rsc";
44
+ * import { router } from "./router.js";
45
+ *
46
+ * export default createRSCHandler({ router });
47
+ * ```
48
+ *
49
+ * @example With custom deps (advanced)
50
+ * ```tsx
51
+ * import { createRSCHandler } from "rsc-router/rsc";
52
+ * import * as rsc from "@vitejs/plugin-rsc/rsc";
53
+ * import { router } from "./router.js";
54
+ *
55
+ * export default createRSCHandler({
56
+ * router,
57
+ * deps: rsc,
58
+ * loadSSRModule: () => import.meta.viteRsc.loadModule("ssr", "index"),
59
+ * });
60
+ * ```
61
+ */
62
+ export function createRSCHandler<TEnv = unknown>(
63
+ options: CreateRSCHandlerOptions<TEnv>
64
+ ) {
65
+ const { router, version = VERSION, nonce: nonceProvider } = options;
66
+
67
+ // Use provided deps or default to @vitejs/plugin-rsc/rsc exports
68
+ const deps = options.deps ?? rscDeps;
69
+ const {
70
+ renderToReadableStream,
71
+ decodeReply,
72
+ createTemporaryReferenceSet,
73
+ loadServerAction,
74
+ decodeAction,
75
+ decodeFormState,
76
+ } = deps;
77
+
78
+ // Use provided loadSSRModule or default to vite RSC module loader
79
+ const loadSSRModule =
80
+ options.loadSSRModule ??
81
+ (() => import.meta.viteRsc.loadModule("ssr", "index"));
82
+
83
+ /**
84
+ * Wrapper for invokeOnError that binds the router's onError callback.
85
+ * Uses the shared utility from router/error-handling.ts for consistent behavior.
86
+ */
87
+ function callOnError(
88
+ error: unknown,
89
+ phase: ErrorPhase,
90
+ context: Parameters<typeof invokeOnError<TEnv>>[3]
91
+ ): void {
92
+ invokeOnError(router.onError, error, phase, context, "RSC");
93
+ }
94
+
95
+ return async function handler(
96
+ request: Request,
97
+ env: TEnv = {} as TEnv
98
+ ): Promise<Response> {
99
+ // Resolve nonce if provider is set
100
+ let nonce: string | undefined;
101
+ if (nonceProvider) {
102
+ const result = await nonceProvider(request, env);
103
+ nonce = result === true ? generateNonce() : result;
104
+ }
105
+
106
+ const url = new URL(request.url);
107
+
108
+ // Match global middleware
109
+ const matchedMiddleware = matchMiddleware(url.pathname, router.middleware);
110
+
111
+ // Shared variables between middleware and route handlers
112
+ const variables: Record<string, any> = {};
113
+
114
+ // Store nonce in variables so middleware can access via ctx.get('nonce')
115
+ if (nonce) {
116
+ variables.nonce = nonce;
117
+ }
118
+
119
+ // Resolve cache store configuration
120
+ // Priority: options.cache (handler override) > router.cache (router default)
121
+ // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
122
+ let cacheStore = undefined;
123
+ const cacheOption = options.cache ?? router.cache;
124
+ if (cacheOption && !url.searchParams.has("__no_cache")) {
125
+ const cacheConfig =
126
+ typeof cacheOption === "function" ? cacheOption(env) : cacheOption;
127
+
128
+ if (cacheConfig.enabled !== false) {
129
+ cacheStore = cacheConfig.store;
130
+ }
131
+ }
132
+
133
+ // Create unified request context with all methods
134
+ // Includes: stub response, handle store, loader memoization, use(), cookies, headers, cache store
135
+ // params starts empty, populated after route matching via setRequestContextParams
136
+ const requestContext = createRequestContext({
137
+ env,
138
+ request,
139
+ url,
140
+ variables,
141
+ cacheStore,
142
+ });
143
+
144
+ // Wrap entire request handling in request context
145
+ // Makes context available via getRequestContext() throughout:
146
+ // - Middleware execution
147
+ // - Route handlers and loaders
148
+ // - Server components during rendering
149
+ // - Error boundaries
150
+ // - Streaming
151
+ return runWithRequestContext(requestContext, async () => {
152
+ // Core handler logic (wrapped by middleware)
153
+ const coreHandler = async (): Promise<Response> => {
154
+ return coreRequestHandler(request, env, url, variables, nonce);
155
+ };
156
+
157
+ // Execute middleware chain if any, otherwise call core handler directly
158
+ if (matchedMiddleware.length > 0) {
159
+ return executeMiddleware(
160
+ matchedMiddleware,
161
+ request,
162
+ env,
163
+ variables,
164
+ coreHandler
165
+ );
166
+ }
167
+
168
+ return coreHandler();
169
+ });
170
+ };
171
+
172
+ // Core request handling logic (separated for middleware wrapping)
173
+ async function coreRequestHandler(
174
+ request: Request,
175
+ env: TEnv,
176
+ url: URL,
177
+ variables: Record<string, any>,
178
+ nonce: string | undefined
179
+ ): Promise<Response> {
180
+ // First, check for route-level middleware
181
+ const preview = await router.previewMatch(request, env);
182
+ if (preview?.routeMiddleware && preview.routeMiddleware.length > 0) {
183
+ // Convert route middleware to app middleware format for execution
184
+ const middlewareEntries = preview.routeMiddleware.map((mw) => ({
185
+ entry: {
186
+ pattern: null,
187
+ regex: null,
188
+ paramNames: [],
189
+ handler: mw.handler,
190
+ mountPrefix: null,
191
+ },
192
+ params: mw.params,
193
+ }));
194
+
195
+ // Execute route middleware wrapping the actual request handling
196
+ return executeMiddleware(middlewareEntries, request, env, variables, () =>
197
+ coreRequestHandlerInner(request, env, url, variables, nonce)
198
+ );
199
+ }
200
+
201
+ // No route middleware, proceed directly
202
+ return coreRequestHandlerInner(request, env, url, variables, nonce);
203
+ }
204
+
205
+ // Inner request handler (actual RSC logic, wrapped by route middleware if any)
206
+ async function coreRequestHandlerInner(
207
+ request: Request,
208
+ env: TEnv,
209
+ url: URL,
210
+ variables: Record<string, any>,
211
+ nonce: string | undefined
212
+ ): Promise<Response> {
213
+ // Early return for static file requests that don't need RSC handling
214
+ if (url.pathname === "/favicon.ico" || url.pathname === "/robots.txt") {
215
+ return new Response(null, { status: 404 });
216
+ }
217
+
218
+ const isPartial = url.searchParams.has("_rsc_partial");
219
+ const isAction =
220
+ request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
221
+ const actionId =
222
+ request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
223
+
224
+ // Version mismatch detection - client may have stale code after HMR/deployment
225
+ // If versions don't match, tell the client to reload
226
+ const clientVersion = url.searchParams.get("_rsc_v");
227
+ if (version && clientVersion && clientVersion !== version) {
228
+ console.log(
229
+ `[RSC] Version mismatch: client=${clientVersion}, server=${version}. Forcing reload.`
230
+ );
231
+
232
+ // Clean URL by removing RSC params
233
+ const cleanUrl = new URL(url);
234
+ cleanUrl.searchParams.delete("_rsc_partial");
235
+ cleanUrl.searchParams.delete("_rsc_segments");
236
+ cleanUrl.searchParams.delete("_rsc_v");
237
+ cleanUrl.searchParams.delete("_rsc_stale");
238
+ cleanUrl.searchParams.delete("_rsc_action");
239
+ cleanUrl.searchParams.delete("_rsc_prev");
240
+
241
+ // For actions, reload current page (referer)
242
+ // For navigation, load the target URL
243
+ const reloadUrl = isAction
244
+ ? request.headers.get("referer") || cleanUrl.toString()
245
+ : cleanUrl.toString();
246
+
247
+ // Return special response that tells client to reload
248
+ return createResponseWithMergedHeaders(null, {
249
+ status: 200,
250
+ headers: {
251
+ "X-RSC-Reload": reloadUrl,
252
+ "content-type": "text/x-component;charset=utf-8",
253
+ },
254
+ });
255
+ }
256
+
257
+ // Get handle store from request context (created at start of request)
258
+ const handleStore = requireRequestContext()._handleStore;
259
+
260
+ try {
261
+ // ============================================================================
262
+ // PROGRESSIVE ENHANCEMENT: No-JS Form Submissions
263
+ // ============================================================================
264
+ const progressiveResult = await handleProgressiveEnhancement(
265
+ request,
266
+ env,
267
+ url,
268
+ isAction,
269
+ handleStore,
270
+ nonce
271
+ );
272
+ if (progressiveResult) {
273
+ return progressiveResult;
274
+ }
275
+
276
+ // ============================================================================
277
+ // SERVER ACTION EXECUTION (JavaScript-enabled client)
278
+ // ============================================================================
279
+ if (isAction && actionId) {
280
+ return handleServerAction(request, env, url, actionId, handleStore);
281
+ }
282
+
283
+ // ============================================================================
284
+ // LOADER FETCH EXECUTION (data fetching with RSC serialization)
285
+ // ============================================================================
286
+ const isLoaderRequest = url.searchParams.has("_rsc_loader");
287
+ if (isLoaderRequest) {
288
+ return handleLoaderFetch(request, env, url, variables);
289
+ }
290
+
291
+ // ============================================================================
292
+ // REGULAR RSC RENDERING (Navigation)
293
+ // ============================================================================
294
+ return handleRscRendering(request, env, url, isPartial, handleStore, nonce);
295
+ } catch (error) {
296
+ // Check if middleware/handler returned Response
297
+ if (error instanceof Response) {
298
+ return error;
299
+ }
300
+
301
+ // Return 404 for unmatched routes instead of 500
302
+ if (error instanceof RouteNotFoundError) {
303
+ callOnError(error, "routing", {
304
+ request,
305
+ url,
306
+ env,
307
+ handledByBoundary: false,
308
+ });
309
+ return createResponseWithMergedHeaders("Not Found", { status: 404 });
310
+ }
311
+
312
+ // Report unhandled errors
313
+ callOnError(error, "routing", {
314
+ request,
315
+ url,
316
+ env,
317
+ handledByBoundary: false,
318
+ });
319
+ console.error(`[RSC] Error:`, error);
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ // ============================================================================
325
+ // PROGRESSIVE ENHANCEMENT HANDLER
326
+ // When JavaScript is disabled, React renders forms with hidden fields
327
+ // ($ACTION_REF_*, $ACTION_KEY) containing the action reference.
328
+ // We detect these and return HTML instead of RSC stream.
329
+ // ============================================================================
330
+ async function handleProgressiveEnhancement(
331
+ request: Request,
332
+ env: TEnv,
333
+ url: URL,
334
+ isAction: boolean,
335
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
336
+ nonce: string | undefined
337
+ ): Promise<Response | null> {
338
+ const contentType = request.headers.get("content-type") || "";
339
+ const isFormSubmission =
340
+ contentType.includes("multipart/form-data") ||
341
+ contentType.includes("application/x-www-form-urlencoded");
342
+
343
+ if (request.method !== "POST" || isAction || !isFormSubmission) {
344
+ return null;
345
+ }
346
+
347
+ // Clone the request to read FormData without consuming it
348
+ const formData = await request.clone().formData();
349
+
350
+ // Look for React's progressive enhancement hidden fields
351
+ let isDirectAction = false;
352
+ let isUseActionState = false;
353
+ let directActionId: string | null = null;
354
+
355
+ formData.forEach((_value, key) => {
356
+ if (key.startsWith("$ACTION_ID_")) {
357
+ isDirectAction = true;
358
+ directActionId = key.slice("$ACTION_ID_".length);
359
+ } else if (key.startsWith("$ACTION_REF_")) {
360
+ isUseActionState = true;
361
+ }
362
+ });
363
+
364
+ if (!isDirectAction && !isUseActionState) {
365
+ return null;
366
+ }
367
+
368
+ // Execute action and return HTML
369
+ let actionResult: unknown = undefined;
370
+ let reactFormState: ReactFormState | null = null;
371
+
372
+ if (isUseActionState) {
373
+ try {
374
+ const boundAction = await decodeAction(formData);
375
+ actionResult = await boundAction();
376
+ } catch (error) {
377
+ callOnError(error, "action", {
378
+ request,
379
+ url,
380
+ env,
381
+ handledByBoundary: false,
382
+ });
383
+ console.error("[RSC] Progressive enhancement action error:", error);
384
+ }
385
+ } else if (isDirectAction && directActionId) {
386
+ const temporaryReferences = createTemporaryReferenceSet();
387
+
388
+ let args: unknown[] = [];
389
+ try {
390
+ args = await decodeReply(formData, { temporaryReferences });
391
+ } catch {
392
+ args = [formData];
393
+ }
394
+
395
+ try {
396
+ const loadedAction = await loadServerAction(directActionId);
397
+ actionResult = await loadedAction.apply(null, args);
398
+ } catch (error) {
399
+ callOnError(error, "action", {
400
+ request,
401
+ url,
402
+ env,
403
+ actionId: directActionId,
404
+ handledByBoundary: false,
405
+ });
406
+ console.error("[RSC] Progressive enhancement action error:", error);
407
+ }
408
+ }
409
+
410
+ // Decode form state for useActionState progressive enhancement
411
+ try {
412
+ reactFormState = await decodeFormState(actionResult, formData);
413
+ } catch (error) {
414
+ callOnError(error, "action", {
415
+ request,
416
+ url,
417
+ env,
418
+ handledByBoundary: false,
419
+ });
420
+ console.error("[RSC] Failed to decode form state:", error);
421
+ }
422
+
423
+ // Re-render the page and return HTML
424
+ const renderRequest = new Request(url.toString(), {
425
+ method: "GET",
426
+ headers: new Headers({ accept: "text/html" }),
427
+ });
428
+
429
+ const match = await router.match(renderRequest, env);
430
+
431
+ if (match.redirect) {
432
+ return new Response(null, {
433
+ status: 308,
434
+ headers: { Location: match.redirect },
435
+ });
436
+ }
437
+
438
+ const root = renderSegments(match.segments, {
439
+ rootLayout: router.rootLayout,
440
+ });
441
+
442
+ const payload: RscPayload = {
443
+ root,
444
+ metadata: {
445
+ pathname: url.pathname,
446
+ segments: match.segments,
447
+ matched: match.matched,
448
+ diff: match.diff,
449
+ isPartial: false,
450
+ rootLayout: router.rootLayout,
451
+ handles: handleStore.stream(),
452
+ version,
453
+ },
454
+ formState: actionResult,
455
+ };
456
+
457
+ const rscStream = renderToReadableStream<RscPayload>(payload);
458
+ const ssrModule = await loadSSRModule();
459
+ const htmlStream = await ssrModule.renderHTML(rscStream, {
460
+ formState: reactFormState,
461
+ nonce,
462
+ });
463
+
464
+ return new Response(htmlStream, {
465
+ headers: { "content-type": "text/html;charset=utf-8" },
466
+ });
467
+ }
468
+
469
+ // ============================================================================
470
+ // SERVER ACTION HANDLER
471
+ // ============================================================================
472
+ async function handleServerAction(
473
+ request: Request,
474
+ env: TEnv,
475
+ url: URL,
476
+ actionId: string,
477
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"]
478
+ ): Promise<Response> {
479
+ const temporaryReferences = createTemporaryReferenceSet();
480
+
481
+ // Decode action arguments from request body
482
+ const contentType = request.headers.get("content-type") || "";
483
+ let args: unknown[] = [];
484
+ let actionFormData: FormData | undefined;
485
+
486
+ try {
487
+ const body = contentType.includes("multipart/form-data")
488
+ ? await request.formData()
489
+ : await request.text();
490
+
491
+ if (body instanceof FormData) {
492
+ actionFormData = body;
493
+ }
494
+
495
+ if (hasBodyContent(body)) {
496
+ args = await decodeReply(body, { temporaryReferences });
497
+ }
498
+ } catch (error) {
499
+ callOnError(error, "action", {
500
+ request,
501
+ url,
502
+ env,
503
+ actionId,
504
+ handledByBoundary: false,
505
+ });
506
+ throw new Error(`Failed to decode action arguments: ${error}`, {
507
+ cause: error,
508
+ });
509
+ }
510
+
511
+ // Execute the server action
512
+ let returnValue: { ok: boolean; data: unknown };
513
+ let actionStatus = 200;
514
+ let loadedAction: Function | undefined;
515
+
516
+ try {
517
+ loadedAction = await loadServerAction(actionId);
518
+ const data = await loadedAction!.apply(null, args);
519
+ returnValue = { ok: true, data };
520
+ } catch (error) {
521
+ returnValue = { ok: false, data: error };
522
+ actionStatus = 500;
523
+
524
+ // Try to render error boundary
525
+ const errorResult = await router.matchError(request, env, error, "route");
526
+
527
+ // Report the action error (handledByBoundary indicates if error boundary will render)
528
+ callOnError(error, "action", {
529
+ request,
530
+ url,
531
+ env,
532
+ actionId,
533
+ handledByBoundary: !!errorResult,
534
+ });
535
+
536
+ if (errorResult) {
537
+ setRequestContextParams(errorResult.params);
538
+
539
+ const payload: RscPayload = {
540
+ root: null,
541
+ metadata: {
542
+ pathname: url.pathname,
543
+ segments: errorResult.segments,
544
+ isPartial: true,
545
+ matched: errorResult.matched,
546
+ diff: errorResult.diff,
547
+ isError: true,
548
+ handles: handleStore.stream(),
549
+ version,
550
+ },
551
+ returnValue,
552
+ };
553
+
554
+ const rscStream = renderToReadableStream<RscPayload>(payload, {
555
+ temporaryReferences,
556
+ });
557
+
558
+ return createResponseWithMergedHeaders(rscStream, {
559
+ status: actionStatus,
560
+ headers: { "content-type": "text/x-component;charset=utf-8" },
561
+ });
562
+ }
563
+ }
564
+
565
+ // Revalidate after action
566
+ const resolvedActionId =
567
+ (loadedAction as { $id?: string; $$id?: string } | undefined)?.$id ??
568
+ (loadedAction as { $$id?: string } | undefined)?.$$id ??
569
+ actionId;
570
+ const actionContext = {
571
+ actionId: resolvedActionId,
572
+ actionUrl: new URL(request.url),
573
+ actionResult: returnValue.data,
574
+ formData: actionFormData,
575
+ };
576
+
577
+ const matchResult = await router.matchPartial(request, env, actionContext);
578
+
579
+ if (!matchResult) {
580
+ // Fall back to full render
581
+ const fullMatch = await router.match(request, env);
582
+ setRequestContextParams(fullMatch.params);
583
+
584
+ if (fullMatch.redirect) {
585
+ return createResponseWithMergedHeaders(null, {
586
+ status: 308,
587
+ headers: { Location: fullMatch.redirect },
588
+ });
589
+ }
590
+
591
+ const renderStart = performance.now();
592
+ const root = renderSegments(fullMatch.segments, {
593
+ rootLayout: router.rootLayout,
594
+ isAction: true,
595
+ });
596
+ const renderDuration = performance.now() - renderStart;
597
+ const serverTiming = fullMatch.serverTiming
598
+ ? `${fullMatch.serverTiming}, rendering;dur=${renderDuration.toFixed(2)}`
599
+ : `rendering;dur=${renderDuration.toFixed(2)}`;
600
+
601
+ const payload: RscPayload = {
602
+ root,
603
+ metadata: {
604
+ pathname: url.pathname,
605
+ segments: fullMatch.segments,
606
+ matched: fullMatch.matched,
607
+ diff: fullMatch.diff,
608
+ handles: handleStore.stream(),
609
+ version,
610
+ },
611
+ returnValue,
612
+ };
613
+
614
+ const rscStream = renderToReadableStream<RscPayload>(payload, {
615
+ temporaryReferences,
616
+ });
617
+
618
+ const headers: Record<string, string> = {
619
+ "content-type": "text/x-component;charset=utf-8",
620
+ };
621
+ if (serverTiming) {
622
+ headers["Server-Timing"] = serverTiming;
623
+ }
624
+
625
+ return createResponseWithMergedHeaders(rscStream, {
626
+ status: actionStatus,
627
+ headers,
628
+ });
629
+ }
630
+
631
+ // Return updated segments
632
+ setRequestContextParams(matchResult.params);
633
+
634
+ const renderStart = performance.now();
635
+ renderSegments(matchResult.segments, { rootLayout: router.rootLayout, isAction: true });
636
+ const renderDuration = performance.now() - renderStart;
637
+ const serverTiming = matchResult.serverTiming
638
+ ? `${matchResult.serverTiming}, rendering;dur=${renderDuration.toFixed(2)}`
639
+ : `rendering;dur=${renderDuration.toFixed(2)}`;
640
+
641
+ const payload: RscPayload = {
642
+ root: null,
643
+ metadata: {
644
+ pathname: url.pathname,
645
+ segments: matchResult.segments,
646
+ isPartial: true,
647
+ matched: matchResult.matched,
648
+ diff: matchResult.diff,
649
+ slots: matchResult.slots,
650
+ handles: handleStore.stream(),
651
+ version,
652
+ },
653
+ returnValue,
654
+ };
655
+
656
+ const rscStream = renderToReadableStream<RscPayload>(payload, {
657
+ temporaryReferences,
658
+ });
659
+
660
+ const actionHeaders: Record<string, string> = {
661
+ "content-type": "text/x-component;charset=utf-8",
662
+ };
663
+ if (serverTiming) {
664
+ actionHeaders["Server-Timing"] = serverTiming;
665
+ }
666
+
667
+ return createResponseWithMergedHeaders(rscStream, {
668
+ status: actionStatus,
669
+ headers: actionHeaders,
670
+ });
671
+ }
672
+
673
+ // ============================================================================
674
+ // LOADER FETCH HANDLER
675
+ // Supports GET (params in query string) and POST/PUT/PATCH/DELETE (JSON body)
676
+ // ============================================================================
677
+ async function handleLoaderFetch(
678
+ request: Request,
679
+ env: TEnv,
680
+ url: URL,
681
+ variables: Record<string, any>
682
+ ): Promise<Response> {
683
+ const loaderId = url.searchParams.get("_rsc_loader");
684
+
685
+ if (!loaderId) {
686
+ return createResponseWithMergedHeaders("Missing _rsc_loader parameter", {
687
+ status: 400,
688
+ });
689
+ }
690
+
691
+ // Look up loader lazily
692
+ const registeredLoader = await getLoaderLazy(loaderId);
693
+ if (!registeredLoader) {
694
+ return createResponseWithMergedHeaders(
695
+ `Loader "${loaderId}" not found in registry`,
696
+ { status: 404 }
697
+ );
698
+ }
699
+
700
+ // Parse params and body based on request method
701
+ let loaderParams: Record<string, string> = {};
702
+ let loaderBody: unknown = undefined;
703
+ const isBodyMethod = request.method !== "GET" && request.method !== "HEAD";
704
+
705
+ if (isBodyMethod) {
706
+ try {
707
+ const contentType = request.headers.get("content-type") || "";
708
+ if (contentType.includes("application/json")) {
709
+ const jsonBody = (await request.json()) as {
710
+ params?: Record<string, string>;
711
+ body?: unknown;
712
+ };
713
+ loaderParams = jsonBody.params ?? {};
714
+ loaderBody = jsonBody.body;
715
+ }
716
+ } catch {
717
+ return createResponseWithMergedHeaders("Invalid JSON body", {
718
+ status: 400,
719
+ });
720
+ }
721
+ } else {
722
+ const loaderParamsJson = url.searchParams.get("_rsc_loader_params");
723
+ if (loaderParamsJson) {
724
+ try {
725
+ loaderParams = JSON.parse(loaderParamsJson);
726
+ } catch {
727
+ return createResponseWithMergedHeaders(
728
+ "Invalid _rsc_loader_params JSON",
729
+ { status: 400 }
730
+ );
731
+ }
732
+ }
733
+ }
734
+
735
+ // Execute the loader with middleware
736
+ try {
737
+ const { fn, middleware } = registeredLoader;
738
+
739
+ return await executeLoaderMiddleware(
740
+ middleware,
741
+ request,
742
+ env,
743
+ loaderParams,
744
+ variables,
745
+ async () => {
746
+ const ctx = requireRequestContext();
747
+ const loaderCtx: any = {
748
+ ...ctx,
749
+ params: loaderParams,
750
+ body: loaderBody,
751
+ };
752
+
753
+ const result = await fn(loaderCtx);
754
+
755
+ interface LoaderPayload {
756
+ loaderResult: unknown;
757
+ }
758
+ const loaderPayload: LoaderPayload = { loaderResult: result };
759
+ const rscStream =
760
+ renderToReadableStream<LoaderPayload>(loaderPayload);
761
+
762
+ return createResponseWithMergedHeaders(rscStream, {
763
+ headers: { "content-type": "text/x-component;charset=utf-8" },
764
+ });
765
+ }
766
+ );
767
+ } catch (error) {
768
+ const err = error instanceof Error ? error : new Error(String(error));
769
+ const isDev = process.env.NODE_ENV !== "production";
770
+
771
+ console.error("[RSC] Loader error:", error);
772
+
773
+ callOnError(error, "loader", {
774
+ request,
775
+ url,
776
+ env,
777
+ loaderName: loaderId,
778
+ handledByBoundary: false,
779
+ });
780
+
781
+ const errorPayload = {
782
+ loaderResult: null,
783
+ loaderError: {
784
+ message: isDev ? err.message : "An error occurred",
785
+ name: err.name,
786
+ },
787
+ };
788
+ const rscStream = renderToReadableStream(errorPayload);
789
+
790
+ return createResponseWithMergedHeaders(rscStream, {
791
+ status: 500,
792
+ headers: { "content-type": "text/x-component;charset=utf-8" },
793
+ });
794
+ }
795
+ }
796
+
797
+ // ============================================================================
798
+ // RSC RENDERING HANDLER (Navigation)
799
+ // ============================================================================
800
+ async function handleRscRendering(
801
+ request: Request,
802
+ env: TEnv,
803
+ url: URL,
804
+ isPartial: boolean,
805
+ handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
806
+ nonce: string | undefined
807
+ ): Promise<Response> {
808
+ let payload: RscPayload;
809
+ let serverTiming: string | undefined;
810
+
811
+ if (isPartial) {
812
+ // Partial render (navigation)
813
+ const result = await router.matchPartial(request, env);
814
+
815
+ if (!result) {
816
+ // Fall back to full render
817
+ const match = await router.match(request, env);
818
+ setRequestContextParams(match.params);
819
+
820
+ if (match.redirect) {
821
+ return createResponseWithMergedHeaders(null, {
822
+ status: 308,
823
+ headers: { Location: match.redirect },
824
+ });
825
+ }
826
+
827
+ const renderStart = performance.now();
828
+ const root = renderSegments(match.segments, {
829
+ rootLayout: router.rootLayout,
830
+ });
831
+ const renderDuration = performance.now() - renderStart;
832
+ serverTiming = match.serverTiming
833
+ ? `${match.serverTiming}, rendering;dur=${renderDuration.toFixed(2)}`
834
+ : `rendering;dur=${renderDuration.toFixed(2)}`;
835
+
836
+ payload = {
837
+ root,
838
+ metadata: {
839
+ pathname: url.pathname,
840
+ segments: match.segments,
841
+ matched: match.matched,
842
+ diff: match.diff,
843
+ isPartial: false,
844
+ handles: handleStore.stream(),
845
+ version,
846
+ },
847
+ };
848
+ } else {
849
+ setRequestContextParams(result.params);
850
+ serverTiming = result.serverTiming;
851
+
852
+ payload = {
853
+ root: null,
854
+ metadata: {
855
+ pathname: url.pathname,
856
+ segments: result.segments,
857
+ matched: result.matched,
858
+ diff: result.diff,
859
+ isPartial: true,
860
+ slots: result.slots,
861
+ handles: handleStore.stream(),
862
+ version,
863
+ },
864
+ };
865
+ }
866
+ } else {
867
+ // Full render (initial page load)
868
+ const match = await router.match(request, env);
869
+ setRequestContextParams(match.params);
870
+
871
+ if (match.redirect) {
872
+ return createResponseWithMergedHeaders(null, {
873
+ status: 308,
874
+ headers: { Location: match.redirect },
875
+ });
876
+ }
877
+
878
+ // Caching is now handled in router.match() via cache provider in request context
879
+ // match.segments already contains cached or fresh segments as appropriate
880
+
881
+ const renderStart = performance.now();
882
+ const root = renderSegments(match.segments, {
883
+ rootLayout: router.rootLayout,
884
+ });
885
+ const renderDuration = performance.now() - renderStart;
886
+ serverTiming = match.serverTiming
887
+ ? `${match.serverTiming}, rendering;dur=${renderDuration.toFixed(2)}`
888
+ : `rendering;dur=${renderDuration.toFixed(2)}`;
889
+
890
+ payload = {
891
+ root,
892
+ metadata: {
893
+ pathname: url.pathname,
894
+ segments: match.segments,
895
+ matched: match.matched,
896
+ diff: match.diff,
897
+ isPartial: false,
898
+ rootLayout: router.rootLayout,
899
+ handles: handleStore.stream(),
900
+ version,
901
+ },
902
+ };
903
+ }
904
+
905
+ // Serialize to RSC stream
906
+ const rscStream = renderToReadableStream<RscPayload>(payload);
907
+
908
+ // Determine if this is an RSC request or HTML request
909
+ const isRscRequest =
910
+ (!request.headers.get("accept")?.includes("text/html") &&
911
+ !url.searchParams.has("__html")) ||
912
+ url.searchParams.has("__rsc");
913
+
914
+ if (isRscRequest) {
915
+ const rscHeaders: Record<string, string> = {
916
+ "content-type": "text/x-component;charset=utf-8",
917
+ vary: "accept",
918
+ };
919
+ if (serverTiming) {
920
+ rscHeaders["Server-Timing"] = serverTiming;
921
+ }
922
+ return createResponseWithMergedHeaders(rscStream, {
923
+ headers: rscHeaders,
924
+ });
925
+ }
926
+
927
+ // Delegate to SSR for HTML response
928
+ const ssrModule = await loadSSRModule();
929
+ const htmlStream = await ssrModule.renderHTML(rscStream, { nonce });
930
+
931
+ const htmlHeaders: Record<string, string> = {
932
+ "content-type": "text/html;charset=utf-8",
933
+ };
934
+ if (serverTiming) {
935
+ htmlHeaders["Server-Timing"] = serverTiming;
936
+ }
937
+
938
+ return createResponseWithMergedHeaders(htmlStream, {
939
+ headers: htmlHeaders,
940
+ });
941
+ }
942
+ }