vinext 1.0.1 → 1.1.0

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 (159) hide show
  1. package/README.md +72 -1
  2. package/dist/build/app-page-static-eligibility.d.ts +15 -0
  3. package/dist/build/app-page-static-eligibility.js +82 -0
  4. package/dist/build/prerender-paths.js +3 -78
  5. package/dist/build/prerender.js +33 -8
  6. package/dist/build/server-manifest.d.ts +7 -0
  7. package/dist/build/server-manifest.js +11 -1
  8. package/dist/cache/cache-adapters-virtual.d.ts +7 -1
  9. package/dist/cache/cache-adapters-virtual.js +3 -3
  10. package/dist/client/navigation-runtime.d.ts +11 -0
  11. package/dist/client/navigation-runtime.js +1 -1
  12. package/dist/config/config-matchers.js +3 -1
  13. package/dist/config/next-config.d.ts +7 -0
  14. package/dist/config/next-config.js +18 -3
  15. package/dist/entries/app-rsc-entry.d.ts +2 -0
  16. package/dist/entries/app-rsc-entry.js +64 -43
  17. package/dist/index.d.ts +4 -1
  18. package/dist/index.js +59 -30
  19. package/dist/plugins/cache-flight-codec.d.ts +19 -0
  20. package/dist/plugins/cache-flight-codec.js +68 -0
  21. package/dist/plugins/commonjs-esm-facade.d.ts +35 -0
  22. package/dist/plugins/commonjs-esm-facade.js +80 -0
  23. package/dist/plugins/fonts.d.ts +8 -11
  24. package/dist/plugins/fonts.js +71 -34
  25. package/dist/routing/file-matcher.d.ts +1 -0
  26. package/dist/routing/file-matcher.js +1 -1
  27. package/dist/server/api-handler.js +17 -7
  28. package/dist/server/app-browser-entry.js +11 -7
  29. package/dist/server/app-browser-history-controller.d.ts +2 -0
  30. package/dist/server/app-browser-history-controller.js +8 -0
  31. package/dist/server/app-browser-interception-context.js +26 -2
  32. package/dist/server/app-browser-navigation-controller.d.ts +7 -1
  33. package/dist/server/app-browser-navigation-controller.js +93 -10
  34. package/dist/server/app-fallback-renderer.d.ts +2 -0
  35. package/dist/server/app-fallback-renderer.js +1 -0
  36. package/dist/server/app-history-state.d.ts +3 -0
  37. package/dist/server/app-history-state.js +7 -0
  38. package/dist/server/app-middleware.js +2 -1
  39. package/dist/server/app-optimistic-routing.js +1 -1
  40. package/dist/server/app-page-boundary-render.d.ts +5 -0
  41. package/dist/server/app-page-boundary-render.js +6 -4
  42. package/dist/server/app-page-cache-finalizer.d.ts +11 -0
  43. package/dist/server/app-page-cache-finalizer.js +41 -7
  44. package/dist/server/app-page-cache-render.d.ts +18 -0
  45. package/dist/server/app-page-cache-render.js +63 -43
  46. package/dist/server/app-page-cache.d.ts +5 -0
  47. package/dist/server/app-page-cache.js +27 -7
  48. package/dist/server/app-page-dispatch.d.ts +9 -1
  49. package/dist/server/app-page-dispatch.js +47 -30
  50. package/dist/server/app-page-element-builder.d.ts +7 -0
  51. package/dist/server/app-page-element-builder.js +27 -42
  52. package/dist/server/app-page-execution.d.ts +15 -12
  53. package/dist/server/app-page-execution.js +30 -40
  54. package/dist/server/app-page-head.d.ts +5 -0
  55. package/dist/server/app-page-head.js +9 -3
  56. package/dist/server/app-page-probe.d.ts +0 -109
  57. package/dist/server/app-page-probe.js +3 -136
  58. package/dist/server/app-page-render-identity.d.ts +0 -1
  59. package/dist/server/app-page-render-identity.js +1 -1
  60. package/dist/server/app-page-render-observation.js +2 -2
  61. package/dist/server/app-page-render.d.ts +13 -5
  62. package/dist/server/app-page-render.js +219 -119
  63. package/dist/server/app-page-request.d.ts +0 -1
  64. package/dist/server/app-page-request.js +1 -2
  65. package/dist/server/app-page-response.d.ts +4 -3
  66. package/dist/server/app-page-response.js +4 -3
  67. package/dist/server/app-page-route-wiring.d.ts +17 -5
  68. package/dist/server/app-page-route-wiring.js +55 -33
  69. package/dist/server/app-page-rsc-render-status.d.ts +48 -0
  70. package/dist/server/app-page-rsc-render-status.js +54 -0
  71. package/dist/server/app-page-stream.d.ts +15 -8
  72. package/dist/server/app-page-stream.js +6 -0
  73. package/dist/server/app-request-stage-dispatch.js +1 -1
  74. package/dist/server/app-request-stage-independent-entry.js +4 -4
  75. package/dist/server/app-response-header-provenance.d.ts +2 -0
  76. package/dist/server/app-response-header-provenance.js +10 -4
  77. package/dist/server/app-route-handler-dispatch.js +1 -1
  78. package/dist/server/app-router-entry.js +3 -3
  79. package/dist/server/app-rsc-handler.d.ts +4 -0
  80. package/dist/server/app-rsc-handler.js +212 -96
  81. package/dist/server/app-rsc-request-normalization.d.ts +3 -1
  82. package/dist/server/app-rsc-request-normalization.js +4 -2
  83. package/dist/server/app-rsc-response-stage.d.ts +1 -1
  84. package/dist/server/app-rsc-response-stage.js +6 -4
  85. package/dist/server/app-rsc-route-matching.d.ts +9 -1
  86. package/dist/server/app-rsc-route-matching.js +36 -19
  87. package/dist/server/app-server-action-execution.js +1 -1
  88. package/dist/server/app-ssr-entry.d.ts +3 -2
  89. package/dist/server/app-ssr-entry.js +6 -4
  90. package/dist/server/app-ssr-stream.d.ts +6 -1
  91. package/dist/server/app-ssr-stream.js +34 -15
  92. package/dist/server/app-worker-stages.d.ts +2 -2
  93. package/dist/server/app-worker-stages.js +3 -3
  94. package/dist/server/cacheability-request.js +9 -5
  95. package/dist/server/config-headers.js +2 -0
  96. package/dist/server/dev-server.js +12 -2
  97. package/dist/server/headers.d.ts +14 -0
  98. package/dist/server/headers.js +15 -1
  99. package/dist/server/image-optimization.d.ts +16 -0
  100. package/dist/server/image-optimization.js +26 -6
  101. package/dist/server/isr-cache.js +2 -2
  102. package/dist/server/middleware-matcher-pattern.js +8 -4
  103. package/dist/server/middleware-response-headers.js +2 -0
  104. package/dist/server/multi-stage.d.ts +8 -0
  105. package/dist/server/navigation-planner.js +1 -1
  106. package/dist/server/node-response-signal.d.ts +16 -0
  107. package/dist/server/node-response-signal.js +26 -0
  108. package/dist/server/open-redirect.d.ts +47 -0
  109. package/dist/server/open-redirect.js +74 -1
  110. package/dist/server/pages-page-handler.js +21 -4
  111. package/dist/server/pages-request-stage-entry.js +8 -4
  112. package/dist/server/prod-server.d.ts +8 -7
  113. package/dist/server/prod-server.js +153 -69
  114. package/dist/server/request-pipeline.d.ts +20 -8
  115. package/dist/server/request-pipeline.js +21 -7
  116. package/dist/server/response-compression.d.ts +43 -0
  117. package/dist/server/response-compression.js +111 -0
  118. package/dist/server/seed-cache.js +10 -7
  119. package/dist/server/socket-error-backstop.d.ts +14 -0
  120. package/dist/server/socket-error-backstop.js +27 -4
  121. package/dist/shims/cache-adapter-instantiate.d.ts +50 -0
  122. package/dist/shims/cache-adapter-instantiate.js +118 -0
  123. package/dist/shims/cache-callable-runtime.d.ts +2 -40
  124. package/dist/shims/cache-callable-runtime.js +50 -168
  125. package/dist/shims/cache-flight-arguments.d.ts +22 -0
  126. package/dist/shims/cache-flight-arguments.js +56 -0
  127. package/dist/shims/cache-request-state.d.ts +15 -0
  128. package/dist/shims/cache-request-state.js +31 -8
  129. package/dist/shims/cache-runtime.d.ts +6 -19
  130. package/dist/shims/cache-runtime.js +165 -225
  131. package/dist/shims/cache.d.ts +2 -2
  132. package/dist/shims/cache.js +5 -4
  133. package/dist/shims/cacheability-classification.d.ts +7 -0
  134. package/dist/shims/error-boundary-navigation.d.ts +7 -0
  135. package/dist/shims/error-boundary-navigation.js +19 -1
  136. package/dist/shims/error-boundary.d.ts +10 -10
  137. package/dist/shims/error-boundary.js +26 -7
  138. package/dist/shims/fetch-cache.d.ts +6 -0
  139. package/dist/shims/fetch-cache.js +19 -1
  140. package/dist/shims/form.js +1 -1
  141. package/dist/shims/headers.d.ts +13 -0
  142. package/dist/shims/headers.js +47 -4
  143. package/dist/shims/internal/app-page-props-cache-key.d.ts +5 -2
  144. package/dist/shims/internal/app-page-props-cache-key.js +24 -15
  145. package/dist/shims/internal/headers-state.d.ts +0 -1
  146. package/dist/shims/metadata.js +3 -3
  147. package/dist/shims/navigation-errors.d.ts +6 -0
  148. package/dist/shims/navigation-errors.js +11 -1
  149. package/dist/shims/navigation.d.ts +7 -0
  150. package/dist/shims/navigation.js +14 -4
  151. package/dist/shims/request-context.d.ts +9 -0
  152. package/dist/shims/thenable-params.d.ts +4 -0
  153. package/dist/shims/thenable-params.js +25 -3
  154. package/dist/shims/unified-request-context.js +1 -0
  155. package/dist/utils/mdx-scan.d.ts +5 -0
  156. package/dist/utils/mdx-scan.js +8 -1
  157. package/dist/utils/regex-safety.d.ts +10 -0
  158. package/dist/utils/regex-safety.js +70 -18
  159. package/package.json +3 -3
