rsnext/packages/next/server/router.ts
Wyatt Johnson 0b57a01ae6
Refactor Server Router (#39902)
<!--
Thanks for opening a PR! Your contribution is much appreciated.
In order to make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below.
Choose the right checklist for the change that you're making:
-->

This is intended to refactor the router code to reduce the overhead of executing routes. This is related to #32314 that may help reduce the memory overhead as this also replaced the `Set` with a `WeakMap`.

Co-authored-by: JJ Kasper <jj@jjsweb.site>
2022-08-30 12:56:44 -05:00

479 lines
13 KiB
TypeScript

import type { NextConfig } from './config'
import type { ParsedUrlQuery } from 'querystring'
import type { BaseNextRequest, BaseNextResponse } from './base-http'
import type {
RouteMatch,
Params,
} from '../shared/lib/router/utils/route-matcher'
import type { RouteHas } from '../lib/load-custom-routes'
import { getNextInternalQuery, NextUrlWithParsedQuery } from './request-meta'
import { getPathMatch } from '../shared/lib/router/utils/path-match'
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
import { matchHas } from '../shared/lib/router/utils/prepare-destination'
import { removePathPrefix } from '../shared/lib/router/utils/remove-path-prefix'
import { getRequestMeta } from './request-meta'
import { formatNextPathnameInfo } from '../shared/lib/router/utils/format-next-pathname-info'
import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info'
type RouteResult = {
finished: boolean
pathname?: string
query?: ParsedUrlQuery
}
export type Route = {
match: RouteMatch
has?: RouteHas[]
type: string
check?: boolean
statusCode?: number
name: string
matchesBasePath?: true
matchesLocale?: true
matchesLocaleAPIRoutes?: true
matchesTrailingSlash?: true
internal?: true
fn: (
req: BaseNextRequest,
res: BaseNextResponse,
params: Params,
parsedUrl: NextUrlWithParsedQuery,
upgradeHead?: Buffer
) => Promise<RouteResult> | RouteResult
}
export type DynamicRoutes = Array<{ page: string; match: RouteMatch }>
export type PageChecker = (pathname: string) => Promise<boolean>
export default class Router {
public catchAllMiddleware: ReadonlyArray<Route>
private readonly headers: ReadonlyArray<Route>
private readonly fsRoutes: Route[]
private readonly redirects: ReadonlyArray<Route>
private readonly rewrites: {
beforeFiles: ReadonlyArray<Route>
afterFiles: ReadonlyArray<Route>
fallback: ReadonlyArray<Route>
}
private readonly catchAllRoute: Route
private readonly pageChecker: PageChecker
private dynamicRoutes: DynamicRoutes
private readonly useFileSystemPublicRoutes: boolean
private readonly nextConfig: NextConfig
private compiledRoutes: ReadonlyArray<Route>
private needsRecompilation: boolean
/**
* context stores information used by the router.
*/
private readonly context = new WeakMap<
BaseNextRequest,
{
/**
* pageChecks is the memoized record of all checks made against pages to
* help de-duplicate work.
*/
pageChecks: Record<string, boolean>
}
>()
constructor({
headers = [],
fsRoutes = [],
rewrites = {
beforeFiles: [],
afterFiles: [],
fallback: [],
},
redirects = [],
catchAllRoute,
catchAllMiddleware = [],
dynamicRoutes = [],
pageChecker,
useFileSystemPublicRoutes,
nextConfig,
}: {
headers: ReadonlyArray<Route>
fsRoutes: ReadonlyArray<Route>
rewrites: {
beforeFiles: ReadonlyArray<Route>
afterFiles: ReadonlyArray<Route>
fallback: ReadonlyArray<Route>
}
redirects: ReadonlyArray<Route>
catchAllRoute: Route
catchAllMiddleware: ReadonlyArray<Route>
dynamicRoutes: DynamicRoutes | undefined
pageChecker: PageChecker
useFileSystemPublicRoutes: boolean
nextConfig: NextConfig
}) {
this.nextConfig = nextConfig
this.headers = headers
this.fsRoutes = [...fsRoutes]
this.rewrites = rewrites
this.redirects = redirects
this.pageChecker = pageChecker
this.catchAllRoute = catchAllRoute
this.catchAllMiddleware = catchAllMiddleware
this.dynamicRoutes = dynamicRoutes
this.useFileSystemPublicRoutes = useFileSystemPublicRoutes
// Perform the initial route compilation.
this.compiledRoutes = this.compileRoutes()
this.needsRecompilation = false
}
private async checkPage(
req: BaseNextRequest,
pathname: string
): Promise<boolean> {
pathname = normalizeLocalePath(pathname, this.locales).pathname
const context = this.context.get(req)
if (!context) {
throw new Error(
'Invariant: request is not available inside the context, this is an internal error please open an issue.'
)
}
if (context.pageChecks[pathname] !== undefined) {
return context.pageChecks[pathname]
}
const result = await this.pageChecker(pathname)
context.pageChecks[pathname] = result
return result
}
get locales() {
return this.nextConfig.i18n?.locales || []
}
get basePath() {
return this.nextConfig.basePath || ''
}
public setDynamicRoutes(dynamicRoutes: DynamicRoutes) {
this.dynamicRoutes = dynamicRoutes
this.needsRecompilation = true
}
public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray<Route>) {
this.catchAllMiddleware = catchAllMiddleware
this.needsRecompilation = true
}
public addFsRoute(fsRoute: Route) {
// We use unshift so that we're sure the routes is defined before Next's
// default routes.
this.fsRoutes.unshift(fsRoute)
this.needsRecompilation = true
}
private compileRoutes(): ReadonlyArray<Route> {
/*
Desired routes order
- headers
- redirects
- Check filesystem (including pages), if nothing found continue
- User rewrites (checking filesystem and pages each match)
*/
const [middlewareCatchAllRoute] = this.catchAllMiddleware
return [
...(middlewareCatchAllRoute
? this.fsRoutes
.filter((route) => route.name === '_next/data catchall')
.map((route) => ({ ...route, check: false }))
: []),
...this.headers,
...this.redirects,
...(this.useFileSystemPublicRoutes && middlewareCatchAllRoute
? [middlewareCatchAllRoute]
: []),
...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',
match: getPathMatch('/:path*'),
fn: async (req, res, params, parsedUrl, upgradeHead) => {
const pathname = removeTrailingSlash(parsedUrl.pathname || '/')
if (!pathname) {
return { finished: false }
}
if (await this.checkPage(req, pathname)) {
return this.catchAllRoute.fn(
req,
res,
params,
parsedUrl,
upgradeHead
)
}
return { finished: false }
},
} as Route,
]
: []),
...this.rewrites.afterFiles,
...(this.rewrites.fallback.length
? [
{
type: 'route',
name: 'dynamic route/page check',
match: getPathMatch('/:path*'),
fn: async (req, res, _params, parsedCheckerUrl, upgradeHead) => {
return {
finished: await this.checkFsRoutes(
req,
res,
parsedCheckerUrl,
upgradeHead
),
}
},
} as Route,
...this.rewrites.fallback,
]
: []),
// We only check the catch-all route if public page routes hasn't been
// disabled
...(this.useFileSystemPublicRoutes ? [this.catchAllRoute] : []),
]
}
private async checkFsRoutes(
req: BaseNextRequest,
res: BaseNextResponse,
parsedUrl: NextUrlWithParsedQuery,
upgradeHead?: Buffer
) {
const originalFsPathname = parsedUrl.pathname
const fsPathname = removePathPrefix(originalFsPathname!, this.basePath)
for (const route of this.fsRoutes) {
const params = route.match(fsPathname)
if (params) {
parsedUrl.pathname = fsPathname
const { finished } = await route.fn(req, res, params, parsedUrl)
if (finished) {
return true
}
parsedUrl.pathname = originalFsPathname
}
}
let matchedPage = await this.checkPage(req, fsPathname)
// If we didn't match a page check dynamic routes
if (!matchedPage) {
const normalizedFsPathname = normalizeLocalePath(
fsPathname,
this.locales
).pathname
for (const dynamicRoute of this.dynamicRoutes) {
if (dynamicRoute.match(normalizedFsPathname)) {
matchedPage = true
}
}
}
// Matched a page or dynamic route so render it using catchAllRoute
if (matchedPage) {
const params = this.catchAllRoute.match(parsedUrl.pathname)
if (!params) {
throw new Error(
`Invariant: could not match params, this is an internal error please open an issue.`
)
}
parsedUrl.pathname = fsPathname
parsedUrl.query._nextBubbleNoFallback = '1'
const { finished } = await this.catchAllRoute.fn(
req,
res,
params,
parsedUrl,
upgradeHead
)
return finished
}
return false
}
async execute(
req: BaseNextRequest,
res: BaseNextResponse,
parsedUrl: NextUrlWithParsedQuery,
upgradeHead?: Buffer
): Promise<boolean> {
// Only recompile if the routes need to be recompiled, this should only
// happen in development.
if (this.needsRecompilation) {
this.compiledRoutes = this.compileRoutes()
this.needsRecompilation = false
}
if (this.context.has(req)) {
throw new Error(
`Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.`
)
}
this.context.set(req, { pageChecks: {} })
try {
// Create a deep copy of the parsed URL.
const parsedUrlUpdated = {
...parsedUrl,
query: {
...parsedUrl.query,
},
}
for (const route of this.compiledRoutes) {
// only process rewrites for upgrade request
if (upgradeHead && route.type !== 'rewrite') {
continue
}
const originalPathname = parsedUrlUpdated.pathname as string
const pathnameInfo = getNextPathnameInfo(originalPathname, {
nextConfig: this.nextConfig,
parseData: false,
})
if (
pathnameInfo.locale &&
!route.matchesLocaleAPIRoutes &&
pathnameInfo.pathname.match(/^\/api(?:\/|$)/)
) {
continue
}
if (getRequestMeta(req, '_nextHadBasePath')) {
pathnameInfo.basePath = this.basePath
}
const basePath = pathnameInfo.basePath
if (!route.matchesBasePath) {
pathnameInfo.basePath = ''
}
if (
route.matchesLocale &&
parsedUrlUpdated.query.__nextLocale &&
!pathnameInfo.locale
) {
pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale
}
if (
!route.matchesLocale &&
pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale &&
pathnameInfo.locale
) {
pathnameInfo.locale = undefined
}
if (
route.matchesTrailingSlash &&
getRequestMeta(req, '__nextHadTrailingSlash')
) {
pathnameInfo.trailingSlash = true
}
const matchPathname = formatNextPathnameInfo({
ignorePrefix: true,
...pathnameInfo,
})
let params = route.match(matchPathname)
if (route.has && params) {
const hasParams = matchHas(req, route.has, parsedUrlUpdated.query)
if (hasParams) {
Object.assign(params, hasParams)
} else {
params = false
}
}
/**
* If it is a matcher that doesn't match the basePath (like the public
* directory) but Next.js is configured to use a basePath that was
* never there, we consider this an invalid match and keep routing.
*/
if (
params &&
this.basePath &&
!route.matchesBasePath &&
!getRequestMeta(req, '_nextDidRewrite') &&
!basePath
) {
continue
}
if (params) {
parsedUrlUpdated.pathname = matchPathname
const result = await route.fn(
req,
res,
params,
parsedUrlUpdated,
upgradeHead
)
if (result.finished) {
return true
}
if (result.pathname) {
parsedUrlUpdated.pathname = result.pathname
} else {
// since the fs route didn't finish routing we need to re-add the
// basePath to continue checking with the basePath present
parsedUrlUpdated.pathname = originalPathname
}
if (result.query) {
parsedUrlUpdated.query = {
...getNextInternalQuery(parsedUrlUpdated.query),
...result.query,
}
}
// check filesystem
if (
route.check &&
(await this.checkFsRoutes(req, res, parsedUrlUpdated))
) {
return true
}
}
}
// All routes were tested, none were found.
return false
} finally {
this.context.delete(req)
}
}
}