diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index a67bfb58bcb..d360778905f 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -221,7 +221,6 @@ export default class DevServer extends Server { for (const path in exportPathMap) { const { page, query = {} } = exportPathMap[path] - // We use unshift so that we're sure the routes is defined before Next's default routes this.router.addFsRoute({ match: getPathMatch(path), type: 'route', diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 0cb309f823a..ca2ce97b1a7 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -40,7 +40,7 @@ export type Route = { res: BaseNextResponse, params: Params, parsedUrl: NextUrlWithParsedQuery, - upgradeHead?: any + upgradeHead?: Buffer ) => Promise | RouteResult } @@ -49,21 +49,37 @@ export type DynamicRoutes = Array<{ page: string; match: RouteMatch }> export type PageChecker = (pathname: string) => Promise export default class Router { - headers: Route[] - fsRoutes: Route[] - redirects: Route[] - rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] + public catchAllMiddleware: ReadonlyArray + + private readonly headers: ReadonlyArray + private readonly fsRoutes: Route[] + private readonly redirects: ReadonlyArray + private readonly rewrites: { + beforeFiles: ReadonlyArray + afterFiles: ReadonlyArray + fallback: ReadonlyArray } - catchAllRoute: Route - catchAllMiddleware: Route[] - pageChecker: PageChecker - dynamicRoutes: DynamicRoutes - useFileSystemPublicRoutes: boolean - seenRequests: Set - nextConfig: NextConfig + private readonly catchAllRoute: Route + private readonly pageChecker: PageChecker + private dynamicRoutes: DynamicRoutes + private readonly useFileSystemPublicRoutes: boolean + private readonly nextConfig: NextConfig + private compiledRoutes: ReadonlyArray + 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 + } + >() constructor({ headers = [], @@ -81,16 +97,16 @@ export default class Router { useFileSystemPublicRoutes, nextConfig, }: { - headers: Route[] - fsRoutes: Route[] + headers: ReadonlyArray + fsRoutes: ReadonlyArray rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] + beforeFiles: ReadonlyArray + afterFiles: ReadonlyArray + fallback: ReadonlyArray } - redirects: Route[] + redirects: ReadonlyArray catchAllRoute: Route - catchAllMiddleware: Route[] + catchAllMiddleware: ReadonlyArray dynamicRoutes: DynamicRoutes | undefined pageChecker: PageChecker useFileSystemPublicRoutes: boolean @@ -98,7 +114,7 @@ export default class Router { }) { this.nextConfig = nextConfig this.headers = headers - this.fsRoutes = fsRoutes + this.fsRoutes = [...fsRoutes] this.rewrites = rewrites this.redirects = redirects this.pageChecker = pageChecker @@ -106,7 +122,32 @@ export default class Router { this.catchAllMiddleware = catchAllMiddleware this.dynamicRoutes = dynamicRoutes this.useFileSystemPublicRoutes = useFileSystemPublicRoutes - this.seenRequests = new Set() + + // Perform the initial route compilation. + this.compiledRoutes = this.compileRoutes() + this.needsRecompilation = false + } + + private async checkPage( + req: BaseNextRequest, + pathname: string + ): Promise { + 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() { @@ -117,192 +158,201 @@ export default class Router { return this.nextConfig.basePath || '' } - setDynamicRoutes(routes: DynamicRoutes = []) { - this.dynamicRoutes = routes + public setDynamicRoutes(dynamicRoutes: DynamicRoutes) { + this.dynamicRoutes = dynamicRoutes + this.needsRecompilation = true } - setCatchallMiddleware(route?: Route[]) { - this.catchAllMiddleware = route || [] + public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray) { + this.catchAllMiddleware = catchAllMiddleware + this.needsRecompilation = true } - addFsRoute(fsRoute: Route) { + 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 } - async execute( + private compileRoutes(): ReadonlyArray { + /* + 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?: any - ): Promise { - if (this.seenRequests.has(req)) { - throw new Error( - `Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.` - ) - } - this.seenRequests.add(req) - try { - // memoize page check calls so we don't duplicate checks for pages - const pageChecks: { [name: string]: Promise } = {} - const memoizedPageChecker = async (p: string): Promise => { - p = normalizeLocalePath(p, this.locales).pathname + upgradeHead?: Buffer + ) { + const originalFsPathname = parsedUrl.pathname + const fsPathname = removePathPrefix(originalFsPathname!, this.basePath) + + for (const route of this.fsRoutes) { + const params = route.match(fsPathname) - if (pageChecks[p] !== undefined) { - return pageChecks[p] + if (params) { + parsedUrl.pathname = fsPathname + + const { finished } = await route.fn(req, res, params, parsedUrl) + if (finished) { + return true } - const result = this.pageChecker(p) - pageChecks[p] = result - return result + + 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 - let parsedUrlUpdated = parsedUrl + for (const dynamicRoute of this.dynamicRoutes) { + if (dynamicRoute.match(normalizedFsPathname)) { + matchedPage = true + } + } + } - const applyCheckTrue = async (checkParsedUrl: NextUrlWithParsedQuery) => { - const originalFsPathname = checkParsedUrl.pathname - const fsPathname = removePathPrefix(originalFsPathname!, this.basePath) + // 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.` + ) + } - for (const fsRoute of this.fsRoutes) { - const fsParams = fsRoute.match(fsPathname) + parsedUrl.pathname = fsPathname + parsedUrl.query._nextBubbleNoFallback = '1' - if (fsParams) { - checkParsedUrl.pathname = fsPathname + const { finished } = await this.catchAllRoute.fn( + req, + res, + params, + parsedUrl, + upgradeHead + ) - const fsResult = await fsRoute.fn( - req, - res, - fsParams, - checkParsedUrl - ) + return finished + } - if (fsResult.finished) { - return true - } + return false + } - checkParsedUrl.pathname = originalFsPathname - } - } - let matchedPage = await memoizedPageChecker(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 - } - } - } + async execute( + req: BaseNextRequest, + res: BaseNextResponse, + parsedUrl: NextUrlWithParsedQuery, + upgradeHead?: Buffer + ): Promise { + // 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 + } - // Matched a page or dynamic route so render it using catchAllRoute - if (matchedPage) { - const pageParams = this.catchAllRoute.match(checkParsedUrl.pathname) - checkParsedUrl.pathname = fsPathname - checkParsedUrl.query._nextBubbleNoFallback = '1' + 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: {} }) - const result = await this.catchAllRoute.fn( - req, - res, - pageParams as Params, - checkParsedUrl - ) - return result.finished - } + try { + // Create a deep copy of the parsed URL. + const parsedUrlUpdated = { + ...parsedUrl, + query: { + ...parsedUrl.query, + }, } - /* - 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 - const allRoutes = [ - ...(middlewareCatchAllRoute - ? this.fsRoutes - .filter((r) => r.name === '_next/data catchall') - .map((r) => { - return { - ...r, - 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 ( - checkerReq, - checkerRes, - params, - parsedCheckerUrl - ) => { - let { pathname } = parsedCheckerUrl - pathname = removeTrailingSlash(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', - match: getPathMatch('/: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] : []), - ] - - for (const testRoute of allRoutes) { + for (const route of this.compiledRoutes) { // only process rewrites for upgrade request - if (upgradeHead && testRoute.type !== 'rewrite') { + if (upgradeHead && route.type !== 'rewrite') { continue } @@ -314,7 +364,7 @@ export default class Router { if ( pathnameInfo.locale && - !testRoute.matchesLocaleAPIRoutes && + !route.matchesLocaleAPIRoutes && pathnameInfo.pathname.match(/^\/api(?:\/|$)/) ) { continue @@ -325,20 +375,20 @@ export default class Router { } const basePath = pathnameInfo.basePath - if (!testRoute.matchesBasePath) { + if (!route.matchesBasePath) { pathnameInfo.basePath = '' } if ( - testRoute.matchesLocale && - parsedUrl.query.__nextLocale && + route.matchesLocale && + parsedUrlUpdated.query.__nextLocale && !pathnameInfo.locale ) { - pathnameInfo.locale = parsedUrl.query.__nextLocale + pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale } if ( - !testRoute.matchesLocale && + !route.matchesLocale && pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale && pathnameInfo.locale ) { @@ -346,7 +396,7 @@ export default class Router { } if ( - testRoute.matchesTrailingSlash && + route.matchesTrailingSlash && getRequestMeta(req, '__nextHadTrailingSlash') ) { pathnameInfo.trailingSlash = true @@ -357,13 +407,13 @@ export default class Router { ...pathnameInfo, }) - let newParams = testRoute.match(matchPathname) - if (testRoute.has && newParams) { - const hasParams = matchHas(req, testRoute.has, parsedUrlUpdated.query) + let params = route.match(matchPathname) + if (route.has && params) { + const hasParams = matchHas(req, route.has, parsedUrlUpdated.query) if (hasParams) { - Object.assign(newParams, hasParams) + Object.assign(params, hasParams) } else { - newParams = false + params = false } } @@ -373,35 +423,34 @@ export default class Router { * never there, we consider this an invalid match and keep routing. */ if ( - newParams && + params && this.basePath && - !testRoute.matchesBasePath && + !route.matchesBasePath && !getRequestMeta(req, '_nextDidRewrite') && !basePath ) { continue } - if (newParams) { + if (params) { parsedUrlUpdated.pathname = matchPathname - const result = await testRoute.fn( + const result = await route.fn( req, res, - newParams, + params, parsedUrlUpdated, upgradeHead ) - if (result.finished) { return true } - // 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.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) { @@ -412,16 +461,19 @@ export default class Router { } // check filesystem - if (testRoute.check === true) { - if (await applyCheckTrue(parsedUrlUpdated)) { - return true - } + if ( + route.check && + (await this.checkFsRoutes(req, res, parsedUrlUpdated)) + ) { + return true } } } + + // All routes were tested, none were found. return false } finally { - this.seenRequests.delete(req) + this.context.delete(req) } } }