@opennextjs/aws 4.0.3 → 4.1.0

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.
@@ -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
  }
@@ -169,19 +169,13 @@ function escapePathDelimiters(segment, escapeEncoded) {
169
169
  *
170
170
  * SSG cache key needs to be decoded, but some characters needs to be properly escaped
171
171
  * https://github.com/vercel/next.js/blob/34039551d2e5f611c0abde31a197d9985918adaf/packages/next/src/server/lib/router-utils/decode-path-params.ts#L11-L26
172
+ * Decoding must be atomic, as in Next.js. Partially decoding a malformed path
173
+ * could select a different cache route than the one evaluated by middleware.
172
174
  */
173
175
  function decodePathParams(pathname) {
174
176
  return pathname
175
177
  .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
- })
178
+ .map((segment) => escapePathDelimiters(decodeURIComponent(segment), true))
185
179
  .join("/");
186
180
  }
187
181
  export async function cacheInterceptor(event) {
@@ -205,14 +199,24 @@ export async function cacheInterceptor(event) {
205
199
  // We also need to remove trailing slash
206
200
  localizedPath = localizedPath.replace(/\/$/, "");
207
201
  // Then we decode the path params
208
- localizedPath = decodePathParams(localizedPath);
202
+ try {
203
+ localizedPath = decodePathParams(localizedPath) || "/";
204
+ }
205
+ catch {
206
+ // Next.js rejects malformed path params. Do not let cache interception
207
+ // partially decode the path and select a different route identity.
208
+ return event;
209
+ }
210
+ // The route is keyed as `/` in the prerender manifest, but the generated
211
+ // cache asset for the app index route is uploaded as `/index`.
212
+ const cacheKey = localizedPath === "/" ? "/index" : localizedPath;
209
213
  debug("Checking cache for", localizedPath, PrerenderManifest);
210
- const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath ?? "/") ||
214
+ const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) ||
211
215
  Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath));
212
216
  debug("isISR", isISR);
213
217
  if (isISR) {
214
218
  try {
215
- const cachedData = await globalThis.incrementalCache.get(localizedPath ?? "/index");
219
+ const cachedData = await globalThis.incrementalCache.get(cacheKey);
216
220
  debug("cached data in interceptor", cachedData);
217
221
  if (!cachedData?.value) {
218
222
  return event;
@@ -223,7 +227,7 @@ export async function cacheInterceptor(event) {
223
227
  cachedData.value?.type === "route") {
224
228
  const _hasBeenRevalidated = cachedData.shouldBypassTagCache
225
229
  ? false
226
- : await hasBeenRevalidated(localizedPath, tags, cachedData);
230
+ : await hasBeenRevalidated(cacheKey, tags, cachedData);
227
231
  if (_hasBeenRevalidated) {
228
232
  return event;
229
233
  }
@@ -231,7 +235,7 @@ export async function cacheInterceptor(event) {
231
235
  // Check if the cache entry is stale (valid but needs background revalidation)
232
236
  const _isStale = cachedData.shouldBypassTagCache
233
237
  ? false
234
- : await isStale(localizedPath, tags, cachedData.lastModified ?? Date.now());
238
+ : await isStale(cacheKey, tags, cachedData.lastModified ?? Date.now());
235
239
  const host = event.headers.host;
236
240
  switch (cachedData?.value?.type) {
237
241
  case "app":
@@ -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);
@@ -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
+ };
@@ -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 {
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.0",
7
7
  "bin": {
8
8
  "open-next": "./dist/index.js"
9
9
  },
@@ -60,7 +60,7 @@
60
60
  "typescript": "5.9.3"
61
61
  },
62
62
  "peerDependencies": {
63
- "next": ">=15.5.18 <16 || >=16.2.6"
63
+ "next": ">=15.5.21 <16 || >=16.2.11"
64
64
  },
65
65
  "bugs": {
66
66
  "url": "https://github.com/opennextjs/opennextjs-aws/issues"