@rangojs/router 0.0.0-experimental.111 → 0.0.0-experimental.112

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 (49) hide show
  1. package/dist/bin/rango.js +41 -37
  2. package/dist/vite/index.js +144 -191
  3. package/package.json +3 -1
  4. package/src/browser/action-coordinator.ts +53 -36
  5. package/src/browser/event-controller.ts +42 -66
  6. package/src/browser/navigation-bridge.ts +4 -0
  7. package/src/browser/navigation-client.ts +12 -15
  8. package/src/browser/navigation-store.ts +7 -8
  9. package/src/browser/navigation-transaction.ts +7 -21
  10. package/src/browser/partial-update.ts +8 -16
  11. package/src/browser/react/NavigationProvider.tsx +29 -40
  12. package/src/browser/react/use-params.ts +3 -4
  13. package/src/browser/response-adapter.ts +25 -0
  14. package/src/browser/rsc-router.tsx +16 -2
  15. package/src/browser/server-action-bridge.ts +23 -30
  16. package/src/browser/types.ts +2 -0
  17. package/src/build/generate-manifest.ts +29 -31
  18. package/src/build/generate-route-types.ts +2 -0
  19. package/src/build/route-types/router-processing.ts +37 -9
  20. package/src/build/runtime-discovery.ts +9 -20
  21. package/src/decode-loader-results.ts +36 -0
  22. package/src/errors.ts +11 -0
  23. package/src/response-utils.ts +9 -0
  24. package/src/route-content-wrapper.tsx +6 -28
  25. package/src/router/content-negotiation.ts +15 -2
  26. package/src/router/intercept-resolution.ts +4 -18
  27. package/src/router/match-result.ts +32 -30
  28. package/src/router/middleware.ts +46 -78
  29. package/src/router/preview-match.ts +3 -1
  30. package/src/router/request-classification.ts +4 -28
  31. package/src/rsc/handler.ts +20 -65
  32. package/src/rsc/helpers.ts +3 -2
  33. package/src/rsc/origin-guard.ts +28 -10
  34. package/src/rsc/response-route-handler.ts +32 -52
  35. package/src/rsc/rsc-rendering.ts +27 -53
  36. package/src/rsc/runtime-warnings.ts +9 -10
  37. package/src/rsc/server-action.ts +13 -37
  38. package/src/rsc/ssr-setup.ts +16 -0
  39. package/src/segment-system.tsx +5 -39
  40. package/src/vite/discovery/discover-routers.ts +10 -22
  41. package/src/vite/discovery/route-types-writer.ts +38 -82
  42. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  43. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  44. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  45. package/src/vite/plugins/expose-internal-ids.ts +34 -62
  46. package/src/vite/plugins/version-injector.ts +2 -12
  47. package/src/vite/router-discovery.ts +71 -26
  48. package/src/vite/utils/shared-utils.ts +13 -1
  49. package/src/browser/action-response-classifier.ts +0 -99
@@ -307,6 +307,46 @@ export function matchMiddleware<TEnv>(
307
307
  return matches;
308
308
  }
309
309
 
