未验证 提交 f8125c9e 编写于 作者: J Jan Potoms 提交者: GitHub

[Experimental] Implement optional catch all routes (#12887)

上级 da1e2b32
......@@ -80,6 +80,20 @@ export default (req, res) => {
Now, a request to `/api/post/a/b/c` will respond with the text: `Post: a, b, c`.
### Optional catch all API routes
Catch all routes can be made optional by including the parameter in double brackets (`[[...slug]]`).
For example, `pages/api/post/[[...slug]].js` will match `/api/post`, `/api/post/a`, `/api/post/a/b`, and so on.
The `query` objects are as follows:
```json
{ } // GET `/api/post` (empty object)
{ "slug": ["a"] } // `GET /api/post/a` (single-element array)
{ "slug": ["a", "b"] } // `GET /api/post/a/b` (multi-element array)
```
## Caveats
- Predefined API routes take precedence over dynamic API routes, and dynamic API routes over catch all API routes. Take a look at the following examples:
......
......@@ -85,6 +85,20 @@ And in the case of `/post/a/b`, and any other matching path, new parameters will
> A good example of catch all routes is the Next.js docs, a single page called [pages/docs/[...slug].js](https://github.com/zeit/next-site/blob/master/pages/docs/%5B...slug%5D.js) takes care of all the docs you're currently looking at.
### Optional catch all routes
Catch all routes can be made optional by including the parameter in double brackets (`[[...slug]]`).
For example, `pages/post/[[...slug]].js` will match `/post`, `/post/a`, `/post/a/b`, and so on.
The `query` objects are as follows:
```json
{ } // GET `/post` (empty object)
{ "slug": ["a"] } // `GET /post/a` (single-element array)
{ "slug": ["a", "b"] } // `GET /post/a/b` (multi-element array)
```
## Caveats
- Predefined routes take precedence over dynamic routes, and dynamic routes over catch all routes. Take a look at the following examples:
......
......@@ -217,7 +217,6 @@ export default async function build(dir: string, conf = null): Promise<void> {
config
)
const pageKeys = Object.keys(mappedPages)
const dynamicRoutes = pageKeys.filter(page => isDynamicRoute(page))
const conflictingPublicFiles: string[] = []
const hasCustomErrorPage = mappedPages['/_error'].startsWith(
'private-next-pages'
......@@ -283,6 +282,16 @@ export default async function build(dir: string, conf = null): Promise<void> {
}
}
const firstOptionalCatchAllPage =
pageKeys.find(f => /\[\[\.{3}[^\][/]*\]\]/.test(f)) ?? null
if (
config.experimental?.optionalCatchAll !== true &&
firstOptionalCatchAllPage
) {
const msg = `Optional catch-all routes are currently experimental and cannot be used by default ("${firstOptionalCatchAllPage}").`
throw new Error(msg)
}
const routesManifestPath = path.join(distDir, ROUTES_MANIFEST)
const routesManifest: any = {
version: 1,
......@@ -291,15 +300,17 @@ export default async function build(dir: string, conf = null): Promise<void> {
redirects: redirects.map(r => buildCustomRoute(r, 'redirect')),
rewrites: rewrites.map(r => buildCustomRoute(r, 'rewrite')),
headers: headers.map(r => buildCustomRoute(r, 'header')),
dynamicRoutes: getSortedRoutes(dynamicRoutes).map(page => {
const routeRegex = getRouteRegex(page)
return {
page,
regex: routeRegex.re.source,
namedRegex: routeRegex.namedRegex,
routeKeys: Object.keys(routeRegex.groups),
}
}),
dynamicRoutes: getSortedRoutes(pageKeys)
.filter(isDynamicRoute)
.map(page => {
const routeRegex = getRouteRegex(page)
return {
page,
regex: routeRegex.re.source,
namedRegex: routeRegex.namedRegex,
routeKeys: Object.keys(routeRegex.groups),
}
}),
}
await promises.mkdir(distDir, { recursive: true })
......
......@@ -20,15 +20,21 @@ export function getRouteRegex(
const parameterizedRoute = escapedRoute.replace(
/\/\\\[([^/]+?)\\\](?=\/|$)/g,
(_, $1) => {
const isOptional = /^\\\[.*\\\]$/.test($1)
if (isOptional) {
$1 = $1.slice(2, -2)
}
const isCatchAll = /^(\\\.){3}/.test($1)
if (isCatchAll) {
$1 = $1.slice(6)
}
groups[
$1
// Un-escape key
.replace(/\\([|\\{}()[\]^$+*?.-])/g, '$1')
.replace(/^\.{3}/, '')
// eslint-disable-next-line no-sequences
] = { pos: groupIndex++, repeat: isCatchAll }
return isCatchAll ? '/(.+?)' : '/([^/]+?)'
return isCatchAll ? (isOptional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)'
}
)
......
......@@ -3,6 +3,7 @@ class UrlNode {
children: Map<string, UrlNode> = new Map()
slugName: string | null = null
restSlugName: string | null = null
optionalRestSlugName: string | null = null
insert(urlPath: string): void {
this._insert(urlPath.split('/').filter(Boolean), [], false)
......@@ -20,6 +21,9 @@ class UrlNode {
if (this.restSlugName !== null) {
childrenPaths.splice(childrenPaths.indexOf('[...]'), 1)
}
if (this.optionalRestSlugName !== null) {
childrenPaths.splice(childrenPaths.indexOf('[[...]]'), 1)
}
const routes = childrenPaths
.map(c => this.children.get(c)!._smoosh(`${prefix}${c}/`))
......@@ -32,7 +36,14 @@ class UrlNode {
}
if (!this.placeholder) {
routes.unshift(prefix === '/' ? '/' : prefix.slice(0, -1))
const r = prefix === '/' ? '/' : prefix.slice(0, -1)
if (this.optionalRestSlugName != null) {
throw new Error(
`You cannot define a route with the same specificity as a optional catch-all route ("${r}" and "${r}[[...${this.optionalRestSlugName}]]").`
)
}
routes.unshift(r)
}
if (this.restSlugName !== null) {
......@@ -43,6 +54,14 @@ class UrlNode {
)
}
if (this.optionalRestSlugName !== null) {
routes.push(
...this.children
.get('[[...]]')!
._smoosh(`${prefix}[[...${this.optionalRestSlugName}]]/`)
)
}
return routes
}
......@@ -67,11 +86,26 @@ class UrlNode {
if (nextSegment.startsWith('[') && nextSegment.endsWith(']')) {
// Strip `[` and `]`, leaving only `something`
let segmentName = nextSegment.slice(1, -1)
let isOptional = false
if (segmentName.startsWith('[') && segmentName.endsWith(']')) {
// Strip optional `[` and `]`, leaving only `something`
segmentName = segmentName.slice(1, -1)
isOptional = true
}
if (segmentName.startsWith('...')) {
// Strip `...`, leaving only `something`
segmentName = segmentName.substring(3)
isCatchAll = true
}
if (segmentName.startsWith('[') || segmentName.endsWith(']')) {
throw new Error(
`Segment names may not start or end with extra brackets ('${segmentName}').`
)
}
if (segmentName.startsWith('.')) {
throw new Error(
`Segment names may not start with erroneous periods ('${segmentName}').`
......@@ -103,12 +137,37 @@ class UrlNode {
}
if (isCatchAll) {
handleSlug(this.restSlugName, segmentName)
// slugName is kept as it can only be one particular slugName
this.restSlugName = segmentName
// nextSegment is overwritten to [] so that it can later be sorted specifically
nextSegment = '[...]'
if (isOptional) {
if (this.restSlugName != null) {
throw new Error(
`You cannot use both an required and optional catch-all route at the same level ("[...${this.restSlugName}]" and "${urlPaths[0]}" ).`
)
}
handleSlug(this.optionalRestSlugName, segmentName)
// slugName is kept as it can only be one particular slugName
this.optionalRestSlugName = segmentName
// nextSegment is overwritten to [[...]] so that it can later be sorted specifically
nextSegment = '[[...]]'
} else {
if (this.optionalRestSlugName != null) {
throw new Error(
`You cannot use both an optional and required catch-all route at the same level ("[[...${this.optionalRestSlugName}]]" and "${urlPaths[0]}").`
)
}
handleSlug(this.restSlugName, segmentName)
// slugName is kept as it can only be one particular slugName
this.restSlugName = segmentName
// nextSegment is overwritten to [...] so that it can later be sorted specifically
nextSegment = '[...]'
}
} else {
if (isOptional) {
throw new Error(
`Optional route parameters are not yet supported ("${urlPaths[0]}").`
)
}
handleSlug(this.slugName, segmentName)
// slugName is kept as it can only be one particular slugName
this.slugName = segmentName
......
......@@ -53,6 +53,7 @@ const defaultConfig: { [key: string]: any } = {
basePath: '',
pageEnv: false,
productionBrowserSourceMaps: false,
optionalCatchAll: false,
},
future: {
excludeDefaultMomentLocales: false,
......
......@@ -754,13 +754,12 @@ export default class Server {
}
protected getDynamicRoutes() {
const dynamicRoutedPages = Object.keys(this.pagesManifest!).filter(
isDynamicRoute
)
return getSortedRoutes(dynamicRoutedPages).map(page => ({
page,
match: getRouteMatcher(getRouteRegex(page)),
}))
return getSortedRoutes(Object.keys(this.pagesManifest!))
.filter(isDynamicRoute)
.map(page => ({
page,
match: getRouteMatcher(getRouteRegex(page)),
}))
}
private handleCompression(req: IncomingMessage, res: ServerResponse) {
......
......@@ -168,7 +168,7 @@ export default class DevServer extends Server {
)
let resolved = false
return new Promise(resolve => {
return new Promise((resolve, reject) => {
const pagesDir = this.pagesDir
// Watchpack doesn't emit an event for an empty directory
......@@ -187,7 +187,7 @@ export default class DevServer extends Server {
wp.watch([], [pagesDir!], 0)
wp.on('aggregated', () => {
const dynamicRoutedPages = []
const routedPages = []
const knownFiles = wp.getTimeInfoEntries()
for (const [fileName, { accuracy }] of knownFiles) {
if (accuracy === undefined || !regexPageExtension.test(fileName)) {
......@@ -199,22 +199,44 @@ export default class DevServer extends Server {
pageName = pageName.replace(regexPageExtension, '')
pageName = pageName.replace(/\/index$/, '') || '/'
if (!isDynamicRoute(pageName)) {
continue
}
dynamicRoutedPages.push(pageName)
routedPages.push(pageName)
}
try {
this.dynamicRoutes = getSortedRoutes(routedPages)
.filter(isDynamicRoute)
.map(page => ({
page,
match: getRouteMatcher(getRouteRegex(page)),
}))
const firstOptionalCatchAllPage =
this.dynamicRoutes.find(f => /\[\[\.{3}[^\][/]*\]\]/.test(f.page))
?.page ?? null
if (
this.nextConfig.experimental?.optionalCatchAll !== true &&
firstOptionalCatchAllPage
) {
const msg = `Optional catch-all routes are currently experimental and cannot be used by default ("${firstOptionalCatchAllPage}").`
if (resolved) {
console.warn(msg)
} else {
throw new Error(msg)
}
}
this.dynamicRoutes = getSortedRoutes(dynamicRoutedPages).map(page => ({
page,
match: getRouteMatcher(getRouteRegex(page)),
}))
this.router.setDynamicRoutes(this.dynamicRoutes)
this.router.setDynamicRoutes(this.dynamicRoutes)
if (!resolved) {
resolve()
resolved = true
if (!resolved) {
resolve()
resolved = true
}
} catch (e) {
if (!resolved) {
reject(e)
resolved = true
} else {
console.warn('Failed to reload dynamic routes:', e)
}
}
})
})
......
module.exports = { experimental: { optionalCatchAll: true } }
export async function getServerSideProps({ query }) {
return {
props: {
query,
},
}
}
export default function Page(props) {
return (
<div id="optional-route">
top level route param:{' '}
{props.query.optionalName === undefined
? 'undefined'
: `[${props.query.optionalName.join(',')}]`}
</div>
)
}
export default function Page(props) {
return <div>about</div>
}
export default (req, res) => res.json({ slug: req.query.slug })
export async function getServerSideProps({ query }) {
return {
props: {
query,
},
}
}
export default function Page(props) {
return (
<div id="nested-optional-route">
nested route param:{' '}
{props.query.optionalName === undefined
? 'undefined'
: `[${props.query.optionalName.join(',')}]`}
</div>
)
}
/* eslint-env jest */
import cheerio from 'cheerio'
import fs from 'fs-extra'
import {
fetchViaHTTP,
findPort,
killApp,
launchApp,
nextBuild,
nextStart,
renderViaHTTP,
waitFor,
} from 'next-test-utils'
import { join } from 'path'
jest.setTimeout(1000 * 60 * 2)
let app
let appPort
const appDir = join(__dirname, '../')
const DUMMY_PAGE = 'export default () => null'
function runTests() {
it('should render catch-all top-level route with multiple segments', async () => {
const html = await renderViaHTTP(appPort, '/hello/world')
const $ = cheerio.load(html)
expect($('#optional-route').text()).toBe(
'top level route param: [hello,world]'
)
})
it('should render catch-all top-level route with single segment', async () => {
const html = await renderViaHTTP(appPort, '/hello')
const $ = cheerio.load(html)
expect($('#optional-route').text()).toBe('top level route param: [hello]')
})
it('should render catch-all top-level route with no segments', async () => {
const html = await renderViaHTTP(appPort, '/')
const $ = cheerio.load(html)
expect($('#optional-route').text()).toBe('top level route param: undefined')
})
it('should render catch-all nested route with multiple segments', async () => {
const html = await renderViaHTTP(appPort, '/nested/hello/world')
const $ = cheerio.load(html)
expect($('#nested-optional-route').text()).toBe(
'nested route param: [hello,world]'
)
})
it('should render catch-all nested route with single segment', async () => {
const html = await renderViaHTTP(appPort, '/nested/hello')
const $ = cheerio.load(html)
expect($('#nested-optional-route').text()).toBe(
'nested route param: [hello]'
)
})
it('should render catch-all nested route with no segments', async () => {
const html = await renderViaHTTP(appPort, '/nested')
const $ = cheerio.load(html)
expect($('#nested-optional-route').text()).toBe(
'nested route param: undefined'
)
})
it('should render catch-all nested route with no segments and leading slash', async () => {
const html = await renderViaHTTP(appPort, '/nested/')
const $ = cheerio.load(html)
expect($('#nested-optional-route').text()).toBe(
'nested route param: undefined'
)
})
it('should match catch-all api route with multiple segments', async () => {
const res = await fetchViaHTTP(appPort, '/api/post/ab/cd')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ slug: ['ab', 'cd'] })
})
it('should match catch-all api route with single segment', async () => {
const res = await fetchViaHTTP(appPort, '/api/post/a')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ slug: ['a'] })
})
it('should match catch-all api route with no segments', async () => {
const res = await fetchViaHTTP(appPort, '/api/post')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
it('should match catch-all api route with no segments and leading slash', async () => {
const res = await fetchViaHTTP(appPort, '/api/post/')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
}
const nextConfig = join(appDir, 'next.config.js')
function runInvalidPagesTests(buildFn) {
it('should fail to build when optional route has index.js at root', async () => {
const invalidRoute = appDir + 'pages/index.js'
try {
await fs.outputFile(invalidRoute, DUMMY_PAGE, 'utf-8')
const { stderr } = await buildFn(appDir)
await expect(stderr).toMatch(
'You cannot define a route with the same specificity as a optional catch-all route'
)
} finally {
await fs.unlink(invalidRoute)
}
})
it('should fail to build when optional route has same page at root', async () => {
const invalidRoute = appDir + 'pages/nested.js'
try {
await fs.outputFile(invalidRoute, DUMMY_PAGE, 'utf-8')
const { stderr } = await buildFn(appDir)
await expect(stderr).toMatch(
'You cannot define a route with the same specificity as a optional catch-all route'
)
} finally {
await fs.unlink(invalidRoute)
}
})
it('should fail to build when mixed with regular catch-all', async () => {
const invalidRoute = appDir + 'pages/nested/[...param].js'
try {
await fs.outputFile(invalidRoute, DUMMY_PAGE, 'utf-8')
const { stderr } = await buildFn(appDir)
await expect(stderr).toMatch(/You cannot use both .+ at the same level/)
} finally {
await fs.unlink(invalidRoute)
}
})
it('should fail to build when optional but no catch-all', async () => {
const invalidRoute = appDir + 'pages/invalid/[[param]].js'
try {
await fs.outputFile(invalidRoute, DUMMY_PAGE, 'utf-8')
const { stderr } = await buildFn(appDir)
await expect(stderr).toMatch(
'Optional route parameters are not yet supported'
)
} finally {
await fs.unlink(invalidRoute)
}
})
}
describe('Dynamic Optional Routing', () => {
describe('dev mode', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
})
afterAll(() => killApp(app))
runTests()
runInvalidPagesTests(async appDir => {
let stderr = ''
await launchApp(appDir, await findPort(), {
onStderr: msg => {
stderr += msg
},
})
await waitFor(1000)
return { stderr }
})
})
describe('production mode', () => {
beforeAll(async () => {
const curConfig = await fs.readFile(nextConfig, 'utf8')
if (curConfig.includes('target')) {
await fs.writeFile(
nextConfig,
`module.exports = { experimental: { optionalCatchAll: true } }`
)
}
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(() => killApp(app))
runTests()
runInvalidPagesTests(async appDir =>
nextBuild(appDir, [], { stderr: true })
)
})
describe('serverless mode', () => {
let origNextConfig
beforeAll(async () => {
origNextConfig = await fs.readFile(nextConfig, 'utf8')
await fs.writeFile(
nextConfig,
`module.exports = { target: 'serverless', experimental: { optionalCatchAll: true } }`
)
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(async () => {
await fs.writeFile(nextConfig, origNextConfig)
await killApp(app)
})
runTests()
})
})
......@@ -28,10 +28,14 @@ describe('getSortedRoutes', () => {
'/[...rest]',
'/blog/abc/post',
'/blog/abc',
'/p1/[[...incl]]',
'/p/[...rest]',
'/p2/[...rest]',
'/p2/[id]',
'/p2/[id]/abc',
'/p3/[[...rest]]',
'/p3/[id]',
'/p3/[id]/abc',
'/blog/[id]',
'/foo/[d]/bar/baz/[f]',
'/apples/[ab]/[cd]/ef',
......@@ -47,9 +51,13 @@ describe('getSortedRoutes', () => {
"/blog/[id]/comments/[cid]",
"/foo/[d]/bar/baz/[f]",
"/p/[...rest]",
"/p1/[[...incl]]",
"/p2/[id]",
"/p2/[id]/abc",
"/p2/[...rest]",
"/p3/[id]",
"/p3/[id]/abc",
"/p3/[[...rest]]",
"/posts",
"/posts/[id]",
"/[root-slug]",
......@@ -83,26 +91,117 @@ describe('getSortedRoutes', () => {
})
it('catches middle catch-all', () => {
expect(() => getSortedRoutes(['/blog/[...id]/[...id2]'])).toThrowError(
/must be the last part/
expect(() =>
getSortedRoutes(['/blog/[...id]/[...id2]'])
).toThrowErrorMatchingInlineSnapshot(
`"Catch-all must be the last part of the URL."`
)
})
it('catches middle catch-all', () => {
expect(() => getSortedRoutes(['/blog/[...id]/abc'])).toThrowError(
/must be the last part/
expect(() =>
getSortedRoutes(['/blog/[...id]/abc'])
).toThrowErrorMatchingInlineSnapshot(
`"Catch-all must be the last part of the URL."`
)
})
it('catches extra dots in catch-all', () => {
expect(() => getSortedRoutes(['/blog/[....id]/abc'])).toThrowError(
/erroneous period/
expect(() =>
getSortedRoutes(['/blog/[....id]/abc'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start with erroneous periods ('.id')."`
)
})
it('catches missing dots in catch-all', () => {
expect(() => getSortedRoutes(['/blog/[..id]/abc'])).toThrowError(
/erroneous period/
expect(() =>
getSortedRoutes(['/blog/[..id]/abc'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start with erroneous periods ('..id')."`
)
})
it('catches extra brackets for optional', () => {
expect(() =>
getSortedRoutes(['/blog/[[...id]'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start or end with extra brackets ('[...id')."`
)
expect(() =>
getSortedRoutes(['/blog/[[[...id]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start or end with extra brackets ('[...id')."`
)
expect(() =>
getSortedRoutes(['/blog/[...id]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start or end with extra brackets ('id]')."`
)
expect(() =>
getSortedRoutes(['/blog/[[...id]]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start or end with extra brackets ('id]')."`
)
expect(() =>
getSortedRoutes(['/blog/[[[...id]]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Segment names may not start or end with extra brackets ('[...id]')."`
)
})
it('disallows optional params', () => {
expect(() =>
getSortedRoutes(['/[[blog]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Optional route parameters are not yet supported (\\"[[blog]]\\")."`
)
expect(() =>
getSortedRoutes(['/abc/[[blog]]'])
).toThrowErrorMatchingInlineSnapshot(
`"Optional route parameters are not yet supported (\\"[[blog]]\\")."`
)
expect(() =>
getSortedRoutes(['/abc/[[blog]]/def'])
).toThrowErrorMatchingInlineSnapshot(
`"Optional route parameters are not yet supported (\\"[[blog]]\\")."`
)
})
it('disallows mixing required catch all and optional catch all', () => {
expect(() =>
getSortedRoutes(['/[...one]', '/[[...one]]'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot use both an required and optional catch-all route at the same level (\\"[...one]\\" and \\"[[...one]]\\" )."`
)
expect(() =>
getSortedRoutes(['/[[...one]]', '/[...one]'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot use both an optional and required catch-all route at the same level (\\"[[...one]]\\" and \\"[...one]\\")."`
)
})
it('disallows apex and optional catch all', () => {
expect(() =>
getSortedRoutes(['/', '/[[...all]]'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot define a route with the same specificity as a optional catch-all route (\\"/\\" and \\"/[[...all]]\\")."`
)
expect(() =>
getSortedRoutes(['/[[...all]]', '/'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot define a route with the same specificity as a optional catch-all route (\\"/\\" and \\"/[[...all]]\\")."`
)
expect(() =>
getSortedRoutes(['/sub', '/sub/[[...all]]'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot define a route with the same specificity as a optional catch-all route (\\"/sub\\" and \\"/sub[[...all]]\\")."`
)
expect(() =>
getSortedRoutes(['/sub/[[...all]]', '/sub'])
).toThrowErrorMatchingInlineSnapshot(
`"You cannot define a route with the same specificity as a optional catch-all route (\\"/sub\\" and \\"/sub[[...all]]\\")."`
)
})
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册