@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.
- package/dist/bin/rango.js +41 -37
- package/dist/vite/index.js +144 -191
- package/package.json +3 -1
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/event-controller.ts +42 -66
- package/src/browser/navigation-bridge.ts +4 -0
- package/src/browser/navigation-client.ts +12 -15
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +7 -21
- package/src/browser/partial-update.ts +8 -16
- package/src/browser/react/NavigationProvider.tsx +29 -40
- package/src/browser/react/use-params.ts +3 -4
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +16 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +2 -0
- package/src/build/generate-manifest.ts +29 -31
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/router-processing.ts +37 -9
- package/src/build/runtime-discovery.ts +9 -20
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +11 -0
- package/src/response-utils.ts +9 -0
- package/src/route-content-wrapper.tsx +6 -28
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/match-result.ts +32 -30
- package/src/router/middleware.ts +46 -78
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/rsc/handler.ts +20 -65
- package/src/rsc/helpers.ts +3 -2
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/response-route-handler.ts +32 -52
- package/src/rsc/rsc-rendering.ts +27 -53
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +13 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/segment-system.tsx +5 -39
- package/src/vite/discovery/discover-routers.ts +10 -22
- package/src/vite/discovery/route-types-writer.ts +38 -82
- package/src/vite/plugins/cjs-to-esm.ts +3 -7
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-internal-ids.ts +34 -62
- package/src/vite/plugins/version-injector.ts +2 -12
- package/src/vite/router-discovery.ts +71 -26
- package/src/vite/utils/shared-utils.ts +13 -1
- package/src/browser/action-response-classifier.ts +0 -99
package/src/router/middleware.ts
CHANGED
|
@@ -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
|
|
352
|
-
|
|
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
|
|
489
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
281
|
+
// negotiateRoute returns the response plan (variant or plain) or null for RSC.
|
|
283
282
|
const negotiation = await negotiateRoute(request, pathname, snapshot);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
}
|
package/src/rsc/handler.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createElement } from "react";
|
|
11
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
plan.
|
|
935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" },
|
package/src/rsc/helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/rsc/origin-guard.ts
CHANGED
|
@@ -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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
155
|
+
const result = await check(ctx);
|
|
137
156
|
|
|
138
157
|
if (result instanceof Response) return result;
|
|
139
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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
|
-
|
|
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> = {
|