@opennextjs/aws 4.1.6 → 4.1.8
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.
- package/dist/adapters/cache.js +5 -2
- package/dist/build/createAssets.js +37 -35
- package/dist/build/patch/patches/patchBackgroundRevalidation.d.ts +2 -1
- package/dist/build/patch/patches/patchBackgroundRevalidation.js +26 -7
- package/dist/core/routing/cacheInterceptor.d.ts +2 -2
- package/dist/core/routing/cacheInterceptor.js +104 -24
- package/dist/core/routing/matcher.js +2 -1
- package/dist/core/routingHandler.js +8 -5
- package/dist/types/next-types.d.ts +3 -0
- package/dist/utils/promise.d.ts +17 -0
- package/dist/utils/promise.js +37 -15
- package/dist/utils/routeCacheKey.d.ts +124 -0
- package/dist/utils/routeCacheKey.js +210 -0
- package/package.json +3 -3
package/dist/adapters/cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
|
106
|
-
.
|
|
107
|
-
.substring(0, path.join(outputCachePath, relativePath).length - ext.length)
|
|
106
|
+
const legacyKey = relativePath
|
|
107
|
+
.slice(0, -ext.length)
|
|
108
108
|
.replace(/\.prefetch$/, "")
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
131
|
+
Object.values(cacheArtifacts).forEach(({ legacyKey, ...files }) => {
|
|
126
132
|
const cacheFileMeta = files.meta
|
|
127
|
-
? safeParseJsonFile(fs.readFileSync(files.meta, "utf8"),
|
|
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,9 +1,10 @@
|
|
|
1
|
-
export declare const rule = "\nrule:\n kind: binary_expression\n all:\n - has:\n kind:
|
|
1
|
+
export declare const rule = "\nrule:\n kind: binary_expression\n # The names are mangled in the minified runtimes\n pattern: \"!$ENTRY.isStale || $CONTEXT.isPrefetch\"\n all:\n # It has to be the whole check, not a part of a bigger expression\n - not:\n inside:\n kind: binary_expression\n # It has to be guarded by the on-demand revalidation check\n - inside:\n kind: if_statement\n stopBy: end\n has:\n field: condition\n stopBy: end\n any:\n # Next 15.5+ and its minified runtime copies use the same context object\n - pattern: \"!$CONTEXT.isOnDemandRevalidate\"\n # Earlier unminified versions keep this flag in a local\n - pattern: \"!isOnDemandRevalidate\"\nfix:\n 'true'";
|
|
2
2
|
export declare const patchBackgroundRevalidation: {
|
|
3
3
|
name: string;
|
|
4
4
|
patches: {
|
|
5
5
|
versions: ">=14.1.0";
|
|
6
6
|
pathFilter: RegExp;
|
|
7
|
+
contentFilter: RegExp;
|
|
7
8
|
patchCode: import("../codePatcher.js").PatchCodeFn;
|
|
8
9
|
}[];
|
|
9
10
|
};
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import { getCrossPlatformPathRegex } from "../../../utils/regex.js";
|
|
2
2
|
import { createPatchCode } from "../astCodePatcher.js";
|
|
3
|
+
// Matches the check that decides whether a cached entry can be served as is in `ResponseCache.get`:
|
|
4
|
+
// if (entry && !context.isOnDemandRevalidate) { ...; if (!entry.isStale || context.isPrefetch) return ... }
|
|
5
|
+
// which is minified in the compiled runtimes as:
|
|
6
|
+
// if ((a = ...) && !r.isOnDemandRevalidate && (n(a), i = !0, !a.isStale || r.isPrefetch)) return a;
|
|
3
7
|
export const rule = `
|
|
4
8
|
rule:
|
|
5
9
|
kind: binary_expression
|
|
10
|
+
# The names are mangled in the minified runtimes
|
|
11
|
+
pattern: "!$ENTRY.isStale || $CONTEXT.isPrefetch"
|
|
6
12
|
all:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# It has to be the whole check, not a part of a bigger expression
|
|
14
|
+
- not:
|
|
15
|
+
inside:
|
|
16
|
+
kind: binary_expression
|
|
17
|
+
# It has to be guarded by the on-demand revalidation check
|
|
18
|
+
- inside:
|
|
19
|
+
kind: if_statement
|
|
20
|
+
stopBy: end
|
|
21
|
+
has:
|
|
22
|
+
field: condition
|
|
23
|
+
stopBy: end
|
|
24
|
+
any:
|
|
25
|
+
# Next 15.5+ and its minified runtime copies use the same context object
|
|
26
|
+
- pattern: "!$CONTEXT.isOnDemandRevalidate"
|
|
27
|
+
# Earlier unminified versions keep this flag in a local
|
|
28
|
+
- pattern: "!isOnDemandRevalidate"
|
|
13
29
|
fix:
|
|
14
30
|
'true'`;
|
|
15
31
|
export const patchBackgroundRevalidation = {
|
|
@@ -18,7 +34,10 @@ export const patchBackgroundRevalidation = {
|
|
|
18
34
|
{
|
|
19
35
|
// TODO: test for earlier versions of Next
|
|
20
36
|
versions: ">=14.1.0",
|
|
21
|
-
|
|
37
|
+
// Since Next 15.5 route modules use their own `ResponseCache`, which is inlined (and minified)
|
|
38
|
+
// in `next/dist/compiled/next-server/*.runtime.prod.js`
|
|
39
|
+
pathFilter: getCrossPlatformPathRegex(String.raw `(server/response-cache/index\.js|\.runtime\.prod\.js)$`, { escape: false }),
|
|
40
|
+
contentFilter: /\.isStale\s*\|\|\s*[\w$]+\.isPrefetch/,
|
|
22
41
|
patchCode: createPatchCode(rule),
|
|
23
42
|
},
|
|
24
43
|
],
|
|
@@ -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
|
|
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(`${
|
|
79
|
-
MessageGroupId: generateMessageGroupId(
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/utils/promise.d.ts
CHANGED
|
@@ -13,8 +13,25 @@ export declare class DetachedPromise<T = any> {
|
|
|
13
13
|
}
|
|
14
14
|
export declare class DetachedPromiseRunner {
|
|
15
15
|
private promises;
|
|
16
|
+
/**
|
|
17
|
+
* Create a deferred promise tracked by this runner.
|
|
18
|
+
*
|
|
19
|
+
* @returns The tracked promise and its resolve/reject functions.
|
|
20
|
+
*/
|
|
16
21
|
withResolvers<T>(): DetachedPromise<T>;
|
|
22
|
+
/**
|
|
23
|
+
* Track background work and handle failures immediately.
|
|
24
|
+
*
|
|
25
|
+
* @param promise Work to await, including work registered during an active drain.
|
|
26
|
+
* @returns Nothing; failures are logged rather than propagated.
|
|
27
|
+
*/
|
|
17
28
|
add<T>(promise: Promise<T>): void;
|
|
29
|
+
/**
|
|
30
|
+
* Drain tracked work, including additions made while earlier work is pending.
|
|
31
|
+
*
|
|
32
|
+
* Work started after the drain has settled still needs a new runtime waitUntil.
|
|
33
|
+
* @returns Resolves when all work registered during this drain has settled.
|
|
34
|
+
*/
|
|
18
35
|
await(): Promise<void>;
|
|
19
36
|
}
|
|
20
37
|
export declare function runWithOpenNextRequestContext<T>({ isISRRevalidation, waitUntil, requestId, }: {
|
package/dist/utils/promise.js
CHANGED
|
@@ -28,23 +28,41 @@ export class DetachedPromise {
|
|
|
28
28
|
}
|
|
29
29
|
export class DetachedPromiseRunner {
|
|
30
30
|
promises = [];
|
|
31
|
+
/**
|
|
32
|
+
* Create a deferred promise tracked by this runner.
|
|
33
|
+
*
|
|
34
|
+
* @returns The tracked promise and its resolve/reject functions.
|
|
35
|
+
*/
|
|
31
36
|
withResolvers() {
|
|
32
37
|
const detachedPromise = new DetachedPromise();
|
|
33
|
-
this.
|
|
38
|
+
this.add(detachedPromise.promise);
|
|
34
39
|
return detachedPromise;
|
|
35
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Track background work and handle failures immediately.
|
|
43
|
+
*
|
|
44
|
+
* @param promise Work to await, including work registered during an active drain.
|
|
45
|
+
* @returns Nothing; failures are logged rather than propagated.
|
|
46
|
+
*/
|
|
36
47
|
add(promise) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
promise.then(
|
|
48
|
+
// A late promise may reject before the current batch finishes. Attach its
|
|
49
|
+
// rejection handler now so it cannot become an unhandled rejection.
|
|
50
|
+
this.promises.push(promise.then(() => { }, error));
|
|
40
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Drain tracked work, including additions made while earlier work is pending.
|
|
54
|
+
*
|
|
55
|
+
* Work started after the drain has settled still needs a new runtime waitUntil.
|
|
56
|
+
* @returns Resolves when all work registered during this drain has settled.
|
|
57
|
+
*/
|
|
41
58
|
async await() {
|
|
42
59
|
debug(`Awaiting ${this.promises.length} detached promises`);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
let awaited = 0;
|
|
61
|
+
while (awaited < this.promises.length) {
|
|
62
|
+
const batch = this.promises.slice(awaited);
|
|
63
|
+
awaited = this.promises.length;
|
|
64
|
+
await Promise.all(batch);
|
|
65
|
+
}
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
async function awaitAllDetachedPromise() {
|
|
@@ -61,13 +79,17 @@ function provideNextAfterProvider() {
|
|
|
61
79
|
// This is needed by some lib that relies on the vercel request context to properly await stuff.
|
|
62
80
|
// Remove this when vercel builder is updated to provide '@next/request-context'.
|
|
63
81
|
const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for("@vercel/request-context");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
// `get` resolves the store on every call rather than capturing it here: the
|
|
83
|
+
// object is published on `globalThis`, which every request in the isolate
|
|
84
|
+
// shares, so a captured store could belong to a different request.
|
|
67
85
|
const nextAfterContext = {
|
|
68
|
-
get: () =>
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
get: () => {
|
|
87
|
+
const store = globalThis.__openNextAls.getStore();
|
|
88
|
+
return {
|
|
89
|
+
waitUntil: store?.waitUntil ??
|
|
90
|
+
((promise) => store?.pendingPromiseRunner.add(promise)),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
71
93
|
};
|
|
72
94
|
//@ts-expect-error
|
|
73
95
|
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
|
+
"version": "4.1.8",
|
|
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.
|
|
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.
|
|
64
|
+
"next": ">=15.5.27 <16 || >=16.3.8"
|
|
65
65
|
},
|
|
66
66
|
"bugs": {
|
|
67
67
|
"url": "https://github.com/opennextjs/opennextjs-aws/issues"
|