Check for type of route handler returned value at build time (via the TS plugin) and at runtime (#51394)

### What?

Fixes #51130. Before this PR, the package assumes that route handlers return a `Response` which is not necessarily the case.

The linked issue specified three suggestions to resolve this

1. Return a default 200 response
2. Throw a better error message
3. or both

~~In this issue I implemented (3), except that it is a warning and not an error. Do tell if the team wants to follow a different approach, as it is not too hard to change this.~~

This PR implements (2).

### How?

The returned value of the handler is checked at runtime to ensure it is actually a `Response` instance.

The return type `AppRouteHandlerFn` is also modified to `unknown` to avoid similar assumptions elsewhere.

The TS plugin is also modified to check for the return type during build time.



Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com>
This commit is contained in:
Vũ Văn Dũng 2023-09-08 10:26:53 +07:00 committed by GitHub
parent 6ffb8dafbd
commit f47c409174
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 78 additions and 69 deletions

View file

@ -107,6 +107,19 @@ if ('${method}' in entry) {
'${method}'
>
>()
checkFields<
Diff<
{
__tag__: '${method}',
__return_type__: Response | Promise<Response>
},
{
__tag__: '${method}',
__return_type__: ReturnType<MaybeField<TEntry, '${method}'>>
},
'${method}'
>
>()
}
`
).join('')

View file

@ -70,7 +70,8 @@ type AppRouteHandlerFnContext = {
}
/**
* Handler function for app routes.
* Handler function for app routes. If a non-Response value is returned, an error
* will be thrown.
*/
export type AppRouteHandlerFn = (
/**
@ -82,7 +83,7 @@ export type AppRouteHandlerFn = (
* dynamic route).
*/
ctx: AppRouteHandlerFnContext
) => Promise<Response> | Response
) => unknown
/**
* AppRouteHandlers describes the handlers for app routes that is provided by
@ -368,6 +369,11 @@ export class AppRouteRouteModule extends RouteModule<
? parsedUrlQueryToParams(context.params)
: undefined,
})
if (!(res instanceof Response)) {
throw new Error(
`No response is returned from route handler '${this.resolvedPagePath}'. Ensure you return a \`Response\` or a \`NextResponse\` in all branches of your handler.`
)
}
;(context.staticGenerationContext as any).fetchMetrics =
staticGenerationStore.fetchMetrics

View file

