Skip to content

Commit a541feb

Browse files
authored
fix: handle malformed location header when using next/navigation redirect API (#3332)
1 parent 4253e5d commit a541feb

File tree

6 files changed

+115
-39
lines changed

6 files changed

+115
-39
lines changed

src/run/augment-next-response.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ServerResponse } from 'node:http'
2+
import { isPromise } from 'node:util/types'
3+
4+
import type { NextApiResponse } from 'next'
5+
6+
import type { RequestContext } from './handlers/request-context.cjs'
7+
8+
type ResRevalidateMethod = NextApiResponse['revalidate']
9+
10+
function isRevalidateMethod(
11+
key: string,
12+
nextResponseField: unknown,
13+
): nextResponseField is ResRevalidateMethod {
14+
return key === 'revalidate' && typeof nextResponseField === 'function'
15+
}
16+
17+
function isAppendHeaderMethod(
18+
key: string,
19+
nextResponseField: unknown,
20+
): nextResponseField is ServerResponse['appendHeader'] {
21+
return key === 'appendHeader' && typeof nextResponseField === 'function'
22+
}
23+
24+
// Needing to proxy the response object to intercept:
25+
// - the revalidate call for on-demand revalidation on page routes
26+
// - prevent .appendHeader calls for location header to add duplicate values
27+
export const augmentNextResponse = (response: ServerResponse, requestContext: RequestContext) => {
28+
return new Proxy(response, {
29+
get(target: ServerResponse, key: string) {
30+
const originalValue = Reflect.get(target, key)
31+
if (isRevalidateMethod(key, originalValue)) {
32+
return function newRevalidate(...args: Parameters<ResRevalidateMethod>) {
33+
requestContext.didPagesRouterOnDemandRevalidate = true
34+
35+
const result = originalValue.apply(target, args)
36+
if (result && isPromise(result)) {
37+
requestContext.trackBackgroundWork(result)
38+
}
39+
40+
return result
41+
}
42+
}
43+
44+
if (isAppendHeaderMethod(key, originalValue)) {
45+
return function patchedAppendHeader(...args: Parameters<ServerResponse['appendHeader']>) {
46+
if (typeof args[0] === 'string' && (args[0] === 'location' || args[0] === 'Location')) {
47+
let existing = target.getHeader('location')
48+
if (typeof existing !== 'undefined') {
49+
if (!Array.isArray(existing)) {
50+
existing = [String(existing)]
51+
}
52+
if (existing.includes(String(args[1]))) {
53+
// if we already have that location header - bail early
54+
// appendHeader should return the target for chaining
55+
return target
56+
}
57+
}
58+
}
59+
60+
return originalValue.apply(target, args)
61+
}
62+
}
63+
return originalValue
64+
},
65+
})
66+
}

src/run/handlers/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Context } from '@netlify/functions'
55
import type { Span } from '@netlify/otel/opentelemetry'
66
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
77

8+
import { augmentNextResponse } from '../augment-next-response.js'
89
import { getRunConfig, setRunConfig } from '../config.js'
910
import {
1011
adjustDateHeader,
@@ -13,7 +14,6 @@ import {
1314
setCacheTagsHeaders,
1415
setVaryHeaders,
1516
} from '../headers.js'
16-
import { nextResponseProxy } from '../revalidate.js'
1717
import { setFetchBeforeNextPatchedIt } from '../storage/storage.cjs'
1818

1919
import { getLogger, type RequestContext } from './request-context.cjs'
@@ -97,7 +97,7 @@ export default async (
9797

9898
disableFaultyTransferEncodingHandling(res as unknown as ComputeJsOutgoingMessage)
9999

100-
const resProxy = nextResponseProxy(res, requestContext)
100+
const resProxy = augmentNextResponse(res, requestContext)
101101

102102
// We don't await this here, because it won't resolve until the response is finished.
103103
const nextHandlerPromise = nextHandler(req, resProxy).catch((error) => {

src/run/revalidate.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { redirect } from 'next/navigation'
2+
3+
const Page = async () => {
4+
return redirect(`/app-redirect/dest`)
5+
}
6+
7+
export const generateStaticParams = async () => {
8+
return [{ slug: 'prerendered' }]
9+
}
10+
11+
export default Page
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default function Page() {
2+
return (
3+
<main>
4+
<h1>Hello next/navigation#redirect target</h1>
5+
</main>
6+
)
7+
}
8+
9+
export const dynamic = 'force-static'

tests/integration/simple-app.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ test<FixtureTestContext>('Test that the simple next app is working', async (ctx)
108108
shouldHaveAppRouterNotFoundInPrerenderManifest() ? '/_not-found' : undefined,
109109
'/api/cached-permanent',
110110
'/api/cached-revalidate',
111+
'/app-redirect/dest',
112+
'/app-redirect/prerendered',
111113
'/config-redirect',
112114
'/config-redirect/dest',
113115
'/config-rewrite',
@@ -445,6 +447,31 @@ test.skipIf(hasDefaultTurbopackBuilds())<FixtureTestContext>(
445447
},
446448
)
447449

450+
// below version check is not exact (not located exactly when, but Next.js had a bug for prerender pages that should redirect would not work correctly)
451+
// currently only know that 13.5.1 and 14.2.35 doesn't have correct response (either not a 307 or completely missing 'location' header), while 15.5.9 has 307 response with location header
452+
test.skipIf(nextVersionSatisfies('<15.0.0'))<FixtureTestContext>(
453+
`app router page that uses next/navigation#redirect works when page is prerendered`,
454+
async (ctx) => {
455+
await createFixture('simple', ctx)
456+
await runPlugin(ctx)
457+
458+
const response = await invokeFunction(ctx, { url: `/app-redirect/prerendered` })
459+
460+
expect(response.statusCode).toBe(307)
461+
expect(response.headers['location']).toBe('/app-redirect/dest')
462+
},
463+
)
464+
465+
test<FixtureTestContext>(`app router page that uses next/navigation#redirect works when page is NOT prerendered`, async (ctx) => {
466+
await createFixture('simple', ctx)
467+
await runPlugin(ctx)
468+
469+
const response = await invokeFunction(ctx, { url: `/app-redirect/non-prerendered` })
470+
471+
expect(response.statusCode).toBe(307)
472+
expect(response.headers['location']).toBe('/app-redirect/dest')
473+
})
474+
448475
describe('next patching', async () => {
449476
const { cp: originalCp, appendFile } = (await vi.importActual(
450477
'node:fs/promises',

0 commit comments

Comments
 (0)