310
+ // Set-Cookie is appended; for other headers stubOverridesNonCookie=true
311
+ // overwrites (chain ran to completion), false fills only missing slots (an
312
+ // explicit short-circuit Response's own headers win).
313
+ function mergeStubHeaders(
314
+ target: Headers,
315
+ stub: Headers,
316
+ stubOverridesNonCookie: boolean,
317
+ ): void {
318
+ stub.forEach((value, name) => {
319
+ if (name.toLowerCase() === "set-cookie") {
320
+ target.append(name, value);
321
+ } else if (stubOverridesNonCookie || !target.has(name)) {
322
+ target.set(name, value);
323
+ }
324
+ });
325
+ }
326
+
327
+ // Set-Cookie is deduped so a nested inner executeMiddleware that already merged
328
+ // the same reqCtx cookies does not duplicate them; other headers fill if missing.
329
+ function mergeReqCtxStub(
330
+ target: Headers,
331
+ reqCtx: ReturnType<typeof _getRequestContext>,
332
+ ): void {
333
+ if (!reqCtx) return;
334
+ const stubCookies = reqCtx.res.headers.getSetCookie();
335
+ if (stubCookies.length > 0) {
336
+ const existing = new Set(target.getSetCookie());
337
+ for (const cookie of stubCookies) {
338
+ if (!existing.has(cookie)) {
339
+ target.append("set-cookie", cookie);
340
+ }
341
+ }
342
+ }
343
+ reqCtx.res.headers.forEach((value, name) => {
344
+ if (name !== "set-cookie" && !target.has(name)) {
345
+ target.set(name, value);
346
+ }
347
+ });
348
+ }
349
+
310
350
  /**
311
351
  * Execute middleware chain
312
352
  *
@@ -345,36 +385,9 @@ export async function executeMiddleware<TEnv>(
345
385
  // End of chain - call actual RSC handler
346
386
  const response = await finalHandler();
347
387
 
348
- // Merge headers set on stub into the real response.
349
- // Use append for Set-Cookie to preserve multiple cookies.
350
388
  const mergedHeaders = new Headers(response.headers);
351
- stubResponse.headers.forEach((value, name) => {
352
- if (name.toLowerCase() === "set-cookie") {
353
- mergedHeaders.append(name, value);
354
- } else {
355
- mergedHeaders.set(name, value);
356
- }
357
- });
358
- // Also merge shared RequestContext stub (cookies written via cookies().set()).
359
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
360
- // may have already merged the same reqCtx cookies into the response.
361
- const reqCtx = _getRequestContext();
362
- if (reqCtx) {
363
- const stubCookies = reqCtx.res.headers.getSetCookie();
364
- if (stubCookies.length > 0) {
365
- const existing = new Set(mergedHeaders.getSetCookie());
366
- for (const cookie of stubCookies) {
367
- if (!existing.has(cookie)) {
368
- mergedHeaders.append("set-cookie", cookie);
369
- }
370
- }
371
- }
372
- reqCtx.res.headers.forEach((value, name) => {
373
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
374
- mergedHeaders.set(name, value);
375
- }
376
- });
377
- }
389
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, true);
390
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
378
391
 
379
392
  if (isWebSocketUpgradeResponse(response)) {
380
393
  responseHolder.response = response;
@@ -485,33 +498,8 @@ export async function executeMiddleware<TEnv>(
485
498
  return result;
486
499
  }
487
500
  const mergedHeaders = new Headers(result.headers);
488
- stubResponse.headers.forEach((value, name) => {
489
- if (name.toLowerCase() === "set-cookie") {
490
- mergedHeaders.append(name, value);
491
- } else if (!mergedHeaders.has(name)) {
492
- mergedHeaders.set(name, value);
493
- }
494
- });
495
- // Also merge shared RequestContext stub (cookies written via setCookie).
496
- // Dedup Set-Cookie: an inner executeMiddleware (route-level middleware)
497
- // may have already merged the same reqCtx cookies into the response.
498
- const reqCtx = _getRequestContext();
499
- if (reqCtx) {
500
- const stubCookies = reqCtx.res.headers.getSetCookie();
501
- if (stubCookies.length > 0) {
502
- const existing = new Set(mergedHeaders.getSetCookie());
503
- for (const cookie of stubCookies) {
504
- if (!existing.has(cookie)) {
505
- mergedHeaders.append("set-cookie", cookie);
506
- }
507
- }
508
- }
509
- reqCtx.res.headers.forEach((value, name) => {
510
- if (name !== "set-cookie" && !mergedHeaders.has(name)) {
511
- mergedHeaders.set(name, value);
512
- }
513
- });
514
- }
501
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
502
+ mergeReqCtxStub(mergedHeaders, _getRequestContext());
515
503
  const merged = new Response(result.body, {
516
504
  status: result.status,
517
505
  statusText: result.statusText,
@@ -565,21 +553,7 @@ export async function executeMiddleware<TEnv>(
565
553
  // set-cookie on an upgrade is not meaningful.
566
554
  const reqCtx = _getRequestContext();
567
555
  if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
568
- const stubCookies = reqCtx.res.headers.getSetCookie();
569
- if (stubCookies.length > 0) {
570
- const existingCookies = new Set(finalResponse.headers.getSetCookie());
571
- for (const cookie of stubCookies) {
572
- if (!existingCookies.has(cookie)) {
573
- finalResponse.headers.append("set-cookie", cookie);
574
- }
575
- }
576
- }
577
- // Fill in non-cookie headers that aren't already on the response
578
- reqCtx.res.headers.forEach((value, name) => {
579
- if (name !== "set-cookie" && !finalResponse.headers.has(name)) {
580
- finalResponse.headers.set(name, value);
581
- }
582
- });
556
+ mergeReqCtxStub(finalResponse.headers, reqCtx);
583
557
  }
584
558
 
585
559
  return finalResponse;
@@ -688,13 +662,7 @@ export async function executeInterceptMiddleware<TEnv>(
688
662
  // Only fill in missing headers — the returned Response's explicit
689
663
  // headers take precedence, matching executeMiddleware behavior.
690
664
  const mergedHeaders = new Headers(response.headers);
691
- stubResponse.headers.forEach((value, name) => {
692
- if (name.toLowerCase() === "set-cookie") {
693
- mergedHeaders.append(name, value);
694
- } else if (!mergedHeaders.has(name)) {
695
- mergedHeaders.set(name, value);
696
- }
697
- });
665
+ mergeStubHeaders(mergedHeaders, stubResponse.headers, false);
698
666
  return new Response(response.body, {
699
667
  status: response.status,
700
668
  statusText: response.statusText,
@@ -67,9 +67,11 @@ export async function previewMatch<TEnv = any>(
67
67
  responseType: negotiation.responseType,
68
68
  handler: negotiation.handler,
69
69
  params: matched.params,
70
- negotiated: true,
71
70
  manifestEntry: negotiation.manifestEntry,
72
71
  routeKey: matched.routeKey,
72
+ // omitted unless a variant negotiated, preserving the prior public
73
+ // shape (absent for plain response routes, not negotiated:false)
74
+ ...(negotiation.negotiated ? { negotiated: true } : {}),
73
75
  };
74
76
  }
75
77
 
@@ -278,33 +278,9 @@ async function classifyResponseRoute<TEnv>(
278
278
  pathname: string,
279
279
  snapshot: RouteSnapshot<TEnv>,
280
280
  ): Promise<ResponseRoutePlan<TEnv> | null> {
281
- const { manifestEntry, responseType } = snapshot;
282
-
281
+ // negotiateRoute returns the response plan (variant or plain) or null for RSC.
283
282
  const negotiation = await negotiateRoute(request, pathname, snapshot);
284
- if (negotiation) {
285
- return {
286
- mode: "response",
287
- route: snapshot,
288
- ...negotiation,
289
- };
290
- }
291
-
292
- // Non-negotiated response route (no variants, or RSC won negotiation)
293
- if (responseType) {
294
- const handler =
295
- manifestEntry.type === "route" ? manifestEntry.handler : undefined;
296
- if (handler) {
297
- return {
298
- mode: "response",
299
- route: snapshot,
300
- handler,
301
- responseType,
302
- negotiated: false,
303
- manifestEntry,
304
- routeMiddleware: snapshot.routeMiddleware,
305
- };
306
- }
307
- }
308
-
309
- return null;
283
+ return negotiation
284
+ ? { mode: "response", route: snapshot, ...negotiation }
285
+ : null;
310
286
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createElement } from "react";
11
- import { RouteNotFoundError } from "../errors.js";
11
+ import { isRouteNotFoundError } from "../errors.js";
12
12
  import { matchMiddleware, executeMiddleware } from "../router/middleware.js";
13
13
  import {
14
14
  runWithRequestContext,
@@ -66,7 +66,10 @@ import {
66
66
  type ActionContinuation,
67
67
  } from "./server-action.js";
68
68
  import { handleLoaderFetch } from "./loader-fetch.js";
69
- import { checkRequestOrigin, type OriginCheckPhase } from "./origin-guard.js";
69
+ import {
70
+ checkRequestOrigin,
71
+ ORIGIN_CHECK_PHASE_BY_MODE,
72
+ } from "./origin-guard.js";
70
73
  import { handleRscRendering } from "./rsc-rendering.js";
71
74
  import {
72
75
  withTimeout,
@@ -83,6 +86,7 @@ import {
83
86
  startSSRSetup,
84
87
  getSSRSetup,
85
88
  mayNeedSSR,
89
+ isRscRequest,
86
90
  SSR_SETUP_VAR,
87
91
  } from "./ssr-setup.js";
88
92
  import {
@@ -597,10 +601,7 @@ export function createRSCHandler<
597
601
  routerId: router.id,
598
602
  });
599
603
  } catch (error) {
600
- if (
601
- error instanceof RouteNotFoundError ||
602
- (error instanceof Error && error.name === "RouteNotFoundError")
603
- ) {
604
+ if (isRouteNotFoundError(error)) {
604
605
  // Let the render path handle 404 — match()/matchPartial() will
605
606
  // re-throw RouteNotFoundError and the catch block in
606
607
  // executeRenderWithMiddleware renders the not-found page.
@@ -651,14 +652,7 @@ export function createRSCHandler<
651
652
  }
652
653
 
653
654
  // ---- 3. Origin guard (gate for action/loader/PE modes) ----
654
- const originPhase: OriginCheckPhase | null =
655
- plan.mode === "action"
656
- ? "action"
657
- : plan.mode === "loader"
658
- ? "loader"
659
- : plan.mode === "pe-render"
660
- ? "pe-form"
661
- : null;
655
+ const originPhase = ORIGIN_CHECK_PHASE_BY_MODE[plan.mode];
662
656
  if (originPhase) {
663
657
  const originResult = await checkRequestOrigin(
664
658
  request,
@@ -925,47 +919,17 @@ export function createRSCHandler<
925
919
  );
926
920
  }
927
921
 
928
- // ---- Full render / Partial render (or PE that fell through) ----
929
- if (plan.mode === "full-render" || plan.mode === "partial-render") {
930
- const isPartial = plan.mode === "partial-render";
931
- return executeRenderWithMiddleware(
932
- plan.route.routeMiddleware,
933
- plan.negotiated,
934
- plan.route.routeKey,
935
- routeReverse,
936
- request,
937
- env,
938
- url,
939
- variables,
940
- nonce,
941
- handleStore,
942
- isPartial,
943
- );
944
- }
945
-
946
- // PE that fell through (handleProgressiveEnhancement returned null)
947
- // falls back to full render
948
- if (plan.mode === "pe-render") {
949
- return executeRenderWithMiddleware(
950
- plan.route.routeMiddleware,
951
- false,
952
- plan.route.routeKey,
953
- routeReverse,
954
- request,
955
- env,
956
- url,
957
- variables,
958
- nonce,
959
- handleStore,
960
- false,
961
- );
962
- }
963
-
964
- // Redirect plan that wasn't handled above (full-page redirect — let
965
- // the pipeline handle it via match() which returns { redirect: url })
922
+ // Full render, partial render, fallen-through PE, and full-page redirect all
923
+ // render through the same middleware-wrapped path. Only full/partial-render
924
+ // carry negotiation + the partial flag; pe/redirect render plainly.
925
+ const isPartial = plan.mode === "partial-render";
926
+ const negotiated =
927
+ plan.mode === "full-render" || plan.mode === "partial-render"
928
+ ? plan.negotiated
929
+ : false;
966
930
  return executeRenderWithMiddleware(
967
931
  plan.route.routeMiddleware,
968
- false,
932
+ negotiated,
969
933
  plan.route.routeKey,
970
934
  routeReverse,
971
935
  request,
@@ -974,7 +938,7 @@ export function createRSCHandler<
974
938
  variables,
975
939
  nonce,
976
940
  handleStore,
977
- false,
941
+ isPartial,
978
942
  );
979
943
  }
980
944
 
@@ -1054,10 +1018,7 @@ export function createRSCHandler<
1054
1018
  }
1055
1019
 
1056
1020
  // Render 404 page for unmatched routes
1057
- const isRouteNotFound =
1058
- error instanceof RouteNotFoundError ||
1059
- (error instanceof Error && error.name === "RouteNotFoundError");
1060
- if (isRouteNotFound) {
1021
+ if (isRouteNotFoundError(error)) {
1061
1022
  callOnError(error, "routing", {
1062
1023
  request,
1063
1024
  url,
@@ -1104,13 +1065,7 @@ export function createRSCHandler<
1104
1065
  },
1105
1066
  });
1106
1067
 
1107
- const isRscRequest =
1108
- isPartial ||
1109
- (!request.headers.get("accept")?.includes("text/html") &&
1110
- !url.searchParams.has("__html")) ||
1111
- url.searchParams.has("__rsc");
1112
-
1113
- if (isRscRequest) {
1068
+ if (isRscRequest(request, url, isPartial)) {
1114
1069
  return createResponseWithMergedHeaders(rscStream, {
1115
1070
  status: 404,
1116
1071
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -10,6 +10,7 @@ import {
10
10
  } from "../server/request-context.js";
11
11
  import type { RequestContext } from "../server/request-context.js";
12
12
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
13
+ import { isRedirectResponse } from "../response-utils.js";
13
14
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
14
15
 
15
16
  /**
@@ -145,10 +146,10 @@ export function interceptRedirectForPartial(
145
146
  locationState?: Record<string, unknown>,
146
147
  ) => Response,
147
148
  ): Response | null {
148
- const redirectUrl = response.headers.get("Location");
149
- if (!(response.status >= 300 && response.status < 400 && redirectUrl)) {
149
+ if (!isRedirectResponse(response)) {
150
150
  return null;
151
151
  }
152
+ const redirectUrl = response.headers.get("Location")!;
152
153
  const locationState = getLocationState();
153
154
  let intercepted: Response;
154
155
  if (locationState) {
@@ -9,11 +9,29 @@
9
9
  * navigations, bookmarks, and non-browser clients don't send Origin.
10
10
  */