@@ -5,18 +5,20 @@ import { ASSET_PREFIX_URL_DIR, assetPrefixPathname, isAbsoluteAssetPrefix } from
5
5
  import { StaticFileCache, contentTypeForPath, etagFromFilenameHash } from "./static-file-cache.js";
6
6
  import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";
7
7
  import { hasBasePath, stripBasePath } from "../utils/base-path.js";
8
- import { VINEXT_PRERENDER_RENDER_ERROR_HEADER } from "./headers.js";
8
+ import { VINEXT_PRERENDER_RENDER_ERROR_HEADER, VINEXT_PRERENDER_SPECIAL_ERROR_HEADER } from "./headers.js";
9
9
  import { normalizePath } from "./normalize-path.js";
10
10
  import { notFoundResponse } from "./http-error-responses.js";
11
- import { isOpenRedirectShaped } from "./open-redirect.js";
11
+ import { isOpenRedirectShaped, sendRepeatedSlashRedirect } from "./open-redirect.js";
12
12
  import { readStaticFileSignal } from "./static-file-signal.js";
13
13
  import { canonicalizeRequestPathname, filterInternalHeaders } from "./request-pipeline.js";
14
+ import { buildCacheStateHeaders } from "./cache-headers.js";
14
15
  import { parseHttpDate } from "./http-date.js";
15
16
  import { evaluateStaticPreconditions } from "./http-conditional.js";
16
17
  import { buildNextDataNotFoundResponse, encodeUrlParserIgnoredCharacters, isNextDataPathname, normalizeNextDataPagePathname, parseNextDataPathname, shouldAddTrailingSlashToPagesDataPath, urlParserCreatesPagesDataPath } from "./pages-data-route.js";
17
18
  import { setPagesClientAssets } from "./pages-client-assets.js";
18
19
  import { resolveRequestHost, resolveRequestProtocol, trustProxy, trustedHosts } from "./proxy-trust.js";
19
- import { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, isImageOptimizationPath, isSafeImageContentType, parseImageParams } from "./image-optimization.js";
20
+ import { signalFromNodeResponse } from "./node-response-signal.js";
21
+ import { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_RESPONSE_CACHE_STATE, isImageOptimizationPath, isSafeImageContentType, parseImageParams } from "./image-optimization.js";
20
22
  import { installSocketErrorBackstop } from "./socket-error-backstop.js";
21
23
  import { collectInlineCssManifest } from "../build/inline-css.js";
22
24
  import { readTrustedRevalidationHostname } from "./revalidation-host.js";
@@ -24,11 +26,12 @@ import { traceFrameworkRequest } from "./request-tracing.js";
24
26
  import { mergeHeaders } from "./worker-utils.js";
25
27
  import { runPagesRequest, wrapMiddlewareWithBasePath } from "./pages-request-pipeline.js";
26
28
  import { computeClientRuntimeMetadata } from "../utils/client-runtime-metadata.js";
