vinext 0.0.8 → 0.0.10

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 (99) hide show
  1. package/README.md +30 -1
  2. package/dist/cli.js +13 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/entry.js +1 -15
  5. package/dist/client/entry.js.map +1 -1
  6. package/dist/client/validate-module-path.d.ts +15 -0
  7. package/dist/client/validate-module-path.d.ts.map +1 -0
  8. package/dist/client/validate-module-path.js +31 -0
  9. package/dist/client/validate-module-path.js.map +1 -0
  10. package/dist/config/config-matchers.d.ts +12 -0
  11. package/dist/config/config-matchers.d.ts.map +1 -1
  12. package/dist/config/config-matchers.js +28 -0
  13. package/dist/config/config-matchers.js.map +1 -1
  14. package/dist/config/dotenv.d.ts +40 -0
  15. package/dist/config/dotenv.d.ts.map +1 -0
  16. package/dist/config/dotenv.js +100 -0
  17. package/dist/config/dotenv.js.map +1 -0
  18. package/dist/config/next-config.d.ts +4 -0
  19. package/dist/config/next-config.d.ts.map +1 -1
  20. package/dist/config/next-config.js.map +1 -1
  21. package/dist/deploy.d.ts.map +1 -1
  22. package/dist/deploy.js +16 -8
  23. package/dist/deploy.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +99 -111
  26. package/dist/index.js.map +1 -1
  27. package/dist/server/api-handler.d.ts.map +1 -1
  28. package/dist/server/api-handler.js +2 -1
  29. package/dist/server/api-handler.js.map +1 -1
  30. package/dist/server/app-dev-server.d.ts +2 -0
  31. package/dist/server/app-dev-server.d.ts.map +1 -1
  32. package/dist/server/app-dev-server.js +292 -155
  33. package/dist/server/app-dev-server.js.map +1 -1
  34. package/dist/server/app-router-entry.d.ts.map +1 -1
  35. package/dist/server/app-router-entry.js +16 -3
  36. package/dist/server/app-router-entry.js.map +1 -1
  37. package/dist/server/dev-origin-check.d.ts +61 -0
  38. package/dist/server/dev-origin-check.d.ts.map +1 -0
  39. package/dist/server/dev-origin-check.js +164 -0
  40. package/dist/server/dev-origin-check.js.map +1 -0
  41. package/dist/server/dev-server.d.ts +0 -2
  42. package/dist/server/dev-server.d.ts.map +1 -1
  43. package/dist/server/dev-server.js +379 -372
  44. package/dist/server/dev-server.js.map +1 -1
  45. package/dist/server/image-optimization.d.ts +32 -2
  46. package/dist/server/image-optimization.d.ts.map +1 -1
  47. package/dist/server/image-optimization.js +110 -9
  48. package/dist/server/image-optimization.js.map +1 -1
  49. package/dist/server/middleware-codegen.d.ts +41 -0
  50. package/dist/server/middleware-codegen.d.ts.map +1 -0
  51. package/dist/server/middleware-codegen.js +181 -0
  52. package/dist/server/middleware-codegen.js.map +1 -0
  53. package/dist/server/middleware.d.ts.map +1 -1
  54. package/dist/server/middleware.js +12 -7
  55. package/dist/server/middleware.js.map +1 -1
  56. package/dist/server/normalize-path.d.ts +22 -0
  57. package/dist/server/normalize-path.d.ts.map +1 -0
  58. package/dist/server/normalize-path.js +50 -0
  59. package/dist/server/normalize-path.js.map +1 -0
  60. package/dist/server/prod-server.d.ts.map +1 -1
  61. package/dist/server/prod-server.js +89 -25
  62. package/dist/server/prod-server.js.map +1 -1
  63. package/dist/shims/cache-runtime.d.ts +7 -0
  64. package/dist/shims/cache-runtime.d.ts.map +1 -1
  65. package/dist/shims/cache-runtime.js +19 -15
  66. package/dist/shims/cache-runtime.js.map +1 -1
  67. package/dist/shims/cache.d.ts +8 -0
  68. package/dist/shims/cache.d.ts.map +1 -1
  69. package/dist/shims/cache.js +20 -15
  70. package/dist/shims/cache.js.map +1 -1
  71. package/dist/shims/fetch-cache.d.ts +2 -3
  72. package/dist/shims/fetch-cache.d.ts.map +1 -1
  73. package/dist/shims/fetch-cache.js +74 -9
  74. package/dist/shims/fetch-cache.js.map +1 -1
  75. package/dist/shims/head-state.d.ts +6 -1
  76. package/dist/shims/head-state.d.ts.map +1 -1
  77. package/dist/shims/head-state.js +18 -15
  78. package/dist/shims/head-state.js.map +1 -1
  79. package/dist/shims/headers.d.ts +9 -13
  80. package/dist/shims/headers.d.ts.map +1 -1
  81. package/dist/shims/headers.js +26 -47
  82. package/dist/shims/headers.js.map +1 -1
  83. package/dist/shims/image.d.ts.map +1 -1
  84. package/dist/shims/image.js +11 -2
  85. package/dist/shims/image.js.map +1 -1
  86. package/dist/shims/navigation-state.d.ts +6 -1
  87. package/dist/shims/navigation-state.d.ts.map +1 -1
  88. package/dist/shims/navigation-state.js +20 -29
  89. package/dist/shims/navigation-state.js.map +1 -1
  90. package/dist/shims/navigation.js +2 -2
  91. package/dist/shims/navigation.js.map +1 -1
  92. package/dist/shims/router-state.d.ts +6 -1
  93. package/dist/shims/router-state.d.ts.map +1 -1
  94. package/dist/shims/router-state.js +16 -21
  95. package/dist/shims/router-state.js.map +1 -1
  96. package/dist/shims/router.d.ts.map +1 -1
  97. package/dist/shims/router.js +19 -6
  98. package/dist/shims/router.js.map +1 -1
  99. 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, _initRequestScopedCacheState } from "next/cache";
