Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(next-types-plugin): added support for Route Handlers #47185

Merged
merged 22 commits into from Mar 26, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a65cf63
Feat(next-types-plugin): added support for Route Handlers
DuCanhGH Mar 15, 2023
ecd726b
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 15, 2023
d53d3d4
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 16, 2023
a083d0d
extracted reusable function
DuCanhGH Mar 16, 2023
fdb0044
fixed test
DuCanhGH Mar 16, 2023
e5cf4af
fixed test
DuCanhGH Mar 16, 2023
8d554ec
param_number -> param_position
DuCanhGH Mar 16, 2023
4a36610
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 17, 2023
807794f
Suffix -> SearchOrHash because it is unnecessary
DuCanhGH Mar 19, 2023
1651650
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 19, 2023
86f6edd
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 20, 2023
849ae0b
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 22, 2023
351d10b
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 22, 2023
5b6288e
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 22, 2023
dbde06e
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 23, 2023
4afcc0f
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 23, 2023
6379f2d
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
DuCanhGH Mar 25, 2023
f769b31
fix test?
DuCanhGH Mar 25, 2023
3c2747a
use beforeAll, afterAll instead of try finally
DuCanhGH Mar 25, 2023
b35dec8
fix test
DuCanhGH Mar 25, 2023
1e2d1b0
cleanup
DuCanhGH Mar 25, 2023
377a7e0
Merge branch 'canary' into ducanhgh-route-handlers-types-plugin
kodiakhq[bot] Mar 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
107 changes: 86 additions & 21 deletions packages/next/src/build/webpack/plugins/next-types-plugin.ts
@@ -1,17 +1,19 @@
import type { Rewrite, Redirect } from '../../../lib/load-custom-routes'
import type { Token } from 'next/dist/compiled/path-to-regexp'

import path from 'path'
import { promises as fs } from 'fs'

import fs from 'fs/promises'
import { webpack, sources } from 'next/dist/compiled/webpack/webpack'
import { parse } from 'next/dist/compiled/path-to-regexp'
import path from 'path'

import { WEBPACK_LAYERS } from '../../../lib/constants'
import { denormalizePagePath } from '../../../shared/lib/page-path/denormalize-page-path'
import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'
import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep'
import { HTTP_METHODS } from '../../../server/web/http'
import { isDynamicRoute } from '../../../shared/lib/router/utils'
import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths'
import { denormalizePagePath } from '../../../shared/lib/page-path/denormalize-page-path'
import { getPageFromPath } from '../../entries'
import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'

const PLUGIN_NAME = 'NextTypesPlugin'

Expand All @@ -37,19 +39,24 @@ function createTypeGuardFile(
fullPath: string,
relativePath: string,
options: {
type: 'layout' | 'page'
type: 'layout' | 'page' | 'route'
slots?: string[]
}
) {
return `// File: ${fullPath}
import * as entry from '${relativePath}'
import type { ResolvingMetadata } from 'next/dist/lib/metadata/types/metadata-interface'
import type { NextRequest } from 'next/server'

type TEntry = typeof entry

// Check that the entry is a valid entry
checkFields<Diff<{
default: Function
${
options.type === 'route'
? HTTP_METHODS.map((method) => `${method}?: Function`).join('\n ')
: 'default: Function'
}
config?: {}
generateStaticParams?: Function
revalidate?: RevalidateRange<TEntry> | false
Expand All @@ -58,18 +65,64 @@ checkFields<Diff<{
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
preferredRegion?: 'auto' | 'home' | 'edge'
${
options.type === 'page'
options.type === 'page' || options.type === 'route'
? "runtime?: 'nodejs' | 'experimental-edge' | 'edge'"
: ''
}
${
options.type === 'route'
? ''
: `
metadata?: any
generateMetadata?: Function
`
}
}, TEntry, ''>>()

// Check the prop type of the entry function
${
options.type === 'route'
? `// Check the prop type of the entry function
${HTTP_METHODS.map(
(method) => `
if ('${method}' in entry) {
checkFields<
Diff<
{
__tag__: '${method}',
__param_position__: string,
__param_type__: Request | NextRequest
},
{
__tag__: '${method}',
__param_position__: 'first',
__param_type__: FirstArg<MaybeField<TEntry, '${method}'>>
},
'${method}'
>
>();
checkFields<
Diff<
{
__tag__: '${method}',
__param_position__: string,
__param_type__: PageParams
},
{
__tag__: '${method}',
__param_position__: 'second',
__param_type__: SecondArg<MaybeField<TEntry, '${method}'>>
},
'${method}'
>
>();
}
`
).join('')}
`
: `// Check the prop type of the entry function
checkFields<Diff<${
options.type === 'page' ? 'PageProps' : 'LayoutProps'
}, FirstArg<TEntry['default']>, 'default'>>()
options.type === 'page' ? 'PageProps' : 'LayoutProps'
}, FirstArg<TEntry['default']>, 'default'>>()

