rsnext/packages/next/next-server/server/router.ts
JJ Kasper 681fbbd04d
Fix basePath and public folder check ending routes early (#16356)
This corrects the basePath being required check for filesystem routes to not consider the public folder catch-all route since it always matches even if the public file isn't present and instead moves the basePath check inside of the public-folder catch-all. Tests already exist that catch this by adding a public folder to the existing `basepath` test suite

Fixes: https://github.com/vercel/next.js/issues/16332
Closes: https://github.com/vercel/next.js/pull/16350
2020-08-19 17:30:33 +00:00

279 lines
8 KiB
TypeScript

import { IncomingMessage, ServerResponse } from 'http'
import { UrlWithParsedQuery } from 'url'
import pathMatch from './lib/path-match'
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
type: string
check?: boolean
statusCode?: number
name: string
requireBasePath?: false
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[]
rewrites: Route[]
redirects: Route[]
catchAllRoute: Route
pageChecker: PageChecker
dynamicRoutes: DynamicRoutes
useFileSystemPublicRoutes: boolean
constructor({
basePath = '',
headers = [],
fsRoutes = [],
rewrites = [],
redirects = [],
catchAllRoute,
dynamicRoutes = [],
pageChecker,
useFileSystemPublicRoutes,
}: {
basePath: string
headers: Route[]
fsRoutes: Route[]
rewrites: Route[]
redirects: Route[]
catchAllRoute: Route
dynamicRoutes: DynamicRoutes | undefined
pageChecker: PageChecker
useFileSystemPublicRoutes: boolean
}) {
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
}
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> => {
if (pageChecks[p]) {
return pageChecks[p]
}
const result = this.pageChecker(p)
pageChecks[p] = result
return result
}
let parsedUrlUpdated = parsedUrl
/*
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.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) => {
const { pathname } = parsedCheckerUrl
if (!pathname) {
return { finished: false }
}
if (await memoizedPageChecker(pathname)) {
return this.catchAllRoute.fn(
checkerReq,
checkerRes,
params,
parsedCheckerUrl
)
}
return { finished: false }
},
} as Route,
]
: []),
...this.rewrites,
// 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
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
if (!keepBasePath) {
currentPathname = replaceBasePath(this.basePath, currentPathname!)
}
const newParams = testRoute.match(currentPathname)
// 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) {
const originalFsPathname = parsedUrlUpdated.pathname
const fsPathname = replaceBasePath(this.basePath, originalFsPathname!)
for (const fsRoute of this.fsRoutes) {
const fsParams = fsRoute.match(fsPathname)
if (fsParams) {
parsedUrlUpdated.pathname = fsPathname
const fsResult = await fsRoute.fn(
req,
res,
fsParams,
parsedUrlUpdated
)
if (fsResult.finished) {
return true
}
parsedUrlUpdated.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) {
parsedUrlUpdated.pathname = fsPathname
const pageParams = this.catchAllRoute.match(
parsedUrlUpdated.pathname
)
await this.catchAllRoute.fn(
req,
res,
pageParams as Params,
parsedUrlUpdated
)
return true
}
}
}
}
return false
}
}