@opennextjs/aws 4.1.14 → 4.1.15

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.
@@ -1,5 +1,15 @@
1
1
  import type { ComposableCacheEntry } from "../types/cache.js";
2
2
  declare const _default: {
3
+ /**
4
+ * Reads a composable cache entry.
5
+ *
6
+ * The incremental cache and the tag cache are accessed with a key derived from `cacheKey`,
7
+ * see `getComposableCacheStorageKey`.
8
+ *
9
+ * @param cacheKey The cache key provided by Next.js
10
+ * @returns The cache entry, or `undefined` on a miss or when the entry has been revalidated.
11
+ * Errors are caught and reported as a miss.
12
+ */
3
13
  get(cacheKey: string): Promise<{
4
14
  value: import("stream/web").ReadableStream<any>;
5
15
  tags: string[];
@@ -8,6 +18,16 @@ declare const _default: {
8
18
  expire: number;
9
19
  revalidate: number;
10
20
  } | undefined>;
21
+ /**
22
+ * Writes a composable cache entry.
23
+ *
24
+ * The incremental cache and the tag cache are accessed with a key derived from `cacheKey`,
25
+ * see `getComposableCacheStorageKey`.
26
+ *
27
+ * @param cacheKey The cache key provided by Next.js
28
+ * @param pendingEntry The entry to store, resolves once the cached function has completed
29
+ * @throws When the entry can not be resolved or when the incremental cache or tag cache write fails
30
+ */
11
31
  set(cacheKey: string, pendingEntry: Promise<ComposableCacheEntry>): Promise<void>;
12
32
  refreshTags(): Promise<void>;
13
33
  /**
@@ -1,4 +1,4 @@
1
- import { isStale, writeTags } from "../utils/cache.js";
1
+ import { getComposableCacheStorageKey, isStale, writeTags } from "../utils/cache.js";
2
2
  import { fromReadableStream, toReadableStream } from "../utils/stream.js";
3
3
  import { debug } from "./logger";
4
4
  /**
@@ -17,12 +17,25 @@ import { debug } from "./logger";
17
17
  * flight from the same render while making a cross-request share impossible.
18
18
  * With no ALS store (e.g. at build time) the dedupe is skipped and `get` falls
19
19
  * through to the incremental cache.
20
+ *
21
+ * The map is keyed by the cache key provided by Next.js: it is an in-memory exact string
22
+ * comparison so there is no need to use the storage key (see `getComposableCacheStorageKey`).
20
23
  */
21
24
  const PENDING_WRITES_CACHE_KEY = "composable-cache:pending-writes";
22
25
  const getPendingWritePromiseMap = () => globalThis.__openNextAls
23
26
  ?.getStore()
24
27
  ?.requestCache?.getOrCreate(PENDING_WRITES_CACHE_KEY);
25
28
  export default {
29
+ /**
30
+ * Reads a composable cache entry.
31
+ *
32
+ * The incremental cache and the tag cache are accessed with a key derived from `cacheKey`,
33
+ * see `getComposableCacheStorageKey`.
34
+ *
35
+ * @param cacheKey The cache key provided by Next.js
36
+ * @returns The cache entry, or `undefined` on a miss or when the entry has been revalidated.
37
+ * Errors are caught and reported as a miss.
38
+ */
26
39
  async get(cacheKey) {
27
40
  try {
28
41
  // We first check if we have a pending write for this cache key
@@ -37,7 +50,8 @@ export default {
37
50
  }));
38
51
  }
39
52
  }
40
- const result = await globalThis.incrementalCache.get(cacheKey, "composable");
53
+ const storageKey = getComposableCacheStorageKey(cacheKey);
54
+ const result = await globalThis.incrementalCache.get(storageKey, "composable");
41
55
  if (!result?.value?.value) {
42
56
  return undefined;
43
57
  }
@@ -54,7 +68,7 @@ export default {
54
68
  // Check if tags are stale – entry is valid but needs background revalidation
55
69
  const isCacheStale = result.shouldBypassTagCache
56
70
  ? false
57
- : await isStale(cacheKey, result.value.tags, result.lastModified);
71
+ : await isStale(storageKey, result.value.tags, result.lastModified);
58
72
  if (isCacheStale) {
59
73
  revalidate = -1;
60
74
  }
@@ -63,13 +77,13 @@ export default {
63
77
  globalThis.tagCache.mode === undefined) {
64
78
  const hasBeenRevalidated = result.shouldBypassTagCache
65
79
  ? false
66
- : (await globalThis.tagCache.getLastModified(cacheKey, result.lastModified)) === -1;
80
+ : (await globalThis.tagCache.getLastModified(storageKey, result.lastModified)) === -1;
67
81
  if (hasBeenRevalidated)
68
82
  return undefined;
69
83
  // Check if tags are stale – entry is valid but needs background revalidation
70
84
  const isCacheStale = result.shouldBypassTagCache
71
85
  ? false
72
- : await isStale(cacheKey, result.value.tags, result.lastModified);
86
+ : await isStale(storageKey, result.value.tags, result.lastModified);
73
87
  if (isCacheStale) {
74
88
  revalidate = -1;
75
89
  }
@@ -85,6 +99,16 @@ export default {
85
99
  return undefined;
86
100
  }
87
101
  },
102
+ /**
103
+ * Writes a composable cache entry.
104
+ *
105
+ * The incremental cache and the tag cache are accessed with a key derived from `cacheKey`,
106
+ * see `getComposableCacheStorageKey`.
107
+ *
108
+ * @param cacheKey The cache key provided by Next.js
109
+ * @param pendingEntry The entry to store, resolves once the cached function has completed
110
+ * @throws When the entry can not be resolved or when the incremental cache or tag cache write fails
111
+ */
88
112
  async set(cacheKey, pendingEntry) {
89
113
  const promiseEntry = pendingEntry.then(async (entry) => ({
90
114
  ...entry,
@@ -95,15 +119,16 @@ export default {
95
119
  const entry = await promiseEntry.finally(() => {
96
120
  pendingWritePromiseMap?.delete(cacheKey);
97
121
  });
98
- await globalThis.incrementalCache.set(cacheKey, {
122
+ const storageKey = getComposableCacheStorageKey(cacheKey);
123
+ await globalThis.incrementalCache.set(storageKey, {
99
124
  ...entry,
100
125
  value: entry.value,
101
126
  }, "composable");
102
127
  if (globalThis.tagCache.mode === "original") {
103
- const storedTags = await globalThis.tagCache.getByPath(cacheKey);
128
+ const storedTags = await globalThis.tagCache.getByPath(storageKey);
104
129
  const tagsToWrite = entry.tags.filter((tag) => !storedTags.includes(tag));
105
130
  if (tagsToWrite.length > 0) {
106
- await writeTags(tagsToWrite.map((tag) => ({ tag, path: cacheKey })));
131
+ await writeTags(tagsToWrite.map((tag) => ({ tag, path: storageKey })));
107
132
  }
108
133
  }
109
134
  },
@@ -1,3 +1,4 @@
1
+ import { convertEdgeResponseHeaders } from "../http/util.js";
1
2
  import { runWithOpenNextRequestContext } from "../utils/promise.js";
2
3
  import { emptyReadableStream } from "../utils/stream.js";
3
4
  // We import it like that so that the edge plugin can replace it
@@ -26,17 +27,7 @@ const defaultHandler = async (internalEvent, options) => {
26
27
  url: internalEvent.url,
27
28
  body: convertBodyToReadableStream(internalEvent.method, internalEvent.body),
28
29
  });
29
- const responseHeaders = {};
30
- response.headers.forEach((value, key) => {
31
- // Headers.forEach folds same-name headers; use getSetCookie() below instead.
32
- if (key.toLowerCase() === "set-cookie")
33
- return;
34
- responseHeaders[key] = value;
35
- });
36
- const setCookies = response.headers.getSetCookie();
37
- if (setCookies.length > 0) {
38
- responseHeaders["set-cookie"] = setCookies;
39
- }
30
+ const responseHeaders = convertEdgeResponseHeaders(response.headers);
40
31
  const body = response.body ?? emptyReadableStream();
41
32
  return {
42
33
  type: "core",
@@ -24,6 +24,30 @@ const VARY_HEADER = "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Rou
24
24
  const NEXT_SEGMENT_PREFETCH_HEADER = "next-router-segment-prefetch";
25
25
  const NEXT_PRERENDER_HEADER = "x-nextjs-prerender";
26
26
  const NEXT_POSTPONED_HEADER = "x-nextjs-postponed";
27
+ /**
28
+ * Internal header that can be stored in a cache entry but must not be sent to the client.
29
+ *
30
+ * It is set when a route handler calls `response.cookies` and contains the `HttpOnly` cookies.
31
+ * Next.js strips it in `sendResponse` when it serves a cached response.
32
+ */
33
+ const MIDDLEWARE_SET_COOKIE_HEADER = "x-middleware-set-cookie";
34
+ /**
35
+ * Returns the headers stored in a cache entry, without the internal `x-middleware-set-cookie` header.
36
+ *
37
+ * @param headers The headers stored in the cache entry meta
38
+ * @returns A copy of the headers without the internal header
39
+ */
40
+ function getCachedResponseHeaders(headers) {
41
+ const result = {};
42
+ for (const [key, value] of Object.entries(headers ?? {})) {
43
+ if (value === undefined ||
44
+ key.toLowerCase() === MIDDLEWARE_SET_COOKIE_HEADER) {
45
+ continue;
46
+ }
47
+ result[key] = value;
48
+ }
49
+ return result;
50
+ }
27
51
  async function computeCacheControl(path, body, host, revalidate, lastModified, isStaleFromTagCache = false, revalidationPath = path, revalidationKey = path) {
28
52
  let finalRevalidate = CACHE_ONE_YEAR;
29
53
  const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find((p) => p[0] === path)?.[1];
@@ -194,7 +218,7 @@ async function generateResult(event, localizedPath, cachedValue, lastModified, i
194
218
  const headers = {
195
219
  ...cacheControl,
196
220
  "content-type": type,
197
- ...cachedValue.meta?.headers,
221
+ ...getCachedResponseHeaders(cachedValue.meta?.headers),
198
222
  vary: VARY_HEADER,
199
223
  ...additionalHeaders,
200
224
  };
@@ -400,8 +424,7 @@ export async function cacheInterceptor(event, resolvedRoutes) {
400
424
  statusCode: cachedData.value.meta?.status ?? 307,
401
425
  body: emptyReadableStream(),
402
426
  headers: {
403
- ...(cachedData.value.meta?.headers ??
404
- {}),
427
+ ...getCachedResponseHeaders(cachedData.value.meta?.headers),
405
428
  ...cacheControl,
406
429
  },
407
430
  isBase64Encoded: false,
@@ -413,7 +436,7 @@ export async function cacheInterceptor(event, resolvedRoutes) {
413
436
  const statusCode = computeStatusCode(event.rewriteStatusCode, cachedData.value.meta?.status);
414
437
  const headers = {
415
438
  ...cacheControl,
416
- ...cachedData.value.meta?.headers,
439
+ ...getCachedResponseHeaders(cachedData.value.meta?.headers),
417
440
  vary: VARY_HEADER,
418
441
  };
419
442
  // See the note in `generateResult`.
@@ -426,6 +426,17 @@ function handleRepeatedSlashRedirect(event) {
426
426
  }
427
427
  return false;
428
428
  }
429
+ /**
430
+ * Returns the internal trailing slash redirect for the request, if any.
431
+ *
432
+ * Mirrors the internal redirects that Next.js generates from `trailingSlash`. They apply to every
433
+ * path, including API routes, and run before the middleware so that it always sees the canonical
434
+ * pathname. Data requests (`/_next/data/<buildId>/*.json`) are not redirected.
435
+ * See https://github.com/vercel/next.js/blob/324b297/packages/next/src/lib/load-custom-routes.ts#L791-L849
436
+ *
437
+ * @param event The incoming request
438
+ * @returns The 308 redirect, or `false` when the path is already canonical
439
+ */
429
440
  function handleTrailingSlashRedirect(event) {
430
441
  // When rawPath is `//domain`, `url.host` would be `domain`.
431
442
  // https://github.com/opennextjs/opennextjs-aws/issues/355
@@ -433,14 +444,15 @@ function handleTrailingSlashRedirect(event) {
433
444
  if (
434
445
  // Someone is trying to redirect to a different origin, let's not do that
435
446
  url.host !== "localhost" ||
436
- NextConfig.skipTrailingSlashRedirect ||
437
- // We should not apply trailing slash redirect to API routes
438
- event.rawPath.startsWith("/api/")) {
447
+ NextConfig.skipTrailingSlashRedirect) {
439
448
  return false;
440
449
  }
441
450
  const emptyBody = emptyReadableStream();
442
451
  if (NextConfig.trailingSlash &&
443
- !(event.query.__nextDataReq === "1") &&
452
+ // Data requests (`/_next/data/<buildId>/*.json`) are never redirected, as with Next.js.
453
+ // The header is set by `fixDataPage`, a client supplied value is removed by the routing handler.
454
+ // The `__nextDataReq` query parameter can not be used as it is set by the client.
455
+ event.headers["x-nextjs-data"] !== "1" &&
444
456
  !event.rawPath.endsWith("/") &&
445
457
  !event.rawPath.match(/[\w-]+\.[\w]+$/g)) {
446
458
  const headersLocation = event.url.split("?");
@@ -8,6 +8,10 @@ type MiddlewareLoader = () => Promise<{
8
8
  *
9
9
  * Middleware request-header overrides are normalized and filtered to Next.js's allowlist.
10
10
  *
11
+ * Next.js internal headers are never exposed on the response.
12
+ * `x-middleware-set-cookie` is only forwarded on the request headers of the returned `MiddlewareEvent`,
13
+ * regardless of the allowlist.
14
+ *
11
15
  * @param internalEvent The internal request event.
12
16
  * @param initialSearch The initial query string received by the handler.
13
17
  * @param middlewareLoader The middleware loader, overridden by unit tests.
@@ -9,6 +9,8 @@ const middlewareManifest = MiddlewareManifest;
9
9
  const functionsConfigManifest = FunctionsConfigManifest;
10
10
  const middleMatch = getMiddlewareMatch(middlewareManifest, functionsConfigManifest);
11
11
  const REDIRECTS = new Set([301, 302, 303, 307, 308]);
12
+ // Internal header set by `NextResponse` when `response.cookies` is modified.
13
+ const MIDDLEWARE_SET_COOKIE_HEADER = "x-middleware-set-cookie";
12
14
  function defaultMiddlewareLoader() {
13
15
  // @ts-expect-error - This is bundled
14
16
  return import("./middleware.mjs");
@@ -18,6 +20,10 @@ function defaultMiddlewareLoader() {
18
20
  *
19
21
  * Middleware request-header overrides are normalized and filtered to Next.js's allowlist.
20
22
  *
23
+ * Next.js internal headers are never exposed on the response.
24
+ * `x-middleware-set-cookie` is only forwarded on the request headers of the returned `MiddlewareEvent`,
25
+ * regardless of the allowlist.
26
+ *
21
27
  * @param internalEvent The internal request event.
22
28
  * @param initialSearch The initial query string received by the handler.
23
29
  * @param middlewareLoader The middleware loader, overridden by unit tests.
@@ -92,6 +98,7 @@ export async function handleMiddleware(internalEvent, initialSearch, middlewareL
92
98
  const responseHeaders = result.headers;
93
99
  const reqHeaders = {};
94
100
  const resHeaders = {};
101
+ let middlewareSetCookie;
95
102
  // These are internal headers used by Next.js, we don't want to expose them to the client
96
103
  // NOTE: keys must be in lowercase to match the normalized header keys.
97
104
  const filteredHeaders = [
@@ -112,6 +119,13 @@ export async function handleMiddleware(internalEvent, initialSearch, middlewareL
112
119
  else {
113
120
  if (filteredHeaders.includes(normalizedKey))
114
121
  return;
122
+ // `x-middleware-set-cookie` holds a copy of the cookies set by the middleware,
123
+ // including the `HttpOnly` ones. It must never be sent to the client.
124
+ // It is only forwarded on the request (see below) so that `cookies().get()` sees them.
125
+ if (normalizedKey === MIDDLEWARE_SET_COOKIE_HEADER) {
126
+ middlewareSetCookie = value;
127
+ return;
128
+ }
115
129
  // Headers.forEach folds same-name headers; use getSetCookie() below instead.
116
130
  if (normalizedKey === "set-cookie")
117
131
  return;
@@ -139,6 +153,11 @@ export async function handleMiddleware(internalEvent, initialSearch, middlewareL
139
153
  const newHeaders = Object.fromEntries([...Object.entries(internalEvent.headers), ...Object.entries(reqHeaders)]
140
154
  .map(([key, value]) => [key.toLowerCase(), value])
141
155
  .filter(([key]) => !overriddenHeaders || overriddenHeaders.has(key)));
156
+ // Next.js forwards `x-middleware-set-cookie` after applying the override allowlist,
157
+ // so it is never filtered out (see `resolve-routes.ts`).
158
+ if (middlewareSetCookie !== undefined) {
159
+ newHeaders[MIDDLEWARE_SET_COOKIE_HEADER] = middlewareSetCookie;
160
+ }
142
161
  // If the middleware returned a Rewrite, set the `url` to the pathname of the rewrite
143
162
  // NOTE: the header was added to `req` from above
144
163
  const rewriteUrl = responseHeaders.get("x-middleware-rewrite");
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Returns whether a segment of the pathname is the placeholder of a matched dynamic App Router page.
3
+ *
4
+ * Next.js versions before 16.4.5 treat a param whose value is its placeholder (e.g. `[slug]` for `[slug]`) as an
5
+ * unresolved fallback param. Such values are not legitimate and must not reach Next.js.
6
+ *
7
+ * Segments are decoded like Next.js decodes param values (catch-all values are split on `/` first).
8
+ * Segments with a malformed percent encoding never match.
9
+ * Pathnames without an opening bracket return early, without matching the dynamic routes.
10
+ *
11
+ * @param pathname The internal pathname, which may include the basePath, a locale prefix and a trailing slash
12
+ * @returns Whether a segment of the pathname is a placeholder of a matched dynamic App Router page
13
+ */
14
+ export declare function hasRouteParamPlaceholder(pathname: string): boolean;
@@ -0,0 +1,42 @@
1
+ import { dynamicRouteMatcher } from "./routeMatcher";
2
+ // The placeholders of a route pattern: `[slug]`, `[...slug]` or `[[...slug]]`
3
+ const PLACEHOLDER_REGEX = /\[\[\.\.\.[^\]]+\]\]|\[(?:\.\.\.)?[^\]]+\]/g;
4
+ // A placeholder starts with `[`, which is either literal or percent encoded in the pathname
5
+ const OPENING_BRACKET_REGEX = /\[|%5b/i;
6
+ /**
7
+ * Returns whether a segment of the pathname is the placeholder of a matched dynamic App Router page.
8
+ *
9
+ * Next.js versions before 16.4.5 treat a param whose value is its placeholder (e.g. `[slug]` for `[slug]`) as an
10
+ * unresolved fallback param. Such values are not legitimate and must not reach Next.js.
11
+ *
12
+ * Segments are decoded like Next.js decodes param values (catch-all values are split on `/` first).
13
+ * Segments with a malformed percent encoding never match.
14
+ * Pathnames without an opening bracket return early, without matching the dynamic routes.
15
+ *
16
+ * @param pathname The internal pathname, which may include the basePath, a locale prefix and a trailing slash
17
+ * @returns Whether a segment of the pathname is a placeholder of a matched dynamic App Router page
18
+ */
19
+ export function hasRouteParamPlaceholder(pathname) {
20
+ if (!OPENING_BRACKET_REGEX.test(pathname)) {
21
+ return false;
22
+ }
23
+ const placeholders = new Set(dynamicRouteMatcher(pathname)
24
+ .filter(({ type }) => type === "app")
25
+ .flatMap(({ route }) => route.match(PLACEHOLDER_REGEX) ?? []));
26
+ return (placeholders.size > 0 &&
27
+ pathname.split("/").some((segment) => placeholders.has(safeDecode(segment))));
28
+ }
29
+ /**
30
+ * Decodes a URI component without throwing.
31
+ *
32
+ * @param value The encoded value
33
+ * @returns The decoded value, or an empty string when the encoding is malformed
34
+ */
35
+ function safeDecode(value) {
36
+ try {
37
+ return decodeURIComponent(value);
38
+ }
39
+ catch {
40
+ return "";
41
+ }
42
+ }
@@ -6,6 +6,7 @@ import { detectLocale } from "./routing/i18n";
6
6
  import { fixDataPage, getNextConfigHeaders, handleBeforeFilesRewrites, handleFallbackFalse, handleRedirects, handleRewrites, } from "./routing/matcher";
7
7
  import { handleMiddleware } from "./routing/middleware";
8
8
  import { dynamicRouteMatcher, staticRouteMatcher, } from "./routing/routeMatcher";
9
+ import { hasRouteParamPlaceholder } from "./routing/routeParamPlaceholder";
9
10
  import { constructNextUrl, normalizeLocationHeader } from "./routing/util";
10
11
  export const MIDDLEWARE_HEADER_PREFIX = "x-middleware-response-";
11
12
  export const MIDDLEWARE_HEADER_PREFIX_LEN = MIDDLEWARE_HEADER_PREFIX.length;
@@ -159,7 +160,7 @@ export default async function routingHandler(event, { assetResolver }) {
159
160
  const isRouteFoundBeforeAllRewrites = isStaticRoute || isDynamicRoute || isExternalRewrite;
160
161
  // Resolve against the final internal pathname after every rewrite phase.
161
162
  // These ordered matches are also the authoritative cache owner candidates.
162
- const resolvedRoutes = [
163
+ let resolvedRoutes = [
163
164
  ...staticRouteMatcher(eventOrResult.rawPath),
164
165
  ...dynamicRouteMatcher(eventOrResult.rawPath),
165
166
  ];
@@ -169,7 +170,12 @@ export default async function routingHandler(event, { assetResolver }) {
169
170
  isNextImageRoute ||
170
171
  // We need to check again once all rewrites have been applied
171
172
  staticRouteMatcher(eventOrResult.rawPath).length > 0 ||
172
- dynamicRouteMatcher(eventOrResult.rawPath).length > 0)) {
173
+ dynamicRouteMatcher(eventOrResult.rawPath).length > 0) ||
174
+ // Next.js versions before 16.4.5 treat a param whose value is its placeholder (e.g. `[slug]`) as unresolved.
175
+ // Next.js receives both the rawPath and the URL pathname, which is normalized (e.g. dot segments).
176
+ (!isExternalRewrite &&
177
+ [eventOrResult.rawPath, new URL(eventOrResult.url).pathname].some(hasRouteParamPlaceholder))) {
178
+ resolvedRoutes = [];
173
179
  eventOrResult = {
174
180
  ...eventOrResult,
175
181
  rawPath: "/404",
@@ -0,0 +1,35 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { type Writable } from "node:stream";
3
+ /**
4
+ * Largest `content-length` that is verified by buffering the body.
5
+ *
6
+ * It matches the largest response compressed by CloudFront, which only compresses responses with a `content-length`.
7
+ * Larger responses are streamed without `content-length`.
8
+ */
9
+ export declare const MAX_VERIFIED_CONTENT_LENGTH = 10000000;
10
+ /**
11
+ * Writes the head of a Node.js `ServerResponse` and returns the stream to write the body to.
12
+ *
13
+ * The framing headers set by the app may not match the body that is actually written (e.g. a `fetch()` response
14
+ * body is decoded but keeps the upstream `content-length`). The bytes past a stale `content-length` would stay on
15
+ * the keep-alive connection and be read as the next response.
16
+ *
17
+ * - `transfer-encoding` is always removed, Node.js picks the transfer coding.
18
+ * - A `content-length` that is not a valid length is removed.
19
+ * - `content-length` is kept for HEAD requests and 304 responses, Node.js does not write a body for them.
20
+ * - Otherwise `content-length` is only kept when the body has exactly the declared length. The body is buffered
21
+ * until this is known, and the head is written then, so the first byte is only sent once the whole body is
22
+ * received. When the length doesn't match, or is larger than `MAX_VERIFIED_CONTENT_LENGTH`, `content-length` is
23
+ * removed and Node.js frames the body itself (chunked on HTTP/1.1).
24
+ *
25
+ * The status code and the headers are always validated synchronously, even when the head is written later.
26
+ *
27
+ * @param res The Node.js response.
28
+ * @param method The request method.
29
+ * @param statusCode The response status code.
30
+ * @param headers The response headers, keys are matched case-insensitively.
31
+ * @returns The stream to write the body to.
32
+ * @throws When the status code or a header is invalid, or the headers were already sent (same errors as
33
+ * `res.writeHead()`).
34
+ */
35
+ export declare function writeResponseHead(res: ServerResponse, method: string | undefined, statusCode: number, headers: Record<string, string>): Writable;
@@ -0,0 +1,181 @@
1
+ import { Transform } from "node:stream";
2
+ import { error } from "../adapters/logger";
3
+ /**
4
+ * Largest `content-length` that is verified by buffering the body.
5
+ *
6
+ * It matches the largest response compressed by CloudFront, which only compresses responses with a `content-length`.
7
+ * Larger responses are streamed without `content-length`.
8
+ */
9
+ export const MAX_VERIFIED_CONTENT_LENGTH = 10_000_000;
10
+ /**
11
+ * Writes the head of a Node.js `ServerResponse` and returns the stream to write the body to.
12
+ *
13
+ * The framing headers set by the app may not match the body that is actually written (e.g. a `fetch()` response
14
+ * body is decoded but keeps the upstream `content-length`). The bytes past a stale `content-length` would stay on
15
+ * the keep-alive connection and be read as the next response.
16
+ *
17
+ * - `transfer-encoding` is always removed, Node.js picks the transfer coding.
18
+ * - A `content-length` that is not a valid length is removed.
19
+ * - `content-length` is kept for HEAD requests and 304 responses, Node.js does not write a body for them.
20
+ * - Otherwise `content-length` is only kept when the body has exactly the declared length. The body is buffered
21
+ * until this is known, and the head is written then, so the first byte is only sent once the whole body is
22
+ * received. When the length doesn't match, or is larger than `MAX_VERIFIED_CONTENT_LENGTH`, `content-length` is
23
+ * removed and Node.js frames the body itself (chunked on HTTP/1.1).
24
+ *
25
+ * The status code and the headers are always validated synchronously, even when the head is written later.
26
+ *
27
+ * @param res The Node.js response.
28
+ * @param method The request method.
29
+ * @param statusCode The response status code.
30
+ * @param headers The response headers, keys are matched case-insensitively.
31
+ * @returns The stream to write the body to.
32
+ * @throws When the status code or a header is invalid, or the headers were already sent (same errors as
33
+ * `res.writeHead()`).
34
+ */
35
+ export function writeResponseHead(res, method, statusCode, headers) {
36
+ validateHead(res, statusCode);
37
+ let contentLength;
38
+ for (const [key, value] of Object.entries(headers)) {
39
+ const lowerKey = key.toLowerCase();
40
+ if (lowerKey === "content-length") {
41
+ contentLength = value;
42
+ }
43
+ else if (lowerKey !== "transfer-encoding") {
44
+ // `setHeader()` validates the header like `writeHead()` does
45
+ res.setHeader(key, value);
46
+ }
47
+ }
48
+ const expectedLength = parseContentLength(contentLength);
49
+ if (!Number.isNaN(expectedLength) &&
50
+ (method?.toUpperCase() === "HEAD" || statusCode === 304)) {
51
+ res.setHeader("content-length", String(expectedLength));
52
+ res.writeHead(statusCode);
53
+ res.flushHeaders();
54
+ return res;
55
+ }
56
+ if (statusCode === 204 || !(expectedLength <= MAX_VERIFIED_CONTENT_LENGTH)) {
57
+ res.writeHead(statusCode);
58
+ res.flushHeaders();
59
+ return res;
60
+ }
61
+ const body = new ContentLengthVerifier(res, statusCode, expectedLength);
62
+ body.pipe(res);
63
+ return body;
64
+ }
65
+ /**
66
+ * Checks that the head can be written, the way `res.writeHead()` does.
67
+ *
68
+ * The headers are validated by `res.setHeader()`.
69
+ *
70
+ * @param res The Node.js response.
71
+ * @param statusCode The response status code.
72
+ * @throws {Error} When the headers were already sent (code `ERR_HTTP_HEADERS_SENT`).
73
+ * @throws {RangeError} When the status code is not in 100-999 (code `ERR_HTTP_INVALID_STATUS_CODE`).
74
+ */
75
+ function validateHead(res, statusCode) {
76
+ if (res.headersSent) {
77
+ throw Object.assign(new Error("Cannot write headers after they are sent to the client"), { code: "ERR_HTTP_HEADERS_SENT" });
78
+ }
79
+ const code = statusCode | 0;
80
+ if (code < 100 || code > 999) {
81
+ throw Object.assign(new RangeError(`Invalid status code: ${statusCode}`), {
82
+ code: "ERR_HTTP_INVALID_STATUS_CODE",
83
+ });
84
+ }
85
+ }
86
+ /**
87
+ * Parses a `content-length` header value.
88
+ *
89
+ * @param contentLength The header value.
90
+ * @returns The length, or `NaN` when the value is missing or not a valid length.
91
+ */
92
+ function parseContentLength(contentLength) {
93
+ const value = contentLength?.trim() ?? "";
94
+ const length = /^\d+$/.test(value) ? Number(value) : Number.NaN;
95
+ return Number.isSafeInteger(length) ? length : Number.NaN;
96
+ }
97
+ /**
98
+ * Buffers the body until it is known whether it has the declared length, then writes the head and the body.
99
+ *
100
+ * The headers, other than `content-length`, are already set on the response.
101
+ */
102
+ class ContentLengthVerifier extends Transform {
103
+ res;
104
+ statusCode;
105
+ expectedLength;
106
+ chunks = [];
107
+ length = 0;
108
+ headWritten = false;
109
+ /**
110
+ * @param res The Node.js response.
111
+ * @param statusCode The response status code, already validated.
112
+ * @param expectedLength The declared `content-length`.
113
+ */
114
+ constructor(res, statusCode, expectedLength) {
115
+ super();
116
+ this.res = res;
117
+ this.statusCode = statusCode;
118
+ this.expectedLength = expectedLength;
119
+ }
120
+ /**
121
+ * Buffers the chunk until the head is written, then passes it through.
122
+ *
123
+ * The head is written without `content-length` as soon as the body is longer than declared.
124
+ *
125
+ * @param chunk The body chunk.
126
+ * @param _encoding Unused, chunks are buffers.
127
+ * @param callback Called once the chunk is processed.
128
+ */
129
+ _transform(chunk, _encoding, callback) {
130
+ if (this.headWritten) {
131
+ callback(null, chunk);
132
+ return;
133
+ }
134
+ this.chunks.push(chunk);
135
+ this.length += chunk.length;
136
+ if (this.length > this.expectedLength) {
137
+ this.writeHead(false);
138
+ }
139
+ callback();
140
+ }
141
+ /**
142
+ * Writes the head, when not already written, once the whole body is received.
143
+ *
144
+ * @param callback Called once the head and the buffered chunks are written.
145
+ */
146
+ _flush(callback) {
147
+ if (!this.headWritten) {
148
+ this.writeHead(this.length === this.expectedLength);
149
+ }
150
+ callback();
151
+ }
152
+ /**
153
+ * Writes the head and the buffered chunks.
154
+ *
155
+ * This runs from the stream callbacks, where a thrown error would be uncaught and crash the process. The
156
+ * head is validated synchronously in `writeResponseHead()`, if writing it still fails, the response is destroyed.
157
+ *
158
+ * @param keepContentLength Whether the body has the declared length.
159
+ */
160
+ writeHead(keepContentLength) {
161
+ this.headWritten = true;
162
+ const chunks = this.chunks;
163
+ this.chunks = [];
164
+ try {
165
+ if (keepContentLength) {
166
+ this.res.setHeader("content-length", String(this.expectedLength));
167
+ }
168
+ this.res.writeHead(this.statusCode);
169
+ }
170
+ catch (e) {
171
+ error("Failed to write the response head", e);
172
+ this.res.destroy();
173
+ // Without an error, no `error` event is emitted
174
+ this.destroy();
175
+ return;
176
+ }
177
+ for (const chunk of chunks) {
178
+ this.push(chunk);
179
+ }
180
+ }
181
+ }
@@ -8,6 +8,17 @@ export declare const convertHeader: (header: http.OutgoingHttpHeader) => string;
8
8
  * @returns A list of Set-Cookie header
9
9
  */
10
10
  export declare function parseSetCookieHeader(cookies: string | string[] | null | undefined): string[];
11
+ /**
12
+ * Converts the headers of a response returned by an edge function.
13
+ *
14
+ * `Set-Cookie` headers are returned as a list.
15
+ * The internal `x-middleware-set-cookie` header is dropped: `NextResponse` sets it when
16
+ * `response.cookies` is modified (i.e. in a route handler) and it contains the `HttpOnly` cookies.
17
+ *
18
+ * @param headers The headers of the response
19
+ * @returns The response headers to send to the client
20
+ */
21
+ export declare function convertEdgeResponseHeaders(headers: Pick<Headers, "forEach" | "getSetCookie">): Record<string, string | string[]>;
11
22
  /**
12
23
  *
13
24
  * Get the query object from an iterable of [key, value] pairs
package/dist/http/util.js CHANGED
@@ -58,6 +58,32 @@ export function parseSetCookieHeader(cookies) {
58
58
  }
59
59
  return cookies;
60
60
  }
61
+ /**
62
+ * Converts the headers of a response returned by an edge function.
63
+ *
64
+ * `Set-Cookie` headers are returned as a list.
65
+ * The internal `x-middleware-set-cookie` header is dropped: `NextResponse` sets it when
66
+ * `response.cookies` is modified (i.e. in a route handler) and it contains the `HttpOnly` cookies.
67
+ *
68
+ * @param headers The headers of the response
69
+ * @returns The response headers to send to the client
70
+ */
71
+ export function convertEdgeResponseHeaders(headers) {
72
+ const responseHeaders = {};
73
+ headers.forEach((value, key) => {
74
+ const lowerKey = key.toLowerCase();
75
+ // Headers.forEach folds same-name headers; use getSetCookie() below instead.
76
+ if (lowerKey === "set-cookie" || lowerKey === "x-middleware-set-cookie") {
77
+ return;
78
+ }
79
+ responseHeaders[key] = value;
80
+ });
81
+ const setCookies = headers.getSetCookie();
82
+ if (setCookies.length > 0) {
83
+ responseHeaders["set-cookie"] = setCookies;
84
+ }
85
+ return responseHeaders;
86
+ }
61
87
  /**
62
88
  *
63
89
  * Get the query object from an iterable of [key, value] pairs
@@ -4,7 +4,11 @@ const fetchProxy = {
4
4
  // @ts-ignore
5
5
  proxy: async (internalEvent) => {
6
6
  const { url, headers: eventHeaders, method, body } = internalEvent;
7
- const headers = Object.fromEntries(Object.entries(eventHeaders).filter(([key]) => key.toLowerCase() !== "cf-connecting-ip"));
7
+ const headers = Object.fromEntries(Object.entries(eventHeaders).filter(([key]) => !["cf-connecting-ip", "x-forwarded-host"].includes(key.toLowerCase())));
8
+ // Like Next.js, do not relay the `x-forwarded-host` sent by the client
9
+ if (eventHeaders.host) {
10
+ headers["x-forwarded-host"] = eventHeaders.host;
11
+ }
8
12
  const response = await fetch(url, {
9
13
  method,
10
14
  headers,
@@ -6,6 +6,7 @@ function filterHeadersForProxy(headers) {
6
6
  const filteredHeaders = {};
7
7
  const disallowedHeaders = [
8
8
  "host",
9
+ "x-forwarded-host",
9
10
  "connection",
10
11
  "via",
11
12
  "x-cache",
@@ -30,6 +31,10 @@ const nodeProxy = {
30
31
  debug("proxyRequest", url);
31
32
  return new Promise((resolve, reject) => {
32
33
  const filteredHeaders = filterHeadersForProxy(headers);
34
+ // Like Next.js, do not relay the `x-forwarded-host` sent by the client
35
+ if (headers.host) {
36
+ filteredHeaders["x-forwarded-host"] = headers.host;
37
+ }
33
38
  debug("filteredHeaders", filteredHeaders);
34
39
  const req = request(url, {
35
40
  headers: filteredHeaders,
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import express from "express";
3
3
  import { NextConfig } from "../../adapters/config/index.js";
4
+ import { writeResponseHead } from "../../http/framing.js";
4
5
  import { getMonorepoRelativePath } from "../../utils/normalize-path.js";
5
6
  const wrapper = async (handler, converter) => {
6
7
  const app = express();
@@ -16,8 +17,7 @@ const wrapper = async (handler, converter) => {
16
17
  const internalEvent = await converter.convertFrom(req);
17
18
  const streamCreator = {
18
19
  writeHeaders: (prelude) => {
19
- res.writeHead(prelude.statusCode, prelude.headers);
20
- return res;
20
+ return writeResponseHead(res, req.method, prelude.statusCode, prelude.headers);
21
21
  },
22
22
  };
23
23
  await imageHandler(internalEvent, { streamCreator });
@@ -34,9 +34,7 @@ const wrapper = async (handler, converter) => {
34
34
  const streamCreator = {
35
35
  writeHeaders: (prelude) => {
36
36
  res.setHeader("Set-Cookie", prelude.cookies);
37
- res.writeHead(prelude.statusCode, prelude.headers);
38
- res.flushHeaders();
39
- return res;
37
+ return writeResponseHead(res, req.method, prelude.statusCode, prelude.headers);
40
38
  },
41
39
  onFinish: () => { },
42
40
  abortSignal: abortController.signal,
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:http";
2
+ import { writeResponseHead } from "../../http/framing.js";
2
3
  import { debug, error } from "../../adapters/logger";
3
4
  const wrapper = async (handler, converter) => {
4
5
  const server = createServer(async (req, res) => {
@@ -7,9 +8,7 @@ const wrapper = async (handler, converter) => {
7
8
  const streamCreator = {
8
9
  writeHeaders: (prelude) => {
9
10
  res.setHeader("Set-Cookie", prelude.cookies);
10
- res.writeHead(prelude.statusCode, prelude.headers);
11
- res.flushHeaders();
12
- return res;
11
+ return writeResponseHead(res, req.method, prelude.statusCode, prelude.headers);
13
12
  },
14
13
  abortSignal: abortController.signal,
15
14
  };
@@ -1,4 +1,18 @@
1
1
  import type { CacheValue, NextModeTagCacheWriteInput, OriginalTagCacheWriteInput, WithLastModified } from "../types/overrides.js";
2
+ /**
3
+ * Computes the key used to store a composable (`"use cache"`) entry.
4
+ *
5
+ * The cache key provided by Next.js embeds the serialized arguments of the cached function and may
6
+ * contain any UTF-16 string. Storage backends transform keys (path normalization, URL parsing,
7
+ * UTF-8 encoding, ...) which could map distinct Next.js keys to the same stored entry.
8
+ *
9
+ * The storage key is the sha256 of the exact UTF-16 code units of the cache key, as a 64 characters
10
+ * lowercase hex string that every backend can store as is.
11
+ *
12
+ * @param cacheKey The cache key provided by Next.js
13
+ * @returns The key to use for the incremental cache and the tag cache
14
+ */
15
+ export declare function getComposableCacheStorageKey(cacheKey: string): string;
2
16
  /**
3
17
  *
4
18
  * @param key The key for that specific cache entry
@@ -1,6 +1,26 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
1
3
  import { debug } from "../adapters/logger";
2
4
  import { CACHE_TAGS_HEADER } from "./cacheHeaders";
3
5
  import { compareSemver } from "./semver";
6
+ /**
7
+ * Computes the key used to store a composable (`"use cache"`) entry.
8
+ *
9
+ * The cache key provided by Next.js embeds the serialized arguments of the cached function and may
10
+ * contain any UTF-16 string. Storage backends transform keys (path normalization, URL parsing,
11
+ * UTF-8 encoding, ...) which could map distinct Next.js keys to the same stored entry.
12
+ *
13
+ * The storage key is the sha256 of the exact UTF-16 code units of the cache key, as a 64 characters
14
+ * lowercase hex string that every backend can store as is.
15
+ *
16
+ * @param cacheKey The cache key provided by Next.js
17
+ * @returns The key to use for the incremental cache and the tag cache
18
+ */
19
+ export function getComposableCacheStorageKey(cacheKey) {
20
+ return createHash("sha256")
21
+ .update(Buffer.from(cacheKey, "utf16le"))
22
+ .digest("hex");
23
+ }
4
24
  /**
5
25
  *
6
26
  * @param key The key for that specific cache entry
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "access": "public"
4
4
  },
5
5
  "name": "@opennextjs/aws",
6
- "version": "4.1.14",
6
+ "version": "4.1.15",
7
7
  "bin": {
8
8
  "open-next": "./dist/index.js"
9
9
  },
@@ -57,13 +57,13 @@
57
57
  "@types/node": "20.17.6",
58
58
  "concurrently": "^9.2.4",
59
59
  "diff": "^9.0.0",
60
- "next": "16.4.1",
60
+ "next": "16.4.5",
61
61
  "tsc-alias": "^1.9.7",
62
62
  "typescript": "5.9.3"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "diff": "^9.0.0",
66
- "next": ">=15.5.28 <16 || >=16.4.1"
66
+ "next": ">=15.5.29 <16 || >=16.4.5"
67
67
  },
68
68
  "peerDependenciesMeta": {
69
69
  "diff": {