11
11
 
12
+ import type { RequestPlan } from "../router/request-classification.js";
13
+
12
14
  /**
13
15
  * Request phase that triggered the origin check.
14
16
  */
15
17
  export type OriginCheckPhase = "action" | "loader" | "pe-form";
16
18
 
19
+ // Exhaustive over RequestPlan modes so a new mode must be classified here (the
20
+ // security gate) instead of silently falling through to no origin check.
21
+ export const ORIGIN_CHECK_PHASE_BY_MODE: Record<
22
+ RequestPlan["mode"],
23
+ OriginCheckPhase | null
24
+ > = {
25
+ action: "action",
26
+ loader: "loader",
27
+ "pe-render": "pe-form",
28
+ "full-render": null,
29
+ "partial-render": null,
30
+ response: null,
31
+ redirect: null,
32
+ "version-mismatch": null,
33
+ };
34
+
17
35
  /**
18
36
  * Context passed to the originCheck callback.
19
37
  */
@@ -116,14 +134,15 @@ export async function checkRequestOrigin<TEnv = any>(
116
134
  // Disabled by explicit opt-out
117
135
  if (config === false) return null;
118
136
 
119
- // Default: built-in validation (config === true or undefined)
120
- if (config === true || config === undefined) {
121
- const allowed = defaultOriginCheck(request, url);
122
- if (allowed) return null;
123
- return createForbiddenResponse(request);
124
- }
137
+ // Default (true/undefined) becomes a callback returning boolean, so the
138
+ // Response|true|reject resolution below is written once.
139
+ const check: (
140
+ ctx: OriginCheckContext<TEnv>,
141
+ ) => boolean | Response | Promise<boolean | Response> =
142
+ config === true || config === undefined
143
+ ? () => defaultOriginCheck(request, url)
144
+ : config;
125
145
 
126
- // Custom function — build context and call
127
146
  const ctx: OriginCheckContext<TEnv> = {
128
147
  request,
129
148
  url,
@@ -133,9 +152,8 @@ export async function checkRequestOrigin<TEnv = any>(
133
152
  defaultCheck: () => defaultOriginCheck(request, url),
134
153
  };
135
154
 
136
- const result = await config(ctx);
155
+ const result = await check(ctx);
137
156
 
138
157
  if (result instanceof Response) return result;
139
- if (result === true) return null;
140
- return createForbiddenResponse(request);
158
+ return result === true ? null : createForbiddenResponse(request);
141
159
  }
@@ -11,6 +11,7 @@ import { requireRequestContext } from "../server/request-context.js";
11
11
  import { contextGet } from "../context-var.js";
12
12
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
13
13
  import { traverseBack } from "../router/pattern-matching.js";
14
+ import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
14
15
  import { createCacheScope } from "../cache/cache-scope.js";
15
16
  import { executeMiddleware } from "../router/middleware.js";
16
17
  import {
@@ -121,13 +122,15 @@ export async function handleResponseRoute<TEnv>(
121
122
  });
122
123
  };