// Check the arguments and return type of the generateMetadata function
if ('generateMetadata' in entry) {
Expand All @@ -78,13 +131,14 @@ if ('generateMetadata' in entry) {
}, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
}

`
}
// Check the arguments and return type of the generateStaticParams function
if ('generateStaticParams' in entry) {
checkFields<Diff<{ params: PageParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
}

type PageParams = any
export interface PageProps {
params?: any
Expand Down Expand Up @@ -341,7 +395,7 @@ declare namespace __next_route_internal_types__ {
// This keeps autocompletion working for static routes.
'| StaticRoutes'
}
| \`\${StaticRoutes}\${Suffix}\`
| \`\${StaticRoutes}\${SearchOrHash}\`
| (T extends \`\${DynamicRoutes<infer _>}\${Suffix}\` ? T : never)
`
}
Expand Down Expand Up @@ -416,8 +470,8 @@ export class NextTypesPlugin {
return
}

// Filter out non-page files in app dir
if (isApp && !/[/\\]page\.[^.]+$/.test(filePath)) {
// Filter out non-page and non-route files in app dir
if (isApp && !/[/\\](?:page|route)\.[^.]+$/.test(filePath)) {
return
}

Expand Down Expand Up @@ -474,11 +528,12 @@ export class NextTypesPlugin {

const IS_LAYOUT = /[/\\]layout\.[^./\\]+$/.test(mod.resource)
const IS_PAGE = !IS_LAYOUT && /[/\\]page\.[^.]+$/.test(mod.resource)
const IS_ROUTE = !IS_PAGE && /[/\\]route\.[^.]+$/.test(mod.resource)
const relativePathToApp = path.relative(this.appDir, mod.resource)
const relativePathToRoot = path.relative(this.dir, mod.resource)

if (!this.dev) {
if (IS_PAGE) {
if (IS_PAGE || IS_ROUTE) {
this.collectPage(mod.resource)
}
}
Expand All @@ -495,7 +550,7 @@ export class NextTypesPlugin {
relativePathToRoot.replace(/\.(js|jsx|ts|tsx|mjs)$/, '')
)
.replace(/\\/g, '/')
const assetPath = assetDirRelative + '/' + typePath.replace(/\\/g, '/')
const assetPath = assetDirRelative + '/' + normalizePathSep(typePath)

if (IS_LAYOUT) {
const slots = await collectNamedSlots(mod.resource)
Expand All @@ -511,6 +566,12 @@ export class NextTypesPlugin {
type: 'page',
})
)
} else if (IS_ROUTE) {
assets[assetPath] = new sources.RawSource(
createTypeGuardFile(mod.resource, relativeImportPath, {
type: 'route',
})
)
}
}

