import { IncomingMessage, ServerResponse } from 'http' import { parse as parseUrl, UrlWithParsedQuery } from 'url' import { ParsedUrlQuery } from 'querystring' import { compile as compilePathToRegex } from 'next/dist/compiled/path-to-regexp' 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 fn: ( req: IncomingMessage, res: ServerResponse, params: Params, parsedUrl: UrlWithParsedQuery ) => Promise | RouteResult } export type DynamicRoutes = Array<{ page: string; match: RouteMatch }> export type PageChecker = (pathname: string) => Promise export const prepareDestination = ( destination: string, params: Params, query: ParsedUrlQuery, appendParamsToQuery?: boolean ) => { const parsedDestination = parseUrl(destination, true) const destQuery = parsedDestination.query let destinationCompiler = compilePathToRegex( `${parsedDestination.pathname!}${parsedDestination.hash || ''}`, // we don't validate while compiling the destination since we should // have already validated before we got to this point and validating // breaks compiling destinations with named pattern params from the source // e.g. /something:hello(.*) -> /another/:hello is broken with validation // since compile validation is meant for reversing and not for inserting // params from a separate path-regex into another { validate: false } ) let newUrl // update any params in query values for (const [key, strOrArray] of Object.entries(destQuery)) { let value = Array.isArray(strOrArray) ? strOrArray[0] : strOrArray if (value) { // the value needs to start with a forward-slash to be compiled // correctly value = `/${value}` const queryCompiler = compilePathToRegex(value, { validate: false }) value = queryCompiler(params).substr(1) } destQuery[key] = value } // add path params to query if it's not a redirect and not // already defined in destination query if (appendParamsToQuery) { for (const [name, value] of Object.entries(params)) { if (!(name in destQuery)) { destQuery[name] = value } } } try { newUrl = encodeURI(destinationCompiler(params)) const [pathname, hash] = newUrl.split('#') parsedDestination.pathname = pathname parsedDestination.hash = `${hash ? '#' : ''}${hash || ''}` parsedDestination.path = `${pathname}${parsedDestination.search}` delete parsedDestination.search } catch (err) { if (err.message.match(/Expected .*? to not repeat, but got an array/)) { throw new Error( `To use a multi-match in the destination you must add \`*\` at the end of the param name to signify it should repeat. https://err.sh/vercel/next.js/invalid-multi-match` ) } throw err } // Query merge order lowest priority to highest // 1. initial URL query values // 2. path segment values // 3. destination specified query values parsedDestination.query = { ...query, ...parsedDestination.query, } return { newUrl, parsedDestination, } } export default class Router { headers: Route[] fsRoutes: Route[] rewrites: Route[] redirects: Route[] catchAllRoute: Route pageChecker: PageChecker dynamicRoutes: DynamicRoutes useFileSystemPublicRoutes: boolean constructor({ headers = [], fsRoutes = [], rewrites = [], redirects = [], catchAllRoute, dynamicRoutes = [], pageChecker, useFileSystemPublicRoutes, }: { headers: Route[] fsRoutes: Route[] rewrites: Route[] redirects: Route[] catchAllRoute: Route dynamicRoutes: DynamicRoutes | undefined pageChecker: PageChecker useFileSystemPublicRoutes: boolean }) { 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 { // memoize page check calls so we don't duplicate checks for pages const pageChecks: { [name: string]: Promise } = {} const memoizedPageChecker = async (p: string): Promise => { 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', 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] : []), ] for (const testRoute of allRoutes) { const newParams = testRoute.match(parsedUrlUpdated.pathname) // Check if the match function matched if (newParams) { const result = await testRoute.fn(req, res, newParams, parsedUrlUpdated) // The response was handled if (result.finished) { return true } if (result.pathname) { parsedUrlUpdated.pathname = result.pathname } if (result.query) { parsedUrlUpdated.query = { ...parsedUrlUpdated.query, ...result.query, } } // check filesystem if (testRoute.check === true) { for (const fsRoute of this.fsRoutes) { const fsParams = fsRoute.match(parsedUrlUpdated.pathname) if (fsParams) { const fsResult = await fsRoute.fn( req, res, fsParams, parsedUrlUpdated ) if (fsResult.finished) { return true } } } let matchedPage = await memoizedPageChecker( parsedUrlUpdated.pathname! ) // If we didn't match a page check dynamic routes if (!matchedPage) { for (const dynamicRoute of this.dynamicRoutes) { if (dynamicRoute.match(parsedUrlUpdated.pathname)) { matchedPage = true } } } // Matched a page or dynamic route so render it using catchAllRoute if (matchedPage) { const pageParams = this.catchAllRoute.match( parsedUrlUpdated.pathname ) await this.catchAllRoute.fn( req, res, pageParams as Params, parsedUrlUpdated ) return true } } } } return false } }