123
124
 
124
- // JSON response routes: wrap in { data } / { error } envelope
125
- if (preview.responseType === "json") {
126
- try {
127
- const result = await (preview.handler as Function)(responseHandlerCtx);
128
- if (result instanceof Response) {
129
- return rewrapResponse(result);
130
- }
125
+ try {
126
+ const result = await (preview.handler as Function)(responseHandlerCtx);
127
+
128
+ if (result instanceof Response) {
129
+ return rewrapResponse(result);
130
+ }
131
+
132
+ // Handled before the MIME lookup (json is also a RESPONSE_TYPE_MIME key).
133
+ if (preview.responseType === "json") {
131
134
  return createResponseWithMergedHeaders(
132
135
  JSON.stringify({ data: result }),
133
136
  {
@@ -135,10 +138,28 @@ export async function handleResponseRoute<TEnv>(
135
138
  headers: { "content-type": "application/json;charset=utf-8" },
136
139
  },
137
140
  );
138
- } catch (error) {
139
- handlerCtx.callOnError(error, "handler", errorCtx);
140
- const isDev = process.env.NODE_ENV !== "production";
141
- const status = error instanceof RouterError ? error.status : 500;
141
+ }
142
+
143
+ // Object.hasOwn (not truthiness) so prototype names like "toString" are not
144
+ // matched; image/stream/any are absent and fall through to the throw.
145
+ if (Object.hasOwn(RESPONSE_TYPE_MIME, preview.responseType)) {
146
+ return createResponseWithMergedHeaders(String(result), {
147
+ status: 200,
148
+ headers: {
149
+ "content-type": `${RESPONSE_TYPE_MIME[preview.responseType]};charset=utf-8`,
150
+ },
151
+ });
152
+ }
153
+
154
+ throw new Error(
155
+ `Response route handler for "${preview.responseType}" must return a Response object, got ${typeof result}`,
156
+ );
157
+ } catch (error) {
158
+ handlerCtx.callOnError(error, "handler", errorCtx);
159
+ const isDev = process.env.NODE_ENV !== "production";
160
+ const status = error instanceof RouterError ? error.status : 500;
161
+
162
+ if (preview.responseType === "json") {
142
163
  return createResponseWithMergedHeaders(
143
164
  JSON.stringify({
144
165
  error: createResponseErrorPayload(error, isDev),
@@ -149,48 +170,7 @@ export async function handleResponseRoute<TEnv>(
149
170
  },
150
171
  );
151
172
  }
152
- }
153
-
154
- // Non-JSON response routes: catch errors and return plain Response
155
- try {
156
- const result = await (preview.handler as Function)(responseHandlerCtx);
157
-
158
- if (result instanceof Response) {
159
- return rewrapResponse(result);
160
- }
161
173
 
162
- // Auto-wrap based on response type tag
163
- switch (preview.responseType) {
164
- case "text":
165
- return createResponseWithMergedHeaders(String(result), {
166
- status: 200,
167
- headers: { "content-type": "text/plain;charset=utf-8" },
168
- });
169
- case "html":
170
- return createResponseWithMergedHeaders(String(result), {
171
- status: 200,
172
- headers: { "content-type": "text/html;charset=utf-8" },
173
- });
174
- case "xml":
175
- return createResponseWithMergedHeaders(String(result), {
176
- status: 200,
177
- headers: { "content-type": "application/xml;charset=utf-8" },
178
- });
179
- case "md":
180
- return createResponseWithMergedHeaders(String(result), {
181
- status: 200,
182
- headers: { "content-type": "text/markdown;charset=utf-8" },
183
- });
184
- default:
185
- // image, stream, any -- must return Response
186
- throw new Error(
187
- `Response route handler for "${preview.responseType}" must return a Response object, got ${typeof result}`,
188
- );
189
- }
190
- } catch (error) {
191
- handlerCtx.callOnError(error, "handler", errorCtx);
192
- const isDev = process.env.NODE_ENV !== "production";
193
- const status = error instanceof RouterError ? error.status : 500;
194
174
  const message =
195
175
  error instanceof RouterError
196
176
  ? error.message
@@ -13,8 +13,9 @@ import {
13
13
  } from "../server/request-context.js";
14
14
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
15
15
  import { appendMetric } from "../router/metrics.js";
16
- import { getSSRSetup } from "./ssr-setup.js";
16
+ import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
17
17
  import type { RscPayload } from "./types.js";
18
+ import type { MatchResult } from "../types.js";
18
19
  import {
19
20
  createResponseWithMergedHeaders,
20
21
  createSimpleRedirectResponse,
@@ -35,6 +36,28 @@ export async function handleRscRendering<TEnv>(
35
36
  let payload: RscPayload;
36
37
  let hasInterceptSlots = false;
37
38
 
39
+ // Shared by the partial-fallback and full-render paths. The partial-success
40
+ // payload below is intentionally different (omits rootLayout/theme, adds slots).
41
+ const buildFullPayload = (m: MatchResult): RscPayload => ({
42
+ metadata: {
43
+ pathname: url.pathname,
44
+ routerId: ctx.router.id,
45
+ basename: ctx.router.basename,
46
+ segments: m.segments,
47
+ matched: m.matched,
48
+ diff: m.diff,
49
+ resolvedIds: m.resolvedIds,
50
+ params: m.params,
51
+ isPartial: false,
52
+ rootLayout: ctx.router.rootLayout,
53
+ handles: handleStore.stream(),
54
+ version: ctx.version,
55
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
56
+ themeConfig: ctx.router.themeConfig,
57
+ initialTheme: reqCtx.theme,
58
+ },
59
+ });
60
+
38
61
  if (isPartial) {
39
62
  // Partial render (navigation)
40
63
  const result = await ctx.router.matchPartial(request, { env });
@@ -51,25 +74,7 @@ export async function handleRscRendering<TEnv>(
51
74
  return createSimpleRedirectResponse(match.redirect);
52
75
  }
53
76
 
54
- payload = {
55
- metadata: {
56
- pathname: url.pathname,
57
- routerId: ctx.router.id,
58
- basename: ctx.router.basename,
59
- segments: match.segments,
60
- matched: match.matched,
61
- diff: match.diff,
62
- resolvedIds: match.resolvedIds,
63
- params: match.params,
64
- isPartial: false,
65
- rootLayout: ctx.router.rootLayout,
66
- handles: handleStore.stream(),
67
- version: ctx.version,
68
- prefetchCacheTTL: ctx.router.prefetchCacheTTL,
69
- themeConfig: ctx.router.themeConfig,
70
- initialTheme: reqCtx.theme,
71
- },
72
- };
77
+ payload = buildFullPayload(match);
73
78
  } else {
74
79
  setRequestContextParams(result.params, result.routeName);
75
80
 
@@ -135,28 +140,7 @@ export async function handleRscRendering<TEnv>(
135
140
  { headers: { "Content-Type": "application/json" } },
136
141
  );
137
142
  } else {
138
- payload = {
139
- // Initial SSR can reconstruct the tree from segments + rootLayout,
140
- // so we omit root to avoid sending the same structure twice.
141
-
142
- metadata: {
143
- pathname: url.pathname,
144
- routerId: ctx.router.id,
145
- basename: ctx.router.basename,
146
- segments: match.segments,
147
- matched: match.matched,
148
- diff: match.diff,
149
- resolvedIds: match.resolvedIds,
150
- params: match.params,
151
- isPartial: false,
152
- rootLayout: ctx.router.rootLayout,
153
- handles: handleStore.stream(),
154
- version: ctx.version,
155
- prefetchCacheTTL: ctx.router.prefetchCacheTTL,
156
- themeConfig: ctx.router.themeConfig,
157
- initialTheme: reqCtx.theme,
158
- },
159
- };
143
+ payload = buildFullPayload(match);
160
144
  }
161
145
  }
162
146
 
@@ -190,17 +174,7 @@ export async function handleRscRendering<TEnv>(
190
174
  rscSerializeDur,
191
175
  );
192
176
 
193
- // Determine if this is an RSC request or HTML request.
194
- // Partial requests (_rsc_partial) are always RSC -- they come from client-side
195
- // navigation or prefetch fetch(). We cannot rely on Accept alone since some
196
- // browsers may send Accept: text/html for non-HTML requests.
197
- const isRscRequest =
198
- isPartial ||
199
- (!request.headers.get("accept")?.includes("text/html") &&
200
- !url.searchParams.has("__html")) ||
201
- url.searchParams.has("__rsc");
202
-
203
- if (isRscRequest) {
177
+ if (isRscRequest(request, url, isPartial)) {
204
178
  const renderDur = performance.now() - renderStart;
205
179
  appendMetric(metricsStore, "render:total", renderStart, renderDur);
206
180
  const rscHeaders: Record<string, string> = {