204
+ import { _consumeRequestScopedCacheLife, _runWithCacheState } from "next/cache";
203
205
  import { runWithFetchCache } from "vinext/fetch-cache";
204
- import { clearPrivateCache as _clearPrivateCache } from "vinext/cache-runtime";
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
- // Without this, React's default onError returns undefined, the digest is lost,
231
- // and client-side error boundaries can't identify the error type.
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 errorObj = error instanceof Error ? error : new Error(String(error));
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
- function __isSafeRegex(pattern) {
847
- const quantifierAtDepth = [];
848
- let depth = 0;
849
- let i = 0;
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 handling in runWithHeadersContext to ensure
1090
- // headers() and cookies() work throughout the async RSC rendering pipeline.
1091
- // This uses AsyncLocalStorage.run() which properly propagates through awaits.
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
- return runWithHeadersContext(headersCtx, async () => {
1094
- // Initialize per-request state for cache and private cache isolation.
1095
- _initRequestScopedCacheState();
1096
- _clearPrivateCache();
1097
- // Install patched fetch with Next.js caching semantics for this request.
1098
- // runWithFetchCache uses AsyncLocalStorage.run() for proper per-request
1099
- // isolation of collected fetch tags in concurrent environments.
1100
- return runWithFetchCache(async () => {
1101
- const response = await _handleRequest(request);
1102
- // Apply custom headers from next.config.js to non-redirect responses.
1103
- // Skip redirects (3xx) because Response.redirect() creates immutable headers,
1104
- // and Next.js doesn't apply custom headers to redirects anyway.
1105
- if (__configHeaders.length && response && response.headers && !(response.status >= 300 && response.status < 400)) {
1106
- const url = new URL(request.url);
1107
- let pathname = url.pathname;
1108
- ${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
1109
- const extraHeaders = __applyConfigHeaders(pathname);
1110
- for (const h of extraHeaders) {
1111
- response.headers.set(h.key, h.value);
1112
- }
1113
- }
1114
- return response;
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
- // Guard against protocol-relative URL open redirect attacks.
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
- // Next.js returns 404 for these paths. Check the raw pathname before any
1127
- // basePath stripping so the guard cannot be bypassed with a basePath prefix.
1128
- if (pathname.startsWith("//")) {
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 = __basePath && !__redir.destination.startsWith(__basePath)
1155
- ? __basePath + __redir.destination
1156
- : __redir.destination;
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,7 +1256,7 @@ 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" && matchMiddlewarePath(cleanPathname, middlewareMatcher)) {
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
  const nextRequest = request instanceof NextRequest ? request : new NextRequest(request);
@@ -1250,14 +1320,22 @@ async function _handleRequest(request) {
1250
1320
 
1251
1321
  // ── Image optimization passthrough (dev mode — no transformation) ───────
1252
1322
  if (cleanPathname === "/_vinext/image") {
1253
- const __imgUrl = url.searchParams.get("url");
1323
+ const __rawImgUrl = url.searchParams.get("url");
1324
+ // Normalize backslashes: browsers and the URL constructor treat
1325
+ // /\\evil.com as protocol-relative (//evil.com), bypassing the // check.
1326
+ const __imgUrl = __rawImgUrl?.replaceAll("\\\\", "/") ?? null;
1254
1327
  // Allowlist: must start with "/" but not "//" — blocks absolute URLs,
1255
- // protocol-relative, and exotic schemes (data:, javascript:, etc.).
1328
+ // protocol-relative, backslash variants, and exotic schemes.
1256
1329
  if (!__imgUrl || !__imgUrl.startsWith("/") || __imgUrl.startsWith("//")) {
1257
- return new Response(!__imgUrl ? "Missing url parameter" : "Only relative URLs allowed", { status: 400 });
1330
+ return new Response(!__rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed", { status: 400 });
1331
+ }
1332
+ // Validate the constructed URL's origin hasn't changed (defense in depth).
1333
+ const __resolvedImg = new URL(__imgUrl, request.url);
1334
+ if (__resolvedImg.origin !== url.origin) {
1335
+ return new Response("Only relative URLs allowed", { status: 400 });
1258
1336
  }
1259
1337
  // In dev, redirect to the original asset URL so Vite's static serving handles it.
1260
- return Response.redirect(new URL(__imgUrl, request.url).href, 302);
1338
+ return Response.redirect(__resolvedImg.href, 302);
1261
1339
  }
1262
1340
 
1263
1341
  // Handle metadata routes (sitemap.xml, robots.txt, manifest.webmanifest, etc.)
@@ -1315,11 +1393,33 @@ async function _handleRequest(request) {
1315
1393
  // cross-site request forgery, matching Next.js server action behavior.
1316
1394
  const csrfResponse = __validateCsrfOrigin(request);
1317
1395
  if (csrfResponse) return csrfResponse;
1396
+
1397
+ // ── Body size limit ─────────────────────────────────────────────────
1398
+ // Reject payloads larger than the configured limit.
1399
+ // Check Content-Length as a fast path, then enforce on the actual
1400
+ // stream to prevent bypasses via chunked transfer-encoding.
1401
+ const contentLength = parseInt(request.headers.get("content-length") || "0", 10);
1402
+ if (contentLength > __MAX_ACTION_BODY_SIZE) {
1403
+ setHeadersContext(null);
1404
+ setNavigationContext(null);
1405
+ return new Response("Payload Too Large", { status: 413 });
1406
+ }
1407
+
1318
1408
  try {
1319
1409
  const contentType = request.headers.get("content-type") || "";
1320
- const body = contentType.startsWith("multipart/form-data")
1321
- ? await request.formData()
1322
- : await request.text();
1410
+ let body;
1411
+ try {
1412
+ body = contentType.startsWith("multipart/form-data")
1413
+ ? await __readFormDataWithLimit(request, __MAX_ACTION_BODY_SIZE)
1414
+ : await __readBodyWithLimit(request, __MAX_ACTION_BODY_SIZE);
1415
+ } catch (sizeErr) {
1416
+ if (sizeErr && sizeErr.message === "Request body too large") {
1417
+ setHeadersContext(null);
1418
+ setNavigationContext(null);
1419
+ return new Response("Payload Too Large", { status: 413 });
1420
+ }
1421
+ throw sizeErr;
1422
+ }
1323
1423
  const temporaryReferences = createTemporaryReferenceSet();
1324
1424
  const args = await decodeReply(body, { temporaryReferences });
1325
1425
  const action = await loadServerAction(actionId);
@@ -1331,12 +1431,14 @@ async function _handleRequest(request) {
1331
1431
  } catch (e) {
1332
1432
  // Detect redirect() / permanentRedirect() called inside the action.
1333
1433
  // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]".
1434
+ // The URL is encodeURIComponent-encoded to prevent semicolons in the URL
1435
+ // from corrupting the delimiter-based digest format.
1334
1436
  if (e && typeof e === "object" && "digest" in e) {
1335
1437
  const digest = String(e.digest);
1336
1438
  if (digest.startsWith("NEXT_REDIRECT;")) {
1337
1439
  const parts = digest.split(";");
1338
1440
  actionRedirect = {
1339
- url: parts[2],
1441
+ url: decodeURIComponent(parts[2]),
1340
1442
  type: parts[1] || "replace", // "push" or "replace"
1341
1443
  status: parts[3] ? parseInt(parts[3], 10) : 307,
1342
1444
  };
@@ -1345,10 +1447,16 @@ async function _handleRequest(request) {
1345
1447
  // notFound() / forbidden() / unauthorized() in action — package as error
1346
1448
  returnValue = { ok: false, data: e };
1347
1449
  } else {
1348
- returnValue = { ok: false, data: e };
1450
+ // Non-navigation digest error sanitize in production to avoid
1451
+ // leaking internal details (connection strings, paths, etc.)
1452
+ console.error("[vinext] Server action error:", e);
1453
+ returnValue = { ok: false, data: __sanitizeErrorForClient(e) };
1349
1454
  }
1350
1455
  } else {
1351
- returnValue = { ok: false, data: e };
1456
+ // Unhandled error sanitize in production to avoid leaking
1457
+ // internal details (database errors, file paths, stack traces, etc.)
1458
+ console.error("[vinext] Server action error:", e);
1459
+ returnValue = { ok: false, data: __sanitizeErrorForClient(e) };
1352
1460
  }
1353
1461
  }
1354
1462
 
@@ -1363,6 +1471,7 @@ async function _handleRequest(request) {
1363
1471
  setNavigationContext(null);
1364
1472
  const redirectHeaders = new Headers({
1365
1473
  "Content-Type": "text/x-component; charset=utf-8",
1474
+ "Vary": "RSC, Accept",
1366
1475
  "x-action-redirect": actionRedirect.url,
1367
1476
  "x-action-redirect-type": actionRedirect.type,
1368
1477
  "x-action-redirect-status": String(actionRedirect.status),
@@ -1402,7 +1511,7 @@ async function _handleRequest(request) {
1402
1511
  setHeadersContext(null);
1403
1512
  setNavigationContext(null);
1404
1513
 
1405
- const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8" };
1514
+ const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
1406
1515
  const actionResponse = new Response(rscStream, { headers: actionHeaders });
1407
1516
  if (actionPendingCookies.length > 0 || actionDraftCookie) {
1408
1517
  for (const cookie of actionPendingCookies) {
@@ -1559,7 +1668,7 @@ async function _handleRequest(request) {
1559
1668
  const digest = String(err.digest);
1560
1669
  if (digest.startsWith("NEXT_REDIRECT;")) {
1561
1670
  const parts = digest.split(";");
1562
- const redirectUrl = parts[2];
1671
+ const redirectUrl = decodeURIComponent(parts[2]);
1563
1672
  const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
1564
1673
  setHeadersContext(null);
1565
1674
  setNavigationContext(null);
@@ -1705,7 +1814,7 @@ async function _handleRequest(request) {
1705
1814
  setHeadersContext(null);
1706
1815
  setNavigationContext(null);
1707
1816
  return new Response(interceptStream, {
1708
- headers: { "Content-Type": "text/x-component; charset=utf-8" },
1817
+ headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
1709
1818
  });
1710
1819
  }
1711
1820
  // If sourceRoute === route, apply intercept opts to the normal render
@@ -1726,7 +1835,7 @@ async function _handleRequest(request) {
1726
1835
  const digest = String(buildErr.digest);
1727
1836
  if (digest.startsWith("NEXT_REDIRECT;")) {
1728
1837
  const parts = digest.split(";");
1729
- const redirectUrl = parts[2];
1838
+ const redirectUrl = decodeURIComponent(parts[2]);
1730
1839
  const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
1731
1840
  setHeadersContext(null);
1732
1841
  setNavigationContext(null);
@@ -1757,7 +1866,7 @@ async function _handleRequest(request) {
1757
1866
  const digest = String(err.digest);
1758
1867
  if (digest.startsWith("NEXT_REDIRECT;")) {
1759
1868
  const parts = digest.split(";");
1760
- const redirectUrl = parts[2];
1869
+ const redirectUrl = decodeURIComponent(parts[2]);
1761
1870
  const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
1762
1871
  setHeadersContext(null);
1763
1872
  setNavigationContext(null);
@@ -1799,13 +1908,13 @@ async function _handleRequest(request) {
1799
1908
  } catch (layoutErr) {
1800
1909
  if (layoutErr && typeof layoutErr === "object" && "digest" in layoutErr) {
1801
1910
  const digest = String(layoutErr.digest);
1802
- if (digest.startsWith("NEXT_REDIRECT;")) {
1803
- const parts = digest.split(";");
1804
- const redirectUrl = parts[2];
1805
- const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
1806
- setHeadersContext(null);
1807
- setNavigationContext(null);
1808
- return Response.redirect(new URL(redirectUrl, request.url), statusCode);
1911
+ if (digest.startsWith("NEXT_REDIRECT;")) {
1912
+ const parts = digest.split(";");
1913
+ const redirectUrl = decodeURIComponent(parts[2]);
1914
+ const statusCode = parts[3] ? parseInt(parts[3], 10) : 307;
1915
+ setHeadersContext(null);
1916
+ setNavigationContext(null);
1917
+ return Response.redirect(new URL(redirectUrl, request.url), statusCode);
1809
1918
  }
1810
1919
  if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
1811
1920
  const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
@@ -1891,7 +2000,7 @@ async function _handleRequest(request) {
1891
2000
  // The RSC stream is consumed lazily - components render when chunks are read.
1892
2001
  // If we clear context now, headers()/cookies() will fail during rendering.
1893
2002
  // Context will be cleared when the next request starts (via runWithHeadersContext).
1894
- const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8" };
2003
+ const responseHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
1895
2004
  // Include matched route params so the client can hydrate useParams()
1896
2005
  if (params && Object.keys(params).length > 0) {
1897
2006
  responseHeaders["X-Vinext-Params"] = JSON.stringify(params);
@@ -1993,6 +2102,7 @@ async function _handleRequest(request) {
1993
2102
  headers: {
1994
2103
  "Content-Type": "text/html; charset=utf-8",
1995
2104
  "Cache-Control": "no-store, must-revalidate",
2105
+ "Vary": "RSC, Accept",
1996
2106
  },
1997
2107
  }));
1998
2108
  }
@@ -2008,6 +2118,7 @@ async function _handleRequest(request) {
2008
2118
  "Content-Type": "text/html; charset=utf-8",
2009
2119
  "Cache-Control": "s-maxage=31536000, stale-while-revalidate",
2010
2120
  "X-Vinext-Cache": "STATIC",
2121
+ "Vary": "RSC, Accept",
2011
2122
  },
2012
2123
  }));
2013
2124
  }
@@ -2019,6 +2130,7 @@ async function _handleRequest(request) {
2019
2130
  headers: {
2020
2131
  "Content-Type": "text/html; charset=utf-8",
2021
2132
  "Cache-Control": "no-store, must-revalidate",
2133
+ "Vary": "RSC, Accept",
2022
2134
  },
2023
2135
  }));
2024
2136
  }
@@ -2030,12 +2142,13 @@ async function _handleRequest(request) {
2030
2142
  headers: {
2031
2143
  "Content-Type": "text/html; charset=utf-8",
2032
2144
  "Cache-Control": "s-maxage=" + revalidateSeconds + ", stale-while-revalidate",
2145
+ "Vary": "RSC, Accept",
2033
2146
  },
2034
2147
  }));
2035
2148
  }
2036
2149
 
2037
2150
  return attachMiddlewareContext(new Response(htmlStream, {
2038
- headers: { "Content-Type": "text/html; charset=utf-8" },
2151
+ headers: { "Content-Type": "text/html; charset=utf-8", "Vary": "RSC, Accept" },
2039
2152
  }));
2040
2153
  }
2041
2154
 
@@ -2055,6 +2168,7 @@ export function generateSsrEntry() {
2055
2168
  import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
2056
2169
  import { renderToReadableStream } from "react-dom/server.edge";
2057
2170
  import { setNavigationContext } from "next/navigation";
2171
+ import { runWithNavigationContext as _runWithNavCtx } from "vinext/navigation-state";
2058
2172
  import { safeJsonStringify } from "vinext/html";
2059
2173
 
2060
2174
  /**
@@ -2180,6 +2294,10 @@ function createRscEmbedTransform(embedStream) {
2180
2294
  * and the data needs to be passed to SSR since they're separate module instances.
2181
2295
  */
2182
2296
  export async function handleSsr(rscStream, navContext, fontData) {
2297
+ // Wrap in a navigation ALS scope for per-request isolation in the SSR
2298
+ // environment. The SSR environment has separate module instances from RSC,
2299
+ // so it needs its own ALS scope.
2300
+ return _runWithNavCtx(async () => {
2183
2301
  // Set navigation context so hooks like usePathname() work during SSR
2184
2302
  // of "use client" components
2185
2303
  if (navContext) {
@@ -2213,6 +2331,16 @@ export async function handleSsr(rscStream, navContext, fontData) {
2213
2331
  const bootstrapScriptContent =
2214
2332
  await import.meta.viteRsc.loadBootstrapScriptContent("index");
2215
2333
 
2334
+ // djb2 hash for digest generation in the SSR environment.
2335
+ // Matches the RSC environment's __errorDigest function.
2336
+ function ssrErrorDigest(str) {
2337
+ let hash = 5381;
2338
+ for (let i = str.length - 1; i >= 0; i--) {
2339
+ hash = (hash * 33) ^ str.charCodeAt(i);
2340
+ }
2341
+ return (hash >>> 0).toString();
2342
+ }
2343
+
2216
2344
  // Render HTML (streaming SSR)
2217
2345
  // useServerInsertedHTML callbacks are registered during this render.
2218
2346
  // The onError callback preserves the digest for Next.js navigation errors
@@ -2220,12 +2348,20 @@ export async function handleSsr(rscStream, navContext, fontData) {
2220
2348
  // boundaries during RSC streaming. Without this, React's default onError
2221
2349
  // returns undefined and the digest is lost in the $RX() call, preventing
2222
2350
  // client-side error boundaries from identifying the error type.
2351
+ // In production, non-navigation errors also get a digest hash so they
2352
+ // can be correlated with server logs without leaking details to clients.
2223
2353
  const htmlStream = await renderToReadableStream(root, {
2224
2354
  bootstrapScriptContent,
2225
2355
  onError(error) {
2226
2356
  if (error && typeof error === "object" && "digest" in error) {
2227
2357
  return String(error.digest);
2228
2358
  }
2359
+ // In production, generate a digest hash for non-navigation errors
2360
+ if (process.env.NODE_ENV === "production" && error) {
2361
+ const msg = error instanceof Error ? error.message : String(error);
2362
+ const stack = error instanceof Error ? (error.stack || "") : "";
2363
+ return ssrErrorDigest(msg + stack);
2364
+ }
2229
2365
  return undefined;
2230
2366
  },
2231
2367
  });
@@ -2405,6 +2541,7 @@ export async function handleSsr(rscStream, navContext, fontData) {
2405
2541
  setNavigationContext(null);
2406
2542
  clearServerInsertedHTML();
2407
2543
  }
2544
+ }); // end _runWithNavCtx
2408
2545
  }
2409
2546
  `;
2410
2547
  }