vinext 0.0.9 → 0.0.11
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/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/client/entry.js +1 -15
- package/dist/client/entry.js.map +1 -1
- package/dist/client/validate-module-path.d.ts +15 -0
- package/dist/client/validate-module-path.d.ts.map +1 -0
- package/dist/client/validate-module-path.js +31 -0
- package/dist/client/validate-module-path.js.map +1 -0
- package/dist/config/config-matchers.d.ts +20 -0
- package/dist/config/config-matchers.d.ts.map +1 -1
- package/dist/config/config-matchers.js +185 -36
- package/dist/config/config-matchers.js.map +1 -1
- package/dist/config/next-config.d.ts +4 -0
- package/dist/config/next-config.d.ts.map +1 -1
- package/dist/config/next-config.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +20 -12
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +173 -155
- package/dist/index.js.map +1 -1
- package/dist/server/api-handler.d.ts.map +1 -1
- package/dist/server/api-handler.js +2 -1
- package/dist/server/api-handler.js.map +1 -1
- package/dist/server/app-dev-server.d.ts +2 -0
- package/dist/server/app-dev-server.d.ts.map +1 -1
- package/dist/server/app-dev-server.js +305 -159
- package/dist/server/app-dev-server.js.map +1 -1
- package/dist/server/app-router-entry.d.ts.map +1 -1
- package/dist/server/app-router-entry.js +16 -3
- package/dist/server/app-router-entry.js.map +1 -1
- package/dist/server/dev-origin-check.d.ts +61 -0
- package/dist/server/dev-origin-check.d.ts.map +1 -0
- package/dist/server/dev-origin-check.js +164 -0
- package/dist/server/dev-origin-check.js.map +1 -0
- package/dist/server/dev-server.d.ts +0 -2
- package/dist/server/dev-server.d.ts.map +1 -1
- package/dist/server/dev-server.js +390 -372
- package/dist/server/dev-server.js.map +1 -1
- package/dist/server/image-optimization.d.ts +32 -2
- package/dist/server/image-optimization.d.ts.map +1 -1
- package/dist/server/image-optimization.js +110 -9
- package/dist/server/image-optimization.js.map +1 -1
- package/dist/server/middleware-codegen.d.ts +41 -0
- package/dist/server/middleware-codegen.d.ts.map +1 -0
- package/dist/server/middleware-codegen.js +187 -0
- package/dist/server/middleware-codegen.js.map +1 -0
- package/dist/server/middleware.d.ts.map +1 -1
- package/dist/server/middleware.js +37 -19
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/normalize-path.d.ts +22 -0
- package/dist/server/normalize-path.d.ts.map +1 -0
- package/dist/server/normalize-path.js +50 -0
- package/dist/server/normalize-path.js.map +1 -0
- package/dist/server/prod-server.d.ts.map +1 -1
- package/dist/server/prod-server.js +95 -26
- package/dist/server/prod-server.js.map +1 -1
- package/dist/shims/cache-runtime.d.ts +7 -0
- package/dist/shims/cache-runtime.d.ts.map +1 -1
- package/dist/shims/cache-runtime.js +19 -15
- package/dist/shims/cache-runtime.js.map +1 -1
- package/dist/shims/cache.d.ts +8 -0
- package/dist/shims/cache.d.ts.map +1 -1
- package/dist/shims/cache.js +20 -15
- package/dist/shims/cache.js.map +1 -1
- package/dist/shims/fetch-cache.d.ts +2 -3
- package/dist/shims/fetch-cache.d.ts.map +1 -1
- package/dist/shims/fetch-cache.js +80 -9
- package/dist/shims/fetch-cache.js.map +1 -1
- package/dist/shims/head-state.d.ts +6 -1
- package/dist/shims/head-state.d.ts.map +1 -1
- package/dist/shims/head-state.js +18 -15
- package/dist/shims/head-state.js.map +1 -1
- package/dist/shims/head.d.ts.map +1 -1
- package/dist/shims/head.js +4 -1
- package/dist/shims/head.js.map +1 -1
- package/dist/shims/headers.d.ts +9 -13
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/headers.js +30 -49
- package/dist/shims/headers.js.map +1 -1
- package/dist/shims/image.d.ts.map +1 -1
- package/dist/shims/image.js +11 -2
- package/dist/shims/image.js.map +1 -1
- package/dist/shims/navigation-state.d.ts +6 -1
- package/dist/shims/navigation-state.d.ts.map +1 -1
- package/dist/shims/navigation-state.js +20 -29
- package/dist/shims/navigation-state.js.map +1 -1
- package/dist/shims/navigation.js +2 -2
- package/dist/shims/navigation.js.map +1 -1
- package/dist/shims/router-state.d.ts +6 -1
- package/dist/shims/router-state.d.ts.map +1 -1
- package/dist/shims/router-state.js +16 -21
- package/dist/shims/router-state.js.map +1 -1
- package/dist/shims/router.d.ts.map +1 -1
- package/dist/shims/router.js +19 -6
- package/dist/shims/router.js.map +1 -1
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* the SSR entry for HTML generation.
|
|
8
8
|
*/
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
+
import { generateDevOriginCheckCode } from "./dev-origin-check.js";
|
|
11
|
+
import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "./middleware-codegen.js";
|
|
10
12
|
/**
|
|
11
13
|
* Generate the virtual RSC entry module.
|
|
12
14
|
*
|
|
@@ -199,11 +201,11 @@ import { LayoutSegmentProvider } from "vinext/layout-segment-context";
|
|
|
199
201
|
import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
|
|
200
202
|
${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""}
|
|
201
203
|
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(new URL("./metadata-routes.js", import.meta.url).pathname.replace(/\\/g, "/"))};` : ""}
|
|
202
|
-
import { _consumeRequestScopedCacheLife,
|
|
204
|
+
import { _consumeRequestScopedCacheLife, _runWithCacheState } from "next/cache";
|
|
203
205
|
import { runWithFetchCache } from "vinext/fetch-cache";
|
|
204
|
-
import {
|
|
206
|
+
import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
|
|
205
207
|
// Import server-only state module to register ALS-backed accessors.
|
|
206
|
-
import "vinext/navigation-state";
|
|
208
|
+
import { runWithNavigationContext as _runWithNavigationContext } from "vinext/navigation-state";
|
|
207
209
|
import { reportRequestError as _reportRequestError } from "vinext/instrumentation";
|
|
208
210
|
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
|
|
209
211
|
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
|
|
@@ -224,15 +226,64 @@ function setNavigationContext(ctx) {
|
|
|
224
226
|
// based on export const revalidate for testing purposes.
|
|
225
227
|
// Production ISR is handled by prod-server.ts and the Cloudflare worker entry.
|
|
226
228
|
|
|
229
|
+
// djb2 hash — matches Next.js's stringHash for digest generation.
|
|
230
|
+
// Produces a stable numeric string from error message + stack.
|
|
231
|
+
function __errorDigest(str) {
|
|
232
|
+
let hash = 5381;
|
|
233
|
+
for (let i = str.length - 1; i >= 0; i--) {
|
|
234
|
+
hash = (hash * 33) ^ str.charCodeAt(i);
|
|
235
|
+
}
|
|
236
|
+
return (hash >>> 0).toString();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Sanitize an error for client consumption. In production, replaces the error
|
|
240
|
+
// with a generic Error that only carries a digest hash (matching Next.js
|
|
241
|
+
// behavior). In development, returns the original error for debugging.
|
|
242
|
+
// Navigation errors (redirect, notFound, etc.) are always passed through
|
|
243
|
+
// unchanged since their digests are used for client-side routing.
|
|
244
|
+
function __sanitizeErrorForClient(error) {
|
|
245
|
+
// Navigation errors must pass through with their digest intact
|
|
246
|
+
if (error && typeof error === "object" && "digest" in error) {
|
|
247
|
+
const digest = String(error.digest);
|
|
248
|
+
if (
|
|
249
|
+
digest.startsWith("NEXT_REDIRECT;") ||
|
|
250
|
+
digest === "NEXT_NOT_FOUND" ||
|
|
251
|
+
digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")
|
|
252
|
+
) {
|
|
253
|
+
return error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// In development, pass through the original error for debugging
|
|
257
|
+
if (process.env.NODE_ENV !== "production") {
|
|
258
|
+
return error;
|
|
259
|
+
}
|
|
260
|
+
// In production, create a sanitized error with only a digest hash
|
|
261
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
262
|
+
const stack = error instanceof Error ? (error.stack || "") : "";
|
|
263
|
+
const sanitized = new Error(
|
|
264
|
+
"An error occurred in the Server Components render. " +
|
|
265
|
+
"The specific message is omitted in production builds to avoid leaking sensitive details. " +
|
|
266
|
+
"A digest property is included on this error instance which may provide additional details about the nature of the error."
|
|
267
|
+
);
|
|
268
|
+
sanitized.digest = __errorDigest(msg + stack);
|
|
269
|
+
return sanitized;
|
|
270
|
+
}
|
|
271
|
+
|
|
227
272
|
// onError callback for renderToReadableStream — preserves the digest for
|
|
228
273
|
// Next.js navigation errors (redirect, notFound, forbidden, unauthorized)
|
|
229
274
|
// thrown during RSC streaming (e.g. inside Suspense boundaries).
|
|
230
|
-
//
|
|
231
|
-
//
|
|
275
|
+
// For non-navigation errors in production, generates a digest hash so the
|
|
276
|
+
// error can be correlated with server logs without leaking details.
|
|
232
277
|
function rscOnError(error) {
|
|
233
278
|
if (error && typeof error === "object" && "digest" in error) {
|
|
234
279
|
return String(error.digest);
|
|
235
280
|
}
|
|
281
|
+
// In production, generate a digest hash for non-navigation errors
|
|
282
|
+
if (process.env.NODE_ENV === "production" && error) {
|
|
283
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
284
|
+
const stack = error instanceof Error ? (error.stack || "") : "";
|
|
285
|
+
return __errorDigest(msg + stack);
|
|
286
|
+
}
|
|
236
287
|
return undefined;
|
|
237
288
|
}
|
|
238
289
|
|
|
@@ -333,7 +384,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
333
384
|
setNavigationContext(null);
|
|
334
385
|
return new Response(rscStream, {
|
|
335
386
|
status: statusCode,
|
|
336
|
-
headers: { "Content-Type": "text/x-component; charset=utf-8" },
|
|
387
|
+
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
337
388
|
});
|
|
338
389
|
}
|
|
339
390
|
// For HTML (full page load) responses, wrap with layouts only (no client-side
|
|
@@ -355,7 +406,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
355
406
|
const htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData);
|
|
356
407
|
setHeadersContext(null);
|
|
357
408
|
setNavigationContext(null);
|
|
358
|
-
const _respHeaders = { "Content-Type": "text/html; charset=utf-8" };
|
|
409
|
+
const _respHeaders = { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" };
|
|
359
410
|
const _linkParts = (fontData.preloads || []).map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; });
|
|
360
411
|
if (_linkParts.length > 0) _respHeaders["Link"] = _linkParts.join(", ");
|
|
361
412
|
return new Response(htmlStream, {
|
|
@@ -391,7 +442,11 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request) {
|
|
|
391
442
|
ErrorComponent = ErrorComponent${globalErrorVar ? ` ?? ${globalErrorVar}?.default` : ""};
|
|
392
443
|
if (!ErrorComponent) return null;
|
|
393
444
|
|
|
394
|
-
const
|
|
445
|
+
const rawError = error instanceof Error ? error : new Error(String(error));
|
|
446
|
+
// Sanitize the error in production to avoid leaking internal details
|
|
447
|
+
// (database errors, file paths, stack traces) through error.tsx to the client.
|
|
448
|
+
// In development, pass the original error for debugging.
|
|
449
|
+
const errorObj = __sanitizeErrorForClient(rawError);
|
|
395
450
|
// Only pass error — reset is a client-side concern (re-renders the segment) and
|
|
396
451
|
// can't be serialized through RSC. The error.tsx component will receive reset=undefined
|
|
397
452
|
// during SSR, which is fine — onClick={undefined} is harmless, and the real reset
|
|
@@ -428,7 +483,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request) {
|
|
|
428
483
|
setNavigationContext(null);
|
|
429
484
|
return new Response(rscStream, {
|
|
430
485
|
status: 200,
|
|
431
|
-
headers: { "Content-Type": "text/x-component; charset=utf-8" },
|
|
486
|
+
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
432
487
|
});
|
|
433
488
|
}
|
|
434
489
|
// For HTML (full page load) responses, wrap with layouts only.
|
|
@@ -449,7 +504,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request) {
|
|
|
449
504
|
const htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData);
|
|
450
505
|
setHeadersContext(null);
|
|
451
506
|
setNavigationContext(null);
|
|
452
|
-
const _errHeaders = { "Content-Type": "text/html; charset=utf-8" };
|
|
507
|
+
const _errHeaders = { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" };
|
|
453
508
|
const _errLinkParts = (fontData.preloads || []).map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; });
|
|
454
509
|
if (_errLinkParts.length > 0) _errHeaders["Link"] = _errLinkParts.join(", ");
|
|
455
510
|
return new Response(htmlStream, {
|
|
@@ -765,23 +820,7 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
765
820
|
return element;
|
|
766
821
|
}
|
|
767
822
|
|
|
768
|
-
${middlewarePath ?
|
|
769
|
-
function matchMiddlewarePath(pathname, matcher) {
|
|
770
|
-
if (!matcher) return true;
|
|
771
|
-
const patterns = typeof matcher === "string" ? [matcher]
|
|
772
|
-
: Array.isArray(matcher) ? matcher.map(m => typeof m === "string" ? m : m.source)
|
|
773
|
-
: [];
|
|
774
|
-
return patterns.some(pattern => {
|
|
775
|
-
const reStr = "^" + pattern
|
|
776
|
-
.replace(/\\./g, "\\\\.")
|
|
777
|
-
.replace(/:(\\w+)\\*/g, "(?:.*)")
|
|
778
|
-
.replace(/:(\\w+)\\+/g, "(?:.+)")
|
|
779
|
-
.replace(/:(\\w+)/g, "([^/]+)") + "$";
|
|
780
|
-
const re = __safeRegExp(reStr);
|
|
781
|
-
return re ? re.test(pathname) : false;
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
` : ""}
|
|
823
|
+
${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""}
|
|
785
824
|
|
|
786
825
|
const __basePath = ${JSON.stringify(bp)};
|
|
787
826
|
const __trailingSlash = ${JSON.stringify(ts)};
|
|
@@ -790,6 +829,8 @@ const __configRewrites = ${JSON.stringify(rewrites)};
|
|
|
790
829
|
const __configHeaders = ${JSON.stringify(headers)};
|
|
791
830
|
const __allowedOrigins = ${JSON.stringify(allowedOrigins)};
|
|
792
831
|
|
|
832
|
+
${generateDevOriginCheckCode(config?.allowedDevOrigins)}
|
|
833
|
+
|
|
793
834
|
// ── CSRF origin validation for server actions ───────────────────────────
|
|
794
835
|
// Matches Next.js behavior: compare the Origin header against the Host header.
|
|
795
836
|
// If they don't match, the request is rejected with 403 unless the origin is
|
|
@@ -822,8 +863,12 @@ function __validateCsrfOrigin(request) {
|
|
|
822
863
|
return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } });
|
|
823
864
|
}
|
|
824
865
|
|
|
866
|
+
// Only use the Host header for origin comparison — never trust
|
|
867
|
+
// X-Forwarded-Host here, since it can be freely set by the client
|
|
868
|
+
// and would allow the check to be bypassed if it matched a spoofed
|
|
869
|
+
// Origin. The prod server's resolveHost() handles trusted proxy
|
|
870
|
+
// scenarios separately.
|
|
825
871
|
const hostHeader = (
|
|
826
|
-
request.headers.get("x-forwarded-host") ||
|
|
827
872
|
request.headers.get("host") ||
|
|
828
873
|
""
|
|
829
874
|
).split(",")[0].trim().toLowerCase();
|
|
@@ -843,73 +888,10 @@ function __validateCsrfOrigin(request) {
|
|
|
843
888
|
}
|
|
844
889
|
|
|
845
890
|
// ── ReDoS-safe regex compilation ────────────────────────────────────────
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
while (i < pattern.length) {
|
|
851
|
-
const ch = pattern[i];
|
|
852
|
-
if (ch === "\\\\") { i += 2; continue; }
|
|
853
|
-
if (ch === "[") {
|
|
854
|
-
i++;
|
|
855
|
-
while (i < pattern.length && pattern[i] !== "]") {
|
|
856
|
-
if (pattern[i] === "\\\\") i++;
|
|
857
|
-
i++;
|
|
858
|
-
}
|
|
859
|
-
i++;
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (ch === "(") {
|
|
863
|
-
depth++;
|
|
864
|
-
if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false);
|
|
865
|
-
else quantifierAtDepth[depth] = false;
|
|
866
|
-
i++;
|
|
867
|
-
continue;
|
|
868
|
-
}
|
|
869
|
-
if (ch === ")") {
|
|
870
|
-
const hadQ = depth > 0 && quantifierAtDepth[depth];
|
|
871
|
-
if (depth > 0) depth--;
|
|
872
|
-
const next = pattern[i + 1];
|
|
873
|
-
if (next === "+" || next === "*" || next === "{") {
|
|
874
|
-
if (hadQ) return false;
|
|
875
|
-
if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true;
|
|
876
|
-
}
|
|
877
|
-
i++;
|
|
878
|
-
continue;
|
|
879
|
-
}
|
|
880
|
-
if (ch === "+" || ch === "*") {
|
|
881
|
-
if (depth > 0) quantifierAtDepth[depth] = true;
|
|
882
|
-
i++;
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
if (ch === "?") {
|
|
886
|
-
const prev = i > 0 ? pattern[i - 1] : "";
|
|
887
|
-
if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") {
|
|
888
|
-
if (depth > 0) quantifierAtDepth[depth] = true;
|
|
889
|
-
}
|
|
890
|
-
i++;
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
if (ch === "{") {
|
|
894
|
-
let j = i + 1;
|
|
895
|
-
while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++;
|
|
896
|
-
if (j < pattern.length && pattern[j] === "}" && j > i + 1) {
|
|
897
|
-
if (depth > 0) quantifierAtDepth[depth] = true;
|
|
898
|
-
i = j + 1;
|
|
899
|
-
continue;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
i++;
|
|
903
|
-
}
|
|
904
|
-
return true;
|
|
905
|
-
}
|
|
906
|
-
function __safeRegExp(pattern, flags) {
|
|
907
|
-
if (!__isSafeRegex(pattern)) {
|
|
908
|
-
console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern);
|
|
909
|
-
return null;
|
|
910
|
-
}
|
|
911
|
-
try { return new RegExp(pattern, flags); } catch { return null; }
|
|
912
|
-
}
|
|
891
|
+
${generateSafeRegExpCode("modern")}
|
|
892
|
+
|
|
893
|
+
// ── Path normalization ──────────────────────────────────────────────────
|
|
894
|
+
${generateNormalizePathCode("modern")}
|
|
913
895
|
|
|
914
896
|
// ── Config pattern matching (redirects, rewrites, headers) ──────────────
|
|
915
897
|
function __matchConfigPattern(pathname, pattern) {
|
|
@@ -1011,6 +993,12 @@ function __buildRequestContext(request) {
|
|
|
1011
993
|
};
|
|
1012
994
|
}
|
|
1013
995
|
|
|
996
|
+
function __sanitizeDestination(dest) {
|
|
997
|
+
if (dest.startsWith("http://") || dest.startsWith("https://")) return dest;
|
|
998
|
+
if (dest.startsWith("//")) dest = dest.replace(/^\\/\\/+/, "/");
|
|
999
|
+
return dest;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1014
1002
|
function __applyConfigRedirects(pathname, ctx) {
|
|
1015
1003
|
for (const rule of __configRedirects) {
|
|
1016
1004
|
const params = __matchConfigPattern(pathname, rule.source);
|
|
@@ -1018,6 +1006,7 @@ function __applyConfigRedirects(pathname, ctx) {
|
|
|
1018
1006
|
if (ctx && (rule.has || rule.missing)) { if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue; }
|
|
1019
1007
|
let dest = rule.destination;
|
|
1020
1008
|
for (const [key, value] of Object.entries(params)) { dest = dest.replace(":" + key + "*", value); dest = dest.replace(":" + key + "+", value); dest = dest.replace(":" + key, value); }
|
|
1009
|
+
dest = __sanitizeDestination(dest);
|
|
1021
1010
|
return { destination: dest, permanent: rule.permanent };
|
|
1022
1011
|
}
|
|
1023
1012
|
}
|
|
@@ -1031,6 +1020,7 @@ function __applyConfigRewrites(pathname, rules, ctx) {
|
|
|
1031
1020
|
if (ctx && (rule.has || rule.missing)) { if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue; }
|
|
1032
1021
|
let dest = rule.destination;
|
|
1033
1022
|
for (const [key, value] of Object.entries(params)) { dest = dest.replace(":" + key + "*", value); dest = dest.replace(":" + key + "+", value); dest = dest.replace(":" + key, value); }
|
|
1023
|
+
dest = __sanitizeDestination(dest);
|
|
1034
1024
|
return dest;
|
|
1035
1025
|
}
|
|
1036
1026
|
}
|
|
@@ -1041,6 +1031,71 @@ function __isExternalUrl(url) {
|
|
|
1041
1031
|
return url.startsWith("http://") || url.startsWith("https://");
|
|
1042
1032
|
}
|
|
1043
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Maximum server-action request body size (1 MB).
|
|
1036
|
+
* Matches the Next.js default for serverActions.bodySizeLimit.
|
|
1037
|
+
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions#bodysizelimit
|
|
1038
|
+
* Prevents unbounded request body buffering.
|
|
1039
|
+
*/
|
|
1040
|
+
var __MAX_ACTION_BODY_SIZE = 1 * 1024 * 1024;
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Read a request body as text with a size limit.
|
|
1044
|
+
* Enforces the limit on the actual byte stream to prevent bypasses
|
|
1045
|
+
* via chunked transfer-encoding where Content-Length is absent or spoofed.
|
|
1046
|
+
*/
|
|
1047
|
+
async function __readBodyWithLimit(request, maxBytes) {
|
|
1048
|
+
if (!request.body) return "";
|
|
1049
|
+
var reader = request.body.getReader();
|
|
1050
|
+
var decoder = new TextDecoder();
|
|
1051
|
+
var chunks = [];
|
|
1052
|
+
var totalSize = 0;
|
|
1053
|
+
for (;;) {
|
|
1054
|
+
var result = await reader.read();
|
|
1055
|
+
if (result.done) break;
|
|
1056
|
+
totalSize += result.value.byteLength;
|
|
1057
|
+
if (totalSize > maxBytes) {
|
|
1058
|
+
reader.cancel();
|
|
1059
|
+
throw new Error("Request body too large");
|
|
1060
|
+
}
|
|
1061
|
+
chunks.push(decoder.decode(result.value, { stream: true }));
|
|
1062
|
+
}
|
|
1063
|
+
chunks.push(decoder.decode());
|
|
1064
|
+
return chunks.join("");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Read a request body as FormData with a size limit.
|
|
1069
|
+
* Consumes the body stream with a byte counter and then parses the
|
|
1070
|
+
* collected bytes as multipart form data via the Response constructor.
|
|
1071
|
+
*/
|
|
1072
|
+
async function __readFormDataWithLimit(request, maxBytes) {
|
|
1073
|
+
if (!request.body) return new FormData();
|
|
1074
|
+
var reader = request.body.getReader();
|
|
1075
|
+
var chunks = [];
|
|
1076
|
+
var totalSize = 0;
|
|
1077
|
+
for (;;) {
|
|
1078
|
+
var result = await reader.read();
|
|
1079
|
+
if (result.done) break;
|
|
1080
|
+
totalSize += result.value.byteLength;
|
|
1081
|
+
if (totalSize > maxBytes) {
|
|
1082
|
+
reader.cancel();
|
|
1083
|
+
throw new Error("Request body too large");
|
|
1084
|
+
}
|
|
1085
|
+
chunks.push(result.value);
|
|
1086
|
+
}
|
|
1087
|
+
// Reconstruct a Response with the original Content-Type so that
|
|
1088
|
+
// the FormData parser can handle multipart boundaries correctly.
|
|
1089
|
+
var combined = new Uint8Array(totalSize);
|
|
1090
|
+
var offset = 0;
|
|
1091
|
+
for (var chunk of chunks) {
|
|
1092
|
+
combined.set(chunk, offset);
|
|
1093
|
+
offset += chunk.byteLength;
|
|
1094
|
+
}
|
|
1095
|
+
var contentType = request.headers.get("content-type") || "";
|
|
1096
|
+
return new Response(combined, { headers: { "Content-Type": contentType } }).formData();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1044
1099
|
const __hopByHopHeaders = new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailers","transfer-encoding","upgrade"]);
|
|
1045
1100
|
|
|
1046
1101
|
async function __proxyExternalRequest(request, externalUrl) {
|
|
@@ -1086,49 +1141,62 @@ function __applyConfigHeaders(pathname) {
|
|
|
1086
1141
|
}
|
|
1087
1142
|
|
|
1088
1143
|
export default async function handler(request) {
|
|
1089
|
-
// Wrap the entire request
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1144
|
+
// Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure
|
|
1145
|
+
// per-request isolation for all state modules. Each runWith*() creates an
|
|
1146
|
+
// ALS scope that propagates through all async continuations (including RSC
|
|
1147
|
+
// streaming), preventing state leakage between concurrent requests on
|
|
1148
|
+
// Cloudflare Workers and other concurrent runtimes.
|
|
1092
1149
|
const headersCtx = headersContextFromRequest(request);
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1150
|
+
return runWithHeadersContext(headersCtx, () =>
|
|
1151
|
+
_runWithNavigationContext(() =>
|
|
1152
|
+
_runWithCacheState(() =>
|
|
1153
|
+
_runWithPrivateCache(() =>
|
|
1154
|
+
runWithFetchCache(async () => {
|
|
1155
|
+
const response = await _handleRequest(request);
|
|
1156
|
+
// Apply custom headers from next.config.js to non-redirect responses.
|
|
1157
|
+
// Skip redirects (3xx) because Response.redirect() creates immutable headers,
|
|
1158
|
+
// and Next.js doesn't apply custom headers to redirects anyway.
|
|
1159
|
+
if (__configHeaders.length && response && response.headers && !(response.status >= 300 && response.status < 400)) {
|
|
1160
|
+
const url = new URL(request.url);
|
|
1161
|
+
let pathname = url.pathname;
|
|
1162
|
+
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
|
|
1163
|
+
const extraHeaders = __applyConfigHeaders(pathname);
|
|
1164
|
+
for (const h of extraHeaders) {
|
|
1165
|
+
response.headers.set(h.key, h.value);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return response;
|
|
1169
|
+
})
|
|
1170
|
+
)
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
);
|
|
1117
1174
|
}
|
|
1118
1175
|
|
|
1119
1176
|
async function _handleRequest(request) {
|
|
1120
1177
|
const url = new URL(request.url);
|
|
1121
|
-
let pathname = url.pathname;
|
|
1122
1178
|
|
|
1123
|
-
//
|
|
1179
|
+
// ── Cross-origin request protection ─────────────────────────────────
|
|
1180
|
+
// Block requests from non-localhost origins to prevent data exfiltration.
|
|
1181
|
+
const __originBlock = __validateDevRequestOrigin(request);
|
|
1182
|
+
if (__originBlock) return __originBlock;
|
|
1183
|
+
|
|
1184
|
+
// Guard against protocol-relative URL open redirects.
|
|
1124
1185
|
// Paths like //example.com/ would be redirected to //example.com by the
|
|
1125
1186
|
// trailing-slash normalizer, which browsers interpret as http://example.com.
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1187
|
+
// Backslashes are equivalent to forward slashes in the URL spec
|
|
1188
|
+
// (e.g. /\\evil.com is treated as //evil.com by browsers and the URL constructor).
|
|
1189
|
+
// Next.js returns 404 for these paths. Check the RAW pathname before
|
|
1190
|
+
// normalization so the guard fires before normalizePath collapses //.
|
|
1191
|
+
if (url.pathname.replaceAll("\\\\", "/").startsWith("//")) {
|
|
1129
1192
|
return new Response("404 Not Found", { status: 404 });
|
|
1130
1193
|
}
|
|
1131
1194
|
|
|
1195
|
+
// Decode percent-encoding and normalize pathname to canonical form.
|
|
1196
|
+
// decodeURIComponent prevents /%61dmin from bypassing /admin matchers.
|
|
1197
|
+
// __normalizePath collapses //foo///bar → /foo/bar, resolves . and .. segments.
|
|
1198
|
+
let pathname = __normalizePath(decodeURIComponent(url.pathname));
|
|
1199
|
+
|
|
1132
1200
|
${bp ? `
|
|
1133
1201
|
// Strip basePath prefix
|
|
1134
1202
|
if (__basePath && pathname.startsWith(__basePath)) {
|
|
@@ -1151,9 +1219,11 @@ async function _handleRequest(request) {
|
|
|
1151
1219
|
if (__configRedirects.length) {
|
|
1152
1220
|
const __redir = __applyConfigRedirects(pathname, __reqCtx);
|
|
1153
1221
|
if (__redir) {
|
|
1154
|
-
const __redirDest =
|
|
1155
|
-
|
|
1156
|
-
|
|
1222
|
+
const __redirDest = __sanitizeDestination(
|
|
1223
|
+
__basePath && !__redir.destination.startsWith(__basePath)
|
|
1224
|
+
? __basePath + __redir.destination
|
|
1225
|
+
: __redir.destination
|
|
1226
|
+
);
|
|
1157
1227
|
return new Response(null, {
|
|
1158
1228
|
status: __redir.permanent ? 308 : 307,
|
|
1159
1229
|
headers: { Location: __redirDest },
|
|
@@ -1186,10 +1256,18 @@ async function _handleRequest(request) {
|
|
|
1186
1256
|
// Run proxy/middleware if present and path matches
|
|
1187
1257
|
const middlewareFn = middlewareModule.default || middlewareModule.proxy || middlewareModule.middleware;
|
|
1188
1258
|
const middlewareMatcher = middlewareModule.config?.matcher;
|
|
1189
|
-
if (typeof middlewareFn === "function" &&
|
|
1259
|
+
if (typeof middlewareFn === "function" && matchesMiddleware(cleanPathname, middlewareMatcher)) {
|
|
1190
1260
|
try {
|
|
1191
1261
|
// Wrap in NextRequest so middleware gets .nextUrl, .cookies, .geo, .ip, etc.
|
|
1192
|
-
|
|
1262
|
+
// Strip .rsc suffix from the URL — it's an internal transport detail that
|
|
1263
|
+
// middleware should never see (matches Next.js behavior).
|
|
1264
|
+
let mwRequest = request;
|
|
1265
|
+
if (isRscRequest && pathname.endsWith(".rsc")) {
|
|
1266
|
+
const mwUrl = new URL(request.url);
|
|
1267
|
+
mwUrl.pathname = cleanPathname;
|
|
1268
|
+
mwRequest = new Request(mwUrl, request);
|
|
1269
|
+
}
|
|
1270
|
+
const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest);
|
|
1193
1271
|
const mwResponse = await middlewareFn(nextRequest);
|
|
1194
1272
|
if (mwResponse) {
|
|
1195
1273
|
// Check for x-middleware-next (continue)
|
|
@@ -1236,12 +1314,13 @@ async function _handleRequest(request) {
|
|
|
1236
1314
|
|
|
1237
1315
|
// Unpack x-middleware-request-* headers into the request context so that
|
|
1238
1316
|
// headers() returns the middleware-modified headers instead of the original
|
|
1239
|
-
// request headers.
|
|
1240
|
-
// be merged into the outgoing HTTP response
|
|
1317
|
+
// request headers. Strip ALL x-middleware-* headers from the set that will
|
|
1318
|
+
// be merged into the outgoing HTTP response — this prefix is reserved for
|
|
1319
|
+
// internal routing signals and must never reach clients.
|
|
1241
1320
|
if (_middlewareResponseHeaders) {
|
|
1242
1321
|
applyMiddlewareRequestHeaders(_middlewareResponseHeaders);
|
|
1243
1322
|
for (const key of [..._middlewareResponseHeaders.keys()]) {
|
|
1244
|
-
if (key.startsWith("x-middleware-
|
|
1323
|
+
if (key.startsWith("x-middleware-")) {
|
|
1245
1324
|
_middlewareResponseHeaders.delete(key);
|
|
1246
1325
|
}
|
|
1247
1326
|
}
|
|
@@ -1250,14 +1329,22 @@ async function _handleRequest(request) {
|
|
|
1250
1329
|
|
|
1251
1330
|
// ── Image optimization passthrough (dev mode — no transformation) ───────
|
|
1252
1331
|
if (cleanPathname === "/_vinext/image") {
|
|
1253
|
-
const
|
|
1332
|
+
const __rawImgUrl = url.searchParams.get("url");
|
|
1333
|
+
// Normalize backslashes: browsers and the URL constructor treat
|
|
1334
|
+
// /\\evil.com as protocol-relative (//evil.com), bypassing the // check.
|
|
1335
|
+
const __imgUrl = __rawImgUrl?.replaceAll("\\\\", "/") ?? null;
|
|
1254
1336
|
// Allowlist: must start with "/" but not "//" — blocks absolute URLs,
|
|
1255
|
-
// protocol-relative, and exotic schemes
|
|
1337
|
+
// protocol-relative, backslash variants, and exotic schemes.
|
|
1256
1338
|
if (!__imgUrl || !__imgUrl.startsWith("/") || __imgUrl.startsWith("//")) {
|
|
1257
|
-
return new Response(!
|
|
1339
|
+
return new Response(!__rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed", { status: 400 });
|
|
1340
|
+
}
|
|
1341
|
+
// Validate the constructed URL's origin hasn't changed (defense in depth).
|
|
1342
|
+
const __resolvedImg = new URL(__imgUrl, request.url);
|
|
1343
|
+
if (__resolvedImg.origin !== url.origin) {
|
|
1344
|
+
return new Response("Only relative URLs allowed", { status: 400 });
|
|
1258
1345
|
}
|
|
1259
1346
|
// In dev, redirect to the original asset URL so Vite's static serving handles it.
|
|
1260
|
-
return Response.redirect(
|
|
1347
|
+
return Response.redirect(__resolvedImg.href, 302);
|
|
1261
1348
|
}
|
|
1262
1349
|
|
|
1263
1350
|
// Handle metadata routes (sitemap.xml, robots.txt, manifest.webmanifest, etc.)
|
|
@@ -1315,11 +1402,33 @@ async function _handleRequest(request) {
|
|
|
1315
1402
|
// cross-site request forgery, matching Next.js server action behavior.
|
|
1316
1403
|
const csrfResponse = __validateCsrfOrigin(request);
|
|
1317
1404
|
if (csrfResponse) return csrfResponse;
|
|
1405
|
+
|
|
1406
|
+
// ── Body size limit ─────────────────────────────────────────────────
|
|
1407
|
+
// Reject payloads larger than the configured limit.
|
|
1408
|
+
// Check Content-Length as a fast path, then enforce on the actual
|
|
1409
|
+
// stream to prevent bypasses via chunked transfer-encoding.
|
|
1410
|
+
const contentLength = parseInt(request.headers.get("content-length") || "0", 10);
|
|
1411
|
+
if (contentLength > __MAX_ACTION_BODY_SIZE) {
|
|
1412
|
+
setHeadersContext(null);
|
|
1413
|
+
setNavigationContext(null);
|
|
1414
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1318
1417
|
try {
|
|
1319
1418
|
const contentType = request.headers.get("content-type") || "";
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1419
|
+
let body;
|
|
1420
|
+
try {
|
|
1421
|
+
body = contentType.startsWith("multipart/form-data")
|
|
1422
|
+
? await __readFormDataWithLimit(request, __MAX_ACTION_BODY_SIZE)
|
|
1423
|
+
: await __readBodyWithLimit(request, __MAX_ACTION_BODY_SIZE);
|
|
1424
|
+
} catch (sizeErr) {
|
|
1425
|
+
if (sizeErr && sizeErr.message === "Request body too large") {
|
|
1426
|
+
setHeadersContext(null);
|
|
1427
|
+
setNavigationContext(null);
|
|
1428
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
1429
|
+
}
|
|
1430
|
+
throw sizeErr;
|
|
1431
|
+
}
|
|
1323
1432
|
const temporaryReferences = createTemporaryReferenceSet();
|
|
1324
1433
|
const args = await decodeReply(body, { temporaryReferences });
|
|
1325
1434
|
const action = await loadServerAction(actionId);
|
|
@@ -1331,12 +1440,14 @@ async function _handleRequest(request) {
|
|
|
1331
1440
|
} catch (e) {
|
|
1332
1441
|
// Detect redirect() / permanentRedirect() called inside the action.
|
|
1333
1442
|
// These throw errors with digest "NEXT_REDIRECT;replace;url[;status]".
|
|
1443
|
+
// The URL is encodeURIComponent-encoded to prevent semicolons in the URL
|
|
1444
|
+
// from corrupting the delimiter-based digest format.
|
|
1334
1445
|
if (e && typeof e === "object" && "digest" in e) {
|
|
1335
1446
|
const digest = String(e.digest);
|
|
1336
1447
|
if (digest.startsWith("NEXT_REDIRECT;")) {
|
|
1337
1448
|
const parts = digest.split(";");
|
|
1338
1449
|
actionRedirect = {
|
|
1339
|
-
url: parts[2],
|
|
1450
|
+
url: decodeURIComponent(parts[2]),
|
|
1340
1451
|
type: parts[1] || "replace", // "push" or "replace"
|
|
1341
1452
|
status: parts[3] ? parseInt(parts[3], 10) : 307,
|
|
1342
1453
|
};
|
|
@@ -1345,10 +1456,16 @@ async function _handleRequest(request) {
|
|
|
1345
1456
|
// notFound() / forbidden() / unauthorized() in action — package as error
|
|
1346
1457
|
returnValue = { ok: false, data: e };
|
|
1347
1458
|
} else {
|
|
1348
|
-
|
|
1459
|
+
// Non-navigation digest error — sanitize in production to avoid
|
|
1460
|
+
// leaking internal details (connection strings, paths, etc.)
|
|
1461
|
+
console.error("[vinext] Server action error:", e);
|
|
1462
|
+
returnValue = { ok: false, data: __sanitizeErrorForClient(e) };
|
|
1349
1463
|
}
|
|
1350
1464
|
} else {
|
|
1351
|
-
|
|
1465
|
+
// Unhandled error — sanitize in production to avoid leaking
|
|
1466
|
+
// internal details (database errors, file paths, stack traces, etc.)
|
|
1467
|
+
console.error("[vinext] Server action error:", e);
|
|
1468
|
+
returnValue = { ok: false, data: __sanitizeErrorForClient(e) };
|
|
1352
1469
|
}
|
|
1353
1470
|
}
|
|
1354
1471
|
|
|
@@ -1363,6 +1480,7 @@ async function _handleRequest(request) {
|
|
|
1363
1480
|
setNavigationContext(null);
|
|
1364
1481
|
const redirectHeaders = new Headers({
|
|
1365
1482
|
"Content-Type": "text/x-component; charset=utf-8",
|
|
1483
|
+
"Vary": "RSC, Accept",
|
|
1366
1484
|
"x-action-redirect": actionRedirect.url,
|
|
1367
1485
|
"x-action-redirect-type": actionRedirect.type,
|
|
1368
1486
|
"x-action-redirect-status": String(actionRedirect.status),
|
|
@@ -1402,7 +1520,7 @@ async function _handleRequest(request) {
|
|
|
1402
1520
|
setHeadersContext(null);
|
|
1403
1521
|
setNavigationContext(null);
|
|
1404
1522
|
|
|
1405
|
-
const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8" };
|
|
1523
|
+
const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
|
|
1406
1524
|
const actionResponse = new Response(rscStream, { headers: actionHeaders });
|
|
1407
1525
|
if (actionPendingCookies.length > 0 || actionDraftCookie) {
|
|
1408
1526
|
for (const cookie of actionPendingCookies) {
|
|
@@ -1559,7 +1677,7 @@ async function _handleRequest(request) {
|
|
|
1559
1677
|
const digest = String(err.digest);
|
|
1560
1678
|
if (digest.startsWith("NEXT_REDIRECT;")) {
|
|
1561
1679
|
const parts = digest.split(";");
|
|
1562
|
-
const redirectUrl = parts[2];
|
|
1680
|
+
const redirectUrl = decodeURIComponent(parts[2]);
|
|
1563
1681
|
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
|
|
1564
1682
|
setHeadersContext(null);
|
|
1565
1683
|
setNavigationContext(null);
|
|
@@ -1705,7 +1823,7 @@ async function _handleRequest(request) {
|
|
|
1705
1823
|
setHeadersContext(null);
|
|
1706
1824
|
setNavigationContext(null);
|
|
1707
1825
|
return new Response(interceptStream, {
|
|
1708
|
-
headers: { "Content-Type": "text/x-component; charset=utf-8" },
|
|
1826
|
+
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
1709
1827
|
});
|
|
1710
1828
|
}
|
|
1711
1829
|
// If sourceRoute === route, apply intercept opts to the normal render
|
|
@@ -1726,7 +1844,7 @@ async function _handleRequest(request) {
|
|
|
1726
1844
|
const digest = String(buildErr.digest);
|
|
1727
1845
|
if (digest.startsWith("NEXT_REDIRECT;")) {
|
|
1728
1846
|
const parts = digest.split(";");
|
|
1729
|
-
const redirectUrl = parts[2];
|
|
1847
|
+
const redirectUrl = decodeURIComponent(parts[2]);
|
|
1730
1848
|
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
|
|
1731
1849
|
setHeadersContext(null);
|
|
1732
1850
|
setNavigationContext(null);
|
|
@@ -1757,7 +1875,7 @@ async function _handleRequest(request) {
|
|
|
1757
1875
|
const digest = String(err.digest);
|
|
1758
1876
|
if (digest.startsWith("NEXT_REDIRECT;")) {
|
|
1759
1877
|
const parts = digest.split(";");
|
|
1760
|
-
const redirectUrl = parts[2];
|
|
1878
|
+
const redirectUrl = decodeURIComponent(parts[2]);
|
|
1761
1879
|
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
|
|
1762
1880
|
setHeadersContext(null);
|
|
1763
1881
|
setNavigationContext(null);
|
|
@@ -1799,13 +1917,13 @@ async function _handleRequest(request) {
|
|
|
1799
1917
|
} catch (layoutErr) {
|
|
1800
1918
|
if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
|
|
1801
1919
|
const digest = String(layoutErr.digest);
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1920
|
+
if (digest.startsWith("NEXT_REDIRECT;")) {
|
|
1921
|
+
const parts = digest.split(";");
|
|
1922
|
+
const redirectUrl = decodeURIComponent(parts[2]);
|
|
1923
|
+
const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
|
|
1924
|
+
setHeadersContext(null);
|
|
1925
|
+
setNavigationContext(null);
|
|
1926
|
+
return Response.redirect(new URL(redirectUrl, request.url), statusCode);
|
|
1809
1927
|
}
|
|
1810
1928
|
if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
|
|
1811
1929
|
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
|
|
@@ -1891,7 +2009,7 @@ async function _handleRequest(request) {
|
|
|
1891
2009
|
// The RSC stream is consumed lazily - components render when chunks are read.
|
|
1892
2010
|
// If we clear context now, headers()/cookies() will fail during rendering.
|
|
1893
2011
|
// Context will be cleared when the next request starts (via runWithHeadersContext).
|
|
1894
|
-
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8" };
|
|
2012
|
+
const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
|
|
1895
2013
|
// Include matched route params so the client can hydrate useParams()
|
|
1896
2014
|
if (params && Object.keys(params).length > 0) {
|
|
1897
2015
|
responseHeaders["X-Vinext-Params"] = JSON.stringify(params);
|
|
@@ -1993,6 +2111,7 @@ async function _handleRequest(request) {
|
|
|
1993
2111
|
headers: {
|
|
1994
2112
|
"Content-Type": "text/html; charset=utf-8",
|
|
1995
2113
|
"Cache-Control": "no-store, must-revalidate",
|
|
2114
|
+
"Vary": "RSC, Accept",
|
|
1996
2115
|
},
|
|
1997
2116
|
}));
|
|
1998
2117
|
}
|
|
@@ -2008,6 +2127,7 @@ async function _handleRequest(request) {
|
|
|
2008
2127
|
"Content-Type": "text/html; charset=utf-8",
|
|
2009
2128
|
"Cache-Control": "s-maxage=31536000, stale-while-revalidate",
|
|
2010
2129
|
"X-Vinext-Cache": "STATIC",
|
|
2130
|
+
"Vary": "RSC, Accept",
|
|
2011
2131
|
},
|
|
2012
2132
|
}));
|
|
2013
2133
|
}
|
|
@@ -2019,6 +2139,7 @@ async function _handleRequest(request) {
|
|
|
2019
2139
|
headers: {
|
|
2020
2140
|
"Content-Type": "text/html; charset=utf-8",
|
|
2021
2141
|
"Cache-Control": "no-store, must-revalidate",
|
|
2142
|
+
"Vary": "RSC, Accept",
|
|
2022
2143
|
},
|
|
2023
2144
|
}));
|
|
2024
2145
|
}
|
|
@@ -2030,12 +2151,13 @@ async function _handleRequest(request) {
|
|
|
2030
2151
|
headers: {
|
|
2031
2152
|
"Content-Type": "text/html; charset=utf-8",
|
|
2032
2153
|
"Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate",
|
|
2154
|
+
"Vary": "RSC, Accept",
|
|
2033
2155
|
},
|
|
2034
2156
|
}));
|
|
2035
2157
|
}
|
|
2036
2158
|
|
|
2037
2159
|
return attachMiddlewareContext(new Response(htmlStream, {
|
|
2038
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
2160
|
+
headers: { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" },
|
|
2039
2161
|
}));
|
|
2040
2162
|
}
|
|
2041
2163
|
|
|
@@ -2055,6 +2177,7 @@ export function generateSsrEntry() {
|
|
|
2055
2177
|
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
|
|
2056
2178
|
import { renderToReadableStream } from "react-dom/server.edge";
|
|
2057
2179
|
import { setNavigationContext } from "next/navigation";
|
|
2180
|
+
import { runWithNavigationContext as _runWithNavCtx } from "vinext/navigation-state";
|
|
2058
2181
|
import { safeJsonStringify } from "vinext/html";
|
|
2059
2182
|
|
|
2060
2183
|
/**
|
|
@@ -2180,6 +2303,10 @@ function createRscEmbedTransform(embedStream) {
|
|
|
2180
2303
|
* and the data needs to be passed to SSR since they're separate module instances.
|
|
2181
2304
|
*/
|
|
2182
2305
|
export async function handleSsr(rscStream, navContext, fontData) {
|
|
2306
|
+
// Wrap in a navigation ALS scope for per-request isolation in the SSR
|
|
2307
|
+
// environment. The SSR environment has separate module instances from RSC,
|
|
2308
|
+
// so it needs its own ALS scope.
|
|
2309
|
+
return _runWithNavCtx(async () => {
|
|
2183
2310
|
// Set navigation context so hooks like usePathname() work during SSR
|
|
2184
2311
|
// of "use client" components
|
|
2185
2312
|
if (navContext) {
|
|
@@ -2213,6 +2340,16 @@ export async function handleSsr(rscStream, navContext, fontData) {
|
|
|
2213
2340
|
const bootstrapScriptContent =
|
|
2214
2341
|
await import.meta.viteRsc.loadBootstrapScriptContent("index");
|
|
2215
2342
|
|
|
2343
|
+
// djb2 hash for digest generation in the SSR environment.
|
|
2344
|
+
// Matches the RSC environment's __errorDigest function.
|
|
2345
|
+
function ssrErrorDigest(str) {
|
|
2346
|
+
let hash = 5381;
|
|
2347
|
+
for (let i = str.length - 1; i >= 0; i--) {
|
|
2348
|
+
hash = (hash * 33) ^ str.charCodeAt(i);
|
|
2349
|
+
}
|
|
2350
|
+
return (hash >>> 0).toString();
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2216
2353
|
// Render HTML (streaming SSR)
|
|
2217
2354
|
// useServerInsertedHTML callbacks are registered during this render.
|
|
2218
2355
|
// The onError callback preserves the digest for Next.js navigation errors
|
|
@@ -2220,12 +2357,20 @@ export async function handleSsr(rscStream, navContext, fontData) {
|
|
|
2220
2357
|
// boundaries during RSC streaming. Without this, React's default onError
|
|
2221
2358
|
// returns undefined and the digest is lost in the $RX() call, preventing
|
|
2222
2359
|
// client-side error boundaries from identifying the error type.
|
|
2360
|
+
// In production, non-navigation errors also get a digest hash so they
|
|
2361
|
+
// can be correlated with server logs without leaking details to clients.
|
|
2223
2362
|
const htmlStream = await renderToReadableStream(root, {
|
|
2224
2363
|
bootstrapScriptContent,
|
|
2225
2364
|
onError(error) {
|
|
2226
2365
|
if (error && typeof error === "object" && "digest" in error) {
|
|
2227
2366
|
return String(error.digest);
|
|
2228
2367
|
}
|
|
2368
|
+
// In production, generate a digest hash for non-navigation errors
|
|
2369
|
+
if (process.env.NODE_ENV === "production" && error) {
|
|
2370
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2371
|
+
const stack = error instanceof Error ? (error.stack || "") : "";
|
|
2372
|
+
return ssrErrorDigest(msg + stack);
|
|
2373
|
+
}
|
|
2229
2374
|
return undefined;
|
|
2230
2375
|
},
|
|
2231
2376
|
});
|
|
@@ -2405,6 +2550,7 @@ export async function handleSsr(rscStream, navContext, fontData) {
|
|
|
2405
2550
|
setNavigationContext(null);
|
|
2406
2551
|
clearServerInsertedHTML();
|
|
2407
2552
|
}
|
|
2553
|
+
}); // end _runWithNavCtx
|
|
2408
2554
|
}
|
|
2409
2555
|
`;
|
|
2410
2556
|
}
|