@opennextjs/aws 4.1.0 → 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 ?? {
@@ -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",
@@ -239,8 +268,11 @@ export async function cacheInterceptor(event) {
239
268
  const host = event.headers.host;
240
269
  switch (cachedData?.value?.type) {
241
270
  case "app":
242
- case "page":
243
- 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
+ }
244
276
  case "redirect": {
245
277
  const cacheControl = await computeCacheControl(localizedPath, "", host, cachedData.value.revalidate, cachedData.lastModified, _isStale);
246
278
  return {
@@ -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}`);
@@ -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;
@@ -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.1.0",
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.21 <16 || >=16.2.11"
64
+ "next": ">=15.5.24 <16 || >=16.3.3"
64
65
  },
65
66
  "bugs": {
66
67
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"