@opennextjs/aws 4.1.6 → 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.
@@ -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_/";
@@ -334,7 +335,8 @@ export default class Cache {
334
335
  if (paths.length > 0) {
335
336
  // TODO: we should introduce a new method in cdnInvalidationHandler to invalidate paths by tags for cdn that supports it
336
337
  // It also means that we'll need to provide the tags used in every request to the wrapper or converter.
337
- 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) => ({
338
340
  initialPath: path,
339
341
  rawPath: path,
340
342
  resolvedRoutes: [
@@ -408,7 +410,8 @@ export default class Cache {
408
410
  const uniquePaths = Array.from(new Set(toInsert
409
411
  // We need to filter fetch cache key as they are not in the CDN
410
412
  .filter((t) => t.tag.startsWith(SOFT_TAG_PREFIX))
411
- .map((t) => `/${t.path}`)));
413
+ // Fixed Next.js releases store route-scoped cache keys in the tag cache.
414
+ .map((t) => getPathFromRouteCacheKey(`/${t.path}`))));
412
415
  if (uniquePaths.length > 0) {
413
416
  await globalThis.cdnInvalidationHandler.invalidatePaths(uniquePaths.map((path) => ({
414
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");
@@ -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 {
@@ -153,7 +157,7 @@ function getBodyForAppRouter(event, cachedValue) {
153
157
  * request - the caller should then fallback to the server.
154
158
  * @throws When `cachedValue` is neither of type `app` nor `page`
155
159
  */
156
- async function generateResult(event, localizedPath, cachedValue, lastModified, isStaleFromTagCache = false) {
160
+ async function generateResult(event, localizedPath, cachedValue, lastModified, isStaleFromTagCache = false, cacheKey = localizedPath, revalidationPath = localizedPath) {
157
161
  debug("Returning result from experimental cache");
158
162
  let body;
159
163
  let type = "application/octet-stream";
@@ -185,7 +189,7 @@ async function generateResult(event, localizedPath, cachedValue, lastModified, i
185
189
  debug("Missing body in the cache entry, falling back to the server");
186
190
  return undefined;
187
191
  }
188
- 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);
189
193
  const statusCode = computeStatusCode(event.rewriteStatusCode, cachedValue.meta?.status);
190
194
  const headers = {
191
195
  ...cacheControl,
@@ -247,10 +251,83 @@ function decodePathParams(pathname) {
247
251
  .map((segment) => escapePathDelimiters(decodeURIComponent(segment), true))
248
252
  .join("/");
249
253
  }
250
- 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) {
251
324
  if (Boolean(event.headers["next-action"]) ||
252
325
  Boolean(event.headers[PRERENDER_REVALIDATE_HEADER]))
253
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;
254
331
  // Check for Next.js preview mode cookies
255
332
  const cookies = event.headers.cookie || "";
256
333
  const hasPreviewData = cookies.includes("__prerender_bypass") ||
@@ -259,14 +336,21 @@ export async function cacheInterceptor(event) {
259
336
  debug("Preview mode detected, passing through to handler");
260
337
  return event;
261
338
  }
262
- // We localize the path in case i18n is enabled
263
- let localizedPath = localizePath(event);
264
- // If using basePath we need to remove it from the path
265
- if (NextConfig.basePath) {
266
- localizedPath = localizedPath.replace(NextConfig.basePath, "");
267
- }
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 });
268
349
  // We also need to remove trailing slash
269
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 || "/";
270
354
  // Then we decode the path params
271
355
  try {
272
356
  localizedPath = decodePathParams(localizedPath) || "/";
@@ -276,14 +360,10 @@ export async function cacheInterceptor(event) {
276
360
  // partially decode the path and select a different route identity.
277
361
  return event;
278
362
  }
279
- // The route is keyed as `/` in the prerender manifest, but the generated
280
- // cache asset for the app index route is uploaded as `/index`.
281
- const cacheKey = localizedPath === "/" ? "/index" : localizedPath;
282
363
  debug("Checking cache for", localizedPath, PrerenderManifest);
283
- const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
284
- Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
285
- debug("isISR", isISR);
286
- if (isISR) {
364
+ const cacheKey = getCacheKey(event, localizedPath, resolvedRoutes);
365
+ debug("cacheKey", cacheKey);
366
+ if (cacheKey) {
287
367
  try {
288
368
  const cachedData = await globalThis.incrementalCache.get(cacheKey);
289
369
  debug("cached data in interceptor", cachedData);
@@ -309,12 +389,12 @@ export async function cacheInterceptor(event) {
309
389
  switch (cachedData?.value?.type) {
310
390
  case "app":
311
391
  case "page": {
312
- 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);
313
393
  // The cache entry can not serve this request, fallback to the server.
314
394
  return result ?? event;
315
395
  }
316
396
  case "redirect": {
317
- 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);
318
398
  return {
319
399
  type: "core",
320
400
  statusCode: cachedData.value.meta?.status ?? 307,
@@ -328,7 +408,7 @@ export async function cacheInterceptor(event) {
328
408
  };
329
409
  }
330
410
  case "route": {
331
- 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);
332
412
  const isBinary = isBinaryContentType(String(cachedData.value.meta?.headers?.["content-type"]));
333
413
  const statusCode = computeStatusCode(event.rewriteStatusCode, cachedData.value.meta?.status);
334
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,
@@ -151,11 +151,14 @@ export interface MiddlewareManifest {
151
151
  export interface PrerenderManifest {
152
152
  routes: Record<string, {
153
153
  initialRevalidateSeconds: number | false;
154
+ srcRoute?: string | null;
155
+ dataRoute?: string | null;
154
156
  }>;
155
157
  dynamicRoutes: {
156
158
  [route: string]: {
157
159
  routeRegex: string;
158
160
  fallback: string | false | null;
161
+ fallbackSourceRoute?: string;
159
162
  dataRouteRegex: string;
160
163
  };
161
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.6",
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.6",
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.26 <16 || >=16.3.6"
64
+ "next": ">=15.5.27 <16 || >=16.3.8"
65
65
  },
66
66
  "bugs": {
67
67
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"