@opennextjs/aws 4.1.4 → 4.1.6

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.
@@ -11,6 +11,7 @@ export default class Cache {
11
11
  getFetchCache(key: string, softTags?: string[], tags?: string[]): Promise<CacheHandlerValue | null>;
12
12
  getIncrementalCache(key: string): Promise<CacheHandlerValue | null>;
13
13
  set(key: string, data?: IncrementalCacheValue, ctx?: IncrementalCacheContext): Promise<void>;
14
+ private writeCache;
14
15
  revalidateTag(tags: string | string[], durations?: {
15
16
  expire?: number;
16
17
  }): Promise<void>;
@@ -195,11 +195,18 @@ export default class Cache {
195
195
  if (globalThis.openNextConfig.dangerous?.disableIncrementalCache) {
196
196
  return;
197
197
  }
198
- // This one might not even be necessary anymore
199
- // Better be safe than sorry
200
- const detachedPromise = globalThis.__openNextAls
201
- .getStore()
202
- ?.pendingPromiseRunner.withResolvers();
198
+ const store = globalThis.__openNextAls.getStore();
199
+ const writePromise = this.writeCache(key, data, ctx);
200
+ // Next.js ignores the value returned by `set`, so the write is registered on the request's
201
+ // pending promise runner instead of being awaited. `FETCH` writes are awaited: Next.js runs
202
+ // them in a detached chain of its own that only covers the write while `set` waits for it.
203
+ if (data?.kind === "FETCH" || store === undefined) {
204
+ await writePromise;
205
+ return;
206
+ }
207
+ store.pendingPromiseRunner.add(writePromise);
208
+ }
209
+ async writeCache(key, data, ctx) {
203
210
  try {
204
211
  if (data === null || data === undefined) {
205
212
  await globalThis.incrementalCache.delete(key);
@@ -292,10 +299,6 @@ export default class Cache {
292
299
  catch (e) {
293
300
  error("Failed to set cache", e);
294
301
  }
295
- finally {
296
- // We need to resolve the promise even if there was an error
297
- detachedPromise?.resolve();
298
- }
299
302
  }
300
303
  async revalidateTag(tags, durations) {
301
304
  const config = globalThis.openNextConfig.dangerous;
@@ -1,13 +1,34 @@
1
1
  import { isStale, writeTags } from "../utils/cache";
2
2
  import { fromReadableStream, toReadableStream } from "../utils/stream";
3
3
  import { debug } from "./logger";
4
- const pendingWritePromiseMap = new Map();
4
+ /**
5
+ * In-flight writes are tracked per request, not per module.
6
+ *
7
+ * A module-scope map is per isolate. On a runtime that serves one request per
8
+ * sandbox that is the same as per request, but on a runtime that serves many
9
+ * concurrent requests from one isolate (e.g. Cloudflare Workers) a pending
10
+ * write promise created in request A would be handed to request B. If A's
11
+ * context is torn down before the write settles, B awaits a promise that never
12
+ * resolves, `.finally()` never runs, and the key stays poisoned for the life of
13
+ * the isolate.
14
+ *
15
+ * `RequestCache` is the per-request store the tag caches already use for this.
16
+ * Scoping the map to it keeps the read-coherence a `get` needs for a write in
17
+ * flight from the same render while making a cross-request share impossible.
18
+ * With no ALS store (e.g. at build time) the dedupe is skipped and `get` falls
19
+ * through to the incremental cache.
20
+ */
21
+ const PENDING_WRITES_CACHE_KEY = "composable-cache:pending-writes";
22
+ const getPendingWritePromiseMap = () => globalThis.__openNextAls
23
+ ?.getStore()
24
+ ?.requestCache?.getOrCreate(PENDING_WRITES_CACHE_KEY);
5
25
  export default {
6
26
  async get(cacheKey) {
7
27
  try {
8
28
  // We first check if we have a pending write for this cache key
9
29
  // If we do, we return the pending promise instead of fetching the cache
10
- if (pendingWritePromiseMap.has(cacheKey)) {
30
+ const pendingWritePromiseMap = getPendingWritePromiseMap();
31
+ if (pendingWritePromiseMap?.has(cacheKey)) {
11
32
  const stored = pendingWritePromiseMap.get(cacheKey);
12
33
  if (stored) {
13
34
  return stored.then((entry) => ({
@@ -69,9 +90,10 @@ export default {
69
90
  ...entry,
70
91
  value: await fromReadableStream(entry.value),
71
92
  }));
72
- pendingWritePromiseMap.set(cacheKey, promiseEntry);
93
+ const pendingWritePromiseMap = getPendingWritePromiseMap();
94
+ pendingWritePromiseMap?.set(cacheKey, promiseEntry);
73
95
  const entry = await promiseEntry.finally(() => {
74
- pendingWritePromiseMap.delete(cacheKey);
96
+ pendingWritePromiseMap?.delete(cacheKey);
75
97
  });
76
98
  await globalThis.incrementalCache.set(cacheKey, {
77
99
  ...entry,
@@ -15,5 +15,10 @@ export function buildNextjsApp(options) {
15
15
  cp.execSync(command, {
16
16
  stdio: "inherit",
17
17
  cwd: path.dirname(options.appPackageJsonPath),
18
+ // `setStandaloneBuildMode` mutates `process.env` right before this call.
19
+ // Node forwards those mutations to the child by default, but Bun resolves
20
+ // the child environment from a snapshot taken at startup unless `env` is
21
+ // passed explicitly, so `NEXT_PRIVATE_STANDALONE` never reaches the build.
22
+ env: process.env,
18
23
  });
19
24
  }
@@ -168,6 +168,7 @@ async function generateBundle(name, options, fnOptions, codeCustomization) {
168
168
  ? ["experimentalIncrementalCacheHandler"]
169
169
  : ["stableIncrementalCache"]),
170
170
  ...(isAfter152 ? [] : ["composableCache"]),
171
+ ...(isAfter152 ? [] : ["topLevelComposableCache"]),
171
172
  ],
172
173
  }),
173
174
  openNextResolvePlugin({
@@ -1,4 +1,4 @@
1
- export declare const rule = "\nrule:\n kind: binary_expression\n all:\n - has:\n kind: unary_expression\n regex: \"!cachedResponse.isStale\"\n - has:\n kind: member_expression\n regex: \"context.isPrefetch\"\nfix:\n 'true'";
1
+ export declare const rule = "\nrule:\n kind: binary_expression\n all:\n - has:\n kind: unary_expression\n pattern: \"!$ENTRY.isStale\"\n - has:\n kind: member_expression\n regex: \"context.isPrefetch\"\nfix:\n 'true'";
2
2
  export declare const patchBackgroundRevalidation: {
3
3
  name: string;
4
4
  patches: {
@@ -6,7 +6,7 @@ rule:
6
6
  all:
7
7
  - has:
8
8
  kind: unary_expression
9
- regex: "!cachedResponse.isStale"
9
+ pattern: "!$ENTRY.isStale"
10
10
  - has:
11
11
  kind: member_expression
12
12
  regex: "context.isPrefetch"
@@ -38,7 +38,9 @@ async function computeCacheControl(path, body, host, revalidate, lastModified, i
38
38
  // calculate age
39
39
  const age = Math.round((Date.now() - (lastModified ?? 0)) / 1000);
40
40
  const hash = (str) => createHash("md5").update(str).digest("hex");
41
- const etag = hash(body);
41
+ // RFC 9110 entity-tag is a quoted opaque-tag. CloudFront drops unquoted ETags
42
+ // when serving compressed objects.
43
+ const etag = `"${hash(body)}"`;
42
44
  if (revalidate === 0) {
43
45
  // This one should never happen
44
46
  return {
@@ -102,18 +104,33 @@ function getBodyForAppRouter(event, cachedValue) {
102
104
  if (cachedValue.type !== "app") {
103
105
  throw new Error("getBodyForAppRouter called with non-app cache value");
104
106
  }
105
- const segmentHeader = `${event.headers[NEXT_SEGMENT_PREFETCH_HEADER]}`;
106
- const isSegmentResponse = Boolean(segmentHeader) &&
107
- segmentHeader in (cachedValue.segmentData || {}) &&
108
- !NextConfig.experimental?.prefetchInlining;
109
- if (isSegmentResponse) {
110
- return {
111
- body: cachedValue.segmentData[segmentHeader],
112
- additionalHeaders: {
113
- [NEXT_PRERENDER_HEADER]: "1",
114
- [NEXT_POSTPONED_HEADER]: "2",
115
- },
116
- };
107
+ const segmentHeader = event.headers[NEXT_SEGMENT_PREFETCH_HEADER];
108
+ // A request from the client Segment Cache has to be answered with the segment it asked
109
+ // for. The full page payload is not a different-but-valid answer: the router never
110
+ // records the prefetch as satisfied and re-requests it forever.
111
+ // This mirrors how Next serves a cached app page - see the `segmentPrefetchHeader`
112
+ // branch of `packages/next/src/build/templates/app-page.ts`, which responds with the
113
+ // matching segment or an empty 404, and never with the full page.
114
+ //
115
+ // `experimental.prefetchInlining` is deliberately not consulted. Next normalizes every
116
+ // truthy value - including the 16.2+ default - into `{ maxSize, maxBundleSize }`, so it
117
+ // must never be read as a flag. More importantly it has no say here: inlining only
118
+ // changes *which* segments the build emits (an emitted segment then being a bundle that
119
+ // already holds its inlined ancestors), and `segmentData` holds exactly the segments
120
+ // that were emitted, which is exactly the set the router asks for.
121
+ if (typeof segmentHeader === "string" && cachedValue.segmentData) {
122
+ if (Object.hasOwn(cachedValue.segmentData, segmentHeader)) {
123
+ return {
124
+ body: cachedValue.segmentData[segmentHeader],
125
+ additionalHeaders: {
126
+ [NEXT_PRERENDER_HEADER]: "1",
127
+ [NEXT_POSTPONED_HEADER]: "2",
128
+ },
129
+ };
130
+ }
131
+ // The entry holds segments, but not this one. Next answers with an empty 404 here -
132
+ // let the server do that rather than serving a payload of a different shape.
133
+ return undefined;
117
134
  }
118
135
  // `rsc` is absent when the build collected neither a `.rsc` nor a `.prefetch.rsc` file for
119
136
  // this entry - fallback shells, and postponed PPR routes on Next 16.2+, see `CachedFile`.
@@ -1,4 +1,5 @@
1
1
  import { FunctionsConfigManifest, MiddlewareManifest, NextConfig, PrerenderManifest, } from "../../adapters/config/index.js";
2
+ import { parseSetCookieHeader } from "../../http/util.js";
2
3
  import { ISR_HEADER, PRERENDER_REVALIDATE_HEADER } from "../../utils/cacheHeaders.js";
3
4
  import { emptyReadableStream } from "../../utils/stream.js";
4
5
  import { getQueryFromSearchParams } from "../../overrides/converters/utils.js";
@@ -112,7 +113,12 @@ export async function handleMiddleware(internalEvent, initialSearch, middlewareL
112
113
  }
113
114
  }
114
115
  });
115
- const setCookies = responseHeaders.getSetCookie();
116
+ // Next folds the cookies set through `cookies()` in the middleware into a single
117
+ // comma-joined `set-cookie` header, so each entry here may be compound.
118
+ // Next itself splits them back in `runMiddleware` (`next-server.ts`).
119
+ const setCookies = responseHeaders
120
+ .getSetCookie()
121
+ .flatMap((maybeCompoundCookie) => parseSetCookieHeader(maybeCompoundCookie));
116
122
  if (setCookies.length > 0) {
117
123
  resHeaders["set-cookie"] = setCookies;
118
124
  }
package/dist/core/util.js CHANGED
@@ -33,6 +33,19 @@ const nextServer = new NextServer.default({
33
33
  cacheHandler: cacheHandlerPath,
34
34
  cacheMaxMemorySize: 0, // We need to disable memory cache
35
35
  //#endOverride
36
+ // Next.js 16 promoted `cacheHandlers` out of `experimental` to a top-level
37
+ // config key. At runtime `route-module.js#loadCustomCacheHandlers` reads
38
+ // `nextConfig.cacheHandlers` (top-level) — the nested `experimental` copy
39
+ // below is ignored on Next >= 16, so the composable ("use cache") handler
40
+ // must also be registered here or Next falls back to its in-memory default
41
+ // and composable entries never reach the incremental cache override.
42
+ // Older Next versions ignore this unknown top-level key and read the
43
+ // `experimental` copy instead; setting both is safe across versions.
44
+ //#override topLevelComposableCache
45
+ cacheHandlers: {
46
+ default: composableCacheHandlerPath,
47
+ },
48
+ //#endOverride
36
49
  experimental: {
37
50
  ...NextConfig.experimental,
38
51
  // This uses the request.headers.host as the URL
@@ -2,24 +2,49 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getMonorepoRelativePath } from "../../utils/normalize-path";
4
4
  const buildId = process.env.OPEN_NEXT_BUILD_ID;
5
- const basePath = path.join(getMonorepoRelativePath(), `cache/${buildId}`);
6
- const getCacheKey = (key) => {
7
- return path.join(basePath, `${key}.cache`);
5
+ const basePath = path.join(getMonorepoRelativePath(), "cache");
6
+ /**
7
+ * Resolves a cache entry using the same layout as the build's cache assets.
8
+ *
9
+ * @param key The cache entry key
10
+ * @param cacheType The cache namespace, defaulting to the page/route cache
11
+ * @returns The file path for the entry
12
+ */
13
+ const getCacheKey = (key, cacheType = "cache") => {
14
+ return path.join(basePath, cacheType === "fetch" ? "__fetch" : "", `${buildId}`, cacheType === "fetch" ? key : `${key}.${cacheType}`);
8
15
  };
9
16
  const cache = {
10
17
  name: "fs-dev",
11
- get: async (key) => {
12
- const fileData = await fs.readFile(getCacheKey(key), "utf-8");
18
+ /**
19
+ * Reads an entry from the selected cache namespace.
20
+ *
21
+ * @param key The cache entry key
22
+ * @param cacheType The cache namespace, defaulting to the page/route cache
23
+ * @returns The cached value and its last modification time
24
+ * @throws When the entry cannot be read or contains invalid JSON
25
+ */
26
+ get: async (key, cacheType) => {
27
+ const cacheKey = getCacheKey(key, cacheType);
28
+ const fileData = await fs.readFile(cacheKey, "utf-8");
13
29
  const data = JSON.parse(fileData);
14
- const { mtime } = await fs.stat(getCacheKey(key));
30
+ const { mtime } = await fs.stat(cacheKey);
15
31
  return {
16
32
  value: data,
17
33
  lastModified: mtime.getTime(),
18
34
  };
19
35
  },
20
- set: async (key, value, isFetch) => {
36
+ /**
37
+ * Writes an entry to the selected cache namespace.
38
+ *
39
+ * @param key The cache entry key
40
+ * @param value The value to persist
41
+ * @param cacheType The cache namespace, defaulting to the page/route cache
42
+ * @returns A promise that resolves when the entry has been written
43
+ * @throws When serialization or a file system operation fails
44
+ */
45
+ set: async (key, value, cacheType) => {
21
46
  const data = JSON.stringify(value);
22
- const cacheKey = getCacheKey(key);
47
+ const cacheKey = getCacheKey(key, cacheType);
23
48
  // We need to create the directory before writing the file
24
49
  await fs.mkdir(path.dirname(cacheKey), { recursive: true });
25
50
  await fs.writeFile(cacheKey, data);
@@ -29,21 +29,33 @@ const awsFetch = (body) => {
29
29
  };
30
30
  const queue = {
31
31
  send: async ({ MessageBody, MessageDeduplicationId, MessageGroupId }) => {
32
- try {
33
- const { REVALIDATION_QUEUE_URL } = process.env;
34
- const result = await awsFetch(JSON.stringify({
35
- QueueUrl: REVALIDATION_QUEUE_URL,
36
- MessageBody: JSON.stringify(MessageBody),
37
- MessageDeduplicationId,
38
- MessageGroupId,
39
- }));
40
- if (result.status !== 200) {
41
- throw new RecoverableError(`Failed to send message: ${result.status}`);
32
+ const send = (async () => {
33
+ try {
34
+ const { REVALIDATION_QUEUE_URL } = process.env;
35
+ const result = await awsFetch(JSON.stringify({
36
+ QueueUrl: REVALIDATION_QUEUE_URL,
37
+ MessageBody: JSON.stringify(MessageBody),
38
+ MessageDeduplicationId,
39
+ MessageGroupId,
40
+ }));
41
+ if (result.status !== 200) {
42
+ throw new RecoverableError(`Failed to send message: ${result.status}`);
43
+ }
42
44
  }
45
+ catch (e) {
46
+ error(e);
47
+ }
48
+ })();
49
+ // Enqueuing revalidation should not block the response. When the wrapper
50
+ // provides a `waitUntil`, hand the send to it and return immediately so the
51
+ // SQS round trip runs after the body is flushed; otherwise (edge, or a
52
+ // wrapper without `waitUntil`) fall back to awaiting it inline.
53
+ const waitUntil = globalThis.__openNextAls.getStore()?.waitUntil;
54
+ if (waitUntil) {
55
+ waitUntil(send);
56
+ return;
43
57
  }
44
- catch (e) {
45
- error(e);
46
- }
58
+ await send;
47
59
  },
48
60
  name: "sqs",
49
61
  };
@@ -8,12 +8,24 @@ const sqsClient = new SQSClient({
8
8
  });
9
9
  const queue = {
10
10
  send: async ({ MessageBody, MessageDeduplicationId, MessageGroupId }) => {
11
- await sqsClient.send(new SendMessageCommand({
11
+ const send = sqsClient
12
+ .send(new SendMessageCommand({
12
13
  QueueUrl: REVALIDATION_QUEUE_URL,
13
14
  MessageBody: JSON.stringify(MessageBody),
14
15
  MessageDeduplicationId,
15
16
  MessageGroupId,
16
- }));
17
+ }))
18
+ .then(() => undefined);
19
+ // Enqueuing revalidation should not block the response. When the wrapper
20
+ // provides a `waitUntil`, hand the send to it and return immediately so the
21
+ // SQS round trip runs after the body is flushed; otherwise (edge, or a
22
+ // wrapper without `waitUntil`) fall back to awaiting it inline.
23
+ const waitUntil = globalThis.__openNextAls.getStore()?.waitUntil;
24
+ if (waitUntil) {
25
+ waitUntil(send);
26
+ return;
27
+ }
28
+ await send;
17
29
  },
18
30
  name: "sqs",
19
31
  };
@@ -1,5 +1,6 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { debug } from "../../adapters/logger";
3
+ import { DetachedPromiseRunner } from "../../utils/promise";
3
4
  function formatWarmerResponse(event) {
4
5
  const result = new Promise((resolve) => {
5
6
  setTimeout(() => {
@@ -42,6 +43,15 @@ const handler = async (handler, converter) => awslambda.streamifyResponse(async
42
43
  debug(err);
43
44
  responseStream.end();
44
45
  });
46
+ // AWS provides no native `waitUntil`, so back it with the shared
47
+ // `DetachedPromiseRunner`, which tracks the deferred promises and awaits
48
+ // (and logs the failures of) them before the stream closes. This keeps
49
+ // the Lambda alive for deferred work (background revalidation, cache
50
+ // write-through) that starts after the response body is sent — matching
51
+ // the `waitUntil` the Cloudflare wrappers pass through, and enabling the
52
+ // `withWaitUntil` route preloading that otherwise falls back to `none`.
53
+ const promiseRunner = new DetachedPromiseRunner();
54
+ const waitUntil = (promise) => promiseRunner.add(promise);
45
55
  const streamCreator = {
46
56
  writeHeaders: (_prelude) => {
47
57
  // No content-encoding header — API Gateway streaming does not
@@ -55,7 +65,10 @@ const handler = async (handler, converter) => awslambda.streamifyResponse(async
55
65
  return responseStream;
56
66
  },
57
67
  };
58
- const response = await handler(internalEvent, { streamCreator });
68
+ const response = await handler(internalEvent, {
69
+ streamCreator,
70
+ waitUntil,
71
+ });
59
72
  const isUsingEdge = globalThis.isEdgeRuntime ?? false;
60
73
  if (isUsingEdge) {
61
74
  debug("Headers has not been set, we must be in the edge runtime");
@@ -66,6 +79,8 @@ const handler = async (handler, converter) => awslambda.streamifyResponse(async
66
79
  });
67
80
  Readable.fromWeb(response.body).pipe(stream);
68
81
  }
82
+ // Keep the invocation alive until deferred `waitUntil` work settles.
83
+ await promiseRunner.await();
69
84
  });
70
85
  export default {
71
86
  wrapper: handler,
@@ -1,6 +1,7 @@
1
1
  import { Readable } from "node:stream";
2
2
  import zlib from "node:zlib";
3
3
  import { debug, error } from "../../adapters/logger";
4
+ import { DetachedPromiseRunner } from "../../utils/promise";
4
5
  function formatWarmerResponse(event) {
5
6
  const result = new Promise((resolve) => {
6
7
  setTimeout(() => {
@@ -64,7 +65,19 @@ const handler = async (handler, converter) => awslambda.streamifyResponse(async
64
65
  return compressedStream ?? responseStream;
65
66
  },
66
67
  };
67
- const response = await handler(internalEvent, { streamCreator });
68
+ // AWS provides no native `waitUntil`, so back it with the shared
69
+ // `DetachedPromiseRunner`, which tracks the deferred promises and awaits
70
+ // (and logs the failures of) them before the stream closes. This keeps
71
+ // the Lambda alive for deferred work (background revalidation, cache
72
+ // write-through) that starts after the response body is sent — matching
73
+ // the `waitUntil` the Cloudflare wrappers pass through, and enabling the
74
+ // `withWaitUntil` route preloading that otherwise falls back to `none`.
75
+ const promiseRunner = new DetachedPromiseRunner();
76
+ const waitUntil = (promise) => promiseRunner.add(promise);
77
+ const response = await handler(internalEvent, {
78
+ streamCreator,
79
+ waitUntil,
80
+ });
68
81
  const isUsingEdge = globalThis.isEdgeRuntime ?? false;
69
82
  if (isUsingEdge) {
70
83
  debug("Headers has not been set, we must be in the edge runtime");
@@ -75,6 +88,8 @@ const handler = async (handler, converter) => awslambda.streamifyResponse(async
75
88
  });
76
89
  Readable.fromWeb(response.body).pipe(stream);
77
90
  }
91
+ // Keep the invocation alive until deferred `waitUntil` work settles.
92
+ await promiseRunner.await();
78
93
  // return converter.convertTo(response);
79
94
  });
80
95
  export default {
@@ -73,7 +73,10 @@ export interface NextConfig {
73
73
  serverActions?: boolean;
74
74
  appDir?: boolean;
75
75
  optimizeCss?: boolean;
76
- prefetchInlining?: boolean;
76
+ prefetchInlining?: boolean | {
77
+ maxSize: number;
78
+ maxBundleSize: number;
79
+ };
77
80
  };
78
81
  images: ImageConfig;
79
82
  poweredByHeader?: boolean;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "access": "public"
4
4
  },
5
5
  "name": "@opennextjs/aws",
6
- "version": "4.1.4",
6
+ "version": "4.1.6",
7
7
  "bin": {
8
8
  "open-next": "./dist/index.js"
9
9
  },
@@ -56,12 +56,12 @@
56
56
  "@types/express": "5.0.0",
57
57
  "@types/node": "20.17.6",
58
58
  "concurrently": "^9.2.1",
59
- "next": "16.3.4",
59
+ "next": "16.3.6",
60
60
  "tsc-alias": "^1.8.16",
61
61
  "typescript": "5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "next": ">=15.5.24 <16 || >=16.3.3"
64
+ "next": ">=15.5.26 <16 || >=16.3.6"
65
65
  },
66
66
  "bugs": {
67
67
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"