27
- import { readPrerenderSecret } from "../build/server-manifest.js";
29
+ import { readPrerenderSecret, readServerCompress } from "../build/server-manifest.js";
28
30
  import { readTrustedPrerenderRouteParamsFromHeaders, serializePrerenderRouteParamsHeader } from "./prerender-route-params.js";
29
31
  import { registerPrerenderCloudflareLoader } from "../build/prerender-cloudflare-loader.js";
30
32
  import { seedMemoryCacheFromPrerender } from "./seed-cache.js";
31
33
  import { negotiateEncoding, parseAcceptedEncodings, selectContentEncoding } from "./accept-encoding.js";
34
+ import { COMPRESS_THRESHOLD, hasNoTransform, isCompressibleContentType, parseContentLengthHeader, resolveResponseCompression } from "./response-compression.js";
32
35
  import { ifRangeAllowsRange, parseByteRange } from "./http-range.js";
33
36
  import { createRequire } from "node:module";
34
37
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -192,25 +195,6 @@ function readNodeStream(req) {
192
195
  }
193
196
  });
194
197
  }
195
- /** Content types that benefit from compression. */
196
- const COMPRESSIBLE_TYPES = /* @__PURE__ */ new Set([
197
- "text/html",
198
- "text/css",
199
- "text/plain",
200
- "text/xml",
201
- "text/javascript",
202
- "application/javascript",
203
- "application/json",
204
- "application/xml",
205
- "application/xhtml+xml",
206
- "application/rss+xml",
207
- "application/atom+xml",
208
- "image/svg+xml",
209
- "application/manifest+json",
210
- "application/wasm"
211
- ]);
212
- /** Minimum size threshold for compression (in bytes). Below this, compression overhead isn't worth it. */
213
- const COMPRESS_THRESHOLD = 1024;
214
198
  /**
215
199
  * Create a compression stream for the given encoding.
216
200
  */
@@ -276,12 +260,14 @@ const NO_BODY_RESPONSE_STATUSES = /* @__PURE__ */ new Set([
276
260
  205,
277
261
  304
278
262
  ]);
263
+ const IMAGE_REPRESENTATION_HEADERS = buildCacheStateHeaders(IMAGE_RESPONSE_CACHE_STATE);
279
264
  const OMIT_BODY_HEADERS = /* @__PURE__ */ new Set(["content-length", "content-type"]);
280
265
  const OMIT_STATIC_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
281
266
  "content-encoding",
282
267
  "content-length",
283
268
  "content-range",
284
- "content-type"
269
+ "content-type",
270
+ VINEXT_PRERENDER_SPECIAL_ERROR_HEADER
285
271
  ]);
286
272
  function omitHeadersCaseInsensitive(headersRecord, targets) {
287
273
  const filtered = {};
@@ -291,6 +277,23 @@ function omitHeadersCaseInsensitive(headersRecord, targets) {
291
277
  }
292
278
  return filtered;
293
279
  }
280
+ function readHeaderCaseInsensitive(headersRecord, name) {
281
+ const key = Object.keys(headersRecord).find((entry) => entry.toLowerCase() === name);
282
+ if (key === void 0) return void 0;
283
+ const value = headersRecord[key];
284
+ return Array.isArray(value) ? value.join(", ") : value;
285
+ }
286
+ /** Remove one field name from a Vary header, dropping the header if it empties. */
287
+ function omitVaryToken(headers, token) {
288
+ const varyKey = Object.keys(headers).find((key) => key.toLowerCase() === "vary");
289
+ if (varyKey === void 0) return headers;
290
+ const rawVary = headers[varyKey];
291
+ const remaining = (Array.isArray(rawVary) ? rawVary.join(",") : rawVary).split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0 && entry.toLowerCase() !== token.toLowerCase());
292
+ const result = { ...headers };
293
+ delete result[varyKey];
294
+ if (remaining.length > 0) result[varyKey] = remaining.join(", ");
295
+ return result;
296
+ }
294
297
  function mergeVaryHeader(headers, value) {
295
298
  const merged = { ...headers };
296
299
  const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "vary");
@@ -337,6 +340,32 @@ function cancelResponseBody(response) {
337
340
  if (!body || body.locked) return;
338
341
  body.cancel().catch(() => {});
339
342
  }
343
+ /**
344
+ * Buffer a response body, cancelling it if the client disconnects first so a
345
+ * stalled stream does not outlive the connection. Resolves null when aborted.
346
+ */
347
+ async function bufferResponseBodyUntilAborted(response, signal) {
348
+ const body = response.body;
349
+ if (!body) return Buffer.alloc(0);
350
+ const reader = body.getReader();
351
+ const cancel = () => {
352
+ reader.cancel(signal.reason).catch(() => {});
353
+ };
354
+ if (signal.aborted) cancel();
355
+ else signal.addEventListener("abort", cancel, { once: true });
356
+ const chunks = [];
357
+ try {
358
+ while (true) {
359
+ const result = await reader.read();
360
+ if (result.done) break;
361
+ chunks.push(result.value);
362
+ }
363
+ } finally {
364
+ signal.removeEventListener("abort", cancel);
365
+ reader.releaseLock();
366
+ }
367
+ return signal.aborted ? null : Buffer.concat(chunks);
368
+ }
340
369
  function isVinextStreamedHtmlResponse(response) {
341
370
  return response.__vinextStreamedHtmlResponse === true;
342
371
  }