@ -8,7 +8,7 @@ import {
cookieWithRequestMeta,
} from './helpers'
const bathPath = process.env.BASE_PATH ?? ''
const basePath = process.env.BASE_PATH ?? ''
createNextDescribe(
'app-custom-routes',
@ -27,7 +27,7 @@ createNextDescribe(
).toBeTruthy()
}
expect(
JSON.parse(await next.render(bathPath + '/api/hello.json'))
JSON.parse(await next.render(basePath + '/api/hello.json'))
).toEqual({
pathname: '/api/hello.json',
})
@ -47,7 +47,7 @@ createNextDescribe(
).toBeFalsy()
}
expect(
JSON.parse(await next.render(bathPath + '/api/dynamic'))
JSON.parse(await next.render(basePath + '/api/dynamic'))
).toEqual({
pathname: '/api/dynamic',
query: {},
@ -61,7 +61,7 @@ createNextDescribe(
'/static/second/data.json',
'/static/three/data.json',
])('responds correctly on %s', async (path) => {
expect(JSON.parse(await next.render(bathPath + path))).toEqual({
expect(JSON.parse(await next.render(basePath + path))).toEqual({
params: { slug: path.split('/')[2] },
now: expect.any(Number),
})
@ -83,7 +83,7 @@ createNextDescribe(
'/revalidate-1/second/data.json',
'/revalidate-1/three/data.json',
])('revalidates correctly on %s', async (path) => {
const data = JSON.parse(await next.render(bathPath + path))
const data = JSON.parse(await next.render(basePath + path))
expect(data).toEqual({
params: { slug: path.split('/')[2] },
now: expect.any(Number),
@ -91,7 +91,7 @@ createNextDescribe(
await check(async () => {
expect(data).not.toEqual(
JSON.parse(await next.render(bathPath + path))
JSON.parse(await next.render(basePath + path))
)
return 'success'
}, 'success')
@ -117,7 +117,7 @@ createNextDescribe(
it.each(['/basic/endpoint', '/basic/vercel/endpoint'])(
'responds correctly on %s',
async (path) => {
const res = await next.fetch(bathPath + path, { method })
const res = await next.fetch(basePath + path, { method })
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
@ -131,7 +131,7 @@ createNextDescribe(
describe('route groups', () => {
it('routes to the correct handler', async () => {
const res = await next.fetch(bathPath + '/basic/endpoint/nested')
const res = await next.fetch(basePath + '/basic/endpoint/nested')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
@ -141,7 +141,7 @@ createNextDescribe(
describe('request', () => {
it('can read query parameters', async () => {
const res = await next.fetch(bathPath + '/advanced/query?ping=pong')
const res = await next.fetch(basePath + '/advanced/query?ping=pong')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
@ -150,7 +150,7 @@ createNextDescribe(
it('can read query parameters (edge)', async () => {
const res = await next.fetch(
bathPath + '/edge/advanced/query?ping=pong'
basePath + '/edge/advanced/query?ping=pong'
)
expect(res.status).toEqual(200)
@ -162,7 +162,7 @@ createNextDescribe(
describe('response', () => {
// TODO-APP: re-enable when rewrites are supported again
it.skip('supports the NextResponse.rewrite() helper', async () => {
const res = await next.fetch(bathPath + '/hooks/rewrite')
const res = await next.fetch(basePath + '/hooks/rewrite')
expect(res.status).toEqual(200)
@ -173,7 +173,7 @@ createNextDescribe(
})
it('supports the NextResponse.redirect() helper', async () => {
const res = await next.fetch(bathPath + '/hooks/redirect/response', {
const res = await next.fetch(basePath + '/hooks/redirect/response', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
@ -186,7 +186,7 @@ createNextDescribe(
it('supports the NextResponse.json() helper', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(bathPath + '/hooks/json', {
const res = await next.fetch(basePath + '/hooks/json', {
headers: withRequestMeta(meta),
})
@ -212,7 +212,7 @@ createNextDescribe(
},
})
const res = await next.fetch(bathPath + '/advanced/body/streaming', {
const res = await next.fetch(basePath + '/advanced/body/streaming', {
method: 'POST',
body: stream,
})
@ -235,7 +235,7 @@ createNextDescribe(
})
const res = await next.fetch(
bathPath + '/edge/advanced/body/streaming',
basePath + '/edge/advanced/body/streaming',
{
method: 'POST',
body: stream,
@ -248,7 +248,7 @@ createNextDescribe(
it('can read a JSON encoded body', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(bathPath + '/advanced/body/json', {
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
@ -260,7 +260,7 @@ createNextDescribe(
it('can read a JSON encoded body (edge)', async () => {
const body = { ping: 'pong' }
const res = await next.fetch(bathPath + '/edge/advanced/body/json', {
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: JSON.stringify(body),
})
@ -272,7 +272,7 @@ createNextDescribe(
it('can read a JSON encoded body for DELETE requests', async () => {
const body = { name: 'foo' }
const res = await next.fetch(bathPath + '/advanced/body/json', {
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'DELETE',
body: JSON.stringify(body),
})
@ -283,7 +283,7 @@ createNextDescribe(
it('can read a JSON encoded body for OPTIONS requests', async () => {
const body = { name: 'bar' }
const res = await next.fetch(bathPath + '/advanced/body/json', {
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'OPTIONS',
body: JSON.stringify(body),
})
@ -306,7 +306,7 @@ createNextDescribe(
index++
},
})
const res = await next.fetch(bathPath + '/advanced/body/json', {
const res = await next.fetch(basePath + '/advanced/body/json', {
method: 'POST',
body: stream,
})
@ -329,7 +329,7 @@ createNextDescribe(
index++
},
})
const res = await next.fetch(bathPath + '/edge/advanced/body/json', {
const res = await next.fetch(basePath + '/edge/advanced/body/json', {
method: 'POST',
body: stream,
})
@ -341,7 +341,7 @@ createNextDescribe(
it('can read the text body', async () => {
const body = 'hello, world'
const res = await next.fetch(bathPath + '/advanced/body/text', {
const res = await next.fetch(basePath + '/advanced/body/text', {
method: 'POST',
body,
})
@ -353,7 +353,7 @@ createNextDescribe(
it('can read the text body (edge)', async () => {
const body = 'hello, world'
const res = await next.fetch(bathPath + '/edge/advanced/body/text', {
const res = await next.fetch(basePath + '/edge/advanced/body/text', {
method: 'POST',
body,
})
@ -366,7 +366,7 @@ createNextDescribe(
describe('context', () => {
it('provides params to routes with dynamic parameters', async () => {
const res = await next.fetch(bathPath + '/basic/vercel/endpoint')
const res = await next.fetch(basePath + '/basic/vercel/endpoint')
expect(res.status).toEqual(200)
const meta = getRequestMeta(res.headers)
@ -375,7 +375,7 @@ createNextDescribe(
it('provides params to routes with catch-all routes', async () => {
const res = await next.fetch(
bathPath + '/basic/vercel/some/other/resource'
basePath + '/basic/vercel/some/other/resource'
)
expect(res.status).toEqual(200)
@ -387,7 +387,7 @@ createNextDescribe(
})
it('does not provide params to routes without dynamic parameters', async () => {
const res = await next.fetch(bathPath + '/basic/endpoint')
const res = await next.fetch(basePath + '/basic/endpoint')
expect(res.ok).toBeTrue()
@ -399,7 +399,7 @@ createNextDescribe(
describe('hooks', () => {
describe('headers', () => {
it('gets the correct values', async () => {
const res = await next.fetch(bathPath + '/hooks/headers', {
const res = await next.fetch(basePath + '/hooks/headers', {
headers: withRequestMeta({ ping: 'pong' }),
})
@ -412,7 +412,7 @@ createNextDescribe(
describe('cookies', () => {
it('gets the correct values', async () => {
const res = await next.fetch(bathPath + '/hooks/cookies', {
const res = await next.fetch(basePath + '/hooks/cookies', {
headers: cookieWithRequestMeta({ ping: 'pong' }),
})
@ -425,7 +425,7 @@ createNextDescribe(
describe('cookies().has()', () => {
it('gets the correct values', async () => {
const res = await next.fetch(bathPath + '/hooks/cookies/has')
const res = await next.fetch(basePath + '/hooks/cookies/has')
expect(res.status).toEqual(200)
@ -435,7 +435,7 @@ createNextDescribe(
describe('redirect', () => {
it('can respond correctly', async () => {
const res = await next.fetch(bathPath + '/hooks/redirect', {
const res = await next.fetch(basePath + '/hooks/redirect', {
// "Manually" perform the redirect, we want to inspect the
// redirection response, so don't actually follow it.
redirect: 'manual',
@ -449,7 +449,7 @@ createNextDescribe(
describe('notFound', () => {
it('can respond correctly', async () => {
const res = await next.fetch(bathPath + '/hooks/not-found')
const res = await next.fetch(basePath + '/hooks/not-found')
expect(res.status).toEqual(404)
expect(await res.text()).toBeEmpty()
@ -459,7 +459,7 @@ createNextDescribe(
describe('error conditions', () => {
it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => {
const res = await next.fetch(bathPath + '/status/405', {
const res = await next.fetch(basePath + '/status/405', {
method: 'HEADER',
})
@ -468,7 +468,7 @@ createNextDescribe(
})
it('responds with 405 (Method Not Allowed) when method is not implemented', async () => {
const res = await next.fetch(bathPath + '/status/405', {
const res = await next.fetch(basePath + '/status/405', {
method: 'POST',
})
@ -477,7 +477,7 @@ createNextDescribe(
})
it('responds with 500 (Internal Server Error) when the handler throws an error', async () => {
const res = await next.fetch(bathPath + '/status/500')
const res = await next.fetch(basePath + '/status/500')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
@ -491,7 +491,7 @@ createNextDescribe(
// testing that the specific route throws this error in the console.
expect(next.cliOutput).not.toContain(error)
const res = await next.fetch(bathPath + '/status/500/next')
const res = await next.fetch(basePath + '/status/500/next')
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()
@ -507,7 +507,7 @@ createNextDescribe(
describe('automatic implementations', () => {
it('implements HEAD on routes with GET already implemented', async () => {
const res = await next.fetch(bathPath + '/methods/head', {
const res = await next.fetch(basePath + '/methods/head', {
method: 'HEAD',
})
@ -516,7 +516,7 @@ createNextDescribe(
})
it('implements OPTIONS on routes', async () => {
const res = await next.fetch(bathPath + '/methods/options', {
const res = await next.fetch(basePath + '/methods/options', {
method: 'OPTIONS',
})
@ -531,7 +531,7 @@ createNextDescribe(
describe('edge functions', () => {
it('returns response using edge runtime', async () => {
const res = await next.fetch(bathPath + '/edge')
const res = await next.fetch(basePath + '/edge')
expect(res.status).toEqual(200)
expect(await res.text()).toContain('hello, world')
@ -539,7 +539,7 @@ createNextDescribe(
it('returns a response when headers are accessed', async () => {
const meta = { ping: 'pong' }
const res = await next.fetch(bathPath + '/edge/headers', {
const res = await next.fetch(basePath + '/edge/headers', {
headers: withRequestMeta(meta),
})
@ -550,7 +550,7 @@ createNextDescribe(
describe('dynamic = "force-static"', () => {
it('strips search, headers, and domain from request', async () => {
const res = await next.fetch(bathPath + '/dynamic?query=true', {
const res = await next.fetch(basePath + '/dynamic?query=true', {
headers: {
accept: 'application/json',
cookie: 'session=true',
@ -579,7 +579,7 @@ createNextDescribe(
describe('customized metadata routes', () => {
it('should work if conflict with metadata routes convention', async () => {
const res = await next.fetch(bathPath + '/robots.txt')
const res = await next.fetch(basePath + '/robots.txt')
expect(res.status).toEqual(200)
expect(await res.text()).toBe(
@ -601,7 +601,7 @@ createNextDescribe(
])(
'should print an error when using lowercase %p in dev',
async (method: string) => {
await next.fetch(bathPath + '/lowercase/' + method)
await next.fetch(basePath + '/lowercase/' + method)
await check(() => {
expect(next.cliOutput).toContain(
@ -621,7 +621,7 @@ createNextDescribe(
describe('invalid exports', () => {
it('should print an error when exporting a default handler in dev', async () => {
const res = await next.fetch(bathPath + '/default')
const res = await next.fetch(basePath + '/default')
// Ensure we get a 405 (Method Not Allowed) response when there is no
// exported handler for the GET method.
@ -639,5 +639,18 @@ createNextDescribe(
})
})
}
describe('no response returned', () => {
it('should print an error when no response is returned', async () => {
await next.fetch(basePath + '/no-response', { method: 'POST' })
await check(() => {
expect(next.cliOutput).toMatch(
/No response is returned from route handler '.+\/route\.ts'\. Ensure you return a `Response` or a `NextResponse` in all branches of your handler\./
)
return 'yes'
}, 'yes')
})
})
}
)

View file

@ -0,0 +1 @@
export function POST() {}

View file

@ -1,24 +0,0 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "**/*.test.ts"]
}