@opennextjs/aws 4.1.5 → 4.1.7

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>;
@@ -1,6 +1,7 @@
1
1
  import { getStaleLastModified, getTagsFromValue, hasBeenRevalidated, isStale, writeTags, } from "../utils/cache";
2
2
  import { isBinaryContentType } from "../utils/binary";
3
3
  import { CACHE_TAGS_HEADER } from "../utils/cacheHeaders";
4
+ import { getPathFromRouteCacheKey } from "../utils/routeCacheKey";
4
5
  import { compareSemver } from "../utils/semver";
5
6
  import { debug, error, warn } from "./logger";
6
7
  export const SOFT_TAG_PREFIX = "_N_T_/";
@@ -195,11 +196,18 @@ export default class Cache {
195
196
  if (globalThis.openNextConfig.dangerous?.disableIncrementalCache) {
196
197
  return;
197
198
  }
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();
199
+ const store = globalThis.__openNextAls.getStore();
200
+ const writePromise = this.writeCache(key, data, ctx);
201
+ // Next.js ignores the value returned by `set`, so the write is registered on the request's
202
+ // pending promise runner instead of being awaited. `FETCH` writes are awaited: Next.js runs
203
+ // them in a detached chain of its own that only covers the write while `set` waits for it.
204
+ if (data?.kind === "FETCH" || store === undefined) {
205
+ await writePromise;
206
+ return;
207
+ }
208
+ store.pendingPromiseRunner.add(writePromise);
209
+ }
210
+ async writeCache(key, data, ctx) {
203
211
  try {
204
212
  if (data === null || data === undefined) {
205
213
  await globalThis.incrementalCache.delete(key);
@@ -292,10 +300,6 @@ export default class Cache {
292
300
  catch (e) {
293
301
  error("Failed to set cache", e);
294
302
  }
295
- finally {
296
- // We need to resolve the promise even if there was an error
297
- detachedPromise?.resolve();
298
- }
299
303
  }
300
304
  async revalidateTag(tags, durations) {
301
305
  const config = globalThis.openNextConfig.dangerous;
@@ -331,7 +335,8 @@ export default class Cache {
331
335
  if (paths.length > 0) {
332
336
  // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it
333
337
  // It also means that we'll need to provide the tags used in every request to the wrapper or converter.
334
- await globalThis.cdnInvalidationHandler.invalidatePaths(paths.map((path) => ({
338
+ // Fixed Next.js releases store route-scoped cache keys in the tag cache.
339
+ await globalThis.cdnInvalidationHandler.invalidatePaths(paths.map(getPathFromRouteCacheKey).map((path) => ({
335
340
  initialPath: path,
336
341
  rawPath: path,
337
342
  resolvedRoutes: [
@@ -405,7 +410,8 @@ export default class Cache {
405
410
  const uniquePaths = Array.from(new Set(toInsert
406
411
  // We need to filter fetch cache key as they are not in the CDN
407
412
  .filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX))
408
- .map((t) => `/${t.path}`)));
413
+ // Fixed Next.js releases store route-scoped cache keys in the tag cache.
414
+ .map((t) => getPathFromRouteCacheKey(`/${t.path}`))));
409
415
  if (uniquePaths.length > 0) {
410
416
  await globalThis.cdnInvalidationHandler.invalidatePaths(uniquePaths.map((path) => ({
411
417
  initialPath: path,
@@ -5,6 +5,7 @@ import { safeParseJsonFile } from "../utils/safe-json-parse.js";
5
5
  import logger from "../logger.js";
6
6
  import { isBinaryContentType } from "../utils/binary.js";
7
7
  import { CACHE_TAGS_HEADER } from "../utils/cacheHeaders.js";
8
+ import { useRouteCacheKeys } from "../utils/routeCacheKey.js";
8
9
  import * as buildHelper from "./helper.js";
9
10
  /**
10
11
  * Copy the static assets to the output folder
@@ -84,7 +85,7 @@ export function createCacheAssets(options) {
84
85
  relativePath.endsWith(".segment.rsc") ||
85
86
  (relativePath.endsWith(".html") && htmlPages.has(relativePath));
86
87
  // Merge cache files into a single file
87
- const cacheFilesPath = {};
88
+ const cacheArtifacts = {};
88
89
  // Process each source directory
89
90
  sourceDirs.forEach((sourceDir) => {
90
91
  buildHelper.traverseFiles(sourceDir, ({ relativePath }) => !isFileSkipped(relativePath), ({ absolutePath, relativePath }) => {
@@ -102,14 +103,18 @@ export function createCacheAssets(options) {
102
103
  //
103
104
  // Next 16.1 removed `.prefetch.rsc`, so the strip is a no-op on Next 16 where
104
105
  // the `rsc` field always holds a full payload.
105
- const newFilePath = path
106
- .join(outputCachePath, relativePath)
107
- .substring(0, path.join(outputCachePath, relativePath).length - ext.length)
106
+ const legacyKey = relativePath
107
+ .slice(0, -ext.length)
108
108
  .replace(/\.prefetch$/, "")
109
- .concat(".cache");
110
- cacheFilesPath[newFilePath] = {
111
- [ext.slice(1)]: absolutePath,
112
- ...cacheFilesPath[newFilePath],
109
+ .split(path.sep)
110
+ .join(path.posix.sep);
111
+ const artifactId = `${sourceDir}:${legacyKey}`;
112
+ const existingArtifact = cacheArtifacts[artifactId];
113
+ const fileType = ext.slice(1);
114
+ cacheArtifacts[artifactId] = {
115
+ ...existingArtifact,
116
+ legacyKey,
117
+ [fileType]: existingArtifact?.[fileType] ?? absolutePath,
113
118
  };
114
119
  break;
115
120
  }
@@ -121,11 +126,21 @@ export function createCacheAssets(options) {
121
126
  }
122
127
  });
123
128
  });
129
+ const metaFiles = [];
124
130
  // Generate cache file
125
- Object.entries(cacheFilesPath).forEach(([cacheFilePath, files]) => {
131
+ Object.values(cacheArtifacts).forEach(({ legacyKey, ...files }) => {
126
132
  const cacheFileMeta = files.meta
127
- ? safeParseJsonFile(fs.readFileSync(files.meta, "utf8"), cacheFilePath)
133
+ ? safeParseJsonFile(fs.readFileSync(files.meta, "utf8"), files.meta)
128
134
  : undefined;
135
+ const cacheKey = useRouteCacheKeys(options.nextVersion) && cacheFileMeta?.routeCache?.key
136
+ ? cacheFileMeta.routeCache.key.replace(/^[/\\]+/, "")
137
+ : legacyKey;
138
+ const outputRoot = path.resolve(outputCachePath);
139
+ const cacheFilePath = path.resolve(outputRoot, `${cacheKey}.cache`);
140
+ if (!cacheFilePath.startsWith(`${outputRoot}${path.sep}`)) {
141
+ logger.warn(`Cache key resolves outside the cache output: ${cacheKey}`);
142
+ return;
143
+ }
129
144
  const cacheJson = files.json
130
145
  ? safeParseJsonFile(fs.readFileSync(files.json, "utf8"), cacheFilePath)
131
146
  : undefined;
@@ -159,9 +174,19 @@ export function createCacheAssets(options) {
159
174
  // Ensure directory exists before writing
160
175
  fs.mkdirSync(path.dirname(cacheFilePath), { recursive: true });
161
176
  fs.writeFileSync(cacheFilePath, JSON.stringify(cacheFileContent));
177
+ if (!options.config.dangerous?.disableTagCache &&
178
+ cacheFileMeta?.headers?.[CACHE_TAGS_HEADER]) {
179
+ cacheFileMeta.headers[CACHE_TAGS_HEADER]
180
+ .split(",")
181
+ .forEach((tag) => {
182
+ metaFiles.push({
183
+ tag: { S: path.posix.join(buildId, tag.trim()) },
184
+ path: { S: path.posix.join(buildId, cacheKey) },
185
+ revalidatedAt: { N: "1" },
186
+ });
187
+ });
188
+ }
162
189
  });
163
- // We need to traverse the cache to find every .meta file
164
- const metaFiles = [];
165
190
  // Copy fetch-cache to cache folder
166
191
  const fetchCachePath = path.join(appBuildOutputPath, ".next/cache/fetch-cache");
167
192
  if (fs.existsSync(fetchCachePath)) {
@@ -183,29 +208,6 @@ export function createCacheAssets(options) {
183
208
  });
184
209
  }
185
210
  if (!options.config.dangerous?.disableTagCache) {
186
- // Compute dynamodb cache data
187
- // Traverse files inside cache to find all meta files and cache tags associated with them
188
- sourceDirs.forEach((sourceDir) => {
189
- buildHelper.traverseFiles(sourceDir, ({ absolutePath, relativePath }) => absolutePath.endsWith(".meta") && !isFileSkipped(relativePath), ({ absolutePath, relativePath }) => {
190
- const fileContent = fs.readFileSync(absolutePath, "utf8");
191
- const fileData = safeParseJsonFile(fileContent, absolutePath);
192
- if (fileData?.headers?.[CACHE_TAGS_HEADER]) {
193
- fileData.headers[CACHE_TAGS_HEADER]
194
- .split(",")
195
- .forEach((tag) => {
196
- // TODO: We should split the tag using getDerivedTags from next.js or maybe use an in house implementation
197
- metaFiles.push({
198
- tag: { S: path.posix.join(buildId, tag.trim()) },
199
- path: {
200
- S: path.posix.join(buildId, relativePath.replace(".meta", "")),
201
- },
202
- // We don't care about the revalidation time here, we just need to make sure it's there
203
- revalidatedAt: { N: "1" },
204
- });
205
- });
206
- }
207
- });
208
- });
209
211
  if (metaFiles.length > 0) {
210
212
  useTagCache = true;
211
213
  const providerPath = path.join(outputDir, "dynamodb-provider");
@@ -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"
@@ -1,2 +1,2 @@
1
- import type { InternalEvent, InternalResult, MiddlewareEvent } from "../../types/open-next";
2
- export declare function cacheInterceptor(event: MiddlewareEvent): Promise<InternalEvent | InternalResult>;
1
+ import type { InternalEvent, InternalResult, MiddlewareEvent, ResolvedRoute } from "../../types/open-next";
2
+ export declare function cacheInterceptor(event: MiddlewareEvent, resolvedRoutes?: ResolvedRoute[]): Promise<InternalEvent | InternalResult>;
@@ -1,12 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { NextConfig, PrerenderManifest } from "../../adapters/config/index";
2
+ import { AppPathsManifest, NextConfig, PagesManifest, PrerenderManifest, } from "../../adapters/config/index";
3
3
  import { emptyReadableStream, toReadableStream } from "../../utils/stream";
4
4
  import { isBinaryContentType } from "../../utils/binary";
5
5
  import { getTagsFromValue, hasBeenRevalidated, isStale } from "../../utils/cache";
6
6
  import { CACHE_CONTROL_HEADER, NO_STORE_CACHE_CONTROL, OPEN_NEXT_CACHE_HEADER, PRERENDER_REVALIDATE_HEADER, fixCacheControlForError, } from "../../utils/cacheHeaders";
7
+ import { getDynamicRouteCachePathname, getPrerenderRouteCacheKey, getRouteCacheKey, getRouteCacheOwner, useRouteCacheKeys, } from "../../utils/routeCacheKey";
7
8
  import { debug } from "../../adapters/logger";
8
9
  import { localizePath } from "./i18n";
9
10
  import { generateMessageGroupId } from "./queue";
11
+ import { dynamicRouteMatcher } from "./routeMatcher";
10
12
  const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
11
13
  const CACHE_ONE_MONTH = 60 * 60 * 24 * 30;
12
14
  /*
@@ -22,7 +24,7 @@ const VARY_HEADER = "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Rou
22
24
  const NEXT_SEGMENT_PREFETCH_HEADER = "next-router-segment-prefetch";
23
25
  const NEXT_PRERENDER_HEADER = "x-nextjs-prerender";
24
26
  const NEXT_POSTPONED_HEADER = "x-nextjs-postponed";
25
- async function computeCacheControl(path, body, host, revalidate, lastModified, isStaleFromTagCache = false) {
27
+ async function computeCacheControl(path, body, host, revalidate, lastModified, isStaleFromTagCache = false, revalidationPath = path, revalidationKey = path) {
26
28
  let finalRevalidate = CACHE_ONE_YEAR;
27
29
  const existingRoute = Object.entries(PrerenderManifest?.routes ?? {}).find((p) => p[0] === path)?.[1];
28
30
  if (revalidate === undefined && existingRoute) {
@@ -64,7 +66,9 @@ async function computeCacheControl(path, body, host, revalidate, lastModified, i
64
66
  isStaleFromTagCache,
65
67
  });
66
68
  if (isStale) {
67
- let url = NextConfig.trailingSlash ? `${path}/` : path;
69
+ let url = NextConfig.trailingSlash && revalidationPath !== "/"
70
+ ? `${revalidationPath}/`
71
+ : revalidationPath;
68
72
  if (NextConfig.basePath) {
69
73
  url = `${NextConfig.basePath}${url}`;
70
74
  }
@@ -75,8 +79,8 @@ async function computeCacheControl(path, body, host, revalidate, lastModified, i
75
79
  eTag: etag,
76
80
  lastModified: lastModified ?? Date.now(),
77
81
  },
78
- MessageDeduplicationId: hash(`${path}-${lastModified}-${etag}`),
79
- MessageGroupId: generateMessageGroupId(path),
82
+ MessageDeduplicationId: hash(`${revalidationKey}-${lastModified}-${etag}`),
83
+ MessageGroupId: generateMessageGroupId(revalidationKey),
80
84
  });
81
85
  }
82
86
  return {
@@ -104,18 +108,33 @@ function getBodyForAppRouter(event, cachedValue) {
104
108
  if (cachedValue.type !== "app") {
105
109
  throw new Error("getBodyForAppRouter called with non-app cache value");
106
110
  }
107
- const segmentHeader = `${event.headers[NEXT_SEGMENT_PREFETCH_HEADER]}`;
108
- const isSegmentResponse = Boolean(segmentHeader) &&
109
- segmentHeader in (cachedValue.segmentData || {}) &&
110
- !NextConfig.experimental?.prefetchInlining;
111
- if (isSegmentResponse) {
112
- return {
113
- body: cachedValue.segmentData[segmentHeader],
114
- additionalHeaders: {
115
- [NEXT_PRERENDER_HEADER]: "1",
116
- [NEXT_POSTPONED_HEADER]: "2",
117
- },
118
- };
111
+ const segmentHeader = event.headers[NEXT_SEGMENT_PREFETCH_HEADER];
112
+ // A request from the client Segment Cache has to be answered with the segment it asked
113
+ // for. The full page payload is not a different-but-valid answer: the router never
114
+ // records the prefetch as satisfied and re-requests it forever.
115
+ // This mirrors how Next serves a cached app page - see the `segmentPrefetchHeader`
116
+ // branch of `packages/next/src/build/templates/app-page.ts`, which responds with the
117
+ // matching segment or an empty 404, and never with the full page.
118
+ //
119
+ // `experimental.prefetchInlining` is deliberately not consulted. Next normalizes every
120
+ // truthy value - including the 16.2+ default - into `{ maxSize, maxBundleSize }`, so it
121
+ // must never be read as a flag. More importantly it has no say here: inlining only
122
+ // changes *which* segments the build emits (an emitted segment then being a bundle that
123
+ // already holds its inlined ancestors), and `segmentData` holds exactly the segments
124
+ // that were emitted, which is exactly the set the router asks for.
125
+ if (typeof segmentHeader === "string" && cachedValue.segmentData) {
126
+ if (Object.hasOwn(cachedValue.segmentData, segmentHeader)) {
127
+ return {
128
+ body: cachedValue.segmentData[segmentHeader],
129
+ additionalHeaders: {
130
+ [NEXT_PRERENDER_HEADER]: "1",
131
+ [NEXT_POSTPONED_HEADER]: "2",
132
+ },
133
+ };
134
+ }
135
+ // The entry holds segments, but not this one. Next answers with an empty 404 here -
136
+ // let the server do that rather than serving a payload of a different shape.
137
+ return undefined;
119
138
  }
120
139
  // `rsc` is absent when the build collected neither a `.rsc` nor a `.prefetch.rsc` file for
121
140
  // this entry - fallback shells, and postponed PPR routes on Next 16.2+, see `CachedFile`.
@@ -138,7 +157,7 @@ function getBodyForAppRouter(event, cachedValue) {
138
157
  * request - the caller should then fallback to the server.
139
158
  * @throws When `cachedValue` is neither of type `app` nor `page`
140
159
  */
141
- async function generateResult(event, localizedPath, cachedValue, lastModified, isStaleFromTagCache = false) {
160
+ async function generateResult(event, localizedPath, cachedValue, lastModified, isStaleFromTagCache = false, cacheKey = localizedPath, revalidationPath = localizedPath) {
142
161
  debug("Returning result from experimental cache");
143
162
  let body;
144
163
  let type = "application/octet-stream";
@@ -170,7 +189,7 @@ async function generateResult(event, localizedPath, cachedValue, lastModified, i
170
189
  debug("Missing body in the cache entry, falling back to the server");
171
190
  return undefined;
172
191
  }
173
- const cacheControl = await computeCacheControl(localizedPath, body, event.headers.host, cachedValue.revalidate, lastModified, isStaleFromTagCache);
192
+ const cacheControl = await computeCacheControl(localizedPath, body, event.headers.host, cachedValue.revalidate, lastModified, isStaleFromTagCache, revalidationPath, cacheKey);
174
193
  const statusCode = computeStatusCode(event.rewriteStatusCode, cachedValue.meta?.status);
175
194
  const headers = {
176
195
  ...cacheControl,
@@ -232,10 +251,83 @@ function decodePathParams(pathname) {
232
251
  .map((segment) => escapePathDelimiters(decodeURIComponent(segment), true))
233
252
  .join("/");
234
253
  }
235
- export async function cacheInterceptor(event) {
254
+ let routeCacheManifests;
255
+ function getRouteCacheManifests() {
256
+ routeCacheManifests ??= {
257
+ prerenderManifest: {
258
+ routes: PrerenderManifest?.routes ?? {},
259
+ dynamicRoutes: PrerenderManifest?.dynamicRoutes ?? {},
260
+ },
261
+ appPaths: Object.keys(AppPathsManifest ?? {}),
262
+ pagesManifest: PagesManifest ?? {},
263
+ locales: NextConfig.i18n?.locales,
264
+ };
265
+ return routeCacheManifests;
266
+ }
267
+ /**
268
+ * Computes the key of the cache entry serving the request.
269
+ *
270
+ * Next.js 15.5.27 and 16.3.8 scope the key by the route owning the entry. It is computed from:
271
+ * - the prerender manifest for prerendered routes,
272
+ * - the dynamic route Next.js would render for entries cached at runtime.
273
+ *
274
+ * @param event The incoming event
275
+ * @param localizedPath The localized and decoded path, without basePath nor trailing slash
276
+ * @param resolvedRoutes Ordered route matches for the final internal pathname
277
+ * @returns The cache key, or `undefined` when the path is not served by an ISR route
278
+ */
279
+ function getCacheKey(event, localizedPath, resolvedRoutes) {
280
+ if (!useRouteCacheKeys(globalThis.nextVersion)) {
281
+ const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
282
+ Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
283
+ // The route is keyed as `/` in the prerender manifest, but the generated
284
+ // cache asset for the index route is uploaded as `/index`.
285
+ if (!isISR)
286
+ return undefined;
287
+ return localizedPath === "/" ? "/index" : localizedPath;
288
+ }
289
+ const manifests = getRouteCacheManifests();
290
+ if (Object.hasOwn(manifests.prerenderManifest.routes, localizedPath)) {
291
+ const manifestKey = getPrerenderRouteCacheKey(localizedPath, manifests);
292
+ const selectedRoute = resolvedRoutes?.[0];
293
+ // The routing handler always supplies matches. An empty list there means
294
+ // ownership could not be proven and scoped interception must fail closed.
295
+ if (resolvedRoutes && !selectedRoute)
296
+ return undefined;
297
+ if (!selectedRoute)
298
+ return manifestKey;
299
+ const selectedOwner = getRouteCacheOwner(selectedRoute.route, manifests);
300
+ if (!selectedOwner)
301
+ return undefined;
302
+ const selectedKey = getRouteCacheKey(localizedPath, selectedOwner);
303
+ return selectedKey === manifestKey ? manifestKey : undefined;
304
+ }
305
+ // Not prerendered, the entry might have been cached at runtime by a dynamic route.
306
+ // Dynamic routes are sorted by specificity, the first match is the one Next.js renders.
307
+ const dynamicRoute = resolvedRoutes
308
+ ? resolvedRoutes[0]
309
+ : dynamicRouteMatcher(event.rawPath)[0];
310
+ const dynamicPrerender = dynamicRoute
311
+ ? manifests.prerenderManifest.dynamicRoutes[dynamicRoute.route]
312
+ : undefined;
313
+ if (!dynamicRoute ||
314
+ !dynamicPrerender ||
315
+ dynamicPrerender.fallback !== null) {
316
+ return undefined;
317
+ }
318
+ const owner = getRouteCacheOwner(dynamicRoute.route, manifests);
319
+ return owner
320
+ ? getRouteCacheKey(getDynamicRouteCachePathname(localizedPath, manifests.locales), owner)
321
+ : undefined;
322
+ }
323
+ export async function cacheInterceptor(event, resolvedRoutes) {
236
324
  if (Boolean(event.headers["next-action"]) ||
237
325
  Boolean(event.headers[PRERENDER_REVALIDATE_HEADER]))
238
326
  return event;
327
+ // Only GET and HEAD can be answered from the cache. A form posted without
328
+ // JavaScript carries its server action id in the body, not in the next-action header.
329
+ if (event.method !== "GET" && event.method !== "HEAD")
330
+ return event;
239
331
  // Check for Next.js preview mode cookies
240
332
  const cookies = event.headers.cookie || "";
241
333
  const hasPreviewData = cookies.includes("__prerender_bypass") ||
@@ -244,14 +336,21 @@ export async function cacheInterceptor(event) {
244
336
  debug("Preview mode detected, passing through to handler");
245
337
  return event;
246
338
  }
247
- // We localize the path in case i18n is enabled
248
- let localizedPath = localizePath(event);
249
- // If using basePath we need to remove it from the path
250
- if (NextConfig.basePath) {
251
- localizedPath = localizedPath.replace(NextConfig.basePath, "");
252
- }
339
+ const basePath = NextConfig.basePath;
340
+ const pathWithoutBasePath = !basePath
341
+ ? event.rawPath
342
+ : event.rawPath === basePath
343
+ ? "/"
344
+ : event.rawPath.startsWith(`${basePath}/`)
345
+ ? event.rawPath.slice(basePath.length)
346
+ : event.rawPath;
347
+ // Locale detection must inspect the first segment after basePath.
348
+ let localizedPath = localizePath({ ...event, rawPath: pathWithoutBasePath });
253
349
  // We also need to remove trailing slash
254
350
  localizedPath = localizedPath.replace(/\/$/, "");
351
+ // Revalidation must request the original encoded spelling so route selection
352
+ // cannot switch from an encoded catch-all to a more specific canonical route.
353
+ const revalidationPath = localizedPath || "/";
255
354
  // Then we decode the path params
256
355
  try {
257
356
  localizedPath = decodePathParams(localizedPath) || "/";
@@ -261,14 +360,10 @@ export async function cacheInterceptor(event) {
261
360
  // partially decode the path and select a different route identity.
262
361
  return event;
263
362
  }
264
- // The route is keyed as `/` in the prerender manifest, but the generated
265
- // cache asset for the app index route is uploaded as `/index`.
266
- const cacheKey = localizedPath === "/" ? "/index" : localizedPath;
267
363
  debug("Checking cache for", localizedPath, PrerenderManifest);
268
- const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
269
- Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
270
- debug("isISR", isISR);
271
- if (isISR) {
364
+ const cacheKey = getCacheKey(event, localizedPath, resolvedRoutes);
365
+ debug("cacheKey", cacheKey);
366
+ if (cacheKey) {
272
367
  try {
273
368
  const cachedData = await globalThis.incrementalCache.get(cacheKey);
274
369
  debug("cached data in interceptor", cachedData);
@@ -294,12 +389,12 @@ export async function cacheInterceptor(event) {
294
389
  switch (cachedData?.value?.type) {
295
390
  case "app":
296
391
  case "page": {
297
- const result = await generateResult(event, localizedPath, cachedData.value, cachedData.lastModified, _isStale);
392
+ const result = await generateResult(event, localizedPath, cachedData.value, cachedData.lastModified, _isStale, cacheKey, revalidationPath);
298
393
  // The cache entry can not serve this request, fallback to the server.
299
394
  return result ?? event;
300
395
  }
301
396
  case "redirect": {
302
- const cacheControl = await computeCacheControl(localizedPath, "", host, cachedData.value.revalidate, cachedData.lastModified, _isStale);
397
+ const cacheControl = await computeCacheControl(localizedPath, "", host, cachedData.value.revalidate, cachedData.lastModified, _isStale, revalidationPath, cacheKey);
303
398
  return {
304
399
  type: "core",
305
400
  statusCode: cachedData.value.meta?.status ?? 307,
@@ -313,7 +408,7 @@ export async function cacheInterceptor(event) {
313
408
  };
314
409
  }
315
410
  case "route": {
316
- const cacheControl = await computeCacheControl(localizedPath, cachedData.value.body, host, cachedData.value.revalidate, cachedData.lastModified, _isStale);
411
+ const cacheControl = await computeCacheControl(localizedPath, cachedData.value.body, host, cachedData.value.revalidate, cachedData.lastModified, _isStale, revalidationPath, cacheKey);
317
412
  const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"]));
318
413
  const statusCode = computeStatusCode(event.rewriteStatusCode, cachedData.value.meta?.status);
319
414
  const headers = {
@@ -123,7 +123,8 @@ export function handleRewrites(event, rewrites) {
123
123
  const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath;
124
124
  debug("urlParts", { pathname, protocol, hostname, queryString });
125
125
  const toDestinationPath = compile(escapeRegex(pathname, { isPath: true }));
126
- const toDestinationHost = compile(escapeRegex(hostname));
126
+ // A literal numeric port is URL syntax, not a path-to-regexp parameter.
127
+ const toDestinationHost = compile(escapeRegex(hostname).replace(/:(\d+)$/, "\\:$1"));
127
128
  const toDestinationQuery = compile(escapeRegex(queryString));
128
129
  const params = {
129
130
  // params for the source
@@ -157,6 +157,12 @@ export default async function routingHandler(event, { assetResolver }) {
157
157
  }
158
158
  const isNextImageRoute = eventOrResult.rawPath.startsWith("/_next/image");
159
159
  const isRouteFoundBeforeAllRewrites = isStaticRoute || isDynamicRoute || isExternalRewrite;
160
+ // Resolve against the final internal pathname after every rewrite phase.
161
+ // These ordered matches are also the authoritative cache owner candidates.
162
+ const resolvedRoutes = [
163
+ ...staticRouteMatcher(eventOrResult.rawPath),
164
+ ...dynamicRouteMatcher(eventOrResult.rawPath),
165
+ ];
160
166
  // If we still haven't found a route, we show the 404 page
161
167
  // We need to ensure that rewrites are applied before showing the 404 page
162
168
  if (!(isRouteFoundBeforeAllRewrites ||
@@ -175,9 +181,10 @@ export default async function routingHandler(event, { assetResolver }) {
175
181
  };
176
182
  }
177
183
  if (globalThis.openNextConfig.dangerous?.enableCacheInterception &&
184
+ !isExternalRewrite &&
178
185
  !isInternalResult(eventOrResult)) {
179
186
  debug("Cache interception enabled");
180
- eventOrResult = await cacheInterceptor(eventOrResult);
187
+ eventOrResult = await cacheInterceptor(eventOrResult, resolvedRoutes);
181
188
  if (isInternalResult(eventOrResult)) {
182
189
  applyMiddlewareHeaders(eventOrResult, headers);
183
190
  return eventOrResult;
@@ -185,10 +192,6 @@ export default async function routingHandler(event, { assetResolver }) {
185
192
  }
186
193
  // We apply the headers from the middleware response last
187
194
  applyMiddlewareHeaders(eventOrResult, headers);
188
- const resolvedRoutes = [
189
- ...foundStaticRoute,
190
- ...foundDynamicRoute,
191
- ];
192
195
  debug("resolvedRoutes", resolvedRoutes);
193
196
  return {
194
197
  internalEvent: eventOrResult,
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;
@@ -148,11 +151,14 @@ export interface MiddlewareManifest {
148
151
  export interface PrerenderManifest {
149
152
  routes: Record<string, {
150
153
  initialRevalidateSeconds: number | false;
154
+ srcRoute?: string | null;
155
+ dataRoute?: string | null;
151
156
  }>;
152
157
  dynamicRoutes: {
153
158
  [route: string]: {
154
159
  routeRegex: string;
155
160
  fallback: string | false | null;
161
+ fallbackSourceRoute?: string;
156
162
  dataRouteRegex: string;
157
163
  };
158
164
  };
@@ -61,13 +61,17 @@ function provideNextAfterProvider() {
61
61
  // This is needed by some lib that relies on the vercel request context to properly await stuff.
62
62
  // Remove this when vercel builder is updated to provide '@next/request-context'.
63
63
  const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for("@vercel/request-context");
64
- const store = globalThis.__openNextAls.getStore();
65
- const waitUntil = store?.waitUntil ??
66
- ((promise) => store?.pendingPromiseRunner.add(promise));
64
+ // `get` resolves the store on every call rather than capturing it here: the
65
+ // object is published on `globalThis`, which every request in the isolate
66
+ // shares, so a captured store could belong to a different request.
67
67
  const nextAfterContext = {
68
- get: () => ({
69
- waitUntil,
70
- }),
68
+ get: () => {
69
+ const store = globalThis.__openNextAls.getStore();
70
+ return {
71
+ waitUntil: store?.waitUntil ??
72
+ ((promise) => store?.pendingPromiseRunner.add(promise)),
73
+ };
74
+ },
71
75
  };
72
76
  //@ts-expect-error
73
77
  globalThis[NEXT_REQUEST_CONTEXT_SYMBOL] = nextAfterContext;
@@ -0,0 +1,124 @@
1
+ import type { PrerenderManifest } from "../types/next-types.js";
2
+ /**
3
+ * Next.js 15.5.27 and 16.3.8 introduced response cache keys scoped by the route
4
+ * owning each pages, app page, or app route entry:
5
+ * `/route-cache/<kind>/<sha256(sourceRoute)>/$<normalizedPathname>`.
6
+ *
7
+ * The helpers below mirror Next.js so that the build time cache population and the cache
8
+ * interceptor compute the exact same key Next.js uses when reading and writing the cache.
9
+ * https://github.com/vercel/next.js/blob/v16.3.8/packages/next/src/server/lib/route-cache-key.ts
10
+ */
11
+ export type RouteCacheKind = "PAGES" | "APP_PAGE" | "APP_ROUTE";
12
+ export interface RouteCacheOwner {
13
+ kind: RouteCacheKind;
14
+ /**
15
+ * The page pathname for the pages router (i.e. `/blog/[slug]`).
16
+ * The app entry for the app router (i.e. `/(group)/blog/[slug]/page`).
17
+ */
18
+ sourceRoute: string;
19
+ }
20
+ export interface RouteCacheManifests {
21
+ prerenderManifest: Pick<PrerenderManifest, "routes" | "dynamicRoutes">;
22
+ /** The keys of the app paths manifest */
23
+ appPaths: string[];
24
+ pagesManifest: Record<string, string>;
25
+ /** The i18n locales */
26
+ locales?: string[];
27
+ }
28
+ /**
29
+ * Whether the Next.js version uses scoped route cache keys.
30
+ *
31
+ * @param nextVersion The Next.js version
32
+ */
33
+ export declare function useRouteCacheKeys(nextVersion: string): boolean;
34
+ /**
35
+ * Mirrors `normalizePagePath` from Next.js.
36
+ *
37
+ * @example
38
+ * normalizePagePath("/") === "/index"
39
+ * normalizePagePath("/index/foo") === "/index/index/foo"
40
+ */
41
+ export declare function normalizePagePath(page: string): string;
42
+ /**
43
+ * Mirrors `denormalizePagePath` from Next.js, the inverse of `normalizePagePath`.
44
+ *
45
+ * @example
46
+ * denormalizePagePath("/index") === "/"
47
+ * denormalizePagePath("/index/index/foo") === "/index/foo"
48
+ */
49
+ export declare function denormalizePagePath(page: string): string;
50
+ /**
51
+ * Mirrors `normalizeAppPath` from Next.js: removes groups, parallel slots and the leaf
52
+ * `page`/`route` segment from an app entry.
53
+ *
54
+ * @example
55
+ * normalizeAppPath("/(group)/@modal/blog/[slug]/page") === "/blog/[slug]"
56
+ */
57
+ export declare function normalizeAppPath(entry: string): string;
58
+ /**
59
+ * Mirrors `selectAppPageEntry` from Next.js: selects the app entry rendering `route`,
60
+ * a route can have multiple entries when it uses parallel routes.
61
+ *
62
+ * @param route The route, i.e. `/blog/[slug]`
63
+ * @param appPaths The keys of the app paths manifest
64
+ * @returns The app entry, or `undefined` when no entry renders the route
65
+ */
66
+ export declare function selectAppPageEntry(route: string, appPaths: string[]): string | undefined;
67
+ /**
68
+ * Computes the owner of the cache entries for a route.
69
+ *
70
+ * @param sourceRoute The route owning the entry, i.e. `/blog/[slug]`
71
+ * @param manifests.appPaths The keys of the app paths manifest
72
+ * @param manifests.pagesManifest The pages manifest
73
+ * @returns The owner, or `undefined` when the route is not handled by an app entry nor a page
74
+ */
75
+ export declare function getRouteCacheOwner(sourceRoute: string, { appPaths, pagesManifest, }: {
76
+ appPaths: string[];
77
+ pagesManifest: Record<string, string>;
78
+ }): RouteCacheOwner | undefined;
79
+ /**
80
+ * Mirrors `normalizeLocalePath` from Next.js: removes the locale prefix from a pathname.
81
+ *
82
+ * @example
83
+ * normalizeLocalePath("/en/isr", ["en", "fr"]) === "/isr"
84
+ * normalizeLocalePath("/en", ["en", "fr"]) === "/"
85
+ */
86
+ export declare function normalizeLocalePath(pathname: string, locales?: string[]): string;
87
+ /**
88
+ * Mirrors how Next.js resolves the cache pathname of a dynamic route: a route resolving to
89
+ * `/index` (i.e. a root catch-all rendering `/index`) is cached as `/`, or as `/<locale>` with i18n.
90
+ *
91
+ * @example
92
+ * getDynamicRouteCachePathname("/index") === "/"
93
+ * getDynamicRouteCachePathname("/en/index", ["en", "fr"]) === "/en"
94
+ *
95
+ * @param pathname The localized pathname, i.e. `/en/blog/hello` with i18n
96
+ * @param locales The i18n locales
97
+ */
98
+ export declare function getDynamicRouteCachePathname(pathname: string, locales?: string[]): string;
99
+ /**
100
+ * Computes the cache key of an entry from the prerender manifest, i.e. a prerendered
101
+ * route (`routes`) or a fallback shell (`dynamicRoutes`).
102
+ *
103
+ * @param pathname The pathname of the entry, as keyed in the prerender manifest
104
+ * @param manifests The manifests used to compute the owner
105
+ * @returns The cache key, or `undefined` when the pathname is not in the prerender manifest
106
+ * or when its owner could not be found.
107
+ */
108
+ export declare function getPrerenderRouteCacheKey(pathname: string, manifests: RouteCacheManifests): string | undefined;
109
+ /**
110
+ * Mirrors `getRouteCacheKey` from Next.js.
111
+ *
112
+ * @param pathname The pathname of the entry, i.e. `/blog/hello` (`/en/blog/hello` with i18n)
113
+ * @param owner The owner of the entry
114
+ * @returns The cache key, i.e. `/route-cache/APP_PAGE/<sha256>/$/blog/hello`
115
+ */
116
+ export declare function getRouteCacheKey(pathname: string, owner: RouteCacheOwner): string;
117
+ /**
118
+ * Returns the normalized pathname of a route cache key, i.e. `/blog/hello` for
119
+ * `/route-cache/APP_PAGE/<sha256>/$/blog/hello`.
120
+ * Keys that are not route cache keys are returned unchanged.
121
+ *
122
+ * @param key The cache key, with or without the leading `/`
123
+ */
124
+ export declare function getPathFromRouteCacheKey(key: string): string;
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { compareSemver } from "./semver.js";
3
+ const ROUTE_CACHE_DIRECTORY = "route-cache";
4
+ const ROUTE_CACHE_KEY_PREFIX = /^\/?route-cache\/(?:PAGES|APP_PAGE|APP_ROUTE)\/[0-9a-f]{64}\/\$(?=\/)/;
5
+ const DYNAMIC_ROUTE_REGEX = /\/\[[^/]+?\](?=\/|$)/;
6
+ /**
7
+ * Whether the Next.js version uses scoped route cache keys.
8
+ *
9
+ * @param nextVersion The Next.js version
10
+ */
11
+ export function useRouteCacheKeys(nextVersion) {
12
+ return ((compareSemver(nextVersion, ">=", "15.5.27") &&
13
+ compareSemver(nextVersion, "<", "16.0.0")) ||
14
+ compareSemver(nextVersion, ">=", "16.3.8"));
15
+ }
16
+ /**
17
+ * Mirrors `normalizePagePath` from Next.js.
18
+ *
19
+ * @example
20
+ * normalizePagePath("/") === "/index"
21
+ * normalizePagePath("/index/foo") === "/index/index/foo"
22
+ */
23
+ export function normalizePagePath(page) {
24
+ if (/^\/index(\/|$)/.test(page) && !DYNAMIC_ROUTE_REGEX.test(page)) {
25
+ return `/index${page}`;
26
+ }
27
+ if (page === "/") {
28
+ return "/index";
29
+ }
30
+ return page.startsWith("/") ? page : `/${page}`;
31
+ }
32
+ /**
33
+ * Mirrors `denormalizePagePath` from Next.js, the inverse of `normalizePagePath`.
34
+ *
35
+ * @example
36
+ * denormalizePagePath("/index") === "/"
37
+ * denormalizePagePath("/index/index/foo") === "/index/foo"
38
+ */
39
+ export function denormalizePagePath(page) {
40
+ if (page.startsWith("/index/") && !DYNAMIC_ROUTE_REGEX.test(page)) {
41
+ return page.slice(6);
42
+ }
43
+ return page === "/index" ? "/" : page;
44
+ }
45
+ /**
46
+ * Mirrors `normalizeAppPath` from Next.js: removes groups, parallel slots and the leaf
47
+ * `page`/`route` segment from an app entry.
48
+ *
49
+ * @example
50
+ * normalizeAppPath("/(group)/@modal/blog/[slug]/page") === "/blog/[slug]"
51
+ */
52
+ export function normalizeAppPath(entry) {
53
+ const pathname = entry.split("/").reduce((acc, segment, index, segments) => {
54
+ if (!segment)
55
+ return acc;
56
+ // Groups are ignored.
57
+ if (segment.startsWith("(") && segment.endsWith(")"))
58
+ return acc;
59
+ // Parallel segments are ignored.
60
+ if (segment.startsWith("@"))
61
+ return acc;
62
+ // The last segment (if it's a leaf) is ignored.
63
+ if ((segment === "page" || segment === "route") &&
64
+ index === segments.length - 1) {
65
+ return acc;
66
+ }
67
+ return `${acc}/${segment}`;
68
+ }, "");
69
+ return pathname === "" ? "/" : pathname;
70
+ }
71
+ function compareAppPaths(a, b) {
72
+ const aHasSlot = a.includes("/@");
73
+ const bHasSlot = b.includes("/@");
74
+ if (aHasSlot && !bHasSlot)
75
+ return -1;
76
+ if (!aHasSlot && bHasSlot)
77
+ return 1;
78
+ return a.localeCompare(b);
79
+ }
80
+ /**
81
+ * Mirrors `selectAppPageEntry` from Next.js: selects the app entry rendering `route`,
82
+ * a route can have multiple entries when it uses parallel routes.
83
+ *
84
+ * @param route The route, i.e. `/blog/[slug]`
85
+ * @param appPaths The keys of the app paths manifest
86
+ * @returns The app entry, or `undefined` when no entry renders the route
87
+ */
88
+ export function selectAppPageEntry(route, appPaths) {
89
+ let entry;
90
+ for (const appPath of appPaths) {
91
+ if (normalizeAppPath(appPath).replace(/%5F/g, "_") !== route)
92
+ continue;
93
+ if (entry === undefined || compareAppPaths(entry, appPath) < 0) {
94
+ entry = appPath;
95
+ }
96
+ }
97
+ return entry;
98
+ }
99
+ /**
100
+ * Computes the owner of the cache entries for a route.
101
+ *
102
+ * @param sourceRoute The route owning the entry, i.e. `/blog/[slug]`
103
+ * @param manifests.appPaths The keys of the app paths manifest
104
+ * @param manifests.pagesManifest The pages manifest
105
+ * @returns The owner, or `undefined` when the route is not handled by an app entry nor a page
106
+ */
107
+ export function getRouteCacheOwner(sourceRoute, { appPaths, pagesManifest, }) {
108
+ const appEntry = selectAppPageEntry(sourceRoute, appPaths);
109
+ if (appEntry) {
110
+ return {
111
+ kind: appEntry.endsWith("/route") ? "APP_ROUTE" : "APP_PAGE",
112
+ sourceRoute: appEntry,
113
+ };
114
+ }
115
+ if (Object.hasOwn(pagesManifest, sourceRoute)) {
116
+ return { kind: "PAGES", sourceRoute };
117
+ }
118
+ return undefined;
119
+ }
120
+ /**
121
+ * Mirrors `normalizeLocalePath` from Next.js: removes the locale prefix from a pathname.
122
+ *
123
+ * @example
124
+ * normalizeLocalePath("/en/isr", ["en", "fr"]) === "/isr"
125
+ * normalizeLocalePath("/en", ["en", "fr"]) === "/"
126
+ */
127
+ export function normalizeLocalePath(pathname, locales) {
128
+ const segment = pathname.split("/", 2)[1]?.toLowerCase();
129
+ const locale = locales?.find((l) => l.toLowerCase() === segment);
130
+ if (!locale) {
131
+ return pathname;
132
+ }
133
+ return pathname.slice(locale.length + 1) || "/";
134
+ }
135
+ /**
136
+ * Mirrors how Next.js resolves the cache pathname of a dynamic route: a route resolving to
137
+ * `/index` (i.e. a root catch-all rendering `/index`) is cached as `/`, or as `/<locale>` with i18n.
138
+ *
139
+ * @example
140
+ * getDynamicRouteCachePathname("/index") === "/"
141
+ * getDynamicRouteCachePathname("/en/index", ["en", "fr"]) === "/en"
142
+ *
143
+ * @param pathname The localized pathname, i.e. `/en/blog/hello` with i18n
144
+ * @param locales The i18n locales
145
+ */
146
+ export function getDynamicRouteCachePathname(pathname, locales) {
147
+ if (normalizeLocalePath(pathname, locales) !== "/index") {
148
+ return pathname;
149
+ }
150
+ return pathname.slice(0, -"/index".length) || "/";
151
+ }
152
+ /**
153
+ * Computes the cache key of an entry from the prerender manifest, i.e. a prerendered
154
+ * route (`routes`) or a fallback shell (`dynamicRoutes`).
155
+ *
156
+ * @param pathname The pathname of the entry, as keyed in the prerender manifest
157
+ * @param manifests The manifests used to compute the owner
158
+ * @returns The cache key, or `undefined` when the pathname is not in the prerender manifest
159
+ * or when its owner could not be found.
160
+ */
161
+ export function getPrerenderRouteCacheKey(pathname, manifests) {
162
+ const { routes, dynamicRoutes } = manifests.prerenderManifest;
163
+ const prerender = Object.hasOwn(routes, pathname)
164
+ ? routes[pathname]
165
+ : undefined;
166
+ const fallback = !prerender && Object.hasOwn(dynamicRoutes, pathname)
167
+ ? dynamicRoutes[pathname]
168
+ : undefined;
169
+ if (!prerender && !fallback) {
170
+ return undefined;
171
+ }
172
+ const sourceRoute = prerender
173
+ ? prerender.srcRoute
174
+ : (fallback?.fallbackSourceRoute ?? pathname);
175
+ let owner;
176
+ if (sourceRoute) {
177
+ owner = getRouteCacheOwner(sourceRoute, manifests);
178
+ }
179
+ else {
180
+ // Static routes have no `srcRoute`, the pathname is the route.
181
+ // Pages routes are localized while the page itself is not.
182
+ owner = getRouteCacheOwner(pathname, manifests);
183
+ if (!owner && manifests.locales?.length) {
184
+ const pagesOwner = getRouteCacheOwner(normalizeLocalePath(pathname, manifests.locales), manifests);
185
+ owner = pagesOwner?.kind === "PAGES" ? pagesOwner : undefined;
186
+ }
187
+ }
188
+ return owner ? getRouteCacheKey(pathname, owner) : undefined;
189
+ }
190
+ /**
191
+ * Mirrors `getRouteCacheKey` from Next.js.
192
+ *
193
+ * @param pathname The pathname of the entry, i.e. `/blog/hello` (`/en/blog/hello` with i18n)
194
+ * @param owner The owner of the entry
195
+ * @returns The cache key, i.e. `/route-cache/APP_PAGE/<sha256>/$/blog/hello`
196
+ */
197
+ export function getRouteCacheKey(pathname, owner) {
198
+ const source = createHash("sha256").update(owner.sourceRoute).digest("hex");
199
+ return `/${ROUTE_CACHE_DIRECTORY}/${owner.kind}/${source}/$${normalizePagePath(pathname)}`;
200
+ }
201
+ /**
202
+ * Returns the normalized pathname of a route cache key, i.e. `/blog/hello` for
203
+ * `/route-cache/APP_PAGE/<sha256>/$/blog/hello`.
204
+ * Keys that are not route cache keys are returned unchanged.
205
+ *
206
+ * @param key The cache key, with or without the leading `/`
207
+ */
208
+ export function getPathFromRouteCacheKey(key) {
209
+ return key.replace(ROUTE_CACHE_KEY_PREFIX, "");
210
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "access": "public"
4
4
  },
5
5
  "name": "@opennextjs/aws",
6
- "version": "4.1.5",
6
+ "version": "4.1.7",
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.8",
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.27 <16 || >=16.3.8"
65
65
  },
66
66
  "bugs": {
67
67
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"