@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.
@@ -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
- _lastModified = store.lastModified;
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
- rscData: Buffer.from(cacheData.rsc),
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: cacheData.type === "page" ? cacheData.json : cacheData.rsc,
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.toString("utf8"),
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
- : data?.kind === "PAGE"
415
- ? (data.headers?.["x-next-cache-tags"]?.split(",") ?? [])
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.32.6";
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
- const correspondingRoute = routes.find((route) => route.regex.some((r) => new RegExp(r).test(path)));
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, error } from "../../adapters/logger";
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
- try {
96
- const segmentHeader = `${event.headers[NEXT_SEGMENT_PREFETCH_HEADER]}`;
97
- const isSegmentResponse = Boolean(segmentHeader) &&
98
- segmentHeader in (cachedValue.segmentData || {}) &&
99
- !NextConfig.experimental?.prefetchInlining;
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: isSegmentResponse
106
- ? { [NEXT_PRERENDER_HEADER]: "1", [NEXT_POSTPONED_HEADER]: "2" }
107
- : {},
110
+ body: cachedValue.segmentData[segmentHeader],
111
+ additionalHeaders: {
112
+ [NEXT_PRERENDER_HEADER]: "1",
113
+ [NEXT_POSTPONED_HEADER]: "2",
114
+ },
108
115
  };
109
116
  }
110
- catch (e) {
111
- error("Error while getting body for app router from cache:", e);
112
- return { body: cachedValue.rsc, additionalHeaders: {} };
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 { body: appRouterBody, additionalHeaders: appHeaders } = getBodyForAppRouter(event, cachedValue);
125
- body = appRouterBody;
126
- additionalHeaders = appHeaders;
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
- localizedPath = decodePathParams(localizedPath);
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(localizedPath ?? "/index");
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(localizedPath, tags, cachedData);
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(localizedPath, tags, cachedData.lastModified ?? Date.now());
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
- return generateResult(event, localizedPath, cachedData.value, cachedData.lastModified, _isStale);
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
- const hasMatch = middleMatch.some((r) => r.test(normalizedPath));
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: match?.[1] ? `/${match[1]}` : url,
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,7 @@
1
+ import type { WrapperHandler } from "../../types/overrides";
2
+ declare const _default: {
3
+ wrapper: WrapperHandler;
4
+ name: string;
5
+ supportStreaming: true;
6
+ };
7
+ export default _default;
@@ -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
+ };
@@ -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: string;
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
- html: string;
43
- rsc: string;
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
  } | {
@@ -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
@@ -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.0.3",
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.18 <16 || >=16.2.6"
64
+ "next": ">=15.5.24 <16 || >=16.3.3"
64
65
  },
65
66
  "bugs": {
66
67
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"