Skip to content

Commit e8f0f5d

Browse files
authored
fix: Ensure middleware rewrite status code is properly propagated in cache interceptor (opennextjs#974)
changeset fix test fix test refactor deletion of cache tags header review update test
1 parent 2fb5245 commit e8f0f5d

File tree

9 files changed

+123
-25
lines changed

9 files changed

+123
-25
lines changed

.changeset/angry-ways-approve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
fix: Ensure middleware rewrite status code is properly propagated to cache interceptor

packages/open-next/src/adapters/middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,10 @@ const defaultHandler = async (
125125
}
126126
}
127127

128-
result.headers[INTERNAL_EVENT_REQUEST_ID] = requestId;
128+
if (process.env.OPEN_NEXT_REQUEST_ID_HEADER || globalThis.openNextDebug) {
129+
result.headers[INTERNAL_EVENT_REQUEST_ID] = requestId;
130+
}
131+
129132
debug("Middleware response", result);
130133
return result;
131134
},

packages/open-next/src/core/requestHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export async function openNextHandler(
6363
}
6464
debug("internalEvent", internalEvent);
6565

66-
// These 2 will get overwritten by the routing handler if not using an external middleware
66+
// These 3 will get overwritten by the routing handler if not using an external middleware
6767
const internalHeaders = {
6868
initialPath:
6969
initialHeaders[INTERNAL_HEADER_INITIAL_URL] ?? internalEvent.rawPath,

packages/open-next/src/core/routing/cacheInterceptor.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { createHash } from "node:crypto";
22

33
import { NextConfig, PrerenderManifest } from "config/index";
4-
import type { InternalEvent, InternalResult } from "types/open-next";
4+
import type {
5+
InternalEvent,
6+
InternalResult,
7+
MiddlewareEvent,
8+
} from "types/open-next";
59
import type { CacheValue } from "types/overrides";
610
import { emptyReadableStream, toReadableStream } from "utils/stream";
711

@@ -100,7 +104,7 @@ async function computeCacheControl(
100104
}
101105

102106
async function generateResult(
103-
event: InternalEvent,
107+
event: MiddlewareEvent,
104108
localizedPath: string,
105109
cachedValue: CacheValue<"cache">,
106110
lastModified?: number,
@@ -132,8 +136,12 @@ async function generateResult(
132136
);
133137
return {
134138
type: "core",
135-
// sometimes other status codes can be cached, like 404. For these cases, we should return the correct status code
136-
statusCode: cachedValue.meta?.status ?? 200,
139+
// Sometimes other status codes can be cached, like 404. For these cases, we should return the correct status code
140+
// Also set the status code to the rewriteStatusCode if defined
141+
// This can happen in handleMiddleware in routingHandler.
142+
// `NextResponse.rewrite(url, { status: xxx})
143+
// The rewrite status code should take precedence over the cached one
144+
statusCode: event.rewriteStatusCode ?? cachedValue.meta?.status ?? 200,
137145
body: toReadableStream(body, false),
138146
isBase64Encoded: false,
139147
headers: {
@@ -179,7 +187,7 @@ function decodePathParams(pathname: string): string {
179187
}
180188

181189
export async function cacheInterceptor(
182-
event: InternalEvent,
190+
event: MiddlewareEvent,
183191
): Promise<InternalEvent | InternalResult> {
184192
if (
185193
Boolean(event.headers["next-action"]) ||
@@ -287,7 +295,8 @@ export async function cacheInterceptor(
287295

288296
return {
289297
type: "core",
290-
statusCode: cachedData.value.meta?.status ?? 200,
298+
statusCode:
299+
event.rewriteStatusCode ?? cachedData.value.meta?.status ?? 200,
291300
body: toReadableStream(cachedData.value.body, isBinary),
292301
headers: {
293302
...cacheControl,

packages/open-next/src/core/routing/middleware.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
NextConfig,
77
PrerenderManifest,
88
} from "config/index.js";
9-
import type { InternalEvent, InternalResult } from "types/open-next.js";
9+
import type {
10+
InternalEvent,
11+
InternalResult,
12+
MiddlewareEvent,
13+
} from "types/open-next.js";
1014
import { emptyReadableStream } from "utils/stream.js";
1115

1216
import { getQueryFromSearchParams } from "../../overrides/converters/utils.js";
@@ -28,12 +32,6 @@ const middleMatch = getMiddlewareMatch(
2832

2933
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
3034

31-
type MiddlewareEvent = InternalEvent & {
32-
responseHeaders?: Record<string, string | string[]>;
33-
isExternalRewrite?: boolean;
34-
rewriteStatusCode?: number;
35-
};
36-
3735
type Middleware = (request: Request) => Response | Promise<Response>;
3836
type MiddlewareLoader = () => Promise<{ default: Middleware }>;
3937

@@ -198,6 +196,7 @@ export async function handleMiddleware(
198196
cookies: internalEvent.cookies,
199197
remoteAddress: internalEvent.remoteAddress,
200198
isExternalRewrite,
201-
rewriteStatusCode: statusCode,
199+
rewriteStatusCode:
200+
rewriteUrl && !isExternalRewrite ? statusCode : undefined,
202201
} satisfies MiddlewareEvent;
203202
}

packages/open-next/src/types/open-next.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export type InternalEvent = {
3232
readonly remoteAddress: string;
3333
} & BaseEventOrResult<"core">;
3434

35+
export type MiddlewareEvent = InternalEvent & {
36+
responseHeaders?: Record<string, string | string[]>;
37+
isExternalRewrite?: boolean;
38+
rewriteStatusCode?: number;
39+
};
40+
3541
export type InternalResult = {
3642
statusCode: number;
3743
headers: Record<string, string | string[]>;

packages/open-next/src/utils/cache.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ export function getTagsFromValue(value?: CacheValue<"cache">) {
3939
}
4040
// The try catch is necessary for older version of next.js that may fail on this
4141
try {
42-
return value.meta?.headers?.["x-next-cache-tags"]?.split(",") ?? [];
42+
const cacheTags =
43+
value.meta?.headers?.["x-next-cache-tags"]?.split(",") ?? [];
44+
delete value.meta?.headers?.["x-next-cache-tags"];
45+
return cacheTags;
4346
} catch (e) {
4447
return [];
4548
}

packages/tests-e2e/tests/appRouter/middleware.rewrite.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,25 @@ test("Middleware Rewrite", async ({ page }) => {
2323
});
2424

2525
test("Middleware Rewrite External Image", async ({ page }) => {
26-
await page.goto("/rewrite-external");
2726
page.on("response", async (response) => {
2827
expect(response.status()).toBe(200);
2928
expect(response.headers()["content-type"]).toBe("image/png");
3029
expect(response.headers()["cache-control"]).toBe("max-age=600");
3130
const bodyBuffer = await response.body();
3231
expect(validateMd5(bodyBuffer, OPENNEXT_PNG_MD5)).toBe(true);
3332
});
33+
await page.goto("/rewrite-external");
3434
});
3535

3636
test("Middleware Rewrite Status Code", async ({ page }) => {
37+
page.on("response", async (response) => {
38+
// Need to set up the event before navigating to the page to avoid missing it
39+
// We need to check the URL here also cause there will be multiple responses (i.e the fonts, css, js, etc)
40+
if (response.url() === "/rewrite-status-code") {
41+
expect(response.status()).toBe(403);
42+
}
43+
});
3744
await page.goto("/rewrite-status-code");
3845
const el = page.getByText("Rewritten Destination", { exact: true });
3946
await expect(el).toBeVisible();
40-
page.on("response", async (response) => {
41-
expect(response.status()).toBe(403);
42-
});
4347
});

packages/tests-unit/tests/core/routing/cacheInterceptor.test.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
22
import { cacheInterceptor } from "@opennextjs/aws/core/routing/cacheInterceptor.js";
33
import { convertFromQueryString } from "@opennextjs/aws/core/routing/util.js";
4-
import type { InternalEvent } from "@opennextjs/aws/types/open-next.js";
4+
import type { MiddlewareEvent } from "@opennextjs/aws/types/open-next.js";
55
import type { Queue } from "@opennextjs/aws/types/overrides.js";
66
import { fromReadableStream } from "@opennextjs/aws/utils/stream.js";
77
import { vi } from "vitest";
@@ -26,14 +26,14 @@ vi.mock("@opennextjs/aws/adapters/config/index.js", () => ({
2626
}));
2727

2828
vi.mock("@opennextjs/aws/core/routing/i18n/index.js", () => ({
29-
localizePath: (event: InternalEvent) => event.rawPath,
29+
localizePath: (event: MiddlewareEvent) => event.rawPath,
3030
}));
3131

3232
type PartialEvent = Partial<
33-
Omit<InternalEvent, "body" | "rawPath" | "query">
33+
Omit<MiddlewareEvent, "body" | "rawPath" | "query">
3434
> & { body?: string };
3535

36-
function createEvent(event: PartialEvent): InternalEvent {
36+
function createEvent(event: PartialEvent): MiddlewareEvent {
3737
const [rawPath, qs] = (event.url ?? "/").split("?", 2);
3838
return {
3939
type: "core",
@@ -45,6 +45,7 @@ function createEvent(event: PartialEvent): InternalEvent {
4545
query: convertFromQueryString(qs ?? ""),
4646
cookies: event.cookies ?? {},
4747
remoteAddress: event.remoteAddress ?? "::1",
48+
rewriteStatusCode: event.rewriteStatusCode,
4849
};
4950
}
5051

@@ -469,4 +470,72 @@ describe("cacheInterceptor", () => {
469470
}),
470471
);
471472
});
473+
474+
it("should return the rewrite status code when there is active cache", async () => {
475+
const event = createEvent({
476+
url: "/albums",
477+
rewriteStatusCode: 403,
478+
});
479+
incrementalCache.get.mockResolvedValueOnce({
480+
value: {
481+
type: "app",
482+
html: "Hello, world!",
483+
},
484+
});
485+
486+
const result = await cacheInterceptor(event);
487+
expect(result.statusCode).toBe(403);
488+
});
489+
490+
it("should return the rewriteStatusCode if there is a cached status code", async () => {
491+
const event = createEvent({
492+
url: "/albums",
493+
rewriteStatusCode: 203,
494+
});
495+
incrementalCache.get.mockResolvedValueOnce({
496+
value: {
497+
type: "app",
498+
html: "Hello, world!",
499+
meta: {
500+
status: 404,
501+
},
502+
},
503+
});
504+
505+
const result = await cacheInterceptor(event);
506+
expect(result.statusCode).toBe(203);
507+
});
508+
509+
it("should return the cached status code if there is one", async () => {
510+
const event = createEvent({
511+
url: "/albums",
512+
});
513+
incrementalCache.get.mockResolvedValueOnce({
514+
value: {
515+
type: "app",
516+
html: "Hello, world!",
517+
meta: {
518+
status: 405,
519+
},
520+
},
521+
});
522+
523+
const result = await cacheInterceptor(event);
524+
expect(result.statusCode).toBe(405);
525+
});
526+
527+
it("should return 200 if there is no cached status code, nor a rewriteStatusCode", async () => {
528+
const event = createEvent({
529+
url: "/albums",
530+
});
531+
incrementalCache.get.mockResolvedValueOnce({
532+
value: {
533+
type: "app",
534+
html: "Hello, world!",
535+
},
536+
});
537+
538+
const result = await cacheInterceptor(event);
539+
expect(result.statusCode).toBe(200);
540+
});
472541
});

0 commit comments

Comments
 (0)