rsnext/packages/next/next-server/server/router.ts
JJ Kasper d130f63c41
Add handling fo beforeFiles, afterFiles, and fallback rewrites (#23407)
This adds support for returning an object from `rewrites` in `next.config.js` with `beforeFiles`, `afterFiles`, and `fallback` to allow specifying rewrites at different stages of routing. The existing support for returning an array for rewrites is still supported and behaves the same way. The documentation has been updated to include information on these new stages that can be rewritten and removes the outdated note of rewrites not being able to override pages. 



## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR.
- [ ] Related issues linked using `fixes #number`
- [x] Integration tests added
- [x] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.

## Documentation / Examples

- [ ] Make sure the linting passes
2021-03-26 15:19:48 +00:00

376 lines
11 KiB
TypeScript

import { IncomingMessage, ServerResponse } from 'http'
import { UrlWithParsedQuery } from 'url'
import pathMatch from '../lib/router/utils/path-match'
import { removePathTrailingSlash } from '../../client/normalize-trailing-slash'
import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path'
import { RouteHas } from '../../lib/load-custom-routes'
import { matchHas } from '../lib/router/utils/prepare-destination'
export const route = pathMatch()
export type Params = { [param: string]: any }
export type RouteMatch = (pathname: string | null | undefined) => false | Params
type RouteResult = {
finished: boolean
pathname?: string
query?: { [k: string]: string }
}
export type Route = {
match: RouteMatch
has?: RouteHas[]
type: string
check?: boolean
statusCode?: number
name: string
requireBasePath?: false
internal?: true
fn: (
req: IncomingMessage,
res: ServerResponse,
params: Params,
parsedUrl: UrlWithParsedQuery
) => Promise<RouteResult> | RouteResult
}
export type DynamicRoutes = Array<{ page: string; match: RouteMatch }>
export type PageChecker = (pathname: string) => Promise<boolean>
const customRouteTypes = new Set(['rewrite', 'redirect', 'header'])
function replaceBasePath(basePath: string, pathname: string) {
// If replace ends up replacing the full url it'll be `undefined`, meaning we have to default it to `/`
return pathname!.replace(basePath, '') || '/'
}
export default class Router {
basePath: string
headers: Route[]
fsRoutes: Route[]
redirects: Route[]
rewrites: {
beforeFiles: Route[]
afterFiles: Route[]
fallback: Route[]
}
catchAllRoute: Route
pageChecker: PageChecker
dynamicRoutes: DynamicRoutes
useFileSystemPublicRoutes: boolean
locales: string[]
constructor({
basePath = '',
headers = [],
fsRoutes = [],
rewrites = {
beforeFiles: [],
afterFiles: [],
fallback: [],
},
redirects = [],
catchAllRoute,
dynamicRoutes = [],
pageChecker,
useFileSystemPublicRoutes,
locales = [],
}: {
basePath: string
headers: Route[]
fsRoutes: Route[]
rewrites: {
beforeFiles: Route[]
afterFiles: Route[]
fallback: Route[]
}
redirects: Route[]
catchAllRoute: Route
dynamicRoutes: DynamicRoutes | undefined
pageChecker: PageChecker
useFileSystemPublicRoutes: boolean
locales: string[]
}) {
this.basePath = basePath
this.headers = headers
this.fsRoutes = fsRoutes
this.rewrites = rewrites
this.redirects = redirects
this.pageChecker = pageChecker
this.catchAllRoute = catchAllRoute
this.dynamicRoutes = dynamicRoutes
this.useFileSystemPublicRoutes = useFileSystemPublicRoutes
this.locales = locales
}
setDynamicRoutes(routes: DynamicRoutes = []) {
this.dynamicRoutes = routes
}
addFsRoute(fsRoute: Route) {
this.fsRoutes.unshift(fsRoute)
}
async execute(
req: IncomingMessage,
res: ServerResponse,
parsedUrl: UrlWithParsedQuery
): Promise<boolean> {
// memoize page check calls so we don't duplicate checks for pages
const pageChecks: { [name: string]: Promise<boolean> } = {}
const memoizedPageChecker = async (p: string): Promise<boolean> => {
p = normalizeLocalePath(p, this.locales).pathname
if (pageChecks[p]) {
return pageChecks[p]
}
const result = this.pageChecker(p)
pageChecks[p] = result
return result
}
let parsedUrlUpdated = parsedUrl
const applyCheckTrue = async (checkParsedUrl: UrlWithParsedQuery) => {
const originalFsPathname = checkParsedUrl.pathname
const fsPathname = replaceBasePath(this.basePath, originalFsPathname!)
for (const fsRoute of this.fsRoutes) {
const fsParams = fsRoute.match(fsPathname)
if (fsParams) {
checkParsedUrl.pathname = fsPathname
const fsResult = await fsRoute.fn(req, res, fsParams, checkParsedUrl)
if (fsResult.finished) {
return true
}
checkParsedUrl.pathname = originalFsPathname
}
}
let matchedPage = await memoizedPageChecker(fsPathname)
// If we didn't match a page check dynamic routes
if (!matchedPage) {
for (const dynamicRoute of this.dynamicRoutes) {
if (dynamicRoute.match(fsPathname)) {
matchedPage = true
}
}
}
// Matched a page or dynamic route so render it using catchAllRoute
if (matchedPage) {
checkParsedUrl.pathname = fsPathname
const pageParams = this.catchAllRoute.match(checkParsedUrl.pathname)
await this.catchAllRoute.fn(
req,
res,
pageParams as Params,
checkParsedUrl
)
return true
}
}
/*
Desired routes order
- headers
- redirects
- Check filesystem (including pages), if nothing found continue
- User rewrites (checking filesystem and pages each match)
*/
const allRoutes = [
...this.headers,
...this.redirects,
...this.rewrites.beforeFiles,
...this.fsRoutes,
// We only check the catch-all route if public page routes hasn't been
// disabled
...(this.useFileSystemPublicRoutes
? [
{
type: 'route',
name: 'page checker',
requireBasePath: false,
match: route('/:path*'),
fn: async (checkerReq, checkerRes, params, parsedCheckerUrl) => {
let { pathname } = parsedCheckerUrl
pathname = removePathTrailingSlash(pathname || '/')
if (!pathname) {
return { finished: false }
}
if (await memoizedPageChecker(pathname)) {
return this.catchAllRoute.fn(
checkerReq,
checkerRes,
params,
parsedCheckerUrl
)
}
return { finished: false }
},
} as Route,
]
: []),
...this.rewrites.afterFiles,
...(this.rewrites.fallback.length
? [
{
type: 'route',
name: 'dynamic route/page check',
requireBasePath: false,
match: route('/:path*'),
fn: async (
_checkerReq,
_checkerRes,
_params,
parsedCheckerUrl
) => {
return {
finished: await applyCheckTrue(parsedCheckerUrl),
}
},
} as Route,
...this.rewrites.fallback,
]
: []),
// We only check the catch-all route if public page routes hasn't been
// disabled
...(this.useFileSystemPublicRoutes ? [this.catchAllRoute] : []),
]
const originallyHadBasePath =
!this.basePath || (req as any)._nextHadBasePath
for (const testRoute of allRoutes) {
// if basePath is being used, the basePath will still be included
// in the pathname here to allow custom-routes to require containing
// it or not, filesystem routes and pages must always include the basePath
// if it is set
let currentPathname = parsedUrlUpdated.pathname as string
const originalPathname = currentPathname
const requireBasePath = testRoute.requireBasePath !== false
const isCustomRoute = customRouteTypes.has(testRoute.type)
const isPublicFolderCatchall = testRoute.name === 'public folder catchall'
const keepBasePath = isCustomRoute || isPublicFolderCatchall
const keepLocale = isCustomRoute
const currentPathnameNoBasePath = replaceBasePath(
this.basePath,
currentPathname
)
if (!keepBasePath) {
currentPathname = currentPathnameNoBasePath
}
const localePathResult = normalizeLocalePath(
currentPathnameNoBasePath,
this.locales
)
const activeBasePath = keepBasePath ? this.basePath : ''
if (keepLocale) {
if (
!testRoute.internal &&
parsedUrl.query.__nextLocale &&
!localePathResult.detectedLocale
) {
currentPathname = `${activeBasePath}/${parsedUrl.query.__nextLocale}${
currentPathnameNoBasePath === '/' ? '' : currentPathnameNoBasePath
}`
}
if (
(req as any).__nextHadTrailingSlash &&
!currentPathname.endsWith('/')
) {
currentPathname += '/'
}
} else {
currentPathname = `${
(req as any)._nextHadBasePath ? activeBasePath : ''
}${
activeBasePath && localePathResult.pathname === '/'
? ''
: localePathResult.pathname
}`
}
let newParams = testRoute.match(currentPathname)
if (testRoute.has && newParams) {
const hasParams = matchHas(req, testRoute.has, parsedUrlUpdated.query)
if (hasParams) {
Object.assign(newParams, hasParams)
} else {
newParams = false
}
}
// Check if the match function matched
if (newParams) {
// since we require basePath be present for non-custom-routes we
// 404 here when we matched an fs route
if (!keepBasePath) {
if (!originallyHadBasePath && !(req as any)._nextDidRewrite) {
if (requireBasePath) {
// consider this a non-match so the 404 renders
return false
}
// page checker occurs before rewrites so we need to continue
// to check those since they don't always require basePath
continue
}
parsedUrlUpdated.pathname = currentPathname
}
const result = await testRoute.fn(req, res, newParams, parsedUrlUpdated)
// The response was handled
if (result.finished) {
return true
}
// since the fs route didn't match we need to re-add the basePath
// to continue checking rewrites with the basePath present
if (!keepBasePath) {
parsedUrlUpdated.pathname = originalPathname
}
if (result.pathname) {
parsedUrlUpdated.pathname = result.pathname
}
if (result.query) {
parsedUrlUpdated.query = {
...parsedUrlUpdated.query,
...result.query,
}
}
// check filesystem
if (testRoute.check === true) {
if (await applyCheckTrue(parsedUrlUpdated)) {
return true
}
}
}
}
return false
}
}