Expand All @@ -536,10 +597,14 @@ export class NextTypesPlugin {
chunkGroup.chunks.forEach((chunk) => {
if (!chunk.name) return

// Here we only track page chunks.
// Here we only track page and route chunks.
if (
!chunk.name.startsWith('pages/') &&
!(chunk.name.startsWith('app/') && chunk.name.endsWith('/page'))
!(
chunk.name.startsWith('app/') &&
(chunk.name.endsWith('/page') ||
chunk.name.endsWith('/route'))
)
) {
return
}
Expand Down Expand Up @@ -575,7 +640,7 @@ export class NextTypesPlugin {

const linkTypePath = path.join('types', 'link.d.ts')
const assetPath =
assetDirRelative + '/' + linkTypePath.replace(/\\/g, '/')
assetDirRelative + '/' + normalizePathSep(linkTypePath)
assets[assetPath] = new sources.RawSource(
createRouteDefinitions()
) as unknown as webpack.sources.RawSource
Expand Down
79 changes: 52 additions & 27 deletions packages/next/src/lib/typescript/diagnosticFormatter.ts
Expand Up @@ -73,7 +73,11 @@ function getFormattedLayoutAndPageDiagnosticMessageText(
const messageText = message.messageText

if (typeof messageText === 'string') {
const type = /page\.[^.]+$/.test(relativeSourceFilepath) ? 'Page' : 'Layout'
const type = /page\.[^.]+$/.test(relativeSourceFilepath)
? 'Page'
: /route\.[^.]+$/.test(relativeSourceFilepath)
? 'Route'
: 'Layout'

// Reference of error codes:
// https://github.com/Microsoft/TypeScript/blob/main/src/compiler/diagnosticMessages.json
Expand Down Expand Up @@ -212,6 +216,52 @@ function getFormattedLayoutAndPageDiagnosticMessageText(
return main
}

function processNextItems(
indent: number,
next?: import('typescript').DiagnosticMessageChain[]
) {
if (!next) return ''

let result = ''

for (const item of next) {
switch (item.code) {
case 2322:
const types = item.messageText.match(
/Type '(.+)' is not assignable to type '(.+)'./
)
if (types) {
result += '\n' + ' '.repeat(indent * 2)
result += `Expected "${chalk.bold(
types[2]
)}", got "${chalk.bold(types[1])}".`
}
break
default:
}

result += processNextItems(indent + 1, item.next)
}

return result
}

const invalidParamFn = messageText.match(
/Type '{ __tag__: (.+); __param_position__: "(.*)"; __param_type__: (.+); }' does not satisfy/
)
if (invalidParamFn) {
let main = `${type} "${chalk.bold(
relativeSourceFilepath
)}" has an invalid ${invalidParamFn[1]} export:\n Type "${chalk.bold(
invalidParamFn[3]
)}" is not a valid type for the function's ${
invalidParamFn[2]
} argument.`

if ('next' in message) main += processNextItems(1, message.next)
return main
}

const invalidExportFnReturn = messageText.match(
/Type '{ __tag__: "(.+)"; __return_type__: (.+); }' does not satisfy/
)
Expand All @@ -221,33 +271,8 @@ function getFormattedLayoutAndPageDiagnosticMessageText(
)}" has an invalid export:\n "${chalk.bold(
invalidExportFnReturn[2]
)}" is not a valid ${invalidExportFnReturn[1]} return type:`
function processNext(
indent: number,
next?: import('typescript').DiagnosticMessageChain[]
) {
if (!next) return

for (const item of next) {
switch (item.code) {
case 2322:
const types = item.messageText.match(
/Type '(.+)' is not assignable to type '(.+)'./
)
if (types) {
main += '\n' + ' '.repeat(indent * 2)
main += `Expected "${chalk.bold(
types[2]
)}", got "${chalk.bold(types[1])}".`
}
break
default:
}

processNext(indent + 1, item.next)
}
}

if ('next' in message) processNext(1, message.next)
if ('next' in message) main += processNextItems(1, message.next)
return main
}

Expand Down
29 changes: 23 additions & 6 deletions test/e2e/app-dir/app-routes/app-custom-routes.test.ts
@@ -1,18 +1,35 @@
import { createNextDescribe } from 'e2e-utils'
import fs from 'fs-extra'
import { check } from 'next-test-utils'
import path from 'path'
import { Readable } from 'stream'

import {
withRequestMeta,
getRequestMeta,
cookieWithRequestMeta,
} from './helpers'
import { Readable } from 'stream'
import { check } from 'next-test-utils'

createNextDescribe(
'app-custom-routes',
{
files: __dirname,
},
({ next, isNextDev, isNextStart }) => {
({ next, isNextDeploy, isNextDev, isNextStart }) => {
beforeAll(async () => {
if (isNextDev) {
await fs.move(
path.join(next.testDir, 'app/_lowercase'),
path.join(next.testDir, 'app/lowercase'),
{ overwrite: true }
)
await fs.move(
path.join(next.testDir, 'app/_default'),
path.join(next.testDir, 'app/default'),
{ overwrite: true }
)
}
})
DuCanhGH marked this conversation as resolved.
Show resolved Hide resolved
describe('works with api prefix correctly', () => {
it('statically generates correctly with no dynamic usage', async () => {
if (isNextStart) {
Expand Down Expand Up @@ -188,7 +205,7 @@ createNextDescribe(

describe('body', () => {
// we can't stream a body to a function currently only stream response
if (!(global as any).isNextDeploy) {
if (!isNextDeploy) {
it('can handle handle a streaming request and streaming response', async () => {
const body = new Array(10).fill(JSON.stringify({ ping: 'pong' }))
let index = 0
Expand Down Expand Up @@ -257,7 +274,7 @@ createNextDescribe(
})

// we can't stream a body to a function currently only stream response
if (!(global as any).isNextDeploy) {
if (!isNextDeploy) {
it('can read a streamed JSON encoded body', async () => {
const body = { ping: 'pong' }
const encoded = JSON.stringify(body)
Expand Down Expand Up @@ -444,7 +461,7 @@ createNextDescribe(
expect(res.status).toEqual(500)
expect(await res.text()).toBeEmpty()

if (!(global as any).isNextDeploy) {
if (!isNextDeploy) {
await check(() => {
expect(next.cliOutput).toContain(error)
return 'yes'
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/app-routes/handlers/hello.ts
@@ -1,7 +1,7 @@
import { type NextRequest } from 'next/server'
import { withRequestMeta } from '../helpers'

export const helloHandler = async (
const helloHandler = async (
request: NextRequest,
{ params }: { params?: Record<string, string | string[]> }
): Promise<Response> => {
Expand Down