@rangojs/router 0.0.0-experimental.2

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