@@ -370,24 +399,23 @@ function mergeWebResponse(middlewareHeaders, response, statusOverride) {
370
399
  */
371
400
  function sendCompressed(req, res, body, contentType, statusCode, extraHeaders = {}, compress = true, statusText) {
372
401
  const buf = typeof body === "string" ? Buffer.from(body) : body;
373
- const baseType = contentType.split(";")[0].trim();
374
- const varyByEncoding = compress && COMPRESSIBLE_TYPES.has(baseType);
375
- const encoding = compress ? negotiateEncoding(req) : "identity";
376
402
  const headersWithoutBodyHeaders = omitHeadersCaseInsensitive(extraHeaders, OMIT_BODY_HEADERS);
403
+ const { varyAcceptEncoding: varyByEncoding, encoding } = resolveResponseCompression(req, compress, {
404
+ contentType,
405
+ cacheControl: readHeaderCaseInsensitive(extraHeaders, "cache-control"),
406
+ contentEncoding: readHeaderCaseInsensitive(extraHeaders, "content-encoding"),
407
+ contentLength: buf.length
408
+ });
377
409
  const writeHead = (headers, responseStatus = statusCode, responseStatusText = statusText) => {
378
410
  if (responseStatusText) res.writeHead(responseStatus, responseStatusText, headers);
379
411
  else res.writeHead(responseStatus, headers);
380
412
  };
381
- if (encoding !== "identity" && varyByEncoding && buf.length >= 1024) {
413
+ if (encoding !== "identity") {
382
414
  writeHead(mergeVaryHeader({
383
415
  ...headersWithoutBodyHeaders,
384
416
  "Content-Type": contentType,
385
417
  "Content-Encoding": encoding
386
418
  }, "Accept-Encoding"));
387
- if (req.method === "HEAD") {
388
- res.end();
389
- return;
390
- }
391
419
  const compressor = createCompressor(encoding);
392
420
  compressor.end(buf);
393
421
  pipeline(compressor, res, () => {});
@@ -415,8 +443,12 @@ function sendCompressed(req, res, body, contentType, statusCode, extraHeaders =
415
443
  *
416
444
  * Without a cache, falls back to async filesystem probing (still non-blocking,
417
445
  * unlike the old sync existsSync/statSync approach).
446
+ *
447
+ * `extraHeaders` go on every response for the file. `representationHeaders` go
448
+ * only on responses that carry the file's bytes (200 and 206), never on a 304,
449
+ * 412, 416 or 405.
418
450
  */
419
- async function tryServeStatic(req, res, clientDir, pathname, compress, cache, extraHeaders, statusCode) {
451
+ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, extraHeaders, statusCode, representationHeaders) {
420
452
  if (pathname === "/") return false;
421
453
  const responseStatus = statusCode ?? 200;
422
454
  const omitBody = isNoBodyResponseStatus(responseStatus);
@@ -442,23 +474,27 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
442
474
  sendStaticMethodNotAllowed(res, extraHeaders);
443
475
  return true;
444
476
  }
445
- const rawAe = compress ? req.headers["accept-encoding"] : void 0;
477
+ const encodingAllowed = compress && !hasNoTransform((extraHeaders && readHeaderCaseInsensitive(extraHeaders, "cache-control")) ?? entry.original.headers["Cache-Control"]);
478
+ const rawAe = encodingAllowed ? req.headers["accept-encoding"] : void 0;
446
479
  const parsed = typeof rawAe === "string" ? parseAcceptedEncodings(rawAe) : void 0;
447
480
  const availableVariants = [
448
481
  ...entry.zst ? ["zstd"] : [],
449
482
  ...entry.br ? ["br"] : [],
450
483
  ...entry.gz ? ["gzip"] : []
451
484
  ];
452
- const variesByEncoding = compress && availableVariants.length > 0;
485
+ const variesByEncoding = encodingAllowed && availableVariants.length > 0;
453
486
  const selected = parsed ? selectContentEncoding(parsed, availableVariants) : "identity";
454
487
  const variant = selected === "zstd" ? entry.zst : selected === "br" ? entry.br : selected === "gzip" ? entry.gz : entry.original;
488
+ const notModifiedBaseHeaders = encodingAllowed ? entry.notModifiedHeaders : omitVaryToken(entry.notModifiedHeaders, "Accept-Encoding");
489
+ const originalHeaders = encodingAllowed ? entry.original.headers : omitVaryToken(entry.original.headers, "Accept-Encoding");
490
+ const variantHeaders = variant === entry.original ? originalHeaders : variant.headers;
455
491
  const validators = extraHeaders ? resolveStaticValidators(entry.etag, entry.mtimeMs, extraHeaders) : void 0;
456
492
  const effectiveEtag = validators ? validators.etag : entry.etag;
457
493
  const effectiveMtimeMs = validators ? validators.mtimeMs : entry.mtimeMs;
458
494
  const preconditionResult = responseStatus === 200 ? evaluateRequestPreconditions(req, effectiveEtag, effectiveMtimeMs) : "proceed";
459
495
  if (preconditionResult === "precondition-failed") {
460
496
  res.writeHead(412, {
461
- ...entry.notModifiedHeaders,
497
+ ...notModifiedBaseHeaders,
462
498
  ...extraHeaders,
463
499
  "Content-Type": entry.original.headers["Content-Type"],
464
500
  "Accept-Ranges": "bytes"
@@ -468,10 +504,10 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
468
504
  }
469
505
  if (preconditionResult === "not-modified") {
470
506
  const notModifiedHeaders = variesByEncoding ? mergeVaryHeader({
471
- ...entry.notModifiedHeaders,
507
+ ...notModifiedBaseHeaders,
472
508
  ...extraHeaders
473
509
  }, "Accept-Encoding") : {
474
- ...entry.notModifiedHeaders,
510
+ ...notModifiedBaseHeaders,
475
511
  ...extraHeaders
476
512
  };
477
513
  if (selected !== "identity") notModifiedHeaders["Content-Encoding"] = selected;
@@ -482,7 +518,7 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
482
518
  const range = resolveRequestedRange(requestRange, ifRange, entry.original.size, effectiveEtag ?? "", effectiveMtimeMs, responseStatus);
483
519
  if (range.kind === "unsatisfiable") {
484
520
  res.writeHead(416, {
485
- ...entry.notModifiedHeaders,
521
+ ...notModifiedBaseHeaders,
486
522
  ...extraHeaders,
487
523
  "Content-Type": entry.original.headers["Content-Type"],
488
524
  "Accept-Ranges": "bytes",
@@ -494,8 +530,9 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
494
530
  if (range.kind === "range") {
495
531
  const length = range.end - range.start + 1;
496
532
  const rangeHeaders = {
497
- ...entry.original.headers,
533
+ ...originalHeaders,
498
534
  ...extraHeaders,
535
+ ...representationHeaders,
499
536
  "Accept-Ranges": "bytes",
500
537
  "Content-Length": String(length),
501
538
  "Content-Range": `bytes ${range.start}-${range.end}/${entry.original.size}`
@@ -510,8 +547,9 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
510
547
  return true;
511
548
  }
512
549
  const responseHeaders = {
513
- ...variant.headers,
550
+ ...variantHeaders,
514
551
  ...extraHeaders,
552
+ ...representationHeaders,
515
553
  "Accept-Ranges": "bytes"
516
554
  };
517
555
  res.writeHead(responseStatus, variesByEncoding ? mergeVaryHeader(responseHeaders, "Accept-Encoding") : responseHeaders);
@@ -549,8 +587,6 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
549
587
  const isHashed = pathname.includes(`/${ASSET_PREFIX_URL_DIR}/`);
550
588
  const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600";
551
589
  const etag = isHashed && etagFromFilenameHash(resolved.path, ext) || `W/"${resolved.size}-${Math.floor(resolved.mtimeMs / 1e3)}"`;
552
- const baseType = ct.split(";")[0].trim();
553
- const isCompressible = compress && COMPRESSIBLE_TYPES.has(baseType);
554
590
  const baseHeaders = {
555
591
  "Content-Type": ct,
556
592
  "Cache-Control": cacheControl,
@@ -559,7 +595,12 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
559
595
  "Accept-Ranges": "bytes",
560
596
  ...extraHeaders
561
597
  };
562
- const encoding = isCompressible ? negotiateEncoding(req) : "identity";
598
+ const { varyAcceptEncoding: isCompressible, encoding } = resolveResponseCompression(req, compress, {
599
+ contentType: ct,
600
+ cacheControl: (extraHeaders && readHeaderCaseInsensitive(extraHeaders, "cache-control")) ?? cacheControl,
601
+ contentEncoding: extraHeaders && readHeaderCaseInsensitive(extraHeaders, "content-encoding"),
602
+ contentLength: resolved.size
603
+ });
563
604
  const validators = extraHeaders ? resolveStaticValidators(etag, resolved.mtimeMs, extraHeaders) : void 0;
564
605
  const effectiveEtag = validators ? validators.etag : etag;
565
606
  const effectiveMtimeMs = validators ? validators.mtimeMs : resolved.mtimeMs;
@@ -595,6 +636,7 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
595
636
  const length = range.end - range.start + 1;
596
637
  const rangeHeaders = {
597
638
  ...baseHeaders,
639
+ ...representationHeaders,
598
640
  "Content-Length": String(length),
599
641
  "Content-Range": `bytes ${range.start}-${range.end}/${resolved.size}`
600
642
  };
@@ -609,6 +651,7 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
609
651
  if (isCompressible && encoding !== "identity") {
610
652
  res.writeHead(responseStatus, mergeVaryHeader({
611
653
  ...baseHeaders,
654
+ ...representationHeaders,
612
655
  "Content-Encoding": encoding
613
656
  }, "Accept-Encoding"));
614
657
  if (omitBody || req.method === "HEAD") {
@@ -626,6 +669,7 @@ async function tryServeStatic(req, res, clientDir, pathname, compress, cache, ex
626
669
  }
627
670
  const identityHeaders = {
628
671
  ...baseHeaders,
672
+ ...representationHeaders,
629
673
  "Content-Length": String(resolved.size)
630
674
  };
631
675
  res.writeHead(responseStatus, isCompressible ? mergeVaryHeader(identityHeaders, "Accept-Encoding") : identityHeaders);
@@ -734,7 +778,7 @@ async function statIfFile(filePath) {
734
778
  * When `urlOverride` is provided, it is used as the path + query string
735
779
  * instead of `req.url`.
736
780
  */
737
- function nodeToWebRequest(req, urlOverride, prerenderSecret, i18nConfig, authorizeOnDemandRevalidate) {
781
+ function nodeToWebRequest(req, urlOverride, prerenderSecret, i18nConfig, authorizeOnDemandRevalidate, signal) {
738
782
  const proto = resolveRequestProtocol(req);
739
783
  const rawHeaders = nodeHeadersToWebHeaders(req.headers);
740
784
  const revalidationHostname = readTrustedRevalidationHostname(rawHeaders, i18nConfig, authorizeOnDemandRevalidate);
@@ -751,9 +795,10 @@ function nodeToWebRequest(req, urlOverride, prerenderSecret, i18nConfig, authori
751
795
  const hasBody = method !== "GET" && method !== "HEAD";
752
796
  const init = {
753
797
  method,
754
- headers
798
+ headers,
799
+ signal
755
800
  };
756
- if (hasBody) {
801
+ if (hasBody && !signal?.aborted) {
757
802
  init.body = readNodeStream(req);
758
803
  init.duplex = "half";
759
804
  }
@@ -776,22 +821,23 @@ async function sendWebResponse(webResponse, req, res, compress) {
776
821
  if (existing !== void 0) nodeHeaders[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
777
822
  else nodeHeaders[key] = value;
778
823
  });
779
- const alreadyEncoded = webResponse.headers.get("content-encoding") !== null;
780
- if (!webResponse.body) {
781
- writeHead(nodeHeaders);
782
- res.end();
783
- return;
784
- }
785
- const baseType = (webResponse.headers.get("content-type") ?? "").split(";")[0].trim();
786
- const varyByEncoding = compress && !alreadyEncoded && COMPRESSIBLE_TYPES.has(baseType);
787
- const encoding = compress && !alreadyEncoded ? negotiateEncoding(req) : "identity";
788
- const shouldCompress = encoding !== "identity" && COMPRESSIBLE_TYPES.has(baseType);
824
+ const { varyAcceptEncoding: varyByEncoding, encoding } = resolveResponseCompression(req, compress, {
825
+ contentType: webResponse.headers.get("content-type"),
826
+ cacheControl: webResponse.headers.get("cache-control"),
827
+ contentEncoding: webResponse.headers.get("content-encoding"),
828
+ contentLength: webResponse.body ? parseContentLengthHeader(webResponse.headers.get("content-length")) : 0
829
+ });
830
+ const shouldCompress = encoding !== "identity";
789
831
  if (shouldCompress) {
790
832
  delete nodeHeaders["content-length"];
791
- delete nodeHeaders["Content-Length"];
833
+ delete nodeHeaders["content-encoding"];
792
834
  nodeHeaders["Content-Encoding"] = encoding;
793
835
  }
794
836
  writeHead(varyByEncoding ? mergeVaryHeader(nodeHeaders, "Accept-Encoding") : nodeHeaders);
837
+ if (!webResponse.body) {
838
+ res.end();
839
+ return;
840
+ }
795
841
  if (req.method === "HEAD") {
796
842
  cancelResponseBody(webResponse);
797
843
  res.end();
@@ -861,6 +907,7 @@ async function startProdServer(options = {}) {
861
907
  port,
862
908
  host,
863
909
  clientDir,
910
+ serverDir,
864
911
  serverEntryPath,
865
912
  compress,
866
913
  purpose,
@@ -877,6 +924,23 @@ function createNodeExecutionContext(trustedRevalidateOrigin) {
877
924
  trustedRevalidateOrigin
878
925
  };
879
926
  }
927
+ /**
928
+ * Sets the prerender special-error marker only from the page's recorded
929
+ * special error, which middleware, route handlers and external rewrites can't
930
+ * reach, and drops one they set.
931
+ */
932
+ function markPrerenderSpecialError(response, marker) {
933
+ const trusted = marker !== void 0 && marker.status === response.status;
934
+ if (!trusted && !response.headers.has("x-vinext-prerender-special-error")) return response;
935
+ const headers = new Headers(response.headers);
936
+ headers.delete(VINEXT_PRERENDER_SPECIAL_ERROR_HEADER);
937
+ if (trusted) headers.set(VINEXT_PRERENDER_SPECIAL_ERROR_HEADER, JSON.stringify(marker.headers));
938
+ return new Response(response.body, {
939
+ headers,
940
+ status: response.status,
941
+ statusText: response.statusText
942
+ });
943
+ }
880
944
  function resolveTrustedNodeRevalidateOrigin(req, configuredHost, configuredPort) {
881
945
  const port = req.socket.localPort ?? configuredPort;
882
946
  return `http://${normalizeInternalFetchHost(configuredHost)}:${port}`;
@@ -1008,8 +1072,9 @@ function installPagesClientAssets(options) {
1008
1072
  * 4. Stream the Web Response back (with optional compression)
1009
1073
  */
1010
1074
  async function startAppRouterServer(options) {
1011
- const { port, host, clientDir, serverDir, rscEntryPath, compress, purpose, silent } = options;
1075
+ const { port, host, clientDir, serverDir, rscEntryPath, compress: compressOption, purpose, silent } = options;
1012
1076
  const prerenderSecret = readPrerenderSecret(serverDir);
1077
+ const compress = compressOption && readServerCompress(serverDir);
1013
1078
  const rscModule = await importServerEntryModule(rscEntryPath);
1014
1079
  const rscEntryRequire = createServerEntryRequire(rscEntryPath);
1015
1080
  const rscHandler = resolveAppRouterHandler(rscModule.default);
@@ -1048,6 +1113,7 @@ async function startAppRouterServer(options) {
1048
1113
  const handleRequestImpl = async (req, res) => {
1049
1114
  const rawUrl = req.url ?? "/";
1050
1115
  const rawPathname = rawUrl.split("?")[0];
1116
+ if (sendRepeatedSlashRedirect(rawUrl, res)) return;
1051
1117
  if (isOpenRedirectShaped(rawPathname)) {
1052
1118
  res.writeHead(404);
1053
1119
  res.end("This page could not be found");
@@ -1095,14 +1161,23 @@ async function startAppRouterServer(options) {
1095
1161
  "X-Content-Type-Options": "nosniff",
1096
1162
  "Content-Disposition": imageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline"
1097
1163
  };
1098
- if (await tryServeStatic(req, res, clientDir, params.imageUrl, false, staticCache, imageSecurityHeaders)) return;
1164
+ if (await tryServeStatic(req, res, clientDir, params.imageUrl, false, staticCache, imageSecurityHeaders, void 0, IMAGE_REPRESENTATION_HEADERS)) return;
1099
1165
  res.writeHead(404);
1100
1166
  res.end("Image not found");
1101
1167
  return;
1102
1168
  }
1103
1169
  try {
1104
- const request = nodeToWebRequest(req, rawUrl, prerenderSecret, appRouterI18nConfig, appRouterAuthorizeOnDemandRevalidate);
1105
- const response = await rscHandler(request, createNodeExecutionContext(resolveTrustedNodeRevalidateOrigin(req, host, port)));
1170
+ const request = nodeToWebRequest(req, rawUrl, prerenderSecret, appRouterI18nConfig, appRouterAuthorizeOnDemandRevalidate, signalFromNodeResponse(res));
1171
+ const ctx = createNodeExecutionContext(resolveTrustedNodeRevalidateOrigin(req, host, port));
1172
+ const recorded = {};
1173
+ if (purpose === "prerender") ctx.recordPrerenderSpecialError = (marker) => {
1174
+ recorded.marker = marker;
1175
+ };
1176
+ const response = await rscHandler(request, ctx);
1177
+ if (request.signal.aborted) {
1178
+ cancelResponseBody(response);
1179
+ return;
1180
+ }
1106
1181
  const staticFileSignal = readStaticFileSignal(response);
1107
1182
  if (staticFileSignal) {
1108
1183
  let staticFilePath = "/";
@@ -1118,7 +1193,7 @@ async function startAppRouterServer(options) {
1118
1193
  await sendWebResponse(notFoundResponse({ headers: toWebHeaders(staticResponseHeaders) }), req, res, compress);
1119
1194
  return;
1120
1195
  }
1121
- await sendWebResponse(response, req, res, compress);
1196
+ await sendWebResponse(purpose === "prerender" ? markPrerenderSpecialError(response, recorded.marker) : response, req, res, compress);
1122
1197
  } catch (e) {
1123
1198
  console.error("[vinext] Server error:", e);
1124
1199
  if (!res.headersSent) {
@@ -1149,7 +1224,7 @@ async function startAppRouterServer(options) {
1149
1224
  },
1150
1225
  getStatus: () => res.statusCode,
1151
1226
  headers,
1152
- isRsc: new URL(target, "http://localhost").pathname.endsWith(".rsc") || headers.get("RSC") === "1",
1227
+ isRsc: target.split("?", 1)[0].endsWith(".rsc") || headers.get("RSC") === "1",
1153
1228
  method: req.method ?? "GET",
1154
1229
  target
1155
1230
  });
@@ -1195,7 +1270,7 @@ function readPagesServerEntryPageRoutes(value) {
1195
1270
  * - vinextConfig — embedded next.config.js settings
1196
1271
  */
1197
1272
  async function startPagesRouterServer(options) {
1198
- const { port, host, clientDir, serverEntryPath, compress, purpose, silent } = options;
1273
+ const { port, host, clientDir, serverDir, serverEntryPath, compress: compressOption, purpose, silent } = options;
1199
1274
  const serverEntry = await importServerEntryModule(serverEntryPath);
1200
1275
  const serverEntryRequire = createServerEntryRequire(serverEntryPath);
1201
1276
  const { renderPage, handleApiRoute: handleApi, authorizeOnDemandRevalidate, runMiddleware, vinextConfig, buildId: pagesBuildId } = serverEntry;
@@ -1205,6 +1280,7 @@ async function startPagesRouterServer(options) {
1205
1280
  const hasMiddleware = serverEntry.hasMiddleware === true;
1206
1281
  const pageRoutes = readPagesServerEntryPageRoutes(serverEntry.pageRoutes);
1207
1282
  const prerenderSecret = readPrerenderSecret(path.dirname(serverEntryPath));
1283
+ const compress = compressOption && readServerCompress(serverDir);
1208
1284
  const basePath = vinextConfig?.basePath ?? "";
1209
1285
  const assetPrefix = vinextConfig?.assetPrefix ?? "";
1210
1286
  const pagesAssetPathPrefix = assetPrefixPathname(assetPrefix);
@@ -1261,6 +1337,7 @@ async function startPagesRouterServer(options) {
1261
1337
  const handleRequestImpl = async (req, res) => {
1262
1338
  const rawUrl = req.url ?? "/";
1263
1339
  const rawPagesPathnameBeforeNormalize = rawUrl.split("?")[0];
1340
+ if (sendRepeatedSlashRedirect(rawUrl, res)) return;
1264
1341
  if (isOpenRedirectShaped(rawPagesPathnameBeforeNormalize)) {
1265
1342
  res.writeHead(404);
1266
1343
  res.end("This page could not be found");
@@ -1366,10 +1443,12 @@ async function startPagesRouterServer(options) {
1366
1443
  const reqHeaders = filterInternalHeaders(rawReqHeaders);
1367
1444
  if (revalidationHostname) reqHeaders.set("host", revalidationHostname);
1368
1445
  const method = req.method ?? "GET";
1369
- const hasBody = method !== "GET" && method !== "HEAD";
1446
+ const signal = signalFromNodeResponse(res);
1447
+ const hasBody = method !== "GET" && method !== "HEAD" && !signal.aborted;
1370
1448
  const webRequest = new Request(`${protocol}://${revalidationHostname ?? hostHeader}${url}`, {
1371
1449
  method,
1372
1450
  headers: reqHeaders,
1451
+ signal,
1373
1452
  body: hasBody ? readNodeStream(req) : void 0,
1374
1453
  duplex: hasBody ? "half" : void 0
1375
1454
  });
@@ -1421,7 +1500,7 @@ async function startPagesRouterServer(options) {
1421
1500
  "X-Content-Type-Options": "nosniff",
1422
1501
  "Content-Disposition": pagesImageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline"
1423
1502
  };
1424
- if (await tryServeStatic(req, res, clientDir, params.imageUrl, false, staticCache, imageSecurityHeaders)) return true;
1503
+ if (await tryServeStatic(req, res, clientDir, params.imageUrl, false, staticCache, imageSecurityHeaders, void 0, IMAGE_REPRESENTATION_HEADERS)) return true;
1425
1504
  res.writeHead(404);
1426
1505
  res.end("Image not found");
1427
1506
  return true;
@@ -1434,13 +1513,18 @@ async function startPagesRouterServer(options) {
1434
1513
  if (result.type === "handled") return;
1435
1514
  if (result.type === "response") {
1436
1515
  const { response } = result;
1516
+ if (webRequest.signal.aborted) {
1517
+ cancelResponseBody(response);
1518
+ return;
1519
+ }
1437
1520
  const streamedApi = isVinextStreamedApiResponse(response);
1438
1521
  if (isVinextStreamedHtmlResponse(response) || streamedApi || !response.body || result.defaultContentType === void 0) {
1439
1522
  if (streamedApi && result.defaultContentType !== void 0 && !response.headers.has("content-type")) response.headers.set("content-type", result.defaultContentType);
1440
1523
  await sendWebResponse(response, req, res, compress);
1441
1524
  return;
1442
1525
  }
1443
- const responseBody = Buffer.from(await response.arrayBuffer());
1526
+ const responseBody = await bufferResponseBodyUntilAborted(response, webRequest.signal);
1527
+ if (!responseBody) return;
1444
1528
  const ct = response.headers.get("content-type") ?? result.defaultContentType;
1445
1529
  const responseHeaders = {};
1446
1530
  response.headers.forEach((v, k) => {
@@ -1482,4 +1566,4 @@ async function startPagesRouterServer(options) {
1482
1566
  };
1483
1567
  }
1484
1568
  //#endregion
1485
- export { COMPRESSIBLE_TYPES, COMPRESS_THRESHOLD, importServerEntryModule, mergeResponseHeaders, mergeWebResponse, negotiateEncoding, nodeToWebRequest, readNodeStream, rememberCurrentServerEntryImportMtime, resolveAppRouterAssetPath, resolveAppRouterPrerenderSeeder, resolveRequestHost as resolveHost, resolveServerEntryImportUrl, sendCompressed, sendWebResponse, startProdServer, trustProxy, trustedHosts, tryServeStatic, waitForNodeResponseCompletion };
1569
+ export { COMPRESS_THRESHOLD, importServerEntryModule, isCompressibleContentType, mergeResponseHeaders, mergeWebResponse, negotiateEncoding, nodeToWebRequest, readNodeStream, rememberCurrentServerEntryImportMtime, resolveAppRouterAssetPath, resolveAppRouterPrerenderSeeder, resolveRequestHost as resolveHost, resolveServerEntryImportUrl, sendCompressed, sendWebResponse, startProdServer, trustProxy, trustedHosts, tryServeStatic, waitForNodeResponseCompletion };
@@ -1,7 +1,7 @@
1
1
  import { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS } from "./headers.js";
2
2
  import { hasBasePath, stripBasePath } from "../utils/base-path.js";
3
3
  import { StaticFileSignalContext, createStaticFileSignal } from "./static-file-signal.js";
4
- import { isOpenRedirectShaped } from "./open-redirect.js";
4
+ import { getRepeatedSlashRedirect, isOpenRedirectShaped, sendRepeatedSlashRedirect } from "./open-redirect.js";
5
5
  //#region src/server/request-pipeline.d.ts
6
6
  /**
7
7
  * Apply the URL Standard's pathname canonicalization without decoding and
@@ -39,10 +39,21 @@ export declare function canonicalizeRequestUrlPathname(url: string): string;
39
39
  * Backslashes are equivalent to forward slashes in the URL spec
40
40
  * (e.g. `/\evil.com` is treated as `//evil.com` by browsers).
41
41
  *
42
- * Next.js returns 404 for these paths. We check the RAW pathname before
43
- * normalization so the guard fires before normalizePath collapses `//`.
44
- *
45
- * Percent-encoded variants are also blocked because:
42
+ * Like Next.js, any raw path containing a literal backslash or a repeated
43
+ * slash first gets a 308 to the collapsed path (`//evil.com` → `/evil.com`,
44
+ * `//` → `/`); see `getRepeatedSlashRedirect`. We check the RAW
45
+ * pathname before normalization so this fires before normalizePath collapses
46
+ * `//`.
47
+ *
48
+ * Request-based (Worker) entries only see `request.url` after WHATWG URL
49
+ * parsing, which has already turned `\` into `/`; the raw request target is
50
+ * not available there. `/\evil.com` still redirects (it arrives as
51
+ * `//evil.com`), but a lone backslash such as `/foo\bar` arrives as
52
+ * `/foo/bar` and is routed without the redirect. On Cloudflare, requests that
53
+ * match a static asset are also answered by the asset layer before the
54
+ * Worker runs. Node entries (dev and `vinext start`) check the raw target.
55
+ *
56
+ * Percent-encoded variants get a 404, as in Next.js, because:
46
57
  * - `%5C` decodes to `\` (browsers treat `/\evil.com` as `//evil.com`).
47
58
  * - `%2F` decodes to `/` (so `/%2F/evil.com` effectively becomes `//evil.com`).
48
59
  * These forms survive segment-wise decoding that re-encodes path delimiters
@@ -51,9 +62,10 @@ export declare function canonicalizeRequestUrlPathname(url: string): string;
51
62
  * `isOpenRedirectShaped` for the full list of rejected leading-segment forms.
52
63
  *
53
64
  * @param rawPathname - The raw pathname from the URL, before any normalization
54
- * @returns A 404 Response if the path is protocol-relative, or null to continue
65
+ * @param search - The raw query string (including `?`), kept in the 308 Location
66
+ * @returns A 308 or 404 Response, or null to continue
55
67
  */
56
- export declare function guardProtocolRelativeUrl(rawPathname: string): Response | null;
68
+ export declare function guardProtocolRelativeUrl(rawPathname: string, search?: string): Response | null;
57
69
  export type HeaderRecord = Record<string, string | string[]>;
58
70
  type ResolvePublicFileRouteOptions = {
59
71
  cleanPathname: string;
@@ -175,4 +187,4 @@ export declare function cloneRequestWithHeaders(request: Request, headers: Heade
175
187
  */
176
188
  export declare function cloneRequestWithUrl(request: Request, url: string): Request;
177
189
  //#endregion
178
- export { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS, createStaticFileSignal, hasBasePath, isOpenRedirectShaped, stripBasePath };
190
+ export { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS, createStaticFileSignal, getRepeatedSlashRedirect, hasBasePath, isOpenRedirectShaped, sendRepeatedSlashRedirect, stripBasePath };
@@ -3,7 +3,7 @@ import { getUnconsumedMiddlewareRequestHeaders } from "../utils/middleware-reque
3
3
  import { hasBasePath, removeTrailingSlash, stripBasePath } from "../utils/base-path.js";
4
4
  import { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS } from "./headers.js";
5
5
  import { forbiddenResponse, methodNotAllowedResponse, notFoundResponse } from "./http-error-responses.js";
6
- import { isOpenRedirectShaped } from "./open-redirect.js";
6
+ import { getRepeatedSlashRedirect, isOpenRedirectShaped, repeatedSlashRedirectResponse, sendRepeatedSlashRedirect } from "./open-redirect.js";
7
7
  import { createStaticFileSignal } from "./static-file-signal.js";
8
8
  //#region src/server/request-pipeline.ts
9
9
  const PATHNAME_CANONICALIZATION_BASE = new URL("http://vinext.invalid/");
@@ -52,10 +52,21 @@ function canonicalizeRequestUrlPathname(url) {
52
52
  * Backslashes are equivalent to forward slashes in the URL spec
53
53
  * (e.g. `/\evil.com` is treated as `//evil.com` by browsers).
54
54
  *
55
- * Next.js returns 404 for these paths. We check the RAW pathname before
56
- * normalization so the guard fires before normalizePath collapses `//`.
55
+ * Like Next.js, any raw path containing a literal backslash or a repeated
56
+ * slash first gets a 308 to the collapsed path (`//evil.com` → `/evil.com`,
57
+ * `//` → `/`); see `getRepeatedSlashRedirect`. We check the RAW
58
+ * pathname before normalization so this fires before normalizePath collapses
59
+ * `//`.
57
60
  *
58
- * Percent-encoded variants are also blocked because:
61
+ * Request-based (Worker) entries only see `request.url` after WHATWG URL
62
+ * parsing, which has already turned `\` into `/`; the raw request target is
63
+ * not available there. `/\evil.com` still redirects (it arrives as
64
+ * `//evil.com`), but a lone backslash such as `/foo\bar` arrives as
65
+ * `/foo/bar` and is routed without the redirect. On Cloudflare, requests that
66
+ * match a static asset are also answered by the asset layer before the
67
+ * Worker runs. Node entries (dev and `vinext start`) check the raw target.
68
+ *
69
+ * Percent-encoded variants get a 404, as in Next.js, because:
59
70
  * - `%5C` decodes to `\` (browsers treat `/\evil.com` as `//evil.com`).
60
71
  * - `%2F` decodes to `/` (so `/%2F/evil.com` effectively becomes `//evil.com`).
61
72
  * These forms survive segment-wise decoding that re-encodes path delimiters
@@ -64,9 +75,12 @@ function canonicalizeRequestUrlPathname(url) {
64
75
  * `isOpenRedirectShaped` for the full list of rejected leading-segment forms.
65
76
  *
66
77
  * @param rawPathname - The raw pathname from the URL, before any normalization
67
- * @returns A 404 Response if the path is protocol-relative, or null to continue
78
+ * @param search - The raw query string (including `?`), kept in the 308 Location
79
+ * @returns A 308 or 404 Response, or null to continue
68
80
  */
69
- function guardProtocolRelativeUrl(rawPathname) {
81
+ function guardProtocolRelativeUrl(rawPathname, search = "") {
82
+ const slashRedirect = repeatedSlashRedirectResponse(rawPathname + search);
83
+ if (slashRedirect) return slashRedirect;
70
84
  if (isOpenRedirectShaped(rawPathname)) return notFoundResponse();
71
85
  return null;
72
86
  }
@@ -418,4 +432,4 @@ function cloneRequestWithUrl(request, url) {
418
432
  return attachRequestCfMetadata(cloned, request);
419
433
  }
420
434
  //#endregion
421
- export { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS, attachRequestCfMetadata, canonicalizeRequestPathname, canonicalizeRequestUrlPathname, cloneRequestWithHeaders, cloneRequestWithUrl, createStaticFileSignal, filterInternalHeaders, guardProtocolRelativeUrl, hasBasePath, isOpenRedirectShaped, isOriginAllowed, normalizeTrailingSlash, normalizeTrailingSlashPathname, processMiddlewareHeaders, resolvePublicFileRoute, stripBasePath, validateCsrfOrigin, validateServerActionPayload };
435
+ export { INTERNAL_HEADERS, VINEXT_INTERNAL_HEADERS, attachRequestCfMetadata, canonicalizeRequestPathname, canonicalizeRequestUrlPathname, cloneRequestWithHeaders, cloneRequestWithUrl, createStaticFileSignal, filterInternalHeaders, getRepeatedSlashRedirect, guardProtocolRelativeUrl, hasBasePath, isOpenRedirectShaped, isOriginAllowed, normalizeTrailingSlash, normalizeTrailingSlashPathname, processMiddlewareHeaders, resolvePublicFileRoute, sendRepeatedSlashRedirect, stripBasePath, validateCsrfOrigin, validateServerActionPayload };