@opennextjs/aws 4.0.3 → 4.1.1
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 +41 -7
- package/dist/build/copyTracedFiles.js +9 -0
- package/dist/build/createAssets.js +7 -0
- package/dist/build/createImageOptimizationBundle.js +1 -1
- package/dist/build/generateOutput.js +2 -1
- package/dist/build/validateConfig.js +1 -0
- package/dist/core/edgeFunctionHandler.js +16 -1
- package/dist/core/routing/cacheInterceptor.js +72 -36
- package/dist/core/routing/middleware.js +11 -1
- package/dist/core/routing/util.d.ts +10 -0
- package/dist/core/routing/util.js +12 -2
- package/dist/overrides/wrappers/aws-apigw-streaming.d.ts +7 -0
- package/dist/overrides/wrappers/aws-apigw-streaming.js +74 -0
- package/dist/types/cache.d.ts +1 -1
- package/dist/types/open-next.d.ts +1 -1
- package/dist/types/overrides.d.ts +25 -3
- package/dist/utils/cache.d.ts +15 -0
- package/dist/utils/cache.js +28 -0
- package/package.json +3 -2
package/dist/adapters/cache.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTagsFromValue, hasBeenRevalidated, isStale, writeTags, } from "../utils/cache";
|
|
1
|
+
import { getStaleLastModified, getTagsFromValue, hasBeenRevalidated, isStale, writeTags, } from "../utils/cache";
|
|
2
2
|
import { isBinaryContentType } from "../utils/binary";
|
|
3
3
|
import { compareSemver } from "../utils/semver";
|
|
4
4
|
import { debug, error, warn } from "./logger";
|
|
@@ -92,8 +92,12 @@ export default class Cache {
|
|
|
92
92
|
: await isStale(key, tags, _lastModified);
|
|
93
93
|
const store = globalThis.__openNextAls.getStore();
|
|
94
94
|
if (store) {
|
|
95
|
+
// The sentinel is kept as is in the store so that the revalidation queue keeps
|
|
96
|
+
// deduplicating every stale request for the same path.
|
|
95
97
|
store.lastModified = _isStale ? 1 : _lastModified;
|
|
96
|
-
|
|
98
|
+
}
|
|
99
|
+
if (_isStale) {
|
|
100
|
+
_lastModified = getStaleLastModified(cacheData.revalidate);
|
|
97
101
|
}
|
|
98
102
|
if (cacheData?.type === "route") {
|
|
99
103
|
return {
|
|
@@ -111,6 +115,16 @@ export default class Cache {
|
|
|
111
115
|
};
|
|
112
116
|
}
|
|
113
117
|
if (cacheData?.type === "page" || cacheData?.type === "app") {
|
|
118
|
+
// Without the html there is no valid value to return, so we report a miss and let
|
|
119
|
+
// the server render the page. Next.js behaves the same way, its file system cache
|
|
120
|
+
// reads the `.html` file first and turns a read failure into a miss.
|
|
121
|
+
//
|
|
122
|
+
// The check is on `undefined` and not on falsiness because an empty string is a
|
|
123
|
+
// valid value: a PPR route with an empty static shell has an empty `.html` file.
|
|
124
|
+
if (cacheData.html === undefined) {
|
|
125
|
+
warn("No html in the cache entry", key);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
114
128
|
if (compareSemver(globalThis.nextVersion, ">=", "15.0.0") &&
|
|
115
129
|
cacheData?.type === "app") {
|
|
116
130
|
const segmentData = new Map();
|
|
@@ -124,7 +138,13 @@ export default class Cache {
|
|
|
124
138
|
value: {
|
|
125
139
|
kind: "APP_PAGE",
|
|
126
140
|
html: cacheData.html,
|
|
127
|
-
|
|
141
|
+
// `rsc` is absent when the build collected neither a `.rsc` nor a
|
|
142
|
+
// `.prefetch.rsc` file, see `CachedFile`. An undefined value is expected: the
|
|
143
|
+
// Next.js file system cache also leaves `rscData` undefined for fallback
|
|
144
|
+
// shells, and on 16.2+ for postponed PPR routes.
|
|
145
|
+
rscData: cacheData.rsc === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: Buffer.from(cacheData.rsc),
|
|
128
148
|
status: meta?.status,
|
|
129
149
|
headers: meta?.headers,
|
|
130
150
|
postponed: meta?.postponed,
|
|
@@ -132,6 +152,13 @@ export default class Cache {
|
|
|
132
152
|
},
|
|
133
153
|
};
|
|
134
154
|
}
|
|
155
|
+
const pageData = cacheData.type === "page" ? cacheData.json : cacheData.rsc;
|
|
156
|
+
// Only reachable for an app page on Next.js < 15, where an absent `rsc` leaves us
|
|
157
|
+
// without any page data to return.
|
|
158
|
+
if (pageData === undefined) {
|
|
159
|
+
warn("No page data in the cache entry", key);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
135
162
|
return {
|
|
136
163
|
lastModified: _lastModified,
|
|
137
164
|
value: {
|
|
@@ -139,7 +166,7 @@ export default class Cache {
|
|
|
139
166
|
? "PAGES"
|
|
140
167
|
: "PAGE",
|
|
141
168
|
html: cacheData.html,
|
|
142
|
-
pageData
|
|
169
|
+
pageData,
|
|
143
170
|
status: meta?.status,
|
|
144
171
|
headers: meta?.headers,
|
|
145
172
|
},
|
|
@@ -232,7 +259,7 @@ export default class Cache {
|
|
|
232
259
|
await globalThis.incrementalCache.set(key, {
|
|
233
260
|
type: "app",
|
|
234
261
|
html,
|
|
235
|
-
rsc: rscData
|
|
262
|
+
rsc: rscData?.toString("utf8"),
|
|
236
263
|
meta: {
|
|
237
264
|
status,
|
|
238
265
|
headers,
|
|
@@ -408,11 +435,18 @@ export default class Cache {
|
|
|
408
435
|
}
|
|
409
436
|
// Write derivedTags to the tag cache
|
|
410
437
|
// If we use an in house version of getDerivedTags in build we should use it here instead of next's one
|
|
438
|
+
// `x-next-cache-tags` is always a comma separated string: Next.js sets it from
|
|
439
|
+
// `metadata.fetchTags`, which is a string. The header type allows an array because it
|
|
440
|
+
// is shared with headers that can legitimately repeat, so we narrow on `typeof` here.
|
|
441
|
+
// Next.js guards the same way when it reads the header back.
|
|
442
|
+
const pageCacheTags = data?.kind === "PAGE" || data?.kind === "APP_PAGE"
|
|
443
|
+
? data.headers?.["x-next-cache-tags"]
|
|
444
|
+
: undefined;
|
|
411
445
|
const derivedTags = data?.kind === "FETCH"
|
|
412
446
|
? //@ts-expect-error - On older versions of next, ctx was a number, but for these cases we use data?.data?.tags
|
|
413
447
|
(ctx?.tags ?? data?.data?.tags ?? []) // before version 14 next.js used data?.data?.tags so we keep it for backward compatibility
|
|
414
|
-
:
|
|
415
|
-
?
|
|
448
|
+
: typeof pageCacheTags === "string"
|
|
449
|
+
? pageCacheTags.split(",")
|
|
416
450
|
: [];
|
|
417
451
|
debug("derivedTags", derivedTags);
|
|
418
452
|
// Get all tags stored in dynamodb for the given key
|
|
@@ -159,6 +159,15 @@ File ${serverPath} does not exist
|
|
|
159
159
|
if (existsSync(path.join(dotNextDir, INSTRUMENTATION_TRACE_FILE))) {
|
|
160
160
|
// We still need to copy the nft.json file so that computeCopyFilesForPage doesn't throw
|
|
161
161
|
copyFileAndMakeOwnerWritable(path.join(dotNextDir, INSTRUMENTATION_TRACE_FILE), path.join(standaloneNextDir, INSTRUMENTATION_TRACE_FILE));
|
|
162
|
+
// Next 16's standalone output no longer includes `server/instrumentation.js`
|
|
163
|
+
// in the standalone dir, but `computeCopyFilesForPage` asserts it exists
|
|
164
|
+
// there. Copy it from the build dir (mirroring the .nft.json copy above) so
|
|
165
|
+
// the assertion passes and the instrumentation file ships in the bundle.
|
|
166
|
+
const instrumentationServerFile = path.join("server", "instrumentation.js");
|
|
167
|
+
if (existsSync(path.join(dotNextDir, instrumentationServerFile)) &&
|
|
168
|
+
!existsSync(path.join(standaloneNextDir, instrumentationServerFile))) {
|
|
169
|
+
copyFileAndMakeOwnerWritable(path.join(dotNextDir, instrumentationServerFile), path.join(standaloneNextDir, instrumentationServerFile));
|
|
170
|
+
}
|
|
162
171
|
computeCopyFilesForPage("instrumentation");
|
|
163
172
|
logger.debug("Adding instrumentation trace files");
|
|
164
173
|
}
|
|
@@ -94,6 +94,13 @@ export function createCacheAssets(options) {
|
|
|
94
94
|
case ".json":
|
|
95
95
|
case ".body":
|
|
96
96
|
case ".rsc": {
|
|
97
|
+
// Stripping `.prefetch` maps both `<page>.rsc` and `<page>.prefetch.rsc` onto
|
|
98
|
+
// the same cache entry, so the `rsc` field can hold either payload. When both
|
|
99
|
+
// files exist the first one traversed wins, as the spread below keeps the value
|
|
100
|
+
// already recorded for that extension.
|
|
101
|
+
//
|
|
102
|
+
// Next 16.1 removed `.prefetch.rsc`, so the strip is a no-op on Next 16 where
|
|
103
|
+
// the `rsc` field always holds a full payload.
|
|
97
104
|
const newFilePath = path
|
|
98
105
|
.join(outputCachePath, relativePath)
|
|
99
106
|
.substring(0, path.join(outputCachePath, relativePath).length - ext.length)
|
|
@@ -74,7 +74,7 @@ export async function createImageOptimizationBundle(options) {
|
|
|
74
74
|
// Sharp provides pre-build binaries for all platforms. https://github.com/lovell/sharp/blob/main/docs/install.md#cross-platform
|
|
75
75
|
// Target should be same as used by Lambda, see https://github.com/sst/sst/blob/ca6f763fdfddd099ce2260202d0ce48c72e211ea/packages/sst/src/constructs/NextjsSite.ts#L114
|
|
76
76
|
// For SHARP_IGNORE_GLOBAL_LIBVIPS see: https://github.com/lovell/sharp/blob/main/docs/install.md#aws-lambda
|
|
77
|
-
const sharpVersion = process.env.SHARP_VERSION ?? "0.
|
|
77
|
+
const sharpVersion = process.env.SHARP_VERSION ?? "0.35.3";
|
|
78
78
|
// In development, we want to use the local machine settings
|
|
79
79
|
const isDev = config.imageOptimization?.loader === "fs-dev";
|
|
80
80
|
installDependencies(outputPath, config.imageOptimization?.install ?? {
|
|
@@ -8,7 +8,8 @@ async function canStream(opts) {
|
|
|
8
8
|
return false;
|
|
9
9
|
}
|
|
10
10
|
if (typeof opts.override.wrapper === "string") {
|
|
11
|
-
return opts.override.wrapper === "aws-lambda-streaming"
|
|
11
|
+
return (opts.override.wrapper === "aws-lambda-streaming" ||
|
|
12
|
+
opts.override.wrapper === "aws-apigw-streaming");
|
|
12
13
|
}
|
|
13
14
|
const wrapper = await opts.override.wrapper();
|
|
14
15
|
return wrapper.supportStreaming;
|
|
@@ -8,6 +8,7 @@ const compatibilityMatrix = {
|
|
|
8
8
|
],
|
|
9
9
|
"aws-lambda-compressed": ["aws-apigw-v2"],
|
|
10
10
|
"aws-lambda-streaming": ["aws-apigw-v2"],
|
|
11
|
+
"aws-apigw-streaming": ["aws-apigw-v1", "aws-apigw-v2"],
|
|
11
12
|
cloudflare: ["edge"],
|
|
12
13
|
"cloudflare-edge": ["edge"],
|
|
13
14
|
"cloudflare-node": ["edge"],
|
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
export default async function edgeFunctionHandler(request) {
|
|
3
3
|
const path = new URL(request.url).pathname;
|
|
4
4
|
const routes = globalThis._ROUTES;
|
|
5
|
-
|
|
5
|
+
let decodedPath;
|
|
6
|
+
try {
|
|
7
|
+
// Decode the complete pathname atomically so a malformed escape cannot be
|
|
8
|
+
// partially decoded.
|
|
9
|
+
decodedPath = decodeURIComponent(path);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// A malformed pathname cannot provide a decoded route to match.
|
|
13
|
+
}
|
|
14
|
+
// Also match against the decoded pathname, while keeping the encoded URL in
|
|
15
|
+
// the request passed to middleware.
|
|
16
|
+
const correspondingRoute = routes.find((route) => route.regex.some((r) => {
|
|
17
|
+
const regex = new RegExp(r);
|
|
18
|
+
return (regex.test(path) ||
|
|
19
|
+
(decodedPath !== undefined && regex.test(decodedPath)));
|
|
20
|
+
}));
|
|
6
21
|
if (!correspondingRoute) {
|
|
7
22
|
throw new Error(`No route found for ${request.url}`);
|
|
8
23
|
}
|
|
@@ -3,7 +3,7 @@ import { NextConfig, 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
|
-
import { debug
|
|
6
|
+
import { debug } from "../../adapters/logger";
|
|
7
7
|
import { localizePath } from "./i18n";
|
|
8
8
|
import { generateMessageGroupId } from "./queue";
|
|
9
9
|
const CACHE_ONE_YEAR = 60 * 60 * 24 * 365;
|
|
@@ -88,42 +88,65 @@ async function computeCacheControl(path, body, host, revalidate, lastModified, i
|
|
|
88
88
|
etag,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Computes the body of an RSC response from a cached app router entry.
|
|
93
|
+
*
|
|
94
|
+
* @param event The incoming event, used to read the segment prefetch header
|
|
95
|
+
* @param cachedValue The cache entry, must be of type `app`
|
|
96
|
+
* @returns The body and the headers to add to the response, or `undefined` when
|
|
97
|
+
* the entry can not serve the request - the caller should then fallback to the server.
|
|
98
|
+
* @throws When `cachedValue` is not of type `app`
|
|
99
|
+
*/
|
|
91
100
|
function getBodyForAppRouter(event, cachedValue) {
|
|
92
101
|
if (cachedValue.type !== "app") {
|
|
93
102
|
throw new Error("getBodyForAppRouter called with non-app cache value");
|
|
94
103
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const body = isSegmentResponse
|
|
101
|
-
? cachedValue.segmentData[segmentHeader]
|
|
102
|
-
: cachedValue.rsc;
|
|
104
|
+
const segmentHeader = `${event.headers[NEXT_SEGMENT_PREFETCH_HEADER]}`;
|
|
105
|
+
const isSegmentResponse = Boolean(segmentHeader) &&
|
|
106
|
+
segmentHeader in (cachedValue.segmentData || {}) &&
|
|
107
|
+
!NextConfig.experimental?.prefetchInlining;
|
|
108
|
+
if (isSegmentResponse) {
|
|
103
109
|
return {
|
|
104
|
-
body,
|
|
105
|
-
additionalHeaders:
|
|
106
|
-
|
|
107
|
-
:
|
|
110
|
+
body: cachedValue.segmentData[segmentHeader],
|
|
111
|
+
additionalHeaders: {
|
|
112
|
+
[NEXT_PRERENDER_HEADER]: "1",
|
|
113
|
+
[NEXT_POSTPONED_HEADER]: "2",
|
|
114
|
+
},
|
|
108
115
|
};
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
// `rsc` is absent when the build collected neither a `.rsc` nor a `.prefetch.rsc` file for
|
|
118
|
+
// this entry - fallback shells, and postponed PPR routes on Next 16.2+, see `CachedFile`.
|
|
119
|
+
// There is nothing valid to serve, and falling back to an empty payload would break the
|
|
120
|
+
// router and let the CDN cache the empty response, so let the server generate it.
|
|
121
|
+
if (cachedValue.rsc === undefined) {
|
|
122
|
+
return undefined;
|
|
113
123
|
}
|
|
124
|
+
return { body: cachedValue.rsc, additionalHeaders: {} };
|
|
114
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Generates the response to serve for a cached `app` or `page` entry.
|
|
128
|
+
*
|
|
129
|
+
* @param event The incoming event
|
|
130
|
+
* @param localizedPath The localized path, used to compute the cache control
|
|
131
|
+
* @param cachedValue The cache entry, must be of type `app` or `page`
|
|
132
|
+
* @param lastModified Time of the last update to the cache entry
|
|
133
|
+
* @param isStaleFromTagCache Whether the tag cache reported the entry as stale
|
|
134
|
+
* @returns The result to serve, or `undefined` when the entry can not serve the
|
|
135
|
+
* request - the caller should then fallback to the server.
|
|
136
|
+
* @throws When `cachedValue` is neither of type `app` nor `page`
|
|
137
|
+
*/
|
|
115
138
|
async function generateResult(event, localizedPath, cachedValue, lastModified, isStaleFromTagCache = false) {
|
|
116
139
|
debug("Returning result from experimental cache");
|
|
117
|
-
let body
|
|
140
|
+
let body;
|
|
118
141
|
let type = "application/octet-stream";
|
|
119
142
|
let isDataRequest = false;
|
|
120
143
|
let additionalHeaders = {};
|
|
121
144
|
if (cachedValue.type === "app") {
|
|
122
145
|
isDataRequest = event.headers.rsc === "1";
|
|
123
146
|
if (isDataRequest) {
|
|
124
|
-
const
|
|
125
|
-
body =
|
|
126
|
-
additionalHeaders =
|
|
147
|
+
const appRouterResult = getBodyForAppRouter(event, cachedValue);
|
|
148
|
+
body = appRouterResult?.body;
|
|
149
|
+
additionalHeaders = appRouterResult?.additionalHeaders ?? {};
|
|
127
150
|
}
|
|
128
151
|
else {
|
|
129
152
|
body = cachedValue.html;
|
|
@@ -138,6 +161,12 @@ async function generateResult(event, localizedPath, cachedValue, lastModified, i
|
|
|
138
161
|
else {
|
|
139
162
|
throw new Error("generateResult called with unsupported cache value type, only 'app' and 'page' are supported");
|
|
140
163
|
}
|
|
164
|
+
// Next.js does not write every file for every route at build time, so the entry might
|
|
165
|
+
// not hold the data needed to serve this particular request.
|
|
166
|
+
if (body === undefined) {
|
|
167
|
+
debug("Missing body in the cache entry, falling back to the server");
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
141
170
|
const cacheControl = await computeCacheControl(localizedPath, body, event.headers.host, cachedValue.revalidate, lastModified, isStaleFromTagCache);
|
|
142
171
|
return {
|
|
143
172
|
type: "core",
|
|
@@ -169,19 +198,13 @@ function escapePathDelimiters(segment, escapeEncoded) {
|
|
|
169
198
|
*
|
|
170
199
|
* SSG cache key needs to be decoded, but some characters needs to be properly escaped
|
|
171
200
|
* https://github.com/vercel/next.js/blob/34039551d2e5f611c0abde31a197d9985918adaf/packages/next/src/server/lib/router-utils/decode-path-params.ts#L11-L26
|
|
201
|
+
* Decoding must be atomic, as in Next.js. Partially decoding a malformed path
|
|
202
|
+
* could select a different cache route than the one evaluated by middleware.
|
|
172
203
|
*/
|
|
173
204
|
function decodePathParams(pathname) {
|
|
174
205
|
return pathname
|
|
175
206
|
.split("/")
|
|
176
|
-
.map((segment) =>
|
|
177
|
-
try {
|
|
178
|
-
return escapePathDelimiters(decodeURIComponent(segment), true);
|
|
179
|
-
}
|
|
180
|
-
catch (e) {
|
|
181
|
-
// If decodeURIComponent fails, we return the original segment
|
|
182
|
-
return segment;
|
|
183
|
-
}
|
|
184
|
-
})
|
|
207
|
+
.map((segment) => escapePathDelimiters(decodeURIComponent(segment), true))
|
|
185
208
|
.join("/");
|
|
186
209
|
}
|
|
187
210
|
export async function cacheInterceptor(event) {
|
|
@@ -205,14 +228,24 @@ export async function cacheInterceptor(event) {
|
|
|
205
228
|
// We also need to remove trailing slash
|
|
206
229
|
localizedPath = localizedPath.replace(/\/$/, "");
|
|
207
230
|
// Then we decode the path params
|
|
208
|
-
|
|
231
|
+
try {
|
|
232
|
+
localizedPath = decodePathParams(localizedPath) || "/";
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Next.js rejects malformed path params. Do not let cache interception
|
|
236
|
+
// partially decode the path and select a different route identity.
|
|
237
|
+
return event;
|
|
238
|
+
}
|
|
239
|
+
// The route is keyed as `/` in the prerender manifest, but the generated
|
|
240
|
+
// cache asset for the app index route is uploaded as `/index`.
|
|
241
|
+
const cacheKey = localizedPath === "/" ? "/index" : localizedPath;
|
|
209
242
|
debug("Checking cache for", localizedPath, PrerenderManifest);
|
|
210
|
-
const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath
|
|
243
|
+
const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
|
|
211
244
|
Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
|
|
212
245
|
debug("isISR", isISR);
|
|
213
246
|
if (isISR) {
|
|
214
247
|
try {
|
|
215
|
-
const cachedData = await globalThis.incrementalCache.get(
|
|
248
|
+
const cachedData = await globalThis.incrementalCache.get(cacheKey);
|
|
216
249
|
debug("cached data in interceptor", cachedData);
|
|
217
250
|
if (!cachedData?.value) {
|
|
218
251
|
return event;
|
|
@@ -223,7 +256,7 @@ export async function cacheInterceptor(event) {
|
|
|
223
256
|
cachedData.value?.type === "route") {
|
|
224
257
|
const _hasBeenRevalidated = cachedData.shouldBypassTagCache
|
|
225
258
|
? false
|
|
226
|
-
: await hasBeenRevalidated(
|
|
259
|
+
: await hasBeenRevalidated(cacheKey, tags, cachedData);
|
|
227
260
|
if (_hasBeenRevalidated) {
|
|
228
261
|
return event;
|
|
229
262
|
}
|
|
@@ -231,12 +264,15 @@ export async function cacheInterceptor(event) {
|
|
|
231
264
|
// Check if the cache entry is stale (valid but needs background revalidation)
|
|
232
265
|
const _isStale = cachedData.shouldBypassTagCache
|
|
233
266
|
? false
|
|
234
|
-
: await isStale(
|
|
267
|
+
: await isStale(cacheKey, tags, cachedData.lastModified ?? Date.now());
|
|
235
268
|
const host = event.headers.host;
|
|
236
269
|
switch (cachedData?.value?.type) {
|
|
237
270
|
case "app":
|
|
238
|
-
case "page":
|
|
239
|
-
|
|
271
|
+
case "page": {
|
|
272
|
+
const result = await generateResult(event, localizedPath, cachedData.value, cachedData.lastModified, _isStale);
|
|
273
|
+
// The cache entry can not serve this request, fallback to the server.
|
|
274
|
+
return result ?? event;
|
|
275
|
+
}
|
|
240
276
|
case "redirect": {
|
|
241
277
|
const cacheControl = await computeCacheControl(localizedPath, "", host, cachedData.value.revalidate, cachedData.lastModified, _isStale);
|
|
242
278
|
return {
|
|
@@ -29,7 +29,17 @@ export async function handleMiddleware(internalEvent, initialSearch, middlewareL
|
|
|
29
29
|
return internalEvent;
|
|
30
30
|
// We only need the normalizedPath to check if the middleware should run
|
|
31
31
|
const normalizedPath = localizePath(internalEvent);
|
|
32
|
-
|
|
32
|
+
let decodedPath;
|
|
33
|
+
try {
|
|
34
|
+
decodedPath = decodeURIComponent(normalizedPath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// An invalid encoding cannot provide a decoded pathname to match.
|
|
38
|
+
}
|
|
39
|
+
// Next.js also tries the decoded pathname for matching, while preserving the
|
|
40
|
+
// encoded pathname in the request passed to middleware.
|
|
41
|
+
const hasMatch = middleMatch.some((r) => r.test(normalizedPath) ||
|
|
42
|
+
(decodedPath !== undefined && r.test(decodedPath)));
|
|
33
43
|
if (!hasMatch)
|
|
34
44
|
return internalEvent;
|
|
35
45
|
const initialUrl = new URL(normalizedPath, internalEvent.url);
|
|
@@ -11,6 +11,16 @@ import { ReadableStream } from "node:stream/web";
|
|
|
11
11
|
export declare function isExternal(url?: string, host?: string): boolean;
|
|
12
12
|
export declare function convertFromQueryString(query: string): Record<string, string | string[]>;
|
|
13
13
|
/**
|
|
14
|
+
* Splits an URL into its parts
|
|
15
|
+
*
|
|
16
|
+
* The URL is a route destination, i.e. a `path-to-regexp` pattern rather than a
|
|
17
|
+
* well formed URL, so it can not be parsed with `new URL()` which would percent
|
|
18
|
+
* encode some of the pattern characters.
|
|
19
|
+
*
|
|
20
|
+
* @param url The URL to split
|
|
21
|
+
* @param isExternal Whether the URL points to an external host
|
|
22
|
+
* @returns The protocol, hostname, pathname and query string of the URL
|
|
23
|
+
* @throws When `isExternal` is true and the URL is not an absolute HTTP(S) URL
|
|
14
24
|
*
|
|
15
25
|
* @__PURE__
|
|
16
26
|
*/
|
|
@@ -40,6 +40,16 @@ export function convertFromQueryString(query) {
|
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
+
* Splits an URL into its parts
|
|
44
|
+
*
|
|
45
|
+
* The URL is a route destination, i.e. a `path-to-regexp` pattern rather than a
|
|
46
|
+
* well formed URL, so it can not be parsed with `new URL()` which would percent
|
|
47
|
+
* encode some of the pattern characters.
|
|
48
|
+
*
|
|
49
|
+
* @param url The URL to split
|
|
50
|
+
* @param isExternal Whether the URL points to an external host
|
|
51
|
+
* @returns The protocol, hostname, pathname and query string of the URL
|
|
52
|
+
* @throws When `isExternal` is true and the URL is not an absolute HTTP(S) URL
|
|
43
53
|
*
|
|
44
54
|
* @__PURE__
|
|
45
55
|
*/
|
|
@@ -49,12 +59,12 @@ export function getUrlParts(url, isExternal) {
|
|
|
49
59
|
const match = url.match(regex);
|
|
50
60
|
return {
|
|
51
61
|
hostname: "",
|
|
52
|
-
pathname:
|
|
62
|
+
pathname: url.startsWith("/") ? `/${match?.[1] ?? ""}` : "",
|
|
53
63
|
protocol: "",
|
|
54
64
|
queryString: match?.[2] ?? "",
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
|
-
const regex = /^(https?:)\/\/?([^\/\s]+)(\/[^?]*)?(\?.*)?/;
|
|
67
|
+
const regex = /^(https?:)\/\/?([^\/\s?]+)(\/[^?]*)?(\?.*)?/;
|
|
58
68
|
const match = url.match(regex);
|
|
59
69
|
if (!match) {
|
|
60
70
|
throw new Error(`Invalid external URL: ${url}`);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { debug } from "../../adapters/logger";
|
|
3
|
+
function formatWarmerResponse(event) {
|
|
4
|
+
const result = new Promise((resolve) => {
|
|
5
|
+
setTimeout(() => {
|
|
6
|
+
resolve({ serverId, type: "warmer" });
|
|
7
|
+
}, event.delay);
|
|
8
|
+
});
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Streaming wrapper for an API Gateway proxy integration in
|
|
13
|
+
* `ResponseTransferMode: STREAM`.
|
|
14
|
+
*
|
|
15
|
+
* Differs from `aws-lambda-streaming` (the Lambda **Function URL** wrapper) in
|
|
16
|
+
* two ways, both mandated by API Gateway response streaming
|
|
17
|
+
* (https://aws.amazon.com/blogs/compute/building-responsive-apis-with-amazon-api-gateway-response-streaming/):
|
|
18
|
+
*
|
|
19
|
+
* 1. It does NOT set the
|
|
20
|
+
* `application/vnd.awslambda.http-integration-response` content type. That
|
|
21
|
+
* marker is Function-URL-specific metadata; API Gateway forwards it onto
|
|
22
|
+
* the wire, corrupting HTTP framing.
|
|
23
|
+
* 2. It does NOT compress the response. API Gateway streaming explicitly does
|
|
24
|
+
* not support content encoding, so the body is always emitted as
|
|
25
|
+
* `identity`. Compression, if desired, is handled by the CDN in front
|
|
26
|
+
* (e.g. CloudFront).
|
|
27
|
+
*
|
|
28
|
+
* The response framing itself — a JSON metadata prelude followed by an
|
|
29
|
+
* 8-null-byte delimiter, then the payload — is identical to the Function URL
|
|
30
|
+
* format and is what API Gateway expects in STREAM mode.
|
|
31
|
+
*/
|
|
32
|
+
const handler = async (handler, converter) => awslambda.streamifyResponse(async (event, responseStream, context) => {
|
|
33
|
+
context.callbackWaitsForEmptyEventLoop = false;
|
|
34
|
+
if ("type" in event) {
|
|
35
|
+
const result = await formatWarmerResponse(event);
|
|
36
|
+
responseStream.end(Buffer.from(JSON.stringify(result)), "utf-8");
|
|
37
|
+
await globalThis.__next_route_preloader("warmerEvent");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const internalEvent = await converter.convertFrom(event);
|
|
41
|
+
responseStream.on("error", (err) => {
|
|
42
|
+
debug(err);
|
|
43
|
+
responseStream.end();
|
|
44
|
+
});
|
|
45
|
+
const streamCreator = {
|
|
46
|
+
writeHeaders: (_prelude) => {
|
|
47
|
+
// No content-encoding header — API Gateway streaming does not
|
|
48
|
+
// support content encoding; the body is emitted as identity.
|
|
49
|
+
_prelude.headers["content-encoding"] = "identity";
|
|
50
|
+
const prelude = JSON.stringify(_prelude);
|
|
51
|
+
responseStream.write(prelude);
|
|
52
|
+
// 8-null-byte delimiter separating the JSON metadata prelude from
|
|
53
|
+
// the response payload. Same framing API Gateway STREAM expects.
|
|
54
|
+
responseStream.write(new Uint8Array(8));
|
|
55
|
+
return responseStream;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const response = await handler(internalEvent, { streamCreator });
|
|
59
|
+
const isUsingEdge = globalThis.isEdgeRuntime ?? false;
|
|
60
|
+
if (isUsingEdge) {
|
|
61
|
+
debug("Headers has not been set, we must be in the edge runtime");
|
|
62
|
+
const stream = streamCreator.writeHeaders({
|
|
63
|
+
statusCode: response.statusCode,
|
|
64
|
+
headers: response.headers,
|
|
65
|
+
cookies: [],
|
|
66
|
+
});
|
|
67
|
+
Readable.fromWeb(response.body).pipe(stream);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
export default {
|
|
71
|
+
wrapper: handler,
|
|
72
|
+
name: "aws-apigw-streaming",
|
|
73
|
+
supportStreaming: true,
|
|
74
|
+
};
|
package/dist/types/cache.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ interface IncrementalCachedPageValue {
|
|
|
40
40
|
interface IncrementalCachedAppPageValue {
|
|
41
41
|
kind: "APP_PAGE";
|
|
42
42
|
html: string;
|
|
43
|
-
rscData: Buffer;
|
|
43
|
+
rscData: Buffer | undefined;
|
|
44
44
|
headers?: Record<string, undefined | string | string[]>;
|
|
45
45
|
postponed?: string;
|
|
46
46
|
status?: number;
|
|
@@ -96,7 +96,7 @@ export interface Origin {
|
|
|
96
96
|
port?: number;
|
|
97
97
|
customHeaders?: Record<string, string>;
|
|
98
98
|
}
|
|
99
|
-
export type IncludedWrapper = "aws-lambda" | "aws-lambda-streaming" | "aws-lambda-compressed" | "node" | "cloudflare" | "cloudflare-edge" | "cloudflare-node" | "express-dev" | "dummy";
|
|
99
|
+
export type IncludedWrapper = "aws-lambda" | "aws-lambda-streaming" | "aws-apigw-streaming" | "aws-lambda-compressed" | "node" | "cloudflare" | "cloudflare-edge" | "cloudflare-node" | "express-dev" | "dummy";
|
|
100
100
|
export type IncludedConverter = "aws-apigw-v2" | "aws-apigw-v1" | "aws-cloudfront" | "edge" | "node" | "sqs-revalidate" | "dummy";
|
|
101
101
|
export type RouteType = "route" | "page" | "app";
|
|
102
102
|
export interface ResolvedRoute {
|
|
@@ -34,13 +34,35 @@ export type CachedFile = {
|
|
|
34
34
|
meta?: Meta;
|
|
35
35
|
} | {
|
|
36
36
|
type: "page";
|
|
37
|
-
html
|
|
37
|
+
/** Absent when the build did not collect an `.html` file for this entry. */
|
|
38
|
+
html?: string;
|
|
38
39
|
json: Object;
|
|
39
40
|
meta?: Meta;
|
|
40
41
|
} | {
|
|
41
42
|
type: "app";
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The prerendered HTML.
|
|
45
|
+
*
|
|
46
|
+
* Absent when the build did not collect an `.html` file for this entry. Note that
|
|
47
|
+
* Next.js writes the `.html` unconditionally when it prerenders an app page, so this
|
|
48
|
+
* is only expected to be absent for a malformed build output. A PPR route with an
|
|
49
|
+
* empty static shell is *not* one of those cases: the file is written, empty.
|
|
50
|
+
*/
|
|
51
|
+
html?: string;
|
|
52
|
+
/**
|
|
53
|
+
* The RSC payload.
|
|
54
|
+
*
|
|
55
|
+
* Both `<page>.rsc` and `<page>.prefetch.rsc` are stored here, see `createAssets`.
|
|
56
|
+
*
|
|
57
|
+
* Absent when the build emitted neither file, which depends on the Next.js version:
|
|
58
|
+
* - `15.x`: only for fallback shells. A PPR route emits a `.prefetch.rsc` in place of
|
|
59
|
+
* its `.rsc`, so the field is set but holds the *prefetch* payload, which this type
|
|
60
|
+
* can not distinguish from a full one.
|
|
61
|
+
* - `>= 16.2`: `.prefetch.rsc` no longer exists and a PPR route only emits a `.rsc`
|
|
62
|
+
* when it is fully static, so the field is absent for postponed PPR routes and for
|
|
63
|
+
* fallback shells, and holds a full payload otherwise.
|
|
64
|
+
*/
|
|
65
|
+
rsc?: string;
|
|
44
66
|
meta?: Meta;
|
|
45
67
|
segmentData?: Record<string, string>;
|
|
46
68
|
} | {
|
package/dist/utils/cache.d.ts
CHANGED
|
@@ -9,6 +9,21 @@ import type { CacheValue, NextModeTagCacheWriteInput, OriginalTagCacheWriteInput
|
|
|
9
9
|
* In this case, the cache entry is still valid and can be served, but it should trigger a background revalidation to update the cache.
|
|
10
10
|
*/
|
|
11
11
|
export declare function isStale(key: string, tags: string[], lastModified?: number): Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Next.js has no explicit way for a cache handler to report an entry as stale,
|
|
14
|
+
* the only lever we have is the `lastModified` we hand back to the incremental cache.
|
|
15
|
+
*
|
|
16
|
+
* Up to Next 16.2 we return `1` (i.e. right after the epoch), which is enough for Next to
|
|
17
|
+
* compute `revalidateAfter` in the past and mark the entry as stale.
|
|
18
|
+
* Starting with Next 16.3 the incremental cache also compares `lastModified + expire` to now, and
|
|
19
|
+
* forces a blocking revalidation (`isStale === -1`, surfacing as `x-nextjs-cache: REVALIDATED`)
|
|
20
|
+
* when that is in the past. An epoch based value always trips that check, so from that version on
|
|
21
|
+
* we go back just far enough for the entry to be stale while remaining inside its expire window.
|
|
22
|
+
*
|
|
23
|
+
* @param revalidate The revalidate value stored alongside the cache entry, in seconds
|
|
24
|
+
* @returns The `lastModified` to report to Next.js for a stale entry
|
|
25
|
+
*/
|
|
26
|
+
export declare function getStaleLastModified(revalidate?: number | false): number;
|
|
12
27
|
/**
|
|
13
28
|
* @param key The key for that specific cache entry
|
|
14
29
|
* @param tags Array of tags associated with that cache entry
|
package/dist/utils/cache.js
CHANGED
|
@@ -24,6 +24,34 @@ export async function isStale(key, tags, lastModified) {
|
|
|
24
24
|
}
|
|
25
25
|
return (await globalThis.tagCache.isStale?.(key, lastModified)) ?? false;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Next.js has no explicit way for a cache handler to report an entry as stale,
|
|
29
|
+
* the only lever we have is the `lastModified` we hand back to the incremental cache.
|
|
30
|
+
*
|
|
31
|
+
* Up to Next 16.2 we return `1` (i.e. right after the epoch), which is enough for Next to
|
|
32
|
+
* compute `revalidateAfter` in the past and mark the entry as stale.
|
|
33
|
+
* Starting with Next 16.3 the incremental cache also compares `lastModified + expire` to now, and
|
|
34
|
+
* forces a blocking revalidation (`isStale === -1`, surfacing as `x-nextjs-cache: REVALIDATED`)
|
|
35
|
+
* when that is in the past. An epoch based value always trips that check, so from that version on
|
|
36
|
+
* we go back just far enough for the entry to be stale while remaining inside its expire window.
|
|
37
|
+
*
|
|
38
|
+
* @param revalidate The revalidate value stored alongside the cache entry, in seconds
|
|
39
|
+
* @returns The `lastModified` to report to Next.js for a stale entry
|
|
40
|
+
*/
|
|
41
|
+
export function getStaleLastModified(revalidate) {
|
|
42
|
+
// The `expire` check only exists from Next 16.3, before that the sentinel is what Next expects.
|
|
43
|
+
if (!compareSemver(globalThis.nextVersion, ">=", "16.3.0")) {
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
if (typeof revalidate !== "number") {
|
|
47
|
+
// Without a revalidate value Next cannot derive an expire time either,
|
|
48
|
+
// so the historical sentinel is still safe here.
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
// 1ms past the revalidate window. `expire` is always >= `revalidate`, so the entry is
|
|
52
|
+
// stale without being expired (when both are equal, being expired is the correct outcome).
|
|
53
|
+
return Date.now() - revalidate * 1000 - 1;
|
|
54
|
+
}
|
|
27
55
|
/**
|
|
28
56
|
* @param key The key for that specific cache entry
|
|
29
57
|
* @param tags Array of tags associated with that cache entry
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"access": "public"
|
|
4
4
|
},
|
|
5
5
|
"name": "@opennextjs/aws",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.1.1",
|
|
7
7
|
"bin": {
|
|
8
8
|
"open-next": "./dist/index.js"
|
|
9
9
|
},
|
|
@@ -56,11 +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.3",
|
|
59
60
|
"tsc-alias": "^1.8.16",
|
|
60
61
|
"typescript": "5.9.3"
|
|
61
62
|
},
|
|
62
63
|
"peerDependencies": {
|
|
63
|
-
"next": ">=15.5.
|
|
64
|
+
"next": ">=15.5.24 <16 || >=16.3.3"
|
|
64
65
|
},
|
|
65
66
|
"bugs": {
|
|
66
67
|
"url": "https://github.com/opennextjs/opennextjs-aws/issues"
|