From 868f62be9d8bc89d043bf4ae87bebd836bc4c079 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 8 Feb 2023 13:30:45 -0700 Subject: [PATCH 01/22] feat: initial app routes support --- errors/manifest.json | 4 + ...next-response-next-in-app-route-handler.md | 14 + packages/next/src/build/entries.ts | 8 +- packages/next/src/build/index.ts | 12 +- .../build/webpack/loaders/next-app-loader.ts | 41 +- .../loaders/next-serverless-loader/utils.ts | 4 +- .../src/client/components/layout-router.tsx | 5 +- .../next/src/client/components/not-found.ts | 21 +- .../internal/helpers/use-error-handler.ts | 7 +- .../src/client/components/redirect.test.ts | 6 +- .../next/src/client/components/redirect.ts | 51 +- packages/next/src/export/worker.ts | 10 +- packages/next/src/lib/is-app-page-route.ts | 3 + packages/next/src/lib/is-app-route-route.ts | 3 + packages/next/src/server/app-render.tsx | 16 +- .../async-storage/async-storage-wrapper.ts | 21 + .../request-async-storage-wrapper.ts | 110 +++++ ...static-generation-async-storage-wrapper.ts | 55 +++ packages/next/src/server/base-server.ts | 149 +++--- packages/next/src/server/body-streams.ts | 6 +- packages/next/src/server/dev/hot-reloader.ts | 5 + .../next/src/server/dev/next-dev-server.ts | 87 +++- .../src/server/dev/on-demand-entry-handler.ts | 30 +- .../manifest-loaders/manifest-loader.ts | 5 + .../manifest-loaders/node-manifest-loader.ts | 11 + .../src/server/module-loader/module-loader.ts | 6 + .../module-loader/node-module-loader.ts | 10 + packages/next/src/server/next-server.ts | 182 +++++--- .../next/src/server/node-polyfill-headers.ts | 16 + .../absolute-filename-normalizer.test.ts | 33 ++ .../absolute-filename-normalizer.ts | 32 ++ .../normalizers/locale-route-normalizer.ts | 41 ++ .../next/src/server/normalizers/normalizer.ts | 3 + .../src/server/normalizers/normalizers.ts | 20 + .../normalizers/prefixing-normalizer.ts | 10 + .../server/normalizers/wrap-normalizer-fn.ts | 5 + packages/next/src/server/request-meta.ts | 4 +- .../app-route/app-route-route-handler.ts | 320 +++++++++++++ .../route-handlers/app-route/handlers.ts | 37 ++ .../server/route-handlers/route-handler.ts | 22 + .../route-handlers/route-handlers.test.ts | 93 ++++ .../server/route-handlers/route-handlers.ts | 36 ++ packages/next/src/server/route-kind.ts | 20 + .../default-route-matcher-manager.test.ts | 133 ++++++ .../default-route-matcher-manager.ts | 154 ++++++ .../dev-route-matcher-manager.ts | 64 +++ .../route-matcher-manager.ts | 12 + .../app-page-route-matcher.test.ts | 84 ++++ .../route-matchers/app-page-route-matcher.ts | 55 +++ .../app-route-route-matcher.test.ts | 69 +++ .../route-matchers/app-route-route-matcher.ts | 49 ++ .../dev-app-page-route-matcher.test.ts | 67 +++ .../dev-app-page-route-matcher.ts | 43 ++ .../dev-app-route-route-matcher.test.ts | 57 +++ .../dev-app-route-route-matcher.ts | 45 ++ .../route-matchers/dev-fs-route-matcher.ts | 112 +++++ .../dev-pages-api-route-matcher.test.ts | 97 ++++ .../dev-pages-api-route-matcher.ts | 72 +++ .../dev-pages-route-matcher.test.ts | 95 ++++ .../route-matchers/dev-pages-route-matcher.ts | 60 +++ .../file-reader/default-file-reader.ts | 44 ++ .../route-matchers/file-reader/file-reader.ts | 8 + .../pages-api-route-matcher.test.ts | 69 +++ .../route-matchers/pages-api-route-matcher.ts | 46 ++ .../pages-route-matcher.test.ts | 69 +++ .../route-matchers/pages-route-matcher.ts | 48 ++ .../server/route-matchers/route-matcher.ts | 13 + .../src/server/route-matches/route-match.ts | 23 + packages/next/src/server/router.ts | 442 ++++++++---------- packages/next/src/server/web-server.ts | 37 +- packages/next/src/server/web/http.ts | 30 ++ packages/next/src/server/web/types.ts | 4 +- .../lib/page-path/remove-page-path-tail.ts | 2 +- .../src/shared/lib/router/utils/app-paths.ts | 64 ++- .../shared/lib/router/utils/route-matcher.ts | 4 +- .../app-routes/app-custom-routes.test.ts | 303 ++++++++++++ .../app/advanced/body/json/route.ts | 10 + .../app/advanced/body/streaming/route.ts | 25 + .../app/advanced/body/text/route.ts | 10 + .../app-routes/app/advanced/query/route.ts | 11 + .../basic/(grouped)/endpoint/nested/route.ts | 1 + .../basic/[tenantID]/[...resource]/route.ts | 1 + .../app/basic/[tenantID]/endpoint/route.ts | 1 + .../app-routes/app/basic/endpoint/route.ts | 1 + .../app-routes/app/hooks/cookies/route.ts | 14 + .../app-routes/app/hooks/headers/route.ts | 14 + .../app-routes/app/hooks/json/route.ts | 7 + .../app-routes/app/hooks/not-found/route.ts | 5 + .../app/hooks/redirect/response/route.ts | 5 + .../app-routes/app/hooks/redirect/route.ts | 5 + .../app-routes/app/hooks/rewrite/route.ts | 6 + .../app-routes/app/methods/head/route.ts | 3 + .../app-routes/app/methods/options/route.ts | 3 + .../app-routes/app/status/405/route.ts | 1 + .../app-routes/app/status/500/next/route.ts | 5 + .../app-routes/app/status/500/route.ts | 3 + test/e2e/app-dir/app-routes/handlers/hello.ts | 25 + test/e2e/app-dir/app-routes/helpers.ts | 69 +++ test/e2e/app-dir/app-routes/next.config.js | 5 + 99 files changed, 3778 insertions(+), 500 deletions(-) create mode 100644 errors/next-response-next-in-app-route-handler.md create mode 100644 packages/next/src/lib/is-app-page-route.ts create mode 100644 packages/next/src/lib/is-app-route-route.ts create mode 100644 packages/next/src/server/async-storage/async-storage-wrapper.ts create mode 100644 packages/next/src/server/async-storage/request-async-storage-wrapper.ts create mode 100644 packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts create mode 100644 packages/next/src/server/manifest-loaders/manifest-loader.ts create mode 100644 packages/next/src/server/manifest-loaders/node-manifest-loader.ts create mode 100644 packages/next/src/server/module-loader/module-loader.ts create mode 100644 packages/next/src/server/module-loader/node-module-loader.ts create mode 100644 packages/next/src/server/node-polyfill-headers.ts create mode 100644 packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts create mode 100644 packages/next/src/server/normalizers/absolute-filename-normalizer.ts create mode 100644 packages/next/src/server/normalizers/locale-route-normalizer.ts create mode 100644 packages/next/src/server/normalizers/normalizer.ts create mode 100644 packages/next/src/server/normalizers/normalizers.ts create mode 100644 packages/next/src/server/normalizers/prefixing-normalizer.ts create mode 100644 packages/next/src/server/normalizers/wrap-normalizer-fn.ts create mode 100644 packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts create mode 100644 packages/next/src/server/route-handlers/app-route/handlers.ts create mode 100644 packages/next/src/server/route-handlers/route-handler.ts create mode 100644 packages/next/src/server/route-handlers/route-handlers.test.ts create mode 100644 packages/next/src/server/route-handlers/route-handlers.ts create mode 100644 packages/next/src/server/route-kind.ts create mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts create mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts create mode 100644 packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts create mode 100644 packages/next/src/server/route-matcher-managers/route-matcher-manager.ts create mode 100644 packages/next/src/server/route-matchers/app-page-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/app-page-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/app-route-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/app-route-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/dev-fs-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/dev-pages-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/file-reader/default-file-reader.ts create mode 100644 packages/next/src/server/route-matchers/file-reader/file-reader.ts create mode 100644 packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/pages-api-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/pages-route-matcher.test.ts create mode 100644 packages/next/src/server/route-matchers/pages-route-matcher.ts create mode 100644 packages/next/src/server/route-matchers/route-matcher.ts create mode 100644 packages/next/src/server/route-matches/route-match.ts create mode 100644 packages/next/src/server/web/http.ts create mode 100644 test/e2e/app-dir/app-routes/app-custom-routes.test.ts create mode 100644 test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/advanced/query/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/headers/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/json/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/methods/head/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/methods/options/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/status/405/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/status/500/next/route.ts create mode 100644 test/e2e/app-dir/app-routes/app/status/500/route.ts create mode 100644 test/e2e/app-dir/app-routes/handlers/hello.ts create mode 100644 test/e2e/app-dir/app-routes/helpers.ts create mode 100644 test/e2e/app-dir/app-routes/next.config.js diff --git a/errors/manifest.json b/errors/manifest.json index 07d1d8191928c..d73a3e72c68b0 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -778,6 +778,10 @@ "title": "context-in-server-component", "path": "/errors/context-in-server-component.md" }, + { + "title": "next-response-next-in-app-route-handler", + "path": "/errors/next-response-next-in-app-route-handler.md" + }, { "title": "react-client-hook-in-server-component", "path": "/errors/react-client-hook-in-server-component.md" diff --git a/errors/next-response-next-in-app-route-handler.md b/errors/next-response-next-in-app-route-handler.md new file mode 100644 index 0000000000000..9f140bc047dac --- /dev/null +++ b/errors/next-response-next-in-app-route-handler.md @@ -0,0 +1,14 @@ +# `NextResponse.next()` used in a App Route Handler + +#### Why This Error Occurred + +App Route Handler's do not currently support using the `NextResponse.next()` method to forward to the next middleware because the handler is considered the endpoint to the middleware chain. Handlers must always return a `Response` object instead. + +#### Possible Ways to Fix It + +Remove the `NextResponse.next()` and replace it with a correct response handler. + +### Useful Links + +- [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) +- [`NextResponse`](https://nextjs.org/docs/api-reference/next/server#nextresponse) diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index b436ff711e620..fd0fece212a75 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -310,7 +310,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { let appPathsPerRoute: Record = {} if (appDir && appPaths) { for (const pathname in appPaths) { - const normalizedPath = normalizeAppPath(pathname) || '/' + const normalizedPath = normalizeAppPath(pathname) if (!appPathsPerRoute[normalizedPath]) { appPathsPerRoute[normalizedPath] = [] } @@ -403,8 +403,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { }, onServer: () => { if (pagesType === 'app' && appDir) { - const matchedAppPaths = - appPathsPerRoute[normalizeAppPath(page) || '/'] + const matchedAppPaths = appPathsPerRoute[normalizeAppPath(page)] server[serverBundlePath] = getAppEntry({ name: serverBundlePath, pagePath: mappings[page], @@ -420,8 +419,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { onEdgeServer: () => { let appDirLoader: string = '' if (pagesType === 'app') { - const matchedAppPaths = - appPathsPerRoute[normalizeAppPath(page) || '/'] + const matchedAppPaths = appPathsPerRoute[normalizeAppPath(page)] appDirLoader = getAppEntry({ name: serverBundlePath, pagePath: mappings[page], diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index e24fab1063d40..c77b08ab460bd 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -498,7 +498,9 @@ export default async function build( .traceAsyncFn(() => recursiveReadDir( appDir, - new RegExp(`^page\\.(?:${config.pageExtensions.join('|')})$`) + new RegExp( + `^(page|route)\\.(?:${config.pageExtensions.join('|')})$` + ) ) ) } @@ -575,7 +577,7 @@ export default async function build( if (mappedAppPages) { denormalizedAppPages = Object.keys(mappedAppPages) for (const appKey of denormalizedAppPages) { - const normalizedAppPageKey = normalizeAppPath(appKey) || '/' + const normalizedAppPageKey = normalizeAppPath(appKey) const pagePath = mappedPages[normalizedAppPageKey] if (pagePath) { const appPath = mappedAppPages[appKey] @@ -1088,7 +1090,7 @@ export default async function build( ) Object.keys(appPathsManifest).forEach((entry) => { - appPathRoutes[entry] = normalizeAppPath(entry) || '/' + appPathRoutes[entry] = normalizeAppPath(entry) }) await promises.writeFile( path.join(distDir, APP_PATH_ROUTES_MANIFEST), @@ -1379,7 +1381,9 @@ export default async function build( if ( (!isDynamicRoute(page) || !workerResult.prerenderRoutes?.length) && - workerResult.appConfig?.revalidate !== 0 + workerResult.appConfig?.revalidate !== 0 && + // TODO-APP: (wyattjoh) this may be where we can enable prerendering for app handlers + originalAppPath.endsWith('/page') ) { appStaticPaths.set(originalAppPath, [page]) appStaticPathsEncoded.set(originalAppPath, [page]) diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index 5c18116f7a84c..79fcfd301bff2 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -12,6 +12,7 @@ import { APP_DIR_ALIAS } from '../../../lib/constants' import { buildMetadata, discoverStaticMetadataFiles } from './metadata/discover' const isNotResolvedError = (err: any) => err.message.includes("Can't resolve") +import { isAppRouteRoute } from '../../../lib/is-app-route-route' const FILE_TYPES = { layout: 'layout', @@ -25,6 +26,10 @@ const FILE_TYPES = { const GLOBAL_ERROR_FILE_TYPE = 'global-error' const PAGE_SEGMENT = 'page$' +type PathResolver = ( + pathname: string, + resolveDir?: boolean +) => Promise export type ComponentsType = { readonly [componentKey in ValueOf]?: ModuleReference } & { @@ -33,6 +38,35 @@ export type ComponentsType = { readonly metadata?: CollectedMetadata } +async function createAppRouteCode({ + pagePath, + resolver, +}: { + pagePath: string + resolver: PathResolver +}): Promise { + // Split based on any specific path separators (both `/` and `\`)... + const splittedPath = pagePath.split(/[\\/]/) + // Then join all but the last part with the same separator, `/`... + const segmentPath = splittedPath.slice(0, -1).join('/') + // Then add the `/route` suffix... + const matchedPagePath = `${segmentPath}/route` + // This, when used with the resolver will give us the pathname to the built + // route handler file. + const resolvedPagePath = await resolver(matchedPagePath) + + // TODO: verify if other methods need to be injected + // TODO: validate that the handler exports at least one of the supported methods + + return ` + import 'next/dist/server/node-polyfill-headers' + + export * as handlers from ${JSON.stringify(resolvedPagePath)} + + export { requestAsyncStorage } from 'next/dist/client/components/request-async-storage' + ` +} + async function createTreeCodeFromPath( pagePath: string, { @@ -279,8 +313,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { return Object.entries(matched) } - - const resolver = async (pathname: string, resolveDir?: boolean) => { + const resolver: PathResolver = async (pathname, resolveDir) => { if (resolveDir) { return createAbsolutePath(appDir, pathname) } @@ -302,6 +335,10 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { } } + if (isAppRouteRoute(name)) { + return createAppRouteCode({ pagePath, resolver }) + } + const { treeCode, pages: pageListCode, diff --git a/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts index e28abe5b935a2..7abb4f735b309 100644 --- a/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/src/build/webpack/loaders/next-serverless-loader/utils.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http' import type { Rewrite } from '../../../../lib/load-custom-routes' import type { BuildManifest } from '../../../../server/get-page-files' -import type { RouteMatch } from '../../../../shared/lib/router/utils/route-matcher' +import type { RouteMatchFn } from '../../../../shared/lib/router/utils/route-matcher' import type { NextConfig } from '../../../../server/config' import type { GetServerSideProps, @@ -144,7 +144,7 @@ export function getUtils({ trailingSlash?: boolean }) { let defaultRouteRegex: ReturnType | undefined - let dynamicRouteMatcher: RouteMatch | undefined + let dynamicRouteMatcher: RouteMatchFn | undefined let defaultRouteMatches: ParsedUrlQuery | undefined if (pageIsDynamic) { diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 8fb4139462576..5ee00bf8e79b4 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -25,6 +25,7 @@ import { ErrorBoundary } from './error-boundary' import { matchSegment } from './match-segments' import { useRouter } from './navigation' import { handleSmoothScroll } from '../../shared/lib/router/utils/handle-smooth-scroll' +import { getURLFromRedirectError, isRedirectError } from './redirect' /** * Add refetch marker to router state at the point of the current layout segment. @@ -380,8 +381,8 @@ class RedirectErrorBoundary extends React.Component< } static getDerivedStateFromError(error: any) { - if (error?.digest?.startsWith('NEXT_REDIRECT')) { - const url = error.digest.split(';')[1] + if (isRedirectError(error)) { + const url = getURLFromRedirectError(error) return { redirect: url } } // Re-throw if error is not for redirect diff --git a/packages/next/src/client/components/not-found.ts b/packages/next/src/client/components/not-found.ts index 2ca5e894be9bb..b576e6000600a 100644 --- a/packages/next/src/client/components/not-found.ts +++ b/packages/next/src/client/components/not-found.ts @@ -1,8 +1,25 @@ -export const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND' +const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND' +type NotFoundError = Error & { digest: typeof NOT_FOUND_ERROR_CODE } + +/** + * When used in a React server component, this will set the status code to 404. + * When used in a custom app route it will just send a 404 status. + */ export function notFound(): never { // eslint-disable-next-line no-throw-literal const error = new Error(NOT_FOUND_ERROR_CODE) - ;(error as any).digest = NOT_FOUND_ERROR_CODE + ;(error as NotFoundError).digest = NOT_FOUND_ERROR_CODE throw error } + +/** + * Checks an error to determine if it's an error generated by the `notFound()` + * helper. + * + * @param error the error that may reference a not found error + * @returns true if the error is a not found error + */ +export function isNotFoundError(error: any): error is NotFoundError { + return error?.digest === NOT_FOUND_ERROR_CODE +} diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts index 90ea53e8cf547..87f443f200fc9 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts @@ -1,4 +1,6 @@ import { useEffect } from 'react' +import { isNotFoundError } from '../../../not-found' +import { isRedirectError } from '../../../redirect' import { hydrationErrorWarning, hydrationErrorComponentStack, @@ -12,10 +14,7 @@ export const RuntimeErrorHandler = { function isNextRouterError(error: any): boolean { return ( - error && - error.digest && - (error.digest.startsWith('NEXT_REDIRECT') || - error.digest === 'NEXT_NOT_FOUND') + error && error.digest && (isRedirectError(error) || isNotFoundError(error)) ) } diff --git a/packages/next/src/client/components/redirect.test.ts b/packages/next/src/client/components/redirect.test.ts index 5c19b8b99bee9..944ec9b8a23a3 100644 --- a/packages/next/src/client/components/redirect.test.ts +++ b/packages/next/src/client/components/redirect.test.ts @@ -1,13 +1,13 @@ /* eslint-disable jest/no-try-expect */ -import { redirect, REDIRECT_ERROR_CODE } from './redirect' +import { getURLFromRedirectError, isRedirectError, redirect } from './redirect' describe('test', () => { it('should throw a redirect error', () => { try { redirect('/dashboard') throw new Error('did not throw') } catch (err: any) { - expect(err.message).toBe(REDIRECT_ERROR_CODE) - expect(err.digest).toBe(`${REDIRECT_ERROR_CODE};/dashboard`) + expect(isRedirectError(err)).toBeTruthy() + expect(getURLFromRedirectError(err)).toEqual('/dashboard') } }) }) diff --git a/packages/next/src/client/components/redirect.ts b/packages/next/src/client/components/redirect.ts index 5e685f94edbe0..4dee12b8d5f6e 100644 --- a/packages/next/src/client/components/redirect.ts +++ b/packages/next/src/client/components/redirect.ts @@ -1,8 +1,55 @@ -export const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT' +const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT' +type RedirectError = Error & { + digest: `${typeof REDIRECT_ERROR_CODE};${U}` +} + +/** + * When used in a React server component, this will insert a meta tag to + * redirect the user to the target page. When used in a custom app route, it + * will serve a 302 to the caller. + * + * @param url the url to redirect to + */ export function redirect(url: string): never { // eslint-disable-next-line no-throw-literal const error = new Error(REDIRECT_ERROR_CODE) - ;(error as any).digest = REDIRECT_ERROR_CODE + ';' + url + ;(error as RedirectError).digest = `${REDIRECT_ERROR_CODE};${url}` throw error } + +/** + * Checks an error to determine if it's an error generated by the + * `redirect(url)` helper. + * + * @param error the error that may reference a redirect error + * @returns true if the error is a redirect error + */ +export function isRedirectError( + error: any +): error is RedirectError { + return ( + typeof error?.digest === 'string' && + error.digest.startsWith(REDIRECT_ERROR_CODE + ';') && + error.digest.length > REDIRECT_ERROR_CODE.length + 1 + ) +} + +/** + * Returns the encoded URL from the error if it's a RedirectError, null + * otherwise. Note that this does not validate the URL returned. + * + * @param error the error that may be a redirect error + * @return the url if the error was a redirect error + */ +export function getURLFromRedirectError( + error: RedirectError +): U +export function getURLFromRedirectError(error: any): string | null +export function getURLFromRedirectError(error: any): string | null { + if (!isRedirectError(error)) return null + + // Slices off the beginning of the digest that contains the code and the + // separating ';'. + return error.digest.slice(REDIRECT_ERROR_CODE.length + 1) +} diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 28f6b00388430..5a41cce1cf67f 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -29,11 +29,11 @@ import RenderResult from '../server/render-result' import isError from '../lib/is-error' import { addRequestMeta } from '../server/request-meta' import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' -import { REDIRECT_ERROR_CODE } from '../client/components/redirect' import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context' -import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found' -import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error' import { IncrementalCache } from '../server/lib/incremental-cache' +import { isNotFoundError } from '../client/components/not-found' +import { isRedirectError } from '../client/components/redirect' +import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error' loadRequireHook() @@ -391,9 +391,9 @@ export default async function exportPage({ } catch (err: any) { if ( err.digest !== DYNAMIC_ERROR_CODE && - err.digest !== NOT_FOUND_ERROR_CODE && + !isNotFoundError(err) && err.digest !== NEXT_DYNAMIC_NO_SSR_CODE && - !err.digest?.startsWith(REDIRECT_ERROR_CODE) + !isRedirectError(err) ) { throw err } diff --git a/packages/next/src/lib/is-app-page-route.ts b/packages/next/src/lib/is-app-page-route.ts new file mode 100644 index 0000000000000..39b4220d51a2e --- /dev/null +++ b/packages/next/src/lib/is-app-page-route.ts @@ -0,0 +1,3 @@ +export function isAppPageRoute(route: string): boolean { + return route.endsWith('/page') +} diff --git a/packages/next/src/lib/is-app-route-route.ts b/packages/next/src/lib/is-app-route-route.ts new file mode 100644 index 0000000000000..26a048d177e68 --- /dev/null +++ b/packages/next/src/lib/is-app-route-route.ts @@ -0,0 +1,3 @@ +export function isAppRouteRoute(route: string): boolean { + return route.endsWith('/route') +} diff --git a/packages/next/src/server/app-render.tsx b/packages/next/src/server/app-render.tsx index c9d2730456c2b..0ae6e7b0b7cfb 100644 --- a/packages/next/src/server/app-render.tsx +++ b/packages/next/src/server/app-render.tsx @@ -30,11 +30,8 @@ import { } from '../build/webpack/plugins/flight-manifest-plugin' import { ServerInsertedHTMLContext } from '../shared/lib/server-inserted-html' import { stripInternalQueries } from './internal-utils' -import { REDIRECT_ERROR_CODE } from '../client/components/redirect' import { RequestCookies } from './web/spec-extension/cookies' import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context' -import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found' -import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error' import { HeadManagerContext } from '../shared/lib/head-manager-context' import stringHash from 'next/dist/compiled/string-hash' import { @@ -55,6 +52,9 @@ import type { MetadataItems } from '../lib/metadata/resolve-metadata' import { isClientReference } from '../build/is-client-reference' import { getLayoutOrPageModule, LoaderTree } from './lib/app-dir-module' import { warnOnce } from '../shared/lib/utils/warn-once' +import { isNotFoundError } from '../client/components/not-found' +import { isRedirectError } from '../client/components/redirect' +import { NEXT_DYNAMIC_NO_SSR_CODE } from '../shared/lib/lazy-dynamic/no-ssr-error' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -240,9 +240,9 @@ function createErrorHandler( if ( err && (err.digest === DYNAMIC_ERROR_CODE || - err.digest === NOT_FOUND_ERROR_CODE || + isNotFoundError(err) || err.digest === NEXT_DYNAMIC_NO_SSR_CODE || - err.digest?.startsWith(REDIRECT_ERROR_CODE)) + isRedirectError(err)) ) { return err.digest } @@ -2015,11 +2015,11 @@ export async function renderToHTMLOrFlight( return result } catch (err: any) { - const shouldNotIndex = err.digest === NOT_FOUND_ERROR_CODE - if (err.digest === NOT_FOUND_ERROR_CODE) { + const shouldNotIndex = isNotFoundError(err) + if (isNotFoundError(err)) { res.statusCode = 404 } - if (err.digest?.startsWith(REDIRECT_ERROR_CODE)) { + if (isRedirectError(err)) { res.statusCode = 307 } diff --git a/packages/next/src/server/async-storage/async-storage-wrapper.ts b/packages/next/src/server/async-storage/async-storage-wrapper.ts new file mode 100644 index 0000000000000..9845c21617661 --- /dev/null +++ b/packages/next/src/server/async-storage/async-storage-wrapper.ts @@ -0,0 +1,21 @@ +import type { AsyncLocalStorage } from 'async_hooks' + +/** + * Implementations provide a wrapping function that will provide the storage to + * async calls derived from the provided callback function. + */ +export interface AsyncStorageWrapper { + /** + * Wraps the callback with the underlying storage. + * + * @param storage underlying storage object + * @param context context used to create the storage object + * @param callback function to call within the scope of the storage + * @returns the result of the callback + */ + wrap( + storage: AsyncLocalStorage, + context: Context, + callback: () => Result + ): Result +} diff --git a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts new file mode 100644 index 0000000000000..9216e7f75dc06 --- /dev/null +++ b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts @@ -0,0 +1,110 @@ +import { FLIGHT_PARAMETERS } from '../../client/components/app-router-headers' +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http' +import type { AsyncLocalStorage } from 'async_hooks' +import type { PreviewData } from '../../../types' +import type { RequestStore } from '../../client/components/request-async-storage' +import { + ReadonlyHeaders, + ReadonlyRequestCookies, + type RenderOpts, +} from '../app-render' +import { AsyncStorageWrapper } from './async-storage-wrapper' +import type { tryGetPreviewData } from '../api-utils/node' + +function headersWithoutFlight(headers: IncomingHttpHeaders) { + const newHeaders = { ...headers } + for (const param of FLIGHT_PARAMETERS) { + delete newHeaders[param.toString().toLowerCase()] + } + return newHeaders +} + +export type RequestContext = { + req: IncomingMessage + res: ServerResponse + renderOpts?: RenderOpts +} + +export class RequestAsyncStorageWrapper + implements AsyncStorageWrapper +{ + /** + * Tries to get the preview data on the request for the given route. This + * isn't enabled in the edge runtime yet. + */ + private static readonly tryGetPreviewData: typeof tryGetPreviewData | null = + process.env.NEXT_RUNTIME !== 'edge' + ? require('../api-utils/node').tryGetPreviewData + : null + + /** + * Wrap the callback with the given store so it can access the underlying + * store using hooks. + * + * @param storage underlying storage object returned by the module + * @param context context to seed the store + * @param callback function to call within the scope of the context + * @returns the result returned by the callback + */ + public wrap( + storage: AsyncLocalStorage, + context: RequestContext, + callback: () => Result + ): Result { + return RequestAsyncStorageWrapper.wrap(storage, context, callback) + } + + /** + * @deprecated instance method should be used in favor of the static method + */ + public static wrap( + storage: AsyncLocalStorage, + { req, res, renderOpts }: RequestContext, + callback: () => Result + ): Result { + // Reads of this are cached on the `req` object, so this should resolve + // instantly. There's no need to pass this data down from a previous + // invoke, where we'd have to consider server & serverless. + const previewData: PreviewData = + renderOpts && RequestAsyncStorageWrapper.tryGetPreviewData + ? // TODO: investigate why previewProps isn't on RenderOpts + RequestAsyncStorageWrapper.tryGetPreviewData( + req, + res, + (renderOpts as any).previewProps + ) + : false + + let cachedHeadersInstance: ReadonlyHeaders + let cachedCookiesInstance: ReadonlyRequestCookies + + const store: RequestStore = { + get headers() { + if (!cachedHeadersInstance) { + cachedHeadersInstance = new ReadonlyHeaders( + headersWithoutFlight(req.headers) + ) + } + return cachedHeadersInstance + }, + get cookies() { + if (!cachedCookiesInstance) { + cachedCookiesInstance = new ReadonlyRequestCookies({ + headers: { + get: (key) => { + if (key !== 'cookie') { + throw new Error('Only cookie header is supported') + } + return req.headers.cookie + }, + }, + }) + } + return cachedCookiesInstance + }, + previewData, + } + + return storage.run(store, callback) + } +} diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts new file mode 100644 index 0000000000000..1222b2820c28b --- /dev/null +++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts @@ -0,0 +1,55 @@ +import { AsyncStorageWrapper } from './async-storage-wrapper' +import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage' +import type { RenderOpts } from '../app-render' +import type { AsyncLocalStorage } from 'async_hooks' + +export type RequestContext = { + pathname: string + renderOpts: RenderOpts +} + +export class StaticGenerationAsyncStorageWrapper + implements AsyncStorageWrapper +{ + public wrap( + storage: AsyncLocalStorage, + context: RequestContext, + callback: () => Result + ): Result { + return StaticGenerationAsyncStorageWrapper.wrap(storage, context, callback) + } + + /** + * @deprecated instance method should be used in favor of the static method + */ + public static wrap( + storage: AsyncLocalStorage, + { pathname, renderOpts }: RequestContext, + callback: () => Result + ): Result { + /** + * Rules of Static & Dynamic HTML: + * + * 1.) We must generate static HTML unless the caller explicitly opts + * in to dynamic HTML support. + * + * 2.) If dynamic HTML support is requested, we must honor that request + * or throw an error. It is the sole responsibility of the caller to + * ensure they aren't e.g. requesting dynamic HTML for an AMP page. + * + * These rules help ensure that other existing features like request caching, + * coalescing, and ISR continue working as intended. + */ + const isStaticGeneration = + renderOpts.supportsDynamicHTML !== true && !renderOpts.isBot + + const store: StaticGenerationStore = { + isStaticGeneration, + pathname, + incrementalCache: renderOpts.incrementalCache, + isRevalidate: renderOpts.isRevalidate, + } + + return storage.run(store, callback) + } +} diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 4cf2d193bf15a..e6337edfb0070 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1,10 +1,10 @@ import type { __ApiPreviewProps } from './api-utils' import type { CustomRoutes } from '../lib/load-custom-routes' import type { DomainLocale } from './config' -import type { DynamicRoutes, PageChecker, Route } from './router' +import type { RouterOptions } from './router' import type { FontManifest, FontConfig } from './font-utils' import type { LoadComponentsReturnType } from './load-components' -import type { RouteMatch } from '../shared/lib/router/utils/route-matcher' +import type { RouteMatchFn } from '../shared/lib/router/utils/route-matcher' import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' import type { Params } from '../shared/lib/router/utils/route-matcher' import type { NextConfig, NextConfigComplete } from './config-shared' @@ -80,6 +80,8 @@ import { FLIGHT_PARAMETERS, FETCH_CACHE_HEADER, } from '../client/components/app-router-headers' +import type { RouteHandlers } from './route-handlers/route-handlers' +import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -88,7 +90,7 @@ export type FindComponentsResult = { export interface RoutingItem { page: string - match: RouteMatch + match: RouteMatchFn re?: RegExp } @@ -173,18 +175,18 @@ type ResponsePayload = { } export default abstract class Server { - protected dir: string - protected quiet: boolean - protected nextConfig: NextConfigComplete - protected distDir: string - protected publicDir: string - protected hasStaticDir: boolean - protected hasAppDir: boolean - protected pagesManifest?: PagesManifest - protected appPathsManifest?: PagesManifest - protected buildId: string - protected minimalMode: boolean - protected renderOpts: { + protected readonly dir: string + protected readonly quiet: boolean + protected readonly nextConfig: NextConfigComplete + protected readonly distDir: string + protected readonly publicDir: string + protected readonly hasStaticDir: boolean + protected readonly hasAppDir: boolean + protected readonly pagesManifest?: PagesManifest + protected readonly appPathsManifest?: PagesManifest + protected readonly buildId: string + protected readonly minimalMode: boolean + protected readonly renderOpts: { poweredByHeader: boolean buildId: string generateEtags: boolean @@ -222,7 +224,7 @@ export default abstract class Server { protected serverOptions: ServerOptions private responseCache: ResponseCacheBase protected router: Router - protected dynamicRoutes?: DynamicRoutes + // protected dynamicRoutes?: DynamicRoutes protected appPathRoutes?: Record protected customRoutes: CustomRoutes protected serverComponentManifest?: any @@ -260,22 +262,7 @@ export default abstract class Server { protected abstract getCustomRoutes(): CustomRoutes protected abstract hasPage(pathname: string): Promise - protected abstract generateRoutes(): { - headers: Route[] - rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] - } - fsRoutes: Route[] - redirects: Route[] - catchAllRoute: Route - catchAllMiddleware: Route[] - pageChecker: PageChecker - useFileSystemPublicRoutes: boolean - dynamicRoutes: DynamicRoutes | undefined - nextConfig: NextConfig - } + protected abstract generateRoutes(): RouterOptions protected abstract sendRenderResult( req: BaseNextRequest, @@ -324,6 +311,14 @@ export default abstract class Server { forceReload?: boolean }): void + protected readonly matchers: RouteMatcherManager + protected readonly handlers: RouteHandlers + + protected abstract getRoutes(): { + matchers: RouteMatcherManager + handlers: RouteHandlers + } + public constructor(options: ServerOptions) { const { dir = '.', @@ -408,12 +403,12 @@ export default abstract class Server { ? this.nextConfig.crossOrigin : undefined, largePageDataBytes: this.nextConfig.experimental.largePageDataBytes, - } - - // Only the `publicRuntimeConfig` key is exposed to the client side - // It'll be rendered as part of __NEXT_DATA__ on the client side - if (Object.keys(publicRuntimeConfig).length > 0) { - this.renderOpts.runtimeConfig = publicRuntimeConfig + // Only the `publicRuntimeConfig` key is exposed to the client side + // It'll be rendered as part of __NEXT_DATA__ on the client side + runtimeConfig: + Object.keys(publicRuntimeConfig).length > 0 + ? publicRuntimeConfig + : undefined, } // Initialize next/config with the environment configuration @@ -425,6 +420,11 @@ export default abstract class Server { this.pagesManifest = this.getPagesManifest() this.appPathsManifest = this.getAppPathsManifest() + // Configure the routes. + const { matchers, handlers } = this.getRoutes() + this.matchers = matchers + this.handlers = handlers + this.customRoutes = this.getCustomRoutes() this.router = new Router(this.generateRoutes()) this.setAssetPrefix(assetPrefix) @@ -559,9 +559,11 @@ export default abstract class Server { if (urlPathname.startsWith(`/_next/data/`)) { parsedUrl.query.__nextDataReq = '1' } + const normalizedUrlPath = this.stripNextDataPath(urlPathname) matchedPath = this.stripNextDataPath(matchedPath, false) + // Perform locale detection and normalization. if (this.nextConfig.i18n) { const localeResult = normalizeLocalePath( matchedPath, @@ -574,21 +576,27 @@ export default abstract class Server { } } matchedPath = denormalizePagePath(matchedPath) - let srcPathname = matchedPath - if ( - !isDynamicRoute(srcPathname) && - !(await this.hasPage(removeTrailingSlash(srcPathname))) - ) { - for (const dynamicRoute of this.dynamicRoutes || []) { - if (dynamicRoute.match(srcPathname)) { - srcPathname = dynamicRoute.page - break - } - } + let srcPathname = matchedPath + const match = await this.matchers.match(matchedPath) + if (match) { + srcPathname = match.pathname } + const pageIsDynamic = typeof match?.params !== 'undefined' + + // NOTE: converted to the new match syntax + // if ( + // !isDynamicRoute(srcPathname) && + // !(await this.hasPage(removeTrailingSlash(srcPathname))) + // ) { + // for (const dynamicRoute of this.dynamicRoutes || []) { + // if (dynamicRoute.match(srcPathname)) { + // srcPathname = dynamicRoute.page + // break + // } + // } + // } - const pageIsDynamic = isDynamicRoute(srcPathname) const utils = getUtils({ pageIsDynamic, page: srcPathname, @@ -830,7 +838,7 @@ export default abstract class Server { const appPathRoutes: Record = {} Object.keys(this.appPathsManifest || {}).forEach((entry) => { - const normalizedPath = normalizeAppPath(entry) || '/' + const normalizedPath = normalizeAppPath(entry) if (!appPathRoutes[normalizedPath]) { appPathRoutes[normalizedPath] = [] } @@ -1717,33 +1725,20 @@ export default abstract class Server { delete query._nextBubbleNoFallback try { - // Ensure a request to the URL /accounts/[id] will be treated as a dynamic - // route correctly and not loaded immediately without parsing params. - if (!isDynamicRoute(page)) { - const result = await this.renderPageComponent(ctx, bubbleNoFallback) - if (result !== false) return result - } - - if (this.dynamicRoutes) { - for (const dynamicRoute of this.dynamicRoutes) { - const params = dynamicRoute.match(pathname) - if (!params) { - continue - } - page = dynamicRoute.page - const result = await this.renderPageComponent( - { - ...ctx, - pathname: page, - renderOpts: { - ...ctx.renderOpts, - params, - }, + const match = await this.matchers.match(pathname) + if (match) { + const result = await this.renderPageComponent( + { + ...ctx, + pathname: match.pathname, + renderOpts: { + ...ctx.renderOpts, + params: match.params, }, - bubbleNoFallback - ) - if (result !== false) return result - } + }, + bubbleNoFallback + ) + if (result !== false) return result } // currently edge functions aren't receiving the x-matched-path diff --git a/packages/next/src/server/body-streams.ts b/packages/next/src/server/body-streams.ts index 2053240d92351..2370aac8fa5a8 100644 --- a/packages/next/src/server/body-streams.ts +++ b/packages/next/src/server/body-streams.ts @@ -32,14 +32,14 @@ function replaceRequestBody( return base } -export interface ClonableBody { +export interface CloneableBody { finalize(): Promise cloneBodyStream(): Readable } -export function getClonableBody( +export function getCloneableBody( readable: T -): ClonableBody { +): CloneableBody { let buffered: Readable | null = null const endPromise = new Promise( diff --git a/packages/next/src/server/dev/hot-reloader.ts b/packages/next/src/server/dev/hot-reloader.ts index 3a62bbb60d696..2f7fe49abf382 100644 --- a/packages/next/src/server/dev/hot-reloader.ts +++ b/packages/next/src/server/dev/hot-reloader.ts @@ -49,6 +49,8 @@ import ws from 'next/dist/compiled/ws' import { promises as fs } from 'fs' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { UnwrapPromise } from '../../lib/coalesced-function' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' function diff(a: Set, b: Set) { return new Set([...a].filter((v) => !b.has(v))) @@ -1148,10 +1150,12 @@ export default class HotReloader { page, clientOnly, appPaths, + match, }: { page: string clientOnly: boolean appPaths?: string[] | null + match?: RouteMatch }): Promise { // Make sure we don't re-build or dispose prebuilt pages if (page !== '/_error' && BLOCKED_PAGES.indexOf(page) !== -1) { @@ -1167,6 +1171,7 @@ export default class HotReloader { page, clientOnly, appPaths, + match, }) as any } } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index ef526697c0a87..c56c2c2a6070a 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -63,7 +63,7 @@ import { import * as Log from '../../build/output/log' import isError, { getProperError } from '../../lib/is-error' import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' -import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' +import { getSortedRoutes } from '../../shared/lib/router/utils' import { runDependingOnPageType } from '../../build/entries' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' @@ -78,6 +78,16 @@ import { getDefineEnv } from '../../build/webpack-config' import loadJsConfig from '../../build/load-jsconfig' import { formatServerError } from '../../lib/format-server-error' import { pageFiles } from '../../build/webpack/plugins/flight-types-plugin' +import { DevPagesRouteMatcher } from '../route-matchers/dev-pages-route-matcher' +import { DevPagesAPIRouteMatcher } from '../route-matchers/dev-pages-api-route-matcher' +import { DevAppPageRouteMatcher } from '../route-matchers/dev-app-page-route-matcher' +import { DevAppRouteRouteMatcher } from '../route-matchers/dev-app-route-route-matcher' +import { + DevRouteMatcherManager, + RouteEnsurer, +} from '../route-matcher-managers/dev-route-matcher-manager' +import { DefaultRouteMatcherManager } from '../route-matcher-managers/default-route-matcher-manager' +import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -209,6 +219,52 @@ export default class DevServer extends Server { this.appDir = appDir } + protected getRoutes() { + const { pagesDir, appDir } = findPagesDir( + this.dir, + !!this.nextConfig.experimental.appDir + ) + + const ensurer: RouteEnsurer = { + ensure: async (match) => { + await this.hotReloader!.ensurePage({ + match, + page: match.pathname, + clientOnly: false, + }) + }, + } + + const routes = super.getRoutes() + const matchers = new DevRouteMatcherManager(routes.matchers, ensurer) + const handlers = routes.handlers + + // Grab the locale normalizer if it's set. + const localeNormalizer = + routes.matchers instanceof DefaultRouteMatcherManager + ? routes.matchers.localeNormalizer + : new LocaleRouteNormalizer(this.nextConfig.i18n?.locales) + + const extensions = this.nextConfig.pageExtensions + + // If the pages directory is available, then configure those matchers. + if (pagesDir) { + matchers.push( + new DevPagesRouteMatcher(pagesDir, extensions, localeNormalizer) + ) + matchers.push( + new DevPagesAPIRouteMatcher(pagesDir, extensions, localeNormalizer) + ) + } + + if (appDir) { + matchers.push(new DevAppPageRouteMatcher(appDir, extensions)) + matchers.push(new DevAppRouteRouteMatcher(appDir, extensions)) + } + + return { matchers, handlers } + } + protected getBuildId(): string { return 'development' } @@ -423,7 +479,7 @@ export default class DevServer extends Server { } const originalPageName = pageName - pageName = normalizeAppPath(pageName) || '/' + pageName = normalizeAppPath(pageName) if (!appPaths[pageName]) { appPaths[pageName] = [] } @@ -636,14 +692,9 @@ export default class DevServer extends Server { } this.sortedRoutes = sortedRoutes - this.dynamicRoutes = this.sortedRoutes - .filter(isDynamicRoute) - .map((page) => ({ - page, - match: getRouteMatcher(getRouteRegex(page)), - })) + await this.matchers.compile() - this.router.setDynamicRoutes(this.dynamicRoutes) + // this.router.setDynamicRoutes(dynamicRoutes) this.router.setCatchallMiddleware( this.generateCatchAllMiddlewareRoute(true) ) @@ -851,7 +902,8 @@ export default class DevServer extends Server { } if (await this.hasPublicFile(decodedPath)) { - if (await this.hasPage(pathname!)) { + const match = await this.matchers.match(pathname!, { skipDynamic: true }) + if (match) { const err = new Error( `A conflicting public file and page file was found for path ${pathname} https://nextjs.org/docs/messages/conflicting-public-file-page` ) @@ -1229,9 +1281,12 @@ export default class DevServer extends Server { generateRoutes() { const { fsRoutes, ...otherRoutes } = super.generateRoutes() + // Create a shallow copy so we can mutate it. + const routes = [...fsRoutes] + // In development we expose all compiled files for react-error-overlay's line show feature // We use unshift so that we're sure the routes is defined before Next's default routes - fsRoutes.unshift({ + routes.unshift({ match: getPathMatch('/_next/development/:path*'), type: 'route', name: '_next/development catchall', @@ -1244,7 +1299,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.unshift({ + routes.unshift({ match: getPathMatch( `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_CLIENT_PAGES_MANIFEST}` ), @@ -1268,7 +1323,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.unshift({ + routes.unshift({ match: getPathMatch( `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_MIDDLEWARE_MANIFEST}` ), @@ -1284,7 +1339,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.push({ + routes.push({ match: getPathMatch('/:path*'), type: 'route', name: 'catchall public directory route', @@ -1307,7 +1362,7 @@ export default class DevServer extends Server { }, }) - return { fsRoutes, ...otherRoutes } + return { fsRoutes: routes, ...otherRoutes } } // In development public files are not added to the router but handled as a fallback instead @@ -1502,7 +1557,7 @@ export default class DevServer extends Server { return errors[0] } - protected isServeableUrl(untrustedFileUrl: string): boolean { + protected isServableUrl(untrustedFileUrl: string): boolean { // This method mimics what the version of `send` we use does: // 1. decodeURIComponent: // https://github.com/pillarjs/send/blob/0.17.1/index.js#L989 diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 5299a4da7923a..4e9e9abb840b8 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -23,6 +23,8 @@ import { COMPILER_NAMES, RSC_MODULE_TYPES, } from '../../shared/lib/constants' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' const debug = origDebug('next:on-demand-entry-handler') @@ -331,7 +333,7 @@ async function findPagePathData( return { absolutePagePath: join(appDir, pagePath), - bundlePath: posix.join('app', normalizePagePath(pageUrl)), + bundlePath: posix.join('app', pageUrl), page: posix.normalize(pageUrl), } } @@ -371,6 +373,25 @@ async function findPagePathData( } } +async function findRoutePathData( + rootDir: string, + page: string, + extensions: string[], + pagesDir?: string, + appDir?: string, + match?: RouteMatch +): ReturnType { + if (match) { + return { + absolutePagePath: match.filename, + page: match.page, + bundlePath: match.bundlePath, + } + } + + return findPagePathData(rootDir, page, extensions, pagesDir, appDir) +} + export function onDemandEntryHandler({ maxInactiveAge, multiCompiler, @@ -558,10 +579,12 @@ export function onDemandEntryHandler({ page, clientOnly, appPaths = null, + match, }: { page: string clientOnly: boolean appPaths?: string[] | null + match?: RouteMatch }): Promise { const stalledTime = 60 const stalledEnsureTimeout = setTimeout(() => { @@ -571,12 +594,13 @@ export function onDemandEntryHandler({ }, stalledTime * 1000) try { - const pagePathData = await findPagePathData( + const pagePathData = await findRoutePathData( rootDir, page, nextConfig.pageExtensions, pagesDir, - appDir + appDir, + match ) const isInsideAppDir = diff --git a/packages/next/src/server/manifest-loaders/manifest-loader.ts b/packages/next/src/server/manifest-loaders/manifest-loader.ts new file mode 100644 index 0000000000000..7ea625556938f --- /dev/null +++ b/packages/next/src/server/manifest-loaders/manifest-loader.ts @@ -0,0 +1,5 @@ +export type Manifest = Record + +export interface ManifestLoader { + load(name: string): Promise | Manifest +} diff --git a/packages/next/src/server/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/manifest-loaders/node-manifest-loader.ts new file mode 100644 index 0000000000000..030131725616e --- /dev/null +++ b/packages/next/src/server/manifest-loaders/node-manifest-loader.ts @@ -0,0 +1,11 @@ +import path from 'path' +import { SERVER_DIRECTORY } from '../../shared/lib/constants' +import { Manifest, ManifestLoader } from './manifest-loader' + +export class NodeManifestLoader implements ManifestLoader { + constructor(private readonly distDir: string) {} + + public async load(name: string): Promise { + return await require(path.join(this.distDir, SERVER_DIRECTORY, name)) + } +} diff --git a/packages/next/src/server/module-loader/module-loader.ts b/packages/next/src/server/module-loader/module-loader.ts new file mode 100644 index 0000000000000..35376aebbb03b --- /dev/null +++ b/packages/next/src/server/module-loader/module-loader.ts @@ -0,0 +1,6 @@ +/** + * Loads a given module for a given ID. + */ +export interface ModuleLoader { + load(id: string): M +} diff --git a/packages/next/src/server/module-loader/node-module-loader.ts b/packages/next/src/server/module-loader/node-module-loader.ts new file mode 100644 index 0000000000000..8218c6692544f --- /dev/null +++ b/packages/next/src/server/module-loader/node-module-loader.ts @@ -0,0 +1,10 @@ +import { ModuleLoader } from './module-loader' + +/** + * Loads a module using `require(id)`. + */ +export class NodeModuleLoader implements ModuleLoader { + public load(id: string): M { + return require(id) + } +} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 62d64924c7478..421467af69e51 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -3,7 +3,7 @@ import './node-polyfill-fetch' import './node-polyfill-web-streams' import type { TLSSocket } from 'tls' -import type { Route } from './router' +import type { Route, RouterOptions } from './router' import { CacheFs, DecodeError, @@ -21,18 +21,14 @@ import type { PayloadOptions } from './send-payload' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' import type { Params, - RouteMatch, + RouteMatchFn, } from '../shared/lib/router/utils/route-matcher' import type { MiddlewareRouteMatch } from '../shared/lib/router/utils/middleware-route-matcher' -import type { NextConfig } from './config-shared' -import type { DynamicRoutes, PageChecker } from './router' import fs from 'fs' import { join, relative, resolve, sep } from 'path' import { IncomingMessage, ServerResponse } from 'http' import { addRequestMeta, getRequestMeta } from './request-meta' -import { isAPIRoute } from '../lib/is-api-route' -import { isDynamicRoute } from '../shared/lib/router/utils' import { PAGES_MANIFEST, BUILD_ID_FILE, @@ -92,7 +88,7 @@ import { getCustomRoute, stringifyQuery } from './server-route-utils' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' -import { getClonableBody } from './body-streams' +import { getCloneableBody } from './body-streams' import { checkIsManualRevalidate } from './api-utils' import ResponseCache from './response-cache' import { IncrementalCache } from './lib/incremental-cache' @@ -100,6 +96,18 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { setHttpClientAndAgentOptions } from './config' +import { RouteHandlers } from './route-handlers/route-handlers' +import { DefaultRouteMatcherManager } from './route-matcher-managers/default-route-matcher-manager' +import { LocaleRouteNormalizer } from './normalizers/locale-route-normalizer' +import { PagesRouteMatcher } from './route-matchers/pages-route-matcher' +import { PagesAPIRouteMatcher } from './route-matchers/pages-api-route-matcher' +import { AppRouteRouteMatcher } from './route-matchers/app-route-route-matcher' +import { RouteKind } from './route-kind' +import { AppRouteRouteHandler } from './route-handlers/app-route/app-route-route-handler' +import { AppPageRouteMatcher } from './route-matchers/app-page-route-matcher' +import { isRouteMatchKind, RouteMatch } from './route-matches/route-match' +import { NodeManifestLoader } from './manifest-loaders/node-manifest-loader' +import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' export * from './base-server' @@ -124,7 +132,7 @@ const MiddlewareMatcherCache = new WeakMap< const EdgeMatcherCache = new WeakMap< MiddlewareManifest['functions'][string], - RouteMatch + RouteMatchFn >() function getMiddlewareMatcher( @@ -183,7 +191,7 @@ const POSSIBLE_ERROR_CODE_FROM_SERVE_STATIC = new Set([ function getEdgeMatcher( info: MiddlewareManifest['functions'][string] -): RouteMatch { +): RouteMatchFn { const stored = EdgeMatcherCache.get(info) if (stored) { return stored @@ -261,6 +269,49 @@ export default class NextNodeServer extends BaseServer { setHttpClientAndAgentOptions(this.nextConfig) } + protected getRoutes() { + // Configure the locale normalizer, it's used for routes inside `pages/`. + const localeNormalizer = this.nextConfig.i18n?.locales + ? new LocaleRouteNormalizer(this.nextConfig.i18n.locales) + : undefined + + // Configure the matchers and handlers. + const matchers: RouteMatcherManager = new DefaultRouteMatcherManager( + localeNormalizer + ) + const handlers = new RouteHandlers() + + const manifestLoader = new NodeManifestLoader(this.distDir) + + // Match pages under `pages/`. + matchers.push( + new PagesRouteMatcher(this.distDir, manifestLoader, localeNormalizer) + ) + + // NOTE: we don't have a handler for the pages route type yet + + // Match api routes under `pages/api/`. + matchers.push(new PagesAPIRouteMatcher(this.distDir, manifestLoader)) + + // NOTE: we don't have a handler for the pages api route type yet + + // If the app directory is enabled, then add the app matchers and handlers. + if (this.hasAppDir) { + // Match app pages under `app/`. + matchers.push(new AppPageRouteMatcher(this.distDir, manifestLoader)) + + // NOTE: we don't have a handler for the app page route type yet + + matchers.push(new AppRouteRouteMatcher(this.distDir, manifestLoader)) + handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler()) + } + + // Compile the matchers. + matchers.compile() + + return { matchers, handlers } + } + protected loadEnvConfig({ dev, forceReload, @@ -326,10 +377,10 @@ export default class NextNodeServer extends BaseServer { } protected getAppPathsManifest(): PagesManifest | undefined { - if (this.hasAppDir) { - const appPathsManifestPath = join(this.serverDistDir, APP_PATHS_MANIFEST) - return require(appPathsManifestPath) - } + if (!this.hasAppDir) return undefined + + const appPathsManifestPath = join(this.serverDistDir, APP_PATHS_MANIFEST) + return require(appPathsManifestPath) } protected async hasPage(pathname: string): Promise { @@ -1027,22 +1078,7 @@ export default class NextNodeServer extends BaseServer { return cacheFs.readFile(join(this.serverDistDir, 'pages', `${page}.html`)) } - protected generateRoutes(): { - headers: Route[] - rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] - } - fsRoutes: Route[] - redirects: Route[] - catchAllRoute: Route - catchAllMiddleware: Route[] - pageChecker: PageChecker - useFileSystemPublicRoutes: boolean - dynamicRoutes: DynamicRoutes | undefined - nextConfig: NextConfig - } { + protected generateRoutes(): RouterOptions { const publicRoutes = this.generatePublicRoutes() const imageRoutes = this.generateImageRoutes() const staticFilesRoutes = this.generateStaticRoutes() @@ -1203,12 +1239,21 @@ export default class NextNodeServer extends BaseServer { } const bubbleNoFallback = !!query._nextBubbleNoFallback - if (isAPIRoute(pathname)) { - delete query._nextBubbleNoFallback + const match = await this.matchers.match(pathname) - const handled = await this.handleApiRequest(req, res, pathname, query) - if (handled) { - return { finished: true } + // Try to handle the given route with the configured handlers. + if (match) { + let handled = await this.handlers.handle(match, req, res) + if (handled) return { finished: true } + + // If the route was detected as being a Pages API route, then handle + // it. + // TODO: move this behavior into a route handler. + if (isRouteMatchKind(match, RouteKind.PAGES_API)) { + delete query._nextBubbleNoFallback + + handled = await this.handleApiRequest(req, res, query, match) + if (handled) return { finished: true } } } @@ -1233,7 +1278,7 @@ export default class NextNodeServer extends BaseServer { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - this.dynamicRoutes = this.getDynamicRoutes() + // this.dynamicRoutes = this.getDynamicRoutes() } return { @@ -1244,8 +1289,7 @@ export default class NextNodeServer extends BaseServer { catchAllRoute, catchAllMiddleware, useFileSystemPublicRoutes, - dynamicRoutes: this.dynamicRoutes, - pageChecker: this.hasPage.bind(this), + matchers: this.matchers, nextConfig: this.nextConfig, } } @@ -1262,34 +1306,18 @@ export default class NextNodeServer extends BaseServer { protected async handleApiRequest( req: BaseNextRequest, res: BaseNextResponse, - pathname: string, - query: ParsedUrlQuery + query: ParsedUrlQuery, + match: RouteMatch ): Promise { - let page = pathname - let params: Params | undefined = undefined - let pageFound = !isDynamicRoute(page) && (await this.hasPage(page)) - - if (!pageFound && this.dynamicRoutes) { - for (const dynamicRoute of this.dynamicRoutes) { - params = dynamicRoute.match(pathname) || undefined - if (isAPIRoute(dynamicRoute.page) && params) { - page = dynamicRoute.page - pageFound = true - break - } - } - } + const { pathname, params } = match - if (!pageFound) { - return false - } // Make sure the page is built before getting the path // or else it won't be in the manifest yet - await this.ensureApiPage(page) + await this.ensureApiPage(pathname) let builtPagePath try { - builtPagePath = this.getPagePath(page) + builtPagePath = this.getPagePath(pathname) } catch (err) { if (isError(err) && err.code === 'ENOENT') { return false @@ -1297,7 +1325,7 @@ export default class NextNodeServer extends BaseServer { throw err } - return this.runApi(req, res, query, params, page, builtPagePath) + return this.runApi(req, res, query, params, pathname, builtPagePath) } protected getCacheFilesystem(): CacheFs { @@ -1415,7 +1443,7 @@ export default class NextNodeServer extends BaseServer { path: string, parsedUrl?: UrlWithParsedQuery ): Promise { - if (!this.isServeableUrl(path)) { + if (!this.isServableUrl(path)) { return this.render404(req, res, parsedUrl) } @@ -1470,7 +1498,7 @@ export default class NextNodeServer extends BaseServer { : [] } - protected isServeableUrl(untrustedFileUrl: string): boolean { + protected isServableUrl(untrustedFileUrl: string): boolean { // This method mimics what the version of `send` we use does: // 1. decodeURIComponent: // https://github.com/pillarjs/send/blob/0.17.1/index.js#L989 @@ -1740,19 +1768,27 @@ export default class NextNodeServer extends BaseServer { } const page: { name?: string; params?: { [key: string]: string } } = {} - if (await this.hasPage(normalizedPathname)) { - page.name = params.parsedUrl.pathname - } else if (this.dynamicRoutes) { - for (const dynamicRoute of this.dynamicRoutes) { - const matchParams = dynamicRoute.match(normalizedPathname) - if (matchParams) { - page.name = dynamicRoute.page - page.params = matchParams - break - } - } + + const match = await this.matchers.match(normalizedPathname) + if (match) { + page.name = match.params ? match.pathname : params.parsedUrl.pathname + page.params = match.params } + // NOTE: converted to the new match syntax + // if (await this.hasPage(normalizedPathname)) { + // page.name = params.parsedUrl.pathname + // } else if (this.dynamicRoutes) { + // for (const dynamicRoute of this.dynamicRoutes) { + // const matchParams = dynamicRoute.match(normalizedPathname) + // if (matchParams) { + // page.name = dynamicRoute.page + // page.params = matchParams + // break + // } + // } + // } + const middleware = this.getMiddleware() if (!middleware) { return { finished: false } @@ -2077,7 +2113,7 @@ export default class NextNodeServer extends BaseServer { addRequestMeta(req, '__NEXT_INIT_URL', initUrl) addRequestMeta(req, '__NEXT_INIT_QUERY', { ...parsedUrl.query }) addRequestMeta(req, '_protocol', protocol) - addRequestMeta(req, '__NEXT_CLONABLE_BODY', getClonableBody(req.body)) + addRequestMeta(req, '__NEXT_CLONABLE_BODY', getCloneableBody(req.body)) } protected async runEdgeFunction(params: { diff --git a/packages/next/src/server/node-polyfill-headers.ts b/packages/next/src/server/node-polyfill-headers.ts new file mode 100644 index 0000000000000..eabd1007b96db --- /dev/null +++ b/packages/next/src/server/node-polyfill-headers.ts @@ -0,0 +1,16 @@ +/** + * Polyfills the `Headers.getAll(name)` method so it'll work in the edge + * runtime. + */ + +if (!('getAll' in Headers.prototype)) { + // @ts-expect-error - this is polyfilling this method so it doesn't exist yet + Headers.prototype.getAll = function (name: string) { + name = name.toLowerCase() + if (name !== 'set-cookie') + throw new Error('Headers.getAll is only supported for Set-Cookie header') + + const headers = [...this.entries()].filter(([key]) => key === name) + return headers.map(([, value]) => value) + } +} diff --git a/packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts b/packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts new file mode 100644 index 0000000000000..df253729b38c7 --- /dev/null +++ b/packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts @@ -0,0 +1,33 @@ +import { AbsoluteFilenameNormalizer } from './absolute-filename-normalizer' + +describe('AbsoluteFilenameNormalizer', () => { + it.each([ + { + name: 'app', + pathname: '/app/basic/(grouped)/endpoint/nested/route.ts', + expected: '/basic/(grouped)/endpoint/nested/route', + }, + { + name: 'pages', + pathname: '/pages/basic/endpoint/nested.ts', + expected: '/basic/endpoint/nested', + }, + { + name: 'pages', + pathname: '/pages/basic/endpoint/index.ts', + expected: '/basic/endpoint', + }, + ])( + "normalizes '$pathname' to '$expected'", + ({ pathname, expected, name }) => { + const normalizer = new AbsoluteFilenameNormalizer(`/${name}`, [ + 'ts', + 'tsx', + 'js', + 'jsx', + ]) + + expect(normalizer.normalize(pathname)).toEqual(expected) + } + ) +}) diff --git a/packages/next/src/server/normalizers/absolute-filename-normalizer.ts b/packages/next/src/server/normalizers/absolute-filename-normalizer.ts new file mode 100644 index 0000000000000..99b501ca3699f --- /dev/null +++ b/packages/next/src/server/normalizers/absolute-filename-normalizer.ts @@ -0,0 +1,32 @@ +import path from 'path' +import { ensureLeadingSlash } from '../../shared/lib/page-path/ensure-leading-slash' +import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' +import { removePagePathTail } from '../../shared/lib/page-path/remove-page-path-tail' +import { Normalizer } from './normalizer' + +/** + * Normalizes a given filename so that it's relative to the provided directory. + * It will also strip the extension (if provided) and the trailing `/index`. + */ +export class AbsoluteFilenameNormalizer implements Normalizer { + /** + * + * @param dir the directory for which the files should be made relative to + * @param extensions the extensions the file could have + * @param keepIndex when `true` the trailing `/index` is _not_ removed + */ + constructor( + private readonly dir: string, + private readonly extensions: ReadonlyArray + ) {} + + public normalize(pathname: string): string { + return removePagePathTail( + normalizePathSep(ensureLeadingSlash(path.relative(this.dir, pathname))), + { + extensions: this.extensions, + keepIndex: false, + } + ) + } +} diff --git a/packages/next/src/server/normalizers/locale-route-normalizer.ts b/packages/next/src/server/normalizers/locale-route-normalizer.ts new file mode 100644 index 0000000000000..2bc1795bd811c --- /dev/null +++ b/packages/next/src/server/normalizers/locale-route-normalizer.ts @@ -0,0 +1,41 @@ +import { Normalizer } from './normalizer' + +export class LocaleRouteNormalizer implements Normalizer { + private readonly lowerCase: ReadonlyArray + + constructor(private readonly locales: ReadonlyArray | null = null) { + this.lowerCase = + locales && locales.length > 0 + ? locales.map((locale) => locale.toLowerCase()) + : [] + } + + private match(pathname: string): string | null { + if (!this.locales) return null + + // The first segment will be empty, because it has a leading `/`. If + // there is no further segment, there is no locale. + const segments = pathname.split('/') + if (!segments[1]) return null + + // The second segment will contain the locale part if any. + const segment = segments[1].toLowerCase() + + // See if the segment matches one of the locales. + const index = this.lowerCase.indexOf(segment) + if (index < 0) return null + + // Return the case-sensitive locale. + return this.locales[index] + } + + public normalize(pathname: string): string { + if (!this.locales) return pathname + + const locale = this.match(pathname) + if (!locale) return pathname + + // Remove the `/${locale}` part of the pathname. + return pathname.slice(locale.length + 1) || '/' + } +} diff --git a/packages/next/src/server/normalizers/normalizer.ts b/packages/next/src/server/normalizers/normalizer.ts new file mode 100644 index 0000000000000..98b6e06048acf --- /dev/null +++ b/packages/next/src/server/normalizers/normalizer.ts @@ -0,0 +1,3 @@ +export interface Normalizer { + normalize(pathname: string): string +} diff --git a/packages/next/src/server/normalizers/normalizers.ts b/packages/next/src/server/normalizers/normalizers.ts new file mode 100644 index 0000000000000..11226a80daeb2 --- /dev/null +++ b/packages/next/src/server/normalizers/normalizers.ts @@ -0,0 +1,20 @@ +import { Normalizer } from './normalizer' + +/** + * Normalizers combines many normalizers into a single normalizer interface that + * will normalize the inputted pathname with each normalizer in order. + */ +export class Normalizers implements Normalizer { + constructor(private readonly normalizers: Array = []) {} + + public push(normalizer: Normalizer) { + this.normalizers.push(normalizer) + } + + public normalize(pathname: string): string { + return this.normalizers.reduce( + (normalized, normalizer) => normalizer.normalize(normalized), + pathname + ) + } +} diff --git a/packages/next/src/server/normalizers/prefixing-normalizer.ts b/packages/next/src/server/normalizers/prefixing-normalizer.ts new file mode 100644 index 0000000000000..a19e194394ebf --- /dev/null +++ b/packages/next/src/server/normalizers/prefixing-normalizer.ts @@ -0,0 +1,10 @@ +import path from 'path' +import { Normalizer } from './normalizer' + +export class PrefixingNormalizer implements Normalizer { + constructor(private readonly prefix: string) {} + + public normalize(pathname: string): string { + return path.join(this.prefix, pathname) + } +} diff --git a/packages/next/src/server/normalizers/wrap-normalizer-fn.ts b/packages/next/src/server/normalizers/wrap-normalizer-fn.ts new file mode 100644 index 0000000000000..e8b785661e2fa --- /dev/null +++ b/packages/next/src/server/normalizers/wrap-normalizer-fn.ts @@ -0,0 +1,5 @@ +import { Normalizer } from './normalizer' + +export function wrapNormalizerFn(fn: (pathname: string) => string): Normalizer { + return { normalize: fn } +} diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts index aa40d1b84952b..40085d94d0ea8 100644 --- a/packages/next/src/server/request-meta.ts +++ b/packages/next/src/server/request-meta.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from 'http' import type { ParsedUrlQuery } from 'querystring' import type { UrlWithParsedQuery } from 'url' import type { BaseNextRequest } from './base-http' -import type { ClonableBody } from './body-streams' +import type { CloneableBody } from './body-streams' export const NEXT_REQUEST_META = Symbol('NextRequestMeta') @@ -14,7 +14,7 @@ export type NextIncomingMessage = (BaseNextRequest | IncomingMessage) & { export interface RequestMeta { __NEXT_INIT_QUERY?: ParsedUrlQuery __NEXT_INIT_URL?: string - __NEXT_CLONABLE_BODY?: ClonableBody + __NEXT_CLONABLE_BODY?: CloneableBody __nextHadTrailingSlash?: boolean __nextIsLocaleDomain?: boolean __nextStrippedLocale?: boolean diff --git a/packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts b/packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts new file mode 100644 index 0000000000000..f34de562372d0 --- /dev/null +++ b/packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts @@ -0,0 +1,320 @@ +import { isNotFoundError } from '../../../client/components/not-found' +import { + getURLFromRedirectError, + isRedirectError, +} from '../../../client/components/redirect' +import { + handleBadRequestResponse, + handleInternalServerErrorResponse, + handleMethodNotAllowedResponse, + handleNotFoundResponse, + handleTemporaryRedirectResponse, +} from './handlers' +import { + RequestAsyncStorageWrapper, + RequestContext, +} from '../../async-storage/request-async-storage-wrapper' +import type { BaseNextRequest, BaseNextResponse } from '../../base-http' +import type { NodeNextRequest, NodeNextResponse } from '../../base-http/node' +import { getRequestMeta } from '../../request-meta' +import * as Log from '../../../build/output/log' +import { HTTP_METHOD, isHTTPMethod } from '../../web/http' +import { NextRequest } from '../../web/spec-extension/request' +import { fromNodeHeaders } from '../../web/utils' +import type { RouteHandlerFn, RouteHandler } from '../route-handler' +import type { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' +import type { + RequestAsyncStorage, + RequestStore, +} from '../../../client/components/request-async-storage' +import type { RouteMatch } from '../../route-matches/route-match' +import type { ModuleLoader } from '../../module-loader/module-loader' +import { NodeModuleLoader } from '../../module-loader/node-module-loader' +import type { Params } from '../../../shared/lib/router/utils/route-matcher' +import type { RouteKind } from '../../route-kind' + +/** + * Handler function for app routes. + */ +export type AppRouteHandlerFn = ( + /** + * Incoming request object. + */ + req: Request, + /** + * Context properties on the request (including the parameters if this was a + * dynamic route). + */ + ctx: { params?: Params } +) => Response + +/** + * AppRouteModule is the specific userland module that is exported. This will + * contain the HTTP methods that this route can respond to. + */ +export type AppRouteModule = { + /** + * Contains all the exported userland code. + */ + handlers: Record + + /** + * The exported async storage object for this worker/module. + */ + requestAsyncStorage: RequestAsyncStorage +} + +/** + * Wraps the base next request to a request compatible with the app route + * signature. + * + * @param req base request to adapt for use with app routes + * @returns the wrapped request. + */ +function wrapRequest(req: BaseNextRequest): Request { + const { originalRequest } = req as NodeNextRequest + + const url = getRequestMeta(originalRequest, '__NEXT_INIT_URL') + if (!url) throw new Error('Invariant: missing url on request') + + // HEAD and GET requests can not have a body. + const body: BodyInit | null | undefined = + req.method !== 'GET' && req.method !== 'HEAD' && req.body ? req.body : null + + return new NextRequest(url, { + body, + // @ts-expect-error - see https://github.com/whatwg/fetch/pull/1457 + duplex: 'half', + method: req.method, + headers: fromNodeHeaders(req.headers), + }) +} + +function resolveHandlerError(err: any): Response { + if (isRedirectError(err)) { + const redirect = getURLFromRedirectError(err) + if (!redirect) { + throw new Error('Invariant: Unexpected redirect url format') + } + + // This is a redirect error! Send the redirect response. + return handleTemporaryRedirectResponse(redirect) + } + + if (isNotFoundError(err)) { + // This is a not found error! Send the not found response. + return handleNotFoundResponse() + } + + // TODO: validate the correct handling behavior + Log.error(err) + return handleInternalServerErrorResponse() +} + +async function sendResponse( + req: BaseNextRequest, + res: BaseNextResponse, + response: Response +): Promise { + // Copy over the response status. + res.statusCode = response.status + res.statusMessage = response.statusText + + // Copy over the response headers. + response.headers.forEach((value, name) => { + // The append handling is special cased for `set-cookie`. + if (name.toLowerCase() === 'set-cookie') { + res.setHeader(name, value) + } else { + res.appendHeader(name, value) + } + }) + + /** + * The response can't be directly piped to the underlying response. The + * following is duplicated from the edge runtime handler. + * + * See packages/next/server/next-server.ts + */ + + const originalResponse = (res as NodeNextResponse).originalResponse + + // A response body must not be sent for HEAD requests. See https://httpwg.org/specs/rfc9110.html#HEAD + if (response.body && req.method !== 'HEAD') { + const { consumeUint8ArrayReadableStream } = + require('next/dist/compiled/edge-runtime') as typeof import('next/dist/compiled/edge-runtime') + const iterator = consumeUint8ArrayReadableStream(response.body) + try { + for await (const chunk of iterator) { + originalResponse.write(chunk) + } + } finally { + originalResponse.end() + } + } else { + originalResponse.end() + } +} + +/** + * + */ +export class AppRouteRouteHandler implements RouteHandler { + constructor( + private readonly requestAsyncLocalStorageWrapper: AsyncStorageWrapper< + RequestStore, + RequestContext + > = new RequestAsyncStorageWrapper(), + private readonly moduleLoader: ModuleLoader = new NodeModuleLoader() + ) {} + + private resolve( + req: BaseNextRequest, + mod: AppRouteModule + ): AppRouteHandlerFn { + // Ensure that the requested method is a valid method (to prevent RCE's). + if (!isHTTPMethod(req.method)) return handleBadRequestResponse + + // Pull out the handlers from the app route module. + const { handlers } = mod + + // Check to see if the requested method is available. + const handler: AppRouteHandlerFn | undefined = handlers[req.method] + if (handler) return handler + + /** + * If the request got here, then it means that there was not a handler for + * the requested method. We'll try to automatically setup some methods if + * there's enough information to do so. + */ + + // If HEAD is not provided, but GET is, then we respond to HEAD using the + // GET handler without the body. + if (req.method === 'HEAD' && 'GET' in handlers) { + return handlers['GET'] + } + + // If OPTIONS is not provided then implement it. + if (req.method === 'OPTIONS') { + // TODO: check if HEAD is implemented, if so, use it to add more headers + + // Get all the handler methods from the list of handlers. + const methods = Object.keys(handlers).filter((method) => + isHTTPMethod(method) + ) as HTTP_METHOD[] + + // If the list of methods doesn't include OPTIONS, add it, as it's + // automatically implemented. + if (!methods.includes('OPTIONS')) { + methods.push('OPTIONS') + } + + // If the list of methods doesn't include HEAD, but it includes GET, then + // add HEAD as it's automatically implemented. + if (!methods.includes('HEAD') && methods.includes('GET')) { + methods.push('HEAD') + } + + // Sort and join the list with commas to create the `Allow` header. See: + // https://httpwg.org/specs/rfc9110.html#field.allow + const allow = methods.sort().join(', ') + + return () => + new Response(null, { status: 204, headers: { Allow: allow } }) + } + + // A handler for the requested method was not found, so we should respond + // with the method not allowed handler. + return handleMethodNotAllowedResponse + } + + private async execute( + { params }: RouteMatch, + module: AppRouteModule, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise { + // This is added by the webpack loader, we load it directly from the module. + const { requestAsyncStorage } = module + + // Get the handler function for the given method. + const handle = this.resolve(req, module) + + // Run the handler with the request AsyncLocalStorage to inject the helper + // support. + const response = await this.requestAsyncLocalStorageWrapper.wrap( + requestAsyncStorage, + { + req: (req as NodeNextRequest).originalRequest, + res: (res as NodeNextResponse).originalResponse, + }, + () => handle(wrapRequest(req), { params }) + ) + + // If the handler did't return a valid response, then return the internal + // error response. + if (!(response instanceof Response)) { + // TODO: validate the correct handling behavior, maybe log something? + return handleInternalServerErrorResponse() + } + + if (response.headers.has('x-middleware-rewrite')) { + // TODO: move this error into the `NextResponse.rewrite()` function. + // TODO-APP: re-enable support below when we can proxy these type of requests + throw new Error( + 'NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.' + ) + + // // This is a rewrite created via `NextResponse.rewrite()`. We need to send + // // the response up so it can be handled by the backing server. + + // // If the server is running in minimal mode, we just want to forward the + // // response (including the rewrite headers) upstream so it can perform the + // // redirect for us, otherwise return with the special condition so this + // // server can perform a rewrite. + // if (!minimalMode) { + // return { response, condition: 'rewrite' } + // } + + // // Relativize the url so it's relative to the base url. This is so the + // // outgoing headers upstream can be relative. + // const rewritePath = response.headers.get('x-middleware-rewrite')! + // const initUrl = getRequestMeta(req, '__NEXT_INIT_URL')! + // const { pathname } = parseUrl(relativizeURL(rewritePath, initUrl)) + // response.headers.set('x-middleware-rewrite', pathname) + } + + if (response.headers.get('x-middleware-next') === '1') { + // TODO: move this error into the `NextResponse.next()` function. + throw new Error( + 'NextResponse.next() was used in a app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler' + ) + } + + return response + } + + public handle: RouteHandlerFn = async ( + match, + req, + res + ) => { + try { + // Load the module using the module loader. + const module: AppRouteModule = await this.moduleLoader.load( + match.filename + ) + + // TODO: patch fetch + + // Execute the route to get the response. + const response = await this.execute(match, module, req, res) + + // Send the response back to the response. + await sendResponse(req, res, response) + } catch (err) { + // Get the correct response based on the error. + await sendResponse(req, res, resolveHandlerError(err)) + } + } +} diff --git a/packages/next/src/server/route-handlers/app-route/handlers.ts b/packages/next/src/server/route-handlers/app-route/handlers.ts new file mode 100644 index 0000000000000..7fc2948e23ce8 --- /dev/null +++ b/packages/next/src/server/route-handlers/app-route/handlers.ts @@ -0,0 +1,37 @@ +export function handleTemporaryRedirectResponse(url: string): Response { + return new Response(null, { + status: 302, + statusText: 'Found', + headers: { + location: url, + }, + }) +} + +export function handleBadRequestResponse(): Response { + return new Response(null, { + status: 400, + statusText: 'Bad Request', + }) +} + +export function handleNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Not Found', + }) +} + +export function handleMethodNotAllowedResponse(): Response { + return new Response(null, { + status: 405, + statusText: 'Method Not Allowed', + }) +} + +export function handleInternalServerErrorResponse(): Response { + return new Response(null, { + status: 500, + statusText: 'Internal Server Error', + }) +} diff --git a/packages/next/src/server/route-handlers/route-handler.ts b/packages/next/src/server/route-handlers/route-handler.ts new file mode 100644 index 0000000000000..b162f80c2d648 --- /dev/null +++ b/packages/next/src/server/route-handlers/route-handler.ts @@ -0,0 +1,22 @@ +import type { BaseNextRequest, BaseNextResponse } from '../base-http' +import { RouteKind } from '../route-kind' +import type { RouteMatch } from '../route-matches/route-match' + +export type RouteHandlerFn = ( + route: RouteMatch, + req: BaseNextRequest, + res: BaseNextResponse +) => Promise | void + +export interface RouteHandler { + /** + * Handler will return the handler for a given route given the route handler. + * + * @param route the route to execute with + */ + handle( + route: RouteMatch, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise | void +} diff --git a/packages/next/src/server/route-handlers/route-handlers.test.ts b/packages/next/src/server/route-handlers/route-handlers.test.ts new file mode 100644 index 0000000000000..a295548333de8 --- /dev/null +++ b/packages/next/src/server/route-handlers/route-handlers.test.ts @@ -0,0 +1,93 @@ +import { BaseNextRequest, BaseNextResponse } from '../base-http' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteHandlers } from './route-handlers' + +const req = {} as BaseNextRequest +const res = {} as BaseNextResponse + +describe('RouteHandlers', () => { + it('will return false when there are no handlers', async () => { + const handlers = new RouteHandlers() + expect( + await handlers.handle( + { + kind: RouteKind.PAGES, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, + req, + res + ) + ).toEqual(false) + }) + + it('will return false when there is no matching handler', async () => { + const handlers = new RouteHandlers() + const handler = { handle: jest.fn() } + handlers.set(RouteKind.APP_PAGE, handler) + + expect( + await handlers.handle( + { + kind: RouteKind.PAGES, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, + req, + res + ) + ).toEqual(false) + expect(handler.handle).not.toHaveBeenCalled() + }) + + it('will return true when there is a matching handler', async () => { + const handlers = new RouteHandlers() + const handler = { handle: jest.fn() } + handlers.set(RouteKind.APP_PAGE, handler) + + const route: RouteMatch = { + kind: RouteKind.APP_PAGE, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + } + + expect(await handlers.handle(route, req, res)).toEqual(true) + expect(handler.handle).toHaveBeenCalledWith(route, req, res) + }) + + it('will throw when multiple handlers are added for the same type', () => { + const handlers = new RouteHandlers() + const handler = { handle: jest.fn() } + expect(() => handlers.set(RouteKind.APP_PAGE, handler)).not.toThrow() + expect(() => handlers.set(RouteKind.APP_ROUTE, handler)).not.toThrow() + expect(() => handlers.set(RouteKind.APP_PAGE, handler)).toThrow() + expect(() => handlers.set(RouteKind.APP_ROUTE, handler)).toThrow() + }) + + it('will call the correct handler', async () => { + const handlers = new RouteHandlers() + const goodHandler = { handle: jest.fn() } + const badHandler = { handle: jest.fn() } + handlers.set(RouteKind.APP_PAGE, goodHandler) + handlers.set(RouteKind.APP_ROUTE, badHandler) + + const route: RouteMatch = { + kind: RouteKind.APP_PAGE, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + } + + expect(await handlers.handle(route, req, res)).toEqual(true) + expect(goodHandler.handle).toBeCalledWith(route, req, res) + expect(badHandler.handle).not.toBeCalled() + }) +}) diff --git a/packages/next/src/server/route-handlers/route-handlers.ts b/packages/next/src/server/route-handlers/route-handlers.ts new file mode 100644 index 0000000000000..e6f4a6baf9ad7 --- /dev/null +++ b/packages/next/src/server/route-handlers/route-handlers.ts @@ -0,0 +1,36 @@ +import { BaseNextRequest, BaseNextResponse } from '../base-http' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteHandler } from './route-handler' + +/** + * Handlers provides a single entrypoint to configuring the available handler + * for this application. + */ +export class RouteHandlers { + private readonly handlers: Partial<{ + [K in RouteKind]: RouteHandler + }> = {} + + public set(kind: RouteKind, handler: RouteHandler) { + if (kind in this.handlers) { + throw new Error( + 'Invariant: a route handler for this route type has already been configured' + ) + } + + this.handlers[kind] = handler + } + + public async handle( + route: RouteMatch, + req: BaseNextRequest, + res: BaseNextResponse + ) { + const handler = this.handlers[route.kind] + if (!handler) return false + + await handler.handle(route, req, res) + return true + } +} diff --git a/packages/next/src/server/route-kind.ts b/packages/next/src/server/route-kind.ts new file mode 100644 index 0000000000000..25ede65e520bf --- /dev/null +++ b/packages/next/src/server/route-kind.ts @@ -0,0 +1,20 @@ +export const enum RouteKind { + /** + * `PAGES` represents all the React pages that are under `pages/`. + */ + PAGES, + /** + * `PAGES_API` represents all the API routes under `pages/api/`. + */ + PAGES_API, + /** + * `APP_PAGE` represents all the React pages that are under `app/` with the + * filename of `page.{j,t}s{,x}`. + */ + APP_PAGE, + /** + * `APP_ROUTE` represents all the API routes that are under `app/` with the + * filename of `route.{j,t}s{,x}`. + */ + APP_ROUTE, +} diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts new file mode 100644 index 0000000000000..b7aa2a47ee788 --- /dev/null +++ b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts @@ -0,0 +1,133 @@ +import { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { DefaultRouteMatcherManager } from './default-route-matcher-manager' + +describe('DefaultRouteMatcherManager', () => { + it('will throw an error when used before compiled', async () => { + const matchers = new DefaultRouteMatcherManager() + expect( + async () => await matchers.match('/some/not/real/path') + ).not.toThrow() + matchers.push({ routes: jest.fn(async () => []) }) + await expect(matchers.match('/some/not/real/path')).rejects.toThrow() + await matchers.compile() + await expect(matchers.match('/some/not/real/path')).resolves.toEqual(null) + }) + + it('will not error and not match when no matchers are provided', async () => { + const matchers = new DefaultRouteMatcherManager() + await matchers.compile() + expect(await matchers.match('/some/not/real/path')).toEqual(null) + }) + + it('tries to localize routes when provided', async () => { + const localeNormalizer: Normalizer = { + normalize: jest.fn((pathname) => pathname), + } + const matchers = new DefaultRouteMatcherManager(localeNormalizer) + await matchers.compile() + const pathname = '/some/not/real/path' + expect(await matchers.match(pathname)).toEqual(null) + expect(localeNormalizer.normalize).toHaveBeenCalledWith(pathname) + }) + + describe('static routes', () => { + it.each([ + ['/some/static/route', '/some/static/route.js'], + ['/some/other/static/route', '/some/other/static/route.js'], + ])('will match %s to %s', async (pathname, filename) => { + const matchers = new DefaultRouteMatcherManager() + + matchers.push({ + routes: async () => [ + { + kind: RouteKind.APP_PAGE, + pathname: '/some/other/static/route', + filename: '/some/other/static/route.js', + bundlePath: '', + page: '', + }, + { + kind: RouteKind.APP_PAGE, + pathname: '/some/static/route', + filename: '/some/static/route.js', + bundlePath: '', + page: '', + }, + ], + }) + + await matchers.compile() + + expect(await matchers.match(pathname)).toEqual({ + kind: RouteKind.APP_PAGE, + pathname, + filename, + bundlePath: '', + page: '', + }) + }) + }) + + describe('dynamic routes', () => { + it.each([ + { + pathname: '/users/123', + route: { + pathname: '/users/[id]', + filename: '/users/[id].js', + params: { id: '123' }, + }, + }, + { + pathname: '/account/123', + route: { + pathname: '/[...paths]', + filename: '/[...paths].js', + params: { paths: ['account', '123'] }, + }, + }, + { + pathname: '/dashboard/users/123', + route: { + pathname: '/[...paths]', + filename: '/[...paths].js', + params: { paths: ['dashboard', 'users', '123'] }, + }, + }, + ])( + "will match '$pathname' to '$route.filename'", + async ({ pathname, route }) => { + const matchers = new DefaultRouteMatcherManager() + + matchers.push({ + routes: async () => [ + { + kind: RouteKind.APP_PAGE, + pathname: '/[...paths]', + filename: '/[...paths].js', + bundlePath: '', + page: '', + }, + { + kind: RouteKind.APP_PAGE, + pathname: '/users/[id]', + filename: '/users/[id].js', + bundlePath: '', + page: '', + }, + ], + }) + + await matchers.compile() + + expect(await matchers.match(pathname)).toEqual({ + kind: RouteKind.APP_PAGE, + bundlePath: '', + page: '', + ...route, + }) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts new file mode 100644 index 0000000000000..2da4ca5b0f62b --- /dev/null +++ b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts @@ -0,0 +1,154 @@ +import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' +import { removeTrailingSlash } from '../../shared/lib/router/utils/remove-trailing-slash' +import { + getRouteMatcher, + RouteMatchFn, +} from '../../shared/lib/router/utils/route-matcher' +import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' +import { Normalizer } from '../normalizers/normalizer' +import { Normalizers } from '../normalizers/normalizers' +import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' +import { RouteKind } from '../route-kind' +import type { RouteMatch } from '../route-matches/route-match' +import { RouteDefinition, RouteMatcher } from '../route-matchers/route-matcher' +import { RouteMatcherManager } from './route-matcher-manager' + +interface DynamicRoute { + route: RouteDefinition + match: RouteMatchFn +} + +export class DefaultRouteMatcherManager implements RouteMatcherManager { + private readonly matchers: Array> = [] + private readonly normalizers: Normalizer + private normalized: Record> = {} + private dynamic: ReadonlyArray> = [] + private lastCompilationID = this.compilationID + + constructor(public readonly localeNormalizer?: Normalizer) { + const normalizers = new Normalizers([ + // Remove the trailing slash from incoming request pathnames as it may + // impact matching. + wrapNormalizerFn(removeTrailingSlash), + ]) + + // This will strip any locale code from the incoming path if configured. + if (localeNormalizer) normalizers.push(localeNormalizer) + + this.normalizers = normalizers + } + + /** + * When this value changes, it indicates that a change has been introduced + * that requires recompilation. + */ + private get compilationID() { + return this.matchers.length + } + + public push(matcher: RouteMatcher) { + this.matchers.push(matcher) + } + + /** + * Iterates over the matchers routes that have been provided and compiles all + * the dynamic routes. + */ + public async compile(): Promise { + // Grab the compilation ID for this run, we'll verify it at the end to + // ensure that if any routes were added before compilation is finished that + // we error out. + const compilationID = this.compilationID + const matcherRoutes = await Promise.all( + this.matchers.map((matcher) => matcher.routes()) + ) + + // Get all the pathnames (that have been normalized by the matcher) and + // associate them with the given route type. + this.normalized = matcherRoutes.reduce< + Record> + >((normalized, routes) => { + for (const route of routes) { + // Ensure we don't have duplicate routes in the normalized object. + // This can only happen when different matchers provide different + // routes as each matcher is expected to deduplicate routes returned. + if (route.pathname in normalized) { + // TODO: maybe just warn here and continue? + throw new Error( + 'Invariant: unexpected duplicate normalized pathname in matcher' + ) + } + + normalized[route.pathname] = route + } + + return normalized + }, {}) + + this.dynamic = + // Sort the routes according to their resolution order. + getSortedRoutes( + Object.keys(this.normalized) + // Only consider the routes with dynamic parameters. + .filter((pathname) => isDynamicRoute(pathname)) + ).map((pathname) => ({ + route: this.normalized[pathname], + match: getRouteMatcher(getRouteRegex(pathname)), + })) + + // This means that there was a new matcher pushed while we were waiting + if (this.compilationID !== compilationID) { + throw new Error( + 'Invariant: expected compilation to finish before new matchers were added, possible missing await' + ) + } + + // The compilation ID matched, so mark the complication as finished. + this.lastCompilationID = compilationID + } + + /** + * Matches a given request to a specific route match. If none could be found, + * it returns `null`. + * + * @param req the request to match a given route for + * @returns the route match if found + */ + public async match( + pathname: string, + options?: { skipDynamic?: boolean } + ): Promise | null> { + if (this.lastCompilationID !== this.compilationID) { + throw new Error('Invariant: expected routes to be compiled before match') + } + + // Normalize the pathname. + pathname = this.normalizers.normalize(pathname) + + // If this pathname doesn't look like a dynamic route, and this pathname is + // listed in the normalized list of routes, then return it. This ensures + // that when a route like `/user/[id]` is encountered, it doesn't just match + // with the list of normalized routes. + if (!isDynamicRoute(pathname) && pathname in this.normalized) { + return this.normalized[pathname] + } + + // If we should skip handling dynamic routes, exit now. + if (options?.skipDynamic) return null + + // For all the dynamic routes, try and match it. + for (const { route, match } of this.dynamic) { + const params = match(pathname) + + // Could not match the dynamic route, continue! + if (!params) continue + + // Matched! + return { ...route, params } + } + + // We tried direct matching against the pathname and against all the dynamic + // paths, so there was no match. + return null + } +} diff --git a/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts new file mode 100644 index 0000000000000..0b676a4fec40a --- /dev/null +++ b/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts @@ -0,0 +1,64 @@ +import { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { RouteMatcher } from '../route-matchers/route-matcher' +import { RouteMatch } from '../route-matches/route-match' +import { DefaultRouteMatcherManager } from './default-route-matcher-manager' +import { RouteMatcherManager } from './route-matcher-manager' + +export interface RouteEnsurer { + ensure(match: RouteMatch): Promise +} + +export class DevRouteMatcherManager implements RouteMatcherManager { + private readonly development: RouteMatcherManager + + constructor( + private readonly production: RouteMatcherManager, + private readonly ensurer: RouteEnsurer, + localeNormalizer?: Normalizer + ) { + this.development = new DefaultRouteMatcherManager(localeNormalizer) + } + + public push(matcher: RouteMatcher): void { + this.development.push(matcher) + } + + public async match( + pathname: string, + options?: { skipDynamic?: boolean } + ): Promise | null> { + let match = await this.production.match(pathname, options) + if (match) return match + + match = await this.development.match(pathname, options) + if (!match) return null + + // There was a match! This means that we didn't previously match the + // production matcher. Let's ensure the page so it gets built, and then + // recompile the production matcher. + await this.ensurer.ensure(match) + await this.production.compile() + + // Now that the production matcher has been recompiled, we should be able to + // match the pathname on it. If we can't that represents a disconnect + // between the development matchers and the production matchers, which would + // be a big problem! + match = await this.production.match(pathname) + if (!match) { + throw new Error( + 'Invariant: development match was found, but not found after ensuring' + ) + } + + return match + } + + public async compile(): Promise { + // Compile the production routes again. + await this.production.compile() + + // Compile the development routes. + await this.development.compile() + } +} diff --git a/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts new file mode 100644 index 0000000000000..e4bc38984741a --- /dev/null +++ b/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts @@ -0,0 +1,12 @@ +import { RouteKind } from '../route-kind' +import { RouteMatcher } from '../route-matchers/route-matcher' +import { RouteMatch } from '../route-matches/route-match' + +export interface RouteMatcherManager { + push(matcher: RouteMatcher): void + match( + pathname: string, + options?: { skipDynamic?: boolean } + ): Promise | null> + compile(): Promise +} diff --git a/packages/next/src/server/route-matchers/app-page-route-matcher.test.ts b/packages/next/src/server/route-matchers/app-page-route-matcher.test.ts new file mode 100644 index 0000000000000..409039aa070c0 --- /dev/null +++ b/packages/next/src/server/route-matchers/app-page-route-matcher.test.ts @@ -0,0 +1,84 @@ +import { SERVER_DIRECTORY } from '../../shared/lib/constants' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { RouteKind } from '../route-kind' +import { AppPageRouteMatcher } from './app-page-route-matcher' +import { RouteDefinition } from './route-matcher' + +describe('AppPageRouteMatcher', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const matcher = new AppPageRouteMatcher('', loader) + expect(await matcher.routes()).toEqual([]) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record + route: RouteDefinition + }>([ + { + manifest: { + '/page': 'app/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/', + filename: `/${SERVER_DIRECTORY}/app/page.js`, + page: '/page', + bundlePath: 'app/page', + }, + }, + { + manifest: { + '/(marketing)/about/page': 'app/(marketing)/about/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/about', + filename: `/${SERVER_DIRECTORY}/app/(marketing)/about/page.js`, + page: '/(marketing)/about/page', + bundlePath: 'app/(marketing)/about/page', + }, + }, + { + manifest: { + '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/dashboard/users/[id]', + filename: `/${SERVER_DIRECTORY}/app/dashboard/users/[id]/page.js`, + page: '/dashboard/users/[id]/page', + bundlePath: 'app/dashboard/users/[id]/page', + }, + }, + { + manifest: { '/dashboard/users/page': 'app/dashboard/users/page.js' }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/dashboard/users', + filename: `/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, + page: '/dashboard/users/page', + bundlePath: 'app/dashboard/users/page', + }, + }, + ])( + 'returns the correct routes for $route.pathname', + async ({ manifest, route }) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/users/[id]/route': 'app/users/[id]/route.js', + '/users/route': 'app/users/route.js', + ...manifest, + })), + } + const matcher = new AppPageRouteMatcher('', loader) + const routes = await matcher.routes() + + expect(loader.load).toHaveBeenCalled() + expect(routes).toHaveLength(1) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/route-matchers/app-page-route-matcher.ts new file mode 100644 index 0000000000000..970033366f185 --- /dev/null +++ b/packages/next/src/server/route-matchers/app-page-route-matcher.ts @@ -0,0 +1,55 @@ +import path from 'path' +import { isAppPageRoute } from '../../lib/is-app-page-route' +import { + APP_PATHS_MANIFEST, + SERVER_DIRECTORY, +} from '../../shared/lib/constants' +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' +import { RouteKind } from '../route-kind' +import { RouteDefinition, RouteMatcher } from './route-matcher' + +export class AppPageRouteMatcher implements RouteMatcher { + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async routes(): Promise< + ReadonlyArray> + > { + const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + + return ( + Object.keys(manifest) + // This matcher only matches app pages. + .filter((page) => isAppPageRoute(page)) + // Normalize the routes. + .reduce>>((routes, page) => { + const pathname = normalizeAppPath(page) + + // If the route was already added, then don't add it again. + if (routes.find((r) => r.pathname === pathname)) return routes + + const filename = path.join( + this.distDir, + SERVER_DIRECTORY, + manifest[page] + ) + + routes.push({ + kind: RouteKind.APP_PAGE, + pathname, + filename, + page, + bundlePath: path.join('app', page), + }) + + return routes + }, []) + ) + } +} diff --git a/packages/next/src/server/route-matchers/app-route-route-matcher.test.ts b/packages/next/src/server/route-matchers/app-route-route-matcher.test.ts new file mode 100644 index 0000000000000..c9f451011cde5 --- /dev/null +++ b/packages/next/src/server/route-matchers/app-route-route-matcher.test.ts @@ -0,0 +1,69 @@ +import { SERVER_DIRECTORY } from '../../shared/lib/constants' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { RouteKind } from '../route-kind' +import { AppRouteRouteMatcher } from './app-route-route-matcher' +import { RouteDefinition } from './route-matcher' + +describe('AppRouteRouteMatcher', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const matcher = new AppRouteRouteMatcher('', loader) + expect(await matcher.routes()).toEqual([]) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record + route: RouteDefinition + }>([ + { + manifest: { + '/route': 'app/route.js', + }, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/', + filename: `/${SERVER_DIRECTORY}/app/route.js`, + page: '/route', + bundlePath: 'app/route', + }, + }, + { + manifest: { '/users/[id]/route': 'app/users/[id]/route.js' }, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/users/[id]', + filename: `/${SERVER_DIRECTORY}/app/users/[id]/route.js`, + page: '/users/[id]/route', + bundlePath: 'app/users/[id]/route', + }, + }, + { + manifest: { '/users/route': 'app/users/route.js' }, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/users', + filename: `/${SERVER_DIRECTORY}/app/users/route.js`, + page: '/users/route', + bundlePath: 'app/users/route', + }, + }, + ])( + 'returns the correct routes for $route.pathname', + async ({ manifest, route }) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js', + '/dashboard/users/page': 'app/dashboard/users/page.js', + ...manifest, + })), + } + const matcher = new AppRouteRouteMatcher('', loader) + const routes = await matcher.routes() + + expect(routes).toHaveLength(1) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/route-matchers/app-route-route-matcher.ts new file mode 100644 index 0000000000000..d4d2067de6196 --- /dev/null +++ b/packages/next/src/server/route-matchers/app-route-route-matcher.ts @@ -0,0 +1,49 @@ +import path from 'path' +import { isAppRouteRoute } from '../../lib/is-app-route-route' +import { + APP_PATHS_MANIFEST, + SERVER_DIRECTORY, +} from '../../shared/lib/constants' +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' +import { RouteKind } from '../route-kind' +import { RouteDefinition, RouteMatcher } from './route-matcher' + +export class AppRouteRouteMatcher implements RouteMatcher { + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async routes(): Promise< + ReadonlyArray> + > { + const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + + return ( + Object.keys(manifest) + // This matcher only matches app routes. + .filter((page) => isAppRouteRoute(page)) + // Normalize the routes. + .reduce>>((routes, page) => { + const pathname = normalizeAppPath(page) + + // If the route was already added, then don't add it again. + if (routes.find((r) => r.pathname === pathname)) return routes + + routes.push({ + kind: RouteKind.APP_ROUTE, + pathname, + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + page, + bundlePath: path.join('app', page), + }) + + return routes + }, []) + ) + } +} diff --git a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts new file mode 100644 index 0000000000000..38c316790dd63 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts @@ -0,0 +1,67 @@ +import { RouteKind } from '../route-kind' +import { DevAppPageRouteMatcher } from './dev-app-page-route-matcher' +import { FileReader } from './file-reader/file-reader' + +describe('DevAppPageRouteMatcher', () => { + const dir = '' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const matcher = new DevAppPageRouteMatcher(dir, extensions, reader) + const routes = await matcher.routes() + expect(routes).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + }) + + describe('filename matching', () => { + it.each([ + { + filename: `${dir}/(marketing)/about/page.ts`, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/about', + filename: `${dir}/(marketing)/about/page.ts`, + page: '/(marketing)/about/page', + bundlePath: 'app/(marketing)/about/page', + }, + }, + { + filename: `${dir}/some/other/page.ts`, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/some/other', + filename: `${dir}/some/other/page.ts`, + page: '/some/other/page', + bundlePath: 'app/some/other/page', + }, + }, + { + filename: `${dir}/page.ts`, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/', + filename: `${dir}/page.ts`, + page: '/page', + bundlePath: 'app/page', + }, + }, + ])( + "matches the route specified with '$filename'", + async ({ filename, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/some/route.${ext}`), + ...extensions.map((ext) => `${dir}/api/other.${ext}`), + filename, + ]), + } + const matcher = new DevAppPageRouteMatcher(dir, extensions, reader) + const routes = await matcher.routes() + expect(routes).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts new file mode 100644 index 0000000000000..9424402082566 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts @@ -0,0 +1,43 @@ +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { Normalizers } from '../normalizers/normalizers' +import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' +import { RouteKind } from '../route-kind' +import { DevFSRouteMatcher } from './dev-fs-route-matcher' +import { FileReader } from './file-reader/file-reader' +import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' +import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' + +export class DevAppPageRouteMatcher extends DevFSRouteMatcher { + constructor(appDir: string, extensions: string[], reader?: FileReader) { + // Match any page file that ends with `/page.${extension}` under the app + // directory. + const matcher = new RegExp(`\\/page\\.(?:${extensions.join('|')})$`) + const filter = (pathname: string) => matcher.test(pathname) + + const filenameNormalizer = new AbsoluteFilenameNormalizer( + appDir, + extensions + ) + + const pathnameNormalizer = new Normalizers([ + filenameNormalizer, + // The pathname to match should have the trailing `/page` and other route + // group information stripped from it. + wrapNormalizerFn(normalizeAppPath), + ]) + + super({ + dir: appDir, + filter, + pageNormalizer: filenameNormalizer, + pathnameNormalizer, + bundlePathNormalizer: new Normalizers([ + filenameNormalizer, + // Prefix the bundle path with `app/`. + new PrefixingNormalizer('app'), + ]), + kind: RouteKind.APP_PAGE, + reader, + }) + } +} diff --git a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts b/packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts new file mode 100644 index 0000000000000..40636cf9b7825 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts @@ -0,0 +1,57 @@ +import { RouteKind } from '../route-kind' +import { DevAppRouteRouteMatcher } from './dev-app-route-route-matcher' +import { FileReader } from './file-reader/file-reader' + +describe('DevAppRouteRouteMatcher', () => { + const dir = '' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const matcher = new DevAppRouteRouteMatcher(dir, extensions, reader) + const routes = await matcher.routes() + expect(routes).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + }) + + describe('filename matching', () => { + it.each([ + { + filename: `${dir}/some/other/route.ts`, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/some/other', + filename: `${dir}/some/other/route.ts`, + page: '/some/other/route', + bundlePath: 'app/some/other/route', + }, + }, + { + filename: `${dir}/route.ts`, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/', + filename: `${dir}/route.ts`, + page: '/route', + bundlePath: 'app/route', + }, + }, + ])( + "matches the route specified with '$filename'", + async ({ filename, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/some/page.${ext}`), + ...extensions.map((ext) => `${dir}/api/other.${ext}`), + filename, + ]), + } + const matcher = new DevAppRouteRouteMatcher(dir, extensions, reader) + const routes = await matcher.routes() + expect(routes).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts b/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts new file mode 100644 index 0000000000000..67a3399148113 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts @@ -0,0 +1,45 @@ +import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' +import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' +import { Normalizers } from '../normalizers/normalizers' +import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' +import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' +import { RouteKind } from '../route-kind' +import { DevFSRouteMatcher } from './dev-fs-route-matcher' +import { FileReader } from './file-reader/file-reader' + +export class DevAppRouteRouteMatcher extends DevFSRouteMatcher { + constructor(appDir: string, extensions: string[], reader?: FileReader) { + // Match any route file that ends with `/route.${extension}` under the app + // directory. + const matcher = new RegExp(`\\/route\\.(?:${extensions.join('|')})$`) + const filter = (pathname: string) => matcher.test(pathname) + + const filenameNormalizer = new AbsoluteFilenameNormalizer( + appDir, + extensions + ) + + const pageNormalizer = new Normalizers([filenameNormalizer]) + + const pathnameNormalizer = new Normalizers([ + filenameNormalizer, + // The pathname to match should have the trailing `/route` and other route + // group information stripped from it. + wrapNormalizerFn(normalizeAppPath), + ]) + + super({ + dir: appDir, + filter, + pageNormalizer, + pathnameNormalizer, + bundlePathNormalizer: new Normalizers([ + filenameNormalizer, + // Prefix the bundle path with `app/`. + new PrefixingNormalizer('app'), + ]), + kind: RouteKind.APP_ROUTE, + reader, + }) + } +} diff --git a/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts b/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts new file mode 100644 index 0000000000000..f706e45a68651 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts @@ -0,0 +1,112 @@ +import { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { FileReader } from './file-reader/file-reader' +import { DefaultFileReader } from './file-reader/default-file-reader' +import { RouteDefinition, RouteMatcher } from './route-matcher' + +type FilterFn = (filename: string) => boolean + +interface Options { + /** + * The directory to load the files from. + */ + dir: string + + /** + * The filter to include the files matched by this matcher. + */ + filter: FilterFn + + /** + * The normalizer that transforms the absolute filename to page. + */ + pageNormalizer: Normalizer + + /** + * The normalizer that transforms the absolute filename to a request pathname. + */ + pathnameNormalizer: Normalizer + + /** + * The normalizer that transforms the absolute filename to a bundle path. + */ + bundlePathNormalizer: Normalizer + + /** + * The kind of route definitions to emit. + */ + kind: K + + /** + * The reader implementation that provides the files from the directory. + * Defaults to a reader which uses the filesystem. + */ + reader?: FileReader +} + +export class DevFSRouteMatcher implements RouteMatcher { + private readonly dir: string + private readonly filter: FilterFn + private readonly pageNormalizer: Normalizer + private readonly pathnameNormalizer: Normalizer + private readonly bundlePathNormalizer: Normalizer + private readonly kind: K + private readonly reader: FileReader + + constructor({ + dir, + filter, + pageNormalizer, + pathnameNormalizer, + bundlePathNormalizer, + kind, + reader = new DefaultFileReader(), + }: Options) { + this.dir = dir + this.filter = filter + this.pageNormalizer = pageNormalizer + this.pathnameNormalizer = pathnameNormalizer + this.bundlePathNormalizer = bundlePathNormalizer + this.kind = kind + this.reader = reader + } + + public async routes(): Promise>> { + // Read the files in the directory... + let files: ReadonlyArray + + try { + files = await this.reader.read(this.dir) + } catch (err: any) { + if (err.code === 'ENOENT') { + // This can only happen when the underlying directory was removed. + return [] + } + + throw err + } + + return ( + files + // Filter the files by the provided filter... + .filter(this.filter) + .reduce>>((routes, filename) => { + // Normalize the filename into a pathname. + const pathname = this.pathnameNormalizer.normalize(filename) + + // If the route was already added, then don't add it again. + if (routes.find((r) => r.pathname === pathname)) return routes + + routes.push({ + kind: this.kind, + filename, + pathname, + page: this.pageNormalizer.normalize(filename), + bundlePath: this.bundlePathNormalizer.normalize(filename), + }) + + return routes + }, []) + ) + } +} diff --git a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts b/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts new file mode 100644 index 0000000000000..75f6c7dab1ad5 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts @@ -0,0 +1,97 @@ +import { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { FileReader } from './file-reader/file-reader' +import { DevPagesAPIRouteMatcher } from './dev-pages-api-route-matcher' + +describe('DevPagesAPIRouteMatcher', () => { + const dir = '' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const localeNormalizer: Normalizer = { + normalize: jest.fn((pathname) => pathname), + } + const matcher = new DevPagesAPIRouteMatcher( + dir, + extensions, + localeNormalizer, + reader + ) + const routes = await matcher.routes() + expect(routes).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + expect(localeNormalizer.normalize).not.toHaveBeenCalled() + }) + + describe('filename matching', () => { + it.each([ + { + filename: `${dir}/api/other/route.ts`, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api/other/route', + filename: `${dir}/api/other/route.ts`, + page: '/api/other/route', + bundlePath: 'pages/api/other/route', + }, + }, + { + filename: `${dir}/api/other/index.ts`, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api/other', + filename: `${dir}/api/other/index.ts`, + page: '/api/other', + bundlePath: 'pages/api/other', + }, + }, + { + filename: `${dir}/api.ts`, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api', + filename: `${dir}/api.ts`, + page: '/api', + bundlePath: 'pages/api', + }, + }, + { + filename: `${dir}/api/index.ts`, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api', + filename: `${dir}/api/index.ts`, + page: '/api', + bundlePath: 'pages/api', + }, + }, + ])( + "matches the route specified with '$filename'", + async ({ filename, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/some/other/page.${ext}`), + ...extensions.map((ext) => `${dir}/some/other/route.${ext}`), + `${dir}/some/api/route.ts`, + filename, + ]), + } + const localeNormalizer: Normalizer = { + normalize: jest.fn((pathname) => pathname), + } + const matcher = new DevPagesAPIRouteMatcher( + dir, + extensions, + localeNormalizer, + reader + ) + const routes = await matcher.routes() + expect(routes).toHaveLength(1) + expect(localeNormalizer.normalize).toHaveBeenCalledTimes(1) + expect(reader.read).toBeCalledWith(dir) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts b/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts new file mode 100644 index 0000000000000..fdc82c6881661 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts @@ -0,0 +1,72 @@ +import path from '../../shared/lib/isomorphic/path' +import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' +import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' +import { Normalizer } from '../normalizers/normalizer' +import { Normalizers } from '../normalizers/normalizers' +import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' +import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' +import { RouteKind } from '../route-kind' +import { DevFSRouteMatcher } from './dev-fs-route-matcher' +import { FileReader } from './file-reader/file-reader' + +export class DevPagesAPIRouteMatcher extends DevFSRouteMatcher { + constructor( + pagesDir: string, + extensions: string[], + localeNormalizer?: Normalizer, + reader?: FileReader + ) { + // Match any route file that ends with `/${filename}.${extension}` under the + // pages directory. + const matcher = new RegExp(`\\.(?:${extensions.join('|')})$`) + const filter = (filename: string) => { + // If the file does not end in the correct extension, then it's not a + // match. + if (!matcher.test(filename)) return false + + // Pages API routes must exist in the pages directory with the `/api/` + // prefix. The pathnames being tested here though are the full filenames, + // so we need to include the pages directory. + if (filename.startsWith(path.join(pagesDir, '/api/'))) return true + + for (const extension of extensions) { + // We can also match if we have `pages/api.${extension}`, so check to + // see if it's a match. + if (filename === path.join(pagesDir, `api.${extension}`)) { + return true + } + } + + return false + } + + const filenameNormalizer = new AbsoluteFilenameNormalizer( + pagesDir, + extensions + ) + + const pathnameNormalizer = new Normalizers([filenameNormalizer]) + + const bundlePathNormalizer = new Normalizers([ + filenameNormalizer, + // If the bundle path would have ended in a `/`, add a `index` to it. + // new RootIndexNormalizer(), + wrapNormalizerFn(normalizePagePath), + // Prefix the bundle path with `pages/`. + new PrefixingNormalizer('pages'), + ]) + + // If configured, normalize the pathname for locales. + if (localeNormalizer) pathnameNormalizer.push(localeNormalizer) + + super({ + dir: pagesDir, + filter, + pageNormalizer: filenameNormalizer, + pathnameNormalizer, + bundlePathNormalizer, + kind: RouteKind.PAGES_API, + reader, + }) + } +} diff --git a/packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts b/packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts new file mode 100644 index 0000000000000..6a96121514746 --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts @@ -0,0 +1,95 @@ +import { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { FileReader } from './file-reader/file-reader' +import { DevPagesRouteMatcher } from './dev-pages-route-matcher' + +describe('DevPagesRouteMatcher', () => { + const dir = '' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const localeNormalizer: Normalizer = { + normalize: jest.fn((pathname) => pathname), + } + const matcher = new DevPagesRouteMatcher( + dir, + extensions, + localeNormalizer, + reader + ) + const routes = await matcher.routes() + expect(routes).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + expect(localeNormalizer.normalize).not.toHaveBeenCalled() + }) + + describe('filename matching', () => { + it.each([ + { + filename: `${dir}/index.ts`, + route: { + kind: RouteKind.PAGES, + pathname: '/', + filename: `${dir}/index.ts`, + page: '/', + bundlePath: 'pages/index', + }, + }, + { + filename: `${dir}/some/api/route.ts`, + route: { + kind: RouteKind.PAGES, + pathname: '/some/api/route', + filename: `${dir}/some/api/route.ts`, + page: '/some/api/route', + bundlePath: 'pages/some/api/route', + }, + }, + { + filename: `${dir}/some/other/route/index.ts`, + route: { + kind: RouteKind.PAGES, + pathname: '/some/other/route', + filename: `${dir}/some/other/route/index.ts`, + page: '/some/other/route', + bundlePath: 'pages/some/other/route', + }, + }, + { + filename: `${dir}/some/other/route/index/route.ts`, + route: { + kind: RouteKind.PAGES, + pathname: '/some/other/route/index/route', + filename: `${dir}/some/other/route/index/route.ts`, + page: '/some/other/route/index/route', + bundlePath: 'pages/some/other/route/index/route', + }, + }, + ])( + "matches the route specified with '$filename'", + async ({ filename, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/api/other/page.${ext}`), + filename, + ]), + } + const localeNormalizer: Normalizer = { + normalize: jest.fn((pathname) => pathname), + } + const matcher = new DevPagesRouteMatcher( + dir, + extensions, + localeNormalizer, + reader + ) + const routes = await matcher.routes() + expect(routes).toHaveLength(1) + expect(localeNormalizer.normalize).toHaveBeenCalledTimes(1) + expect(reader.read).toBeCalledWith(dir) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts b/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts new file mode 100644 index 0000000000000..a44d910606a3f --- /dev/null +++ b/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts @@ -0,0 +1,60 @@ +import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' +import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' +import { Normalizer } from '../normalizers/normalizer' +import { Normalizers } from '../normalizers/normalizers' +import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' +import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' +import { RouteKind } from '../route-kind' +import { DevFSRouteMatcher } from './dev-fs-route-matcher' +import { FileReader } from './file-reader/file-reader' + +export class DevPagesRouteMatcher extends DevFSRouteMatcher { + constructor( + pagesDir: string, + extensions: string[], + localeNormalizer?: Normalizer, + reader?: FileReader + ) { + // Match any route file that ends with `/${filename}.${extension}` under the + // pages directory. + const matcher = new RegExp(`\\.(?:${extensions.join('|')})$`) + const filter = (filename: string) => { + // Pages routes must exist in the pages directory without the `/api/` + // prefix. The pathnames being tested here though are the full filenames, + // so we need to include the pages directory. + if (filename.startsWith(`${pagesDir}/api/`)) return false + + return matcher.test(filename) + } + + const absolutePathNormalizer = new AbsoluteFilenameNormalizer( + pagesDir, + extensions + ) + + const pageNormalizer = new Normalizers([absolutePathNormalizer]) + + const pathnameNormalizer = new Normalizers([absolutePathNormalizer]) + + const bundlePathNormalizer = new Normalizers([ + absolutePathNormalizer, + // If the bundle path would have ended in a `/`, add a `index` to it. + wrapNormalizerFn(normalizePagePath), + // Prefix the bundle path with `pages/`. + new PrefixingNormalizer('pages'), + ]) + + // If configured, normalize the pathname for locales. + if (localeNormalizer) pathnameNormalizer.push(localeNormalizer) + + super({ + dir: pagesDir, + filter, + pageNormalizer, + pathnameNormalizer, + bundlePathNormalizer, + kind: RouteKind.PAGES, + reader, + }) + } +} diff --git a/packages/next/src/server/route-matchers/file-reader/default-file-reader.ts b/packages/next/src/server/route-matchers/file-reader/default-file-reader.ts new file mode 100644 index 0000000000000..ae122ea286285 --- /dev/null +++ b/packages/next/src/server/route-matchers/file-reader/default-file-reader.ts @@ -0,0 +1,44 @@ +import fs from 'fs/promises' +import path from 'path' +import { FileReader } from './file-reader' + +export class DefaultFileReader implements FileReader { + public async read(dir: string): Promise> { + const pathnames: string[] = [] + + let directories: string[] = [dir] + + while (directories.length > 0) { + // Load all the files in each directory at the same time. + const results = await Promise.all( + directories.map(async (directory) => ({ + directory, + files: await fs.readdir(directory, { withFileTypes: true }), + })) + ) + + // Empty the directories, we'll fill it later if some of the files are + // directories. + directories = [] + + // For each result of directory scans... + for (const { files, directory } of results) { + // And for each file in it... + for (const file of files) { + // Handle each file. + const pathname = path.join(directory, file.name) + + // If the file is a directory, then add it to the list of directories, + // they'll be scanned on a later pass. + if (file.isDirectory()) { + directories.push(pathname) + } else { + pathnames.push(pathname) + } + } + } + } + + return pathnames + } +} diff --git a/packages/next/src/server/route-matchers/file-reader/file-reader.ts b/packages/next/src/server/route-matchers/file-reader/file-reader.ts new file mode 100644 index 0000000000000..ece4fd3d65cf8 --- /dev/null +++ b/packages/next/src/server/route-matchers/file-reader/file-reader.ts @@ -0,0 +1,8 @@ +export interface FileReader { + /** + * Reads the directory contents recursively. + * + * @param dir directory to read recursively from + */ + read(dir: string): Promise> | ReadonlyArray +} diff --git a/packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts b/packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts new file mode 100644 index 0000000000000..128dfd1466ac5 --- /dev/null +++ b/packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts @@ -0,0 +1,69 @@ +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { RouteKind } from '../route-kind' +import { PagesAPIRouteMatcher } from './pages-api-route-matcher' +import { RouteDefinition } from './route-matcher' + +describe('PagesAPIRouteMatcher', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const matcher = new PagesAPIRouteMatcher('', loader) + expect(await matcher.routes()).toEqual([]) + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record + route: RouteDefinition + }>([ + { + manifest: { '/api/users/[id]': 'pages/api/users/[id].js' }, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api/users/[id]', + filename: `/${SERVER_DIRECTORY}/pages/api/users/[id].js`, + page: '/api/users/[id]', + bundlePath: 'pages/api/users/[id]', + }, + }, + { + manifest: { '/api/users': 'pages/api/users.js' }, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api/users', + filename: `/${SERVER_DIRECTORY}/pages/api/users.js`, + page: '/api/users', + bundlePath: 'pages/api/users', + }, + }, + { + manifest: { '/api': 'pages/api.js' }, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api', + filename: `/${SERVER_DIRECTORY}/pages/api.js`, + page: '/api', + bundlePath: 'pages/api', + }, + }, + ])( + 'returns the correct routes for $route.pathname', + async ({ manifest, route }) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/users/[id]': 'pages/users/[id].js', + '/users': 'pages/users.js', + ...manifest, + })), + } + const matcher = new PagesAPIRouteMatcher('', loader) + const routes = await matcher.routes() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + expect(routes).toHaveLength(1) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/route-matchers/pages-api-route-matcher.ts new file mode 100644 index 0000000000000..253a3299fe036 --- /dev/null +++ b/packages/next/src/server/route-matchers/pages-api-route-matcher.ts @@ -0,0 +1,46 @@ +import path from 'path' +import { isAPIRoute } from '../../lib/is-api-route' +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' +import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' +import { RouteKind } from '../route-kind' +import { RouteDefinition, RouteMatcher } from './route-matcher' + +export class PagesAPIRouteMatcher implements RouteMatcher { + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async routes(): Promise< + ReadonlyArray> + > { + const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + + return ( + Object.keys(manifest) + // This matcher is only for Pages API routes. + .filter((page) => isAPIRoute(page)) + // Normalize the routes. + .reduce>>((routes, page) => { + const pathname = page + + // If the route was already added, then don't add it again. + if (routes.find((r) => r.pathname === pathname)) return routes + + routes.push({ + kind: RouteKind.PAGES_API, + pathname, + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + page, + bundlePath: path.join('pages', page), + }) + + return routes + }, []) + ) + } +} diff --git a/packages/next/src/server/route-matchers/pages-route-matcher.test.ts b/packages/next/src/server/route-matchers/pages-route-matcher.test.ts new file mode 100644 index 0000000000000..15cc56f750f72 --- /dev/null +++ b/packages/next/src/server/route-matchers/pages-route-matcher.test.ts @@ -0,0 +1,69 @@ +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { RouteKind } from '../route-kind' +import { PagesRouteMatcher } from './pages-route-matcher' +import { RouteDefinition } from './route-matcher' + +describe('PagesRouteMatcher', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const matcher = new PagesRouteMatcher('', loader) + expect(await matcher.routes()).toEqual([]) + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record + route: RouteDefinition + }>([ + { + manifest: { '/users/[id]': 'pages/users/[id].js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/users/[id]', + filename: `/${SERVER_DIRECTORY}/pages/users/[id].js`, + page: '/users/[id]', + bundlePath: 'pages/users/[id]', + }, + }, + { + manifest: { '/users': 'pages/users.js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/users', + filename: `/${SERVER_DIRECTORY}/pages/users.js`, + page: '/users', + bundlePath: 'pages/users', + }, + }, + { + manifest: { '/': 'pages/index.js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/', + filename: `/${SERVER_DIRECTORY}/pages/index.js`, + page: '/', + bundlePath: 'pages/index', + }, + }, + ])( + 'returns the correct routes for $route.pathname', + async ({ manifest, route }) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/api/users/[id]': 'pages/api/users/[id].js', + '/api/users': 'pages/api/users.js', + ...manifest, + })), + } + const matcher = new PagesRouteMatcher('', loader) + const routes = await matcher.routes() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + expect(routes).toHaveLength(1) + expect(routes[0]).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/route-matchers/pages-route-matcher.ts b/packages/next/src/server/route-matchers/pages-route-matcher.ts new file mode 100644 index 0000000000000..a820b788f71df --- /dev/null +++ b/packages/next/src/server/route-matchers/pages-route-matcher.ts @@ -0,0 +1,48 @@ +import path from 'path' +import { isAPIRoute } from '../../lib/is-api-route' +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' +import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' +import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' +import type { Normalizer } from '../normalizers/normalizer' +import { RouteKind } from '../route-kind' +import { RouteDefinition, RouteMatcher } from './route-matcher' + +export class PagesRouteMatcher implements RouteMatcher { + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ), + private readonly localeNormalizer?: Normalizer + ) {} + + public async routes(): Promise< + ReadonlyArray> + > { + const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + + return ( + Object.keys(manifest) + // This matcher is only for Pages routes. + .filter((page) => !isAPIRoute(page)) + // Normalize the routes. + .reduce>>((routes, page) => { + const pathname = this.localeNormalizer?.normalize(page) ?? page + + // If the route was already added, then don't add it again. + if (routes.find((r) => r.pathname === pathname)) return routes + + routes.push({ + kind: RouteKind.PAGES, + pathname, + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + page, + bundlePath: path.join('pages', normalizePagePath(page)), + }) + + return routes + }, []) + ) + } +} diff --git a/packages/next/src/server/route-matchers/route-matcher.ts b/packages/next/src/server/route-matchers/route-matcher.ts new file mode 100644 index 0000000000000..bada18bcee28e --- /dev/null +++ b/packages/next/src/server/route-matchers/route-matcher.ts @@ -0,0 +1,13 @@ +import { RouteKind } from '../route-kind' + +export interface RouteDefinition { + readonly kind: K + readonly bundlePath: string + readonly filename: string + readonly page: string + readonly pathname: string +} + +export interface RouteMatcher { + routes(): Promise>> +} diff --git a/packages/next/src/server/route-matches/route-match.ts b/packages/next/src/server/route-matches/route-match.ts new file mode 100644 index 0000000000000..870c42384ce06 --- /dev/null +++ b/packages/next/src/server/route-matches/route-match.ts @@ -0,0 +1,23 @@ +import { Params } from '../../shared/lib/router/utils/route-matcher' +import { RouteKind } from '../route-kind' +import { RouteDefinition } from '../route-matchers/route-matcher' + +/** + * RouteMatch is the resolved match for a given request. This will contain all + * the dynamic parameters used for this route. + */ +export interface RouteMatch extends RouteDefinition { + /** + * params when provided are the dynamic route parameters that were parsed from + * the incoming request pathname. If a route match is returned without any + * params, it should be considered a static route. + */ + readonly params?: Params +} + +export function isRouteMatchKind( + match: RouteMatch, + kind: K +): match is RouteMatch { + return match.kind === kind +} diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 91ddd1090c93f..6f5e8b550cee4 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -2,7 +2,7 @@ import type { NextConfig } from './config' import type { ParsedUrlQuery } from 'querystring' import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { - RouteMatch, + RouteMatchFn, Params, } from '../shared/lib/router/utils/route-matcher' import type { RouteHas } from '../lib/load-custom-routes' @@ -14,13 +14,14 @@ import { } from './request-meta' import { isAPIRoute } from '../lib/is-api-route' 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' +import { RouteDefinition } from './route-matchers/route-matcher' +import { RouteKind } from './route-kind' +import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' type RouteResult = { finished: boolean @@ -29,7 +30,7 @@ type RouteResult = { } export type Route = { - match: RouteMatch + match: RouteMatchFn has?: RouteHas[] missing?: RouteHas[] type: string @@ -50,7 +51,31 @@ export type Route = { ) => Promise | RouteResult } -export type DynamicRoutes = Array<{ page: string; match: RouteMatch }> +export interface DynamicRoute { + route: RouteDefinition + match: RouteMatchFn +} + +export type DynamicRoutes = ReadonlyArray<{ + route: { pathname: string } + match: RouteMatchFn +}> + +export type RouterOptions = { + headers: ReadonlyArray + fsRoutes: ReadonlyArray + rewrites: { + beforeFiles: ReadonlyArray + afterFiles: ReadonlyArray + fallback: ReadonlyArray + } + redirects: ReadonlyArray + catchAllRoute: Route + catchAllMiddleware: ReadonlyArray + matchers: RouteMatcherManager + useFileSystemPublicRoutes: boolean + nextConfig: NextConfig +} export type PageChecker = (pathname: string) => Promise @@ -66,8 +91,7 @@ export default class Router { fallback: ReadonlyArray } private readonly catchAllRoute: Route - private readonly pageChecker: PageChecker - private dynamicRoutes: DynamicRoutes + private readonly matchers: RouteMatcherManager private readonly useFileSystemPublicRoutes: boolean private readonly nextConfig: NextConfig private compiledRoutes: ReadonlyArray @@ -98,35 +122,18 @@ export default class Router { redirects = [], catchAllRoute, catchAllMiddleware = [], - dynamicRoutes = [], - pageChecker, + matchers, useFileSystemPublicRoutes, nextConfig, - }: { - headers: ReadonlyArray - fsRoutes: ReadonlyArray - rewrites: { - beforeFiles: ReadonlyArray - afterFiles: ReadonlyArray - fallback: ReadonlyArray - } - redirects: ReadonlyArray - catchAllRoute: Route - catchAllMiddleware: ReadonlyArray - dynamicRoutes: DynamicRoutes | undefined - pageChecker: PageChecker - useFileSystemPublicRoutes: boolean - nextConfig: NextConfig - }) { + }: RouterOptions) { 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.matchers = matchers this.useFileSystemPublicRoutes = useFileSystemPublicRoutes // Perform the initial route compilation. @@ -134,28 +141,6 @@ export default class Router { 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() { return this.nextConfig.i18n?.locales || [] } @@ -164,10 +149,6 @@ export default class Router { return this.nextConfig.basePath || '' } - public setDynamicRoutes(dynamicRoutes: DynamicRoutes) { - this.dynamicRoutes = dynamicRoutes - this.needsRecompilation = true - } public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray) { this.catchAllMiddleware = catchAllMiddleware this.needsRecompilation = true @@ -217,22 +198,21 @@ export default class Router { 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 } + const match = await this.matchers.match(parsedUrl.pathname!, { + // We need to skip dynamic route matching because the next + // step we're processing the afterFiles rewrites which must + // not include dynamic matches. + skipDynamic: true, + }) + if (!match) return { finished: false } + + return this.catchAllRoute.fn( + req, + res, + params, + parsedUrl, + upgradeHead + ) }, } as Route, ] @@ -276,59 +256,46 @@ export default class Router { for (const route of this.fsRoutes) { const params = route.match(fsPathname) + if (!params) continue - if (params) { - parsedUrl.pathname = fsPathname - - const { finished } = await route.fn(req, res, params, parsedUrl) - if (finished) { - return true - } - - parsedUrl.pathname = originalFsPathname + const { finished } = await route.fn(req, res, params, { + ...parsedUrl, + pathname: fsPathname, + }) + if (finished) { + return true } } - 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 - } - } + const match = await this.matchers.match(fsPathname) + if (!match) { + return false } // 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 + 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.` ) - - return finished } - return false + const { finished } = await this.catchAllRoute.fn( + req, + res, + params, + { + ...parsedUrl, + pathname: fsPathname, + query: { + ...parsedUrl.query, + _nextBubbleNoFallback: '1', + }, + }, + upgradeHead + ) + + return finished } async execute( @@ -344,161 +311,150 @@ export default class Router { 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.` - ) + // Create a deep copy of the parsed URL. + const parsedUrlUpdated = { + ...parsedUrl, + query: { + ...parsedUrl.query, + }, } - 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 } - 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 && + isAPIRoute(pathnameInfo.pathname) + ) { + continue + } - const originalPathname = parsedUrlUpdated.pathname as string - const pathnameInfo = getNextPathnameInfo(originalPathname, { - nextConfig: this.nextConfig, - parseData: false, - }) + if (getRequestMeta(req, '_nextHadBasePath')) { + pathnameInfo.basePath = this.basePath + } - if ( - pathnameInfo.locale && - !route.matchesLocaleAPIRoutes && - isAPIRoute(pathnameInfo.pathname) - ) { - continue - } + const basePath = pathnameInfo.basePath + if (!route.matchesBasePath) { + pathnameInfo.basePath = '' + } - if (getRequestMeta(req, '_nextHadBasePath')) { - pathnameInfo.basePath = this.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 + } - const basePath = pathnameInfo.basePath - if (!route.matchesBasePath) { - pathnameInfo.basePath = '' + if ( + route.matchesTrailingSlash && + getRequestMeta(req, '__nextHadTrailingSlash') + ) { + pathnameInfo.trailingSlash = true + } + + const matchPathname = formatNextPathnameInfo({ + ignorePrefix: true, + ...pathnameInfo, + }) + + let params = route.match(matchPathname) + if ((route.has || route.missing) && params) { + const hasParams = matchHas( + req, + parsedUrlUpdated.query, + route.has, + route.missing + ) + if (hasParams) { + Object.assign(params, hasParams) + } else { + params = false } + } - if ( - route.matchesLocale && - parsedUrlUpdated.query.__nextLocale && - !pathnameInfo.locale - ) { - pathnameInfo.locale = parsedUrlUpdated.query.__nextLocale + /** + * 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) { + const isNextDataNormalizing = route.name === '_next/data normalizing' + + if (isNextDataNormalizing) { + addRequestMeta(req, '_nextDataNormalizing', true) } + parsedUrlUpdated.pathname = matchPathname + const result = await route.fn( + req, + res, + params, + parsedUrlUpdated, + upgradeHead + ) - if ( - !route.matchesLocale && - pathnameInfo.locale === this.nextConfig.i18n?.defaultLocale && - pathnameInfo.locale - ) { - pathnameInfo.locale = undefined + if (isNextDataNormalizing) { + addRequestMeta(req, '_nextDataNormalizing', false) + } + if (result.finished) { + return true } - if ( - route.matchesTrailingSlash && - getRequestMeta(req, '__nextHadTrailingSlash') - ) { - pathnameInfo.trailingSlash = 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 } - const matchPathname = formatNextPathnameInfo({ - ignorePrefix: true, - ...pathnameInfo, - }) - - let params = route.match(matchPathname) - if ((route.has || route.missing) && params) { - const hasParams = matchHas( - req, - parsedUrlUpdated.query, - route.has, - route.missing - ) - if (hasParams) { - Object.assign(params, hasParams) - } else { - params = false + if (result.query) { + parsedUrlUpdated.query = { + ...getNextInternalQuery(parsedUrlUpdated.query), + ...result.query, } } - /** - * 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. - */ + // check filesystem if ( - params && - this.basePath && - !route.matchesBasePath && - !getRequestMeta(req, '_nextDidRewrite') && - !basePath + route.check && + (await this.checkFsRoutes(req, res, parsedUrlUpdated)) ) { - continue - } - - if (params) { - const isNextDataNormalizing = route.name === '_next/data normalizing' - - if (isNextDataNormalizing) { - addRequestMeta(req, '_nextDataNormalizing', true) - } - parsedUrlUpdated.pathname = matchPathname - const result = await route.fn( - req, - res, - params, - parsedUrlUpdated, - upgradeHead - ) - - if (isNextDataNormalizing) { - addRequestMeta(req, '_nextDataNormalizing', false) - } - 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 - } + return true } } - - // All routes were tested, none were found. - return false - } finally { - this.context.delete(req) } + + // All routes were tested, none were found. + return false } } diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 727f01763e1c7..4d98f0684539b 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -5,8 +5,7 @@ import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' import type { Params } from '../shared/lib/router/utils/route-matcher' import type { PayloadOptions } from './send-payload' import type { LoadComponentsReturnType } from './load-components' -import type { DynamicRoutes, PageChecker, Route } from './router' -import type { NextConfig } from './config-shared' +import type { Route, RouterOptions } from './router' import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { UrlWithParsedQuery } from 'url' @@ -27,6 +26,8 @@ import { normalizeVercelUrl, } from '../build/webpack/loaders/next-serverless-loader/utils' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' +import { RouteHandlers } from './route-handlers/route-handlers' +import { DefaultRouteMatcherManager } from './route-matcher-managers/default-route-matcher-manager' interface WebServerOptions extends Options { webServerConfig: { @@ -50,6 +51,15 @@ export default class NextWebServer extends BaseServer { Object.assign(this.renderOpts, options.webServerConfig.extendRenderOpts) } + protected getRoutes() { + const matchers = new DefaultRouteMatcherManager() + const handlers = new RouteHandlers() + + // TODO: implement for edge runtime + + return { matchers, handlers } + } + protected handleCompression() { // For the web server layer, compression is automatically handled by the // upstream proxy (edge runtime or node server) and we can simply skip here. @@ -149,22 +159,7 @@ export default class NextWebServer extends BaseServer { .fontLoaderManifest } - protected generateRoutes(): { - headers: Route[] - rewrites: { - beforeFiles: Route[] - afterFiles: Route[] - fallback: Route[] - } - fsRoutes: Route[] - redirects: Route[] - catchAllRoute: Route - catchAllMiddleware: Route[] - pageChecker: PageChecker - useFileSystemPublicRoutes: boolean - dynamicRoutes: DynamicRoutes | undefined - nextConfig: NextConfig - } { + protected generateRoutes(): RouterOptions { const fsRoutes: Route[] = [ { match: getPathMatch('/_next/data/:path*'), @@ -332,7 +327,7 @@ export default class NextWebServer extends BaseServer { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - this.dynamicRoutes = this.getDynamicRoutes() + // this.dynamicRoutes = this.getDynamicRoutes() } return { @@ -347,8 +342,7 @@ export default class NextWebServer extends BaseServer { catchAllRoute, catchAllMiddleware: [], useFileSystemPublicRoutes, - dynamicRoutes: this.dynamicRoutes, - pageChecker: this.hasPage.bind(this), + matchers: this.matchers, nextConfig: this.nextConfig, } } @@ -357,6 +351,7 @@ export default class NextWebServer extends BaseServer { protected async handleApiRequest() { return false } + protected async renderHTML( req: WebNextRequest, _res: WebNextResponse, diff --git a/packages/next/src/server/web/http.ts b/packages/next/src/server/web/http.ts new file mode 100644 index 0000000000000..e984ed0899cd9 --- /dev/null +++ b/packages/next/src/server/web/http.ts @@ -0,0 +1,30 @@ +/** + * List of valid HTTP methods that can be implemented by Next.js's Custom App + * Routes. + */ +export const HTTP_METHODS = [ + 'GET', + 'HEAD', + 'OPTIONS', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', +] as const + +/** + * A type representing the valid HTTP methods that can be implemented by + * Next.js's Custom App Routes. + */ +export type HTTP_METHOD = typeof HTTP_METHODS[number] + +/** + * Checks to see if the passed string is an HTTP method. Note that this is case + * sensitive. + * + * @param maybeMethod the string that may be an HTTP method + * @returns true if the string is an HTTP method + */ +export function isHTTPMethod(maybeMethod: string): maybeMethod is HTTP_METHOD { + return HTTP_METHODS.includes(maybeMethod as HTTP_METHOD) +} diff --git a/packages/next/src/server/web/types.ts b/packages/next/src/server/web/types.ts index 56ca53e02c5e4..9aec76179da2d 100644 --- a/packages/next/src/server/web/types.ts +++ b/packages/next/src/server/web/types.ts @@ -2,7 +2,7 @@ import type { I18NConfig } from '../config-shared' import type { NextRequest } from '../web/spec-extension/request' import type { NextFetchEvent } from '../web/spec-extension/fetch-event' import type { NextResponse } from './spec-extension/response' -import type { ClonableBody } from '../body-streams' +import type { CloneableBody } from '../body-streams' export interface NodeHeaders { [header: string]: string | string[] | undefined @@ -33,7 +33,7 @@ export interface RequestData { } export type NodejsRequestData = Omit & { - body?: ClonableBody + body?: CloneableBody } export interface FetchEventResult { diff --git a/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts b/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts index 1e1757b98dc3e..e79f56484c4c0 100644 --- a/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts +++ b/packages/next/src/shared/lib/page-path/remove-page-path-tail.ts @@ -14,7 +14,7 @@ import { normalizePathSep } from './normalize-path-sep' export function removePagePathTail( pagePath: string, options: { - extensions: string[] + extensions: ReadonlyArray keepIndex?: boolean } ) { diff --git a/packages/next/src/shared/lib/router/utils/app-paths.ts b/packages/next/src/shared/lib/router/utils/app-paths.ts index 3488d11e9300d..705ca6ac426c3 100644 --- a/packages/next/src/shared/lib/router/utils/app-paths.ts +++ b/packages/next/src/shared/lib/router/utils/app-paths.ts @@ -1,25 +1,53 @@ -// remove (name) from pathname as it's not considered for routing -export function normalizeAppPath(pathname: string) { - return pathname.split('/').reduce((acc, segment, index, segments) => { - // Empty segments are ignored. - if (!segment) { - return acc - } +import { ensureLeadingSlash } from '../../page-path/ensure-leading-slash' - if (segment.startsWith('(') && segment.endsWith(')')) { - return acc - } +/** + * Normalizes an app route so it represents the actual request path. Essentially + * performing the following transformations: + * + * - `/(dashboard)/user/[id]/page` to `/user/[id]` + * - `/(dashboard)/account/page` to `/account` + * - `/user/[id]/page` to `/user/[id]` + * - `/account/page` to `/account` + * - `/page` to `/` + * - `/(dashboard)/user/[id]/route` to `/user/[id]` + * - `/(dashboard)/account/route` to `/account` + * - `/user/[id]/route` to `/user/[id]` + * - `/account/route` to `/account` + * - `/route` to `/` + * - `/` to `/` + * + * @param route the app route to normalize + * @returns the normalized pathname + */ +export function normalizeAppPath(route: string) { + return ensureLeadingSlash( + route.split('/').reduce((pathname, segment, index, segments) => { + // Empty segments are ignored. + if (!segment) { + return pathname + } - if (segment.startsWith('@')) { - return acc - } + // Groups are ignored. + if (segment.startsWith('(') && segment.endsWith(')')) { + return pathname + } - if (segment === 'page' && index === segments.length - 1) { - return acc - } + // Parallel segments are ignored. + if (segment.startsWith('@')) { + return pathname + } - return acc + `/${segment}` - }, '') + // The last segment (if it's a leaf) should be ignored. + if ( + (segment === 'page' || segment === 'route') && + index === segments.length - 1 + ) { + return pathname + } + + return `${pathname}/${segment}` + }, '') + ) } export function normalizeRscPath(pathname: string, enabled?: boolean) { diff --git a/packages/next/src/shared/lib/router/utils/route-matcher.ts b/packages/next/src/shared/lib/router/utils/route-matcher.ts index bdc074c955263..67f9094209671 100644 --- a/packages/next/src/shared/lib/router/utils/route-matcher.ts +++ b/packages/next/src/shared/lib/router/utils/route-matcher.ts @@ -1,7 +1,7 @@ import type { RouteRegex } from './route-regex' import { DecodeError } from '../../utils' -export interface RouteMatch { +export interface RouteMatchFn { (pathname: string | null | undefined): false | Params } @@ -9,7 +9,7 @@ export interface Params { [param: string]: any } -export function getRouteMatcher({ re, groups }: RouteRegex): RouteMatch { +export function getRouteMatcher({ re, groups }: RouteRegex): RouteMatchFn { return (pathname: string | null | undefined) => { const routeMatch = re.exec(pathname!) if (!routeMatch) { diff --git a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts new file mode 100644 index 0000000000000..71aa746b7d002 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts @@ -0,0 +1,303 @@ +import { createNextDescribe } from 'e2e-utils' +import { + withRequestMeta, + getRequestMeta, + cookieWithRequestMeta, +} from './helpers' +import { Readable } from 'stream' + +createNextDescribe( + 'app-custom-routes', + { + files: __dirname, + }, + ({ next, isNextDev }) => { + // TODO: handle next development server case + if (isNextDev) return + + describe('basic fetch request with a response', () => { + describe.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])( + 'made via a %s request', + (method) => { + it.each(['/basic/endpoint', '/basic/vercel/endpoint'])( + 'responds correctly on %s', + async (path) => { + const res = await next.fetch(path, { method }) + + expect(res.status).toEqual(200) + expect(await res.text()).toContain('hello, world') + + const meta = getRequestMeta(res.headers) + expect(meta.method).toEqual(method) + } + ) + } + ) + + describe('route groups', () => { + it('routes to the correct handler', async () => { + const res = await next.fetch('/basic/endpoint/nested') + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.pathname).toEqual('/basic/endpoint/nested') + }) + }) + + describe('request', () => { + it('can read query parameters', async () => { + const res = await next.fetch('/advanced/query?ping=pong') + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.ping).toEqual('pong') + }) + }) + + describe('response', () => { + // TODO-APP: re-enable when rewrites are supported again + it.skip('supports the NextResponse.rewrite() helper', async () => { + const res = await next.fetch('/hooks/rewrite') + + expect(res.status).toEqual(200) + + // This is running in the edge runtime, so we expect not to see this + // header. + expect(res.headers.has('x-middleware-rewrite')).toBeFalse() + expect(await res.text()).toContain('hello, world') + }) + + it('supports the NextResponse.redirect() helper', async () => { + const res = await next.fetch('/hooks/redirect/response', { + // "Manually" perform the redirect, we want to inspect the + // redirection response, so don't actually follow it. + redirect: 'manual', + }) + + expect(res.status).toEqual(307) + expect(res.headers.get('location')).toEqual('https://nextjs.org/') + expect(await res.text()).toBeEmpty() + }) + + it('supports the NextResponse.json() helper', async () => { + const meta = { ping: 'pong' } + const res = await next.fetch('/hooks/json', { + headers: withRequestMeta(meta), + }) + + expect(res.status).toEqual(200) + expect(res.headers.get('content-type')).toEqual('application/json') + expect(await res.json()).toEqual(meta) + }) + }) + }) + + describe('body', () => { + it('can handle handle a streaming request and streaming response', async () => { + const body = new Array(10).fill(JSON.stringify({ ping: 'pong' })) + let index = 0 + const stream = new Readable({ + read() { + if (index >= body.length) return this.push(null) + + this.push(body[index] + '\n') + index++ + }, + }) + + const res = await next.fetch('/advanced/body/streaming', { + method: 'POST', + body: stream, + }) + + expect(res.status).toEqual(200) + expect(await res.text()).toEqual(body.join('\n') + '\n') + }) + + it('can read a JSON encoded body', async () => { + const body = { ping: 'pong' } + const res = await next.fetch('/advanced/body/json', { + method: 'POST', + body: JSON.stringify(body), + }) + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.body).toEqual(body) + }) + + it('can read a streamed JSON encoded body', async () => { + const body = { ping: 'pong' } + const encoded = JSON.stringify(body) + let index = 0 + const stream = new Readable({ + async read() { + if (index >= encoded.length) return this.push(null) + + this.push(encoded[index]) + index++ + }, + }) + const res = await next.fetch('/advanced/body/json', { + method: 'POST', + body: stream, + }) + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.body).toEqual(body) + }) + + it('can read the text body', async () => { + const body = 'hello, world' + const res = await next.fetch('/advanced/body/text', { + method: 'POST', + body, + }) + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.body).toEqual(body) + }) + }) + + describe('context', () => { + it('provides params to routes with dynamic parameters', async () => { + const res = await next.fetch('/basic/vercel/endpoint') + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.params).toEqual({ tenantID: 'vercel' }) + }) + + it('provides params to routes with catch-all routes', async () => { + const res = await next.fetch('/basic/vercel/some/other/resource') + + expect(res.status).toEqual(200) + const meta = getRequestMeta(res.headers) + expect(meta.params).toEqual({ + tenantID: 'vercel', + resource: ['some', 'other', 'resource'], + }) + }) + + it('does not provide params to routes without dynamic parameters', async () => { + const res = await next.fetch('/basic/endpoint') + + expect(res.ok).toBeTrue() + + const meta = getRequestMeta(res.headers) + expect(meta.params).toEqual(null) + }) + }) + + describe('hooks', () => { + describe('headers', () => { + it('gets the correct values', async () => { + const res = await next.fetch('/hooks/headers', { + headers: withRequestMeta({ ping: 'pong' }), + }) + + expect(res.status).toEqual(200) + + const meta = getRequestMeta(res.headers) + expect(meta.ping).toEqual('pong') + }) + }) + + describe('cookies', () => { + it('gets the correct values', async () => { + const res = await next.fetch('/hooks/cookies', { + headers: cookieWithRequestMeta({ ping: 'pong' }), + }) + + expect(res.status).toEqual(200) + + const meta = getRequestMeta(res.headers) + expect(meta.ping).toEqual('pong') + }) + }) + + describe('redirect', () => { + it('can respond correctly', async () => { + const res = await next.fetch('/hooks/redirect', { + // "Manually" perform the redirect, we want to inspect the + // redirection response, so don't actually follow it. + redirect: 'manual', + }) + + expect(res.status).toEqual(302) + expect(res.headers.get('location')).toEqual('https://nextjs.org/') + expect(await res.text()).toBeEmpty() + }) + }) + + describe('notFound', () => { + it('can respond correctly', async () => { + const res = await next.fetch('/hooks/not-found') + + expect(res.status).toEqual(404) + expect(await res.text()).toBeEmpty() + }) + }) + }) + + describe('error conditions', () => { + it('responds with 400 (Bad Request) when the requested method is not a valid HTTP method', async () => { + const res = await next.fetch('/status/405', { method: 'HEADER' }) + + expect(res.status).toEqual(400) + expect(await res.text()).toBeEmpty() + }) + + it('responds with 405 (Method Not Allowed) when method is not implemented', async () => { + const res = await next.fetch('/status/405', { method: 'POST' }) + + expect(res.status).toEqual(405) + expect(await res.text()).toBeEmpty() + }) + + it('responds with 500 (Internal Server Error) when the handler throws an error', async () => { + const res = await next.fetch('/status/500') + + expect(res.status).toEqual(500) + expect(await res.text()).toBeEmpty() + }) + + it('responds with 500 (Internal Server Error) when the handler calls NextResponse.next()', async () => { + const error = + 'https://nextjs.org/docs/messages/next-response-next-in-app-route-handler' + + // Precondition. We shouldn't have seen this before. This ensures we're + // testing that the specific route throws this error in the console. + expect(next.cliOutput).not.toContain(error) + + const res = await next.fetch('/status/500/next') + + expect(res.status).toEqual(500) + expect(await res.text()).toBeEmpty() + expect(next.cliOutput).toContain(error) + }) + }) + + describe('automatic implementations', () => { + it('implements HEAD on routes with GET already implemented', async () => { + const res = await next.fetch('/methods/head', { method: 'HEAD' }) + + expect(res.status).toEqual(200) + expect(await res.text()).toBeEmpty() + }) + + it('implements OPTIONS on routes', async () => { + const res = await next.fetch('/methods/options', { method: 'OPTIONS' }) + + expect(res.status).toEqual(204) + expect(await res.text()).toBeEmpty() + + expect(res.headers.get('allow')).toEqual( + 'DELETE, GET, HEAD, OPTIONS, POST' + ) + }) + }) + } +) diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts new file mode 100644 index 0000000000000..421d40e661af5 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/advanced/body/json/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from 'next/server' +import { withRequestMeta } from '../../../../helpers' + +export async function POST(request: NextRequest) { + const body = await request.json() + return new Response('hello, world', { + status: 200, + headers: withRequestMeta({ body }), + }) +} diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts new file mode 100644 index 0000000000000..7ab8d8c5bc686 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/advanced/body/streaming/route.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from 'next/server' + +export async function POST(request: NextRequest) { + const reader = request.body?.getReader() + if (!reader) { + return new Response(null, { status: 400, statusText: 'Bad Request' }) + } + + // Readable stream here is polyfilled from the Fetch API (from undici). + const stream = new ReadableStream({ + async pull(controller) { + // Read the next chunk from the stream. + const { value, done } = await reader.read() + if (done) { + // Finish the stream. + return controller.close() + } + + // Add the request value to the response stream. + controller.enqueue(value) + }, + }) + + return new Response(stream, { status: 200 }) +} diff --git a/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts b/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts new file mode 100644 index 0000000000000..07b50713feeea --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/advanced/body/text/route.ts @@ -0,0 +1,10 @@ +import type { NextRequest } from 'next/server' +import { withRequestMeta } from '../../../../helpers' + +export async function POST(request: NextRequest) { + const body = await request.text() + return new Response('hello, world', { + status: 200, + headers: withRequestMeta({ body }), + }) +} diff --git a/test/e2e/app-dir/app-routes/app/advanced/query/route.ts b/test/e2e/app-dir/app-routes/app/advanced/query/route.ts new file mode 100644 index 0000000000000..4ecb7198a8223 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/advanced/query/route.ts @@ -0,0 +1,11 @@ +import { withRequestMeta } from '../../../helpers' + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url) + + return new Response('hello, world', { + headers: withRequestMeta({ + ping: searchParams.get('ping'), + }), + }) +} diff --git a/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts b/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts new file mode 100644 index 0000000000000..84976ebe67a77 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/basic/(grouped)/endpoint/nested/route.ts @@ -0,0 +1 @@ +export * from '../../../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts new file mode 100644 index 0000000000000..ef4be6fa84809 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/[...resource]/route.ts @@ -0,0 +1 @@ +export * from '../../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts new file mode 100644 index 0000000000000..ef4be6fa84809 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/basic/[tenantID]/endpoint/route.ts @@ -0,0 +1 @@ +export * from '../../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts b/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts new file mode 100644 index 0000000000000..a950beba77447 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/basic/endpoint/route.ts @@ -0,0 +1 @@ +export * from '../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts b/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts new file mode 100644 index 0000000000000..eba4377462327 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/cookies/route.ts @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers' +import { getRequestMeta, withRequestMeta } from '../../../helpers' + +export async function GET() { + const c = cookies() + + // Put the request meta in the response directly as meta again. + const meta = getRequestMeta(c) + + return new Response(null, { + status: 200, + headers: withRequestMeta(meta), + }) +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts new file mode 100644 index 0000000000000..23ecc800a23b3 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/headers/route.ts @@ -0,0 +1,14 @@ +import { headers } from 'next/headers' +import { getRequestMeta, withRequestMeta } from '../../../helpers' + +export async function GET() { + const h = headers() + + // Put the request meta in the response directly as meta again. + const meta = getRequestMeta(h) + + return new Response(null, { + status: 200, + headers: withRequestMeta(meta), + }) +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/json/route.ts b/test/e2e/app-dir/app-routes/app/hooks/json/route.ts new file mode 100644 index 0000000000000..624e22aca707b --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/json/route.ts @@ -0,0 +1,7 @@ +import { getRequestMeta } from '../../../helpers' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const meta = getRequestMeta(request.headers) + return NextResponse.json(meta) +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts b/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts new file mode 100644 index 0000000000000..6a41a17bc7a46 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/not-found/route.ts @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export async function GET() { + notFound() +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts b/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts new file mode 100644 index 0000000000000..f8dc12de59846 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/redirect/response/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.redirect('https://nextjs.org/') +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts b/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts new file mode 100644 index 0000000000000..d4a1811603adb --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/redirect/route.ts @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export async function GET() { + redirect('https://nextjs.org/') +} diff --git a/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts b/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts new file mode 100644 index 0000000000000..d3ce936274210 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/hooks/rewrite/route.ts @@ -0,0 +1,6 @@ +import { type NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + const url = new URL('/basic/endpoint', request.nextUrl) + return NextResponse.rewrite(url) +} diff --git a/test/e2e/app-dir/app-routes/app/methods/head/route.ts b/test/e2e/app-dir/app-routes/app/methods/head/route.ts new file mode 100644 index 0000000000000..7a0af7c5f337a --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/methods/head/route.ts @@ -0,0 +1,3 @@ +// This route only exports GET, and not HEAD. The test verifies that a request +// via HEAD will be the same as GET but without the response body. +export { GET } from '../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/methods/options/route.ts b/test/e2e/app-dir/app-routes/app/methods/options/route.ts new file mode 100644 index 0000000000000..1c6e54400c072 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/methods/options/route.ts @@ -0,0 +1,3 @@ +// This route exports GET, POST, and DELETE. The test verifies that this route +// will handle the OPTIONS request. +export { GET, POST, DELETE } from '../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/status/405/route.ts b/test/e2e/app-dir/app-routes/app/status/405/route.ts new file mode 100644 index 0000000000000..1123d7ebb9980 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/status/405/route.ts @@ -0,0 +1 @@ +export { GET } from '../../../handlers/hello' diff --git a/test/e2e/app-dir/app-routes/app/status/500/next/route.ts b/test/e2e/app-dir/app-routes/app/status/500/next/route.ts new file mode 100644 index 0000000000000..445bbb15cb753 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/status/500/next/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.next() +} diff --git a/test/e2e/app-dir/app-routes/app/status/500/route.ts b/test/e2e/app-dir/app-routes/app/status/500/route.ts new file mode 100644 index 0000000000000..123adc390e424 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app/status/500/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + throw new Error('this is a runtime error') +} diff --git a/test/e2e/app-dir/app-routes/handlers/hello.ts b/test/e2e/app-dir/app-routes/handlers/hello.ts new file mode 100644 index 0000000000000..9d84fd0810378 --- /dev/null +++ b/test/e2e/app-dir/app-routes/handlers/hello.ts @@ -0,0 +1,25 @@ +import { type NextRequest } from 'next/server' +import { withRequestMeta } from '../helpers' + +export const helloHandler = async ( + request: NextRequest, + { params }: { params?: Record } +): Promise => { + const { pathname } = new URL(request.url) + + return new Response('hello, world', { + headers: withRequestMeta({ + method: request.method, + params: params ?? null, + pathname, + }), + }) +} + +export const GET = helloHandler +export const HEAD = helloHandler +export const OPTIONS = helloHandler +export const POST = helloHandler +export const PUT = helloHandler +export const DELETE = helloHandler +export const PATCH = helloHandler diff --git a/test/e2e/app-dir/app-routes/helpers.ts b/test/e2e/app-dir/app-routes/helpers.ts new file mode 100644 index 0000000000000..08739f879b5fe --- /dev/null +++ b/test/e2e/app-dir/app-routes/helpers.ts @@ -0,0 +1,69 @@ +const KEY = 'x-request-meta' + +/** + * Adds a new header to the headers object and serializes it. To be used in + * conjunction with the `getRequestMeta` function in tests to verify request + * data from the handler. + * + * @param meta metadata to inject into the headers + * @param headers the existing headers on the response to merge with + * @returns the merged headers with the request meta added + */ +export function withRequestMeta( + meta: Record, + headers: Record = {} +): Record { + return { + ...headers, + [KEY]: JSON.stringify(meta), + } +} + +/** + * Adds a cookie to the headers with the provided request metadata. Existing + * cookies will be merged, but it will not merge request metadata that already + * exists on an existing cookie. + * + * @param meta metadata to inject into the headers via a cookie + * @param headers the existing headers on the response to merge with + * @returns the merged headers with the request meta added as a cookie + */ +export function cookieWithRequestMeta( + meta: Record, + { cookie = '', ...headers }: Record = {} +): Record { + if (cookie) cookie += '; ' + + // We encode this with `btoa` because the JSON string can contain characters + // that are invalid in a cookie value. + cookie += `${KEY}=${btoa(JSON.stringify(meta))}` + + return { + ...headers, + cookie, + } +} + +type Cookies = { + get(name: string): { name: string; value: string } | undefined +} + +/** + * Gets request metadata from the response headers or cookie. + * + * @param headersOrCookies response headers from the request or cookies object + * @returns any injected metadata on the request + */ +export function getRequestMeta( + headersOrCookies: Headers | Cookies +): Record { + const headerOrCookie = headersOrCookies.get(KEY) + if (!headerOrCookie) return {} + + // If the value is a string, then parse it now, it was headers. + if (typeof headerOrCookie === 'string') return JSON.parse(headerOrCookie) + + // It's a cookie! Parse it now. The cookie value should be encoded with + // `btoa`, hence the use of `atob`. + return JSON.parse(atob(headerOrCookie.value)) +} diff --git a/test/e2e/app-dir/app-routes/next.config.js b/test/e2e/app-dir/app-routes/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/app-routes/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +} From bc8e4cddcfa882605d46afedb3460ee3c23e5080 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 8 Feb 2023 13:35:09 -0700 Subject: [PATCH 02/22] tests: enable development tests --- test/e2e/app-dir/app-routes/app-custom-routes.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts index 71aa746b7d002..398e3f08bb590 100644 --- a/test/e2e/app-dir/app-routes/app-custom-routes.test.ts +++ b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts @@ -11,10 +11,7 @@ createNextDescribe( { files: __dirname, }, - ({ next, isNextDev }) => { - // TODO: handle next development server case - if (isNextDev) return - + ({ next }) => { describe('basic fetch request with a response', () => { describe.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])( 'made via a %s request', From 1470f87faf8ec79e327ea6ebc9f6c30fb901af53 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 10 Feb 2023 17:03:29 -0700 Subject: [PATCH 03/22] refactor: adjusted to support appPaths for pages development --- packages/next/src/build/entries.ts | 2 +- packages/next/src/server/base-server.ts | 66 ++---- packages/next/src/server/dev/hot-reloader.ts | 5 +- .../next/src/server/dev/next-dev-server.ts | 55 ++--- .../src/server/dev/on-demand-entry-handler.ts | 28 ++- .../helpers}/module-loader/module-loader.ts | 0 .../module-loader/node-module-loader.ts | 0 .../helpers/response-handlers.ts} | 0 .../absolute-filename-normalizer.test.ts | 0 .../absolute-filename-normalizer.ts | 6 +- .../normalizers/locale-route-normalizer.ts | 50 +++++ .../{ => future}/normalizers/normalizer.ts | 0 .../{ => future}/normalizers/normalizers.ts | 0 .../normalizers/prefixing-normalizer.ts | 0 .../normalizers/wrap-normalizer-fn.ts | 0 .../app-page-route-definition.ts | 7 + .../app-route-route-definition.ts | 5 + .../pages-api-route-definition.ts | 5 + .../pages-route-definition.ts | 5 + .../route-definitions/route-definition.ts} | 6 +- .../route-handler-manager.test.ts} | 68 +++--- .../route-handler-manager.ts | 36 ++++ .../route-handlers/app-page-route-handler.ts | 8 + .../app-route-route-handler.ts | 58 +++-- .../route-handlers/pages-api-route-handler.ts | 8 + .../route-handlers/pages-route-handler.ts | 8 + .../future/route-handlers/route-handler.ts | 6 + .../src/server/{ => future}/route-kind.ts | 0 .../default-route-matcher-manager.test.ts | 167 +++++++++++++++ .../default-route-matcher-manager.ts | 199 ++++++++++++++++++ .../dev-route-matcher-manager.ts | 90 ++++++++ .../route-matcher-manager.ts | 56 +++++ .../app-page-route-matcher-provider.test.ts} | 46 ++-- .../app-page-route-matcher-provider.ts | 64 ++++++ .../app-route-route-matcher-provider.test.ts} | 24 +-- .../app-route-route-matcher-provider.ts | 51 +++++ ...ev-app-page-route-matcher-provider.test.ts | 89 ++++++++ .../dev-app-page-route-matcher-provider.ts | 101 +++++++++ ...-app-route-route-matcher-provider.test.ts} | 40 ++-- .../dev-app-route-route-matcher-provider.ts | 78 +++++++ ...-pages-api-route-matcher-provider.test.ts} | 57 ++--- .../dev-pages-api-route-matcher-provider.ts | 100 +++++++++ .../dev-pages-route-matcher-provider.test.ts} | 57 ++--- .../dev/dev-pages-route-matcher-provider.ts | 96 +++++++++ .../file-reader/default-file-reader.ts | 20 +- .../dev/helpers}/file-reader/file-reader.ts | 0 .../manifest-loaders/manifest-loader.ts | 2 +- .../manifest-loaders/node-manifest-loader.ts | 21 ++ .../pages-api-route-matcher-provider.test.ts} | 24 +-- .../pages-api-route-matcher-provider.ts | 49 +++++ .../pages-route-matcher-provider.test.ts | 180 ++++++++++++++++ .../pages-route-matcher-provider.ts | 83 ++++++++ .../route-matcher-provider.ts | 5 + .../route-matchers/app-page-route-matcher.ts | 11 + .../route-matchers/app-route-route-matcher.ts | 11 + .../route-matchers/pages-api-route-matcher.ts | 11 + .../route-matchers/pages-route-matcher.ts | 13 ++ .../future/route-matchers/route-matcher.ts | 39 ++++ .../route-matches/app-page-route-match.ts | 4 + .../route-matches/app-route-route-match.ts | 5 + .../route-matches/pages-api-route-match.ts | 5 + .../future/route-matches/pages-route-match.ts | 4 + .../future/route-matches/route-match.ts | 17 ++ .../manifest-loaders/node-manifest-loader.ts | 11 - packages/next/src/server/next-server.ts | 117 +++++----- .../normalizers/locale-route-normalizer.ts | 41 ---- .../server/route-handlers/route-handler.ts | 22 -- .../server/route-handlers/route-handlers.ts | 36 ---- .../default-route-matcher-manager.test.ts | 133 ------------ .../default-route-matcher-manager.ts | 154 -------------- .../dev-route-matcher-manager.ts | 64 ------ .../route-matcher-manager.ts | 12 -- .../route-matchers/app-page-route-matcher.ts | 55 ----- .../route-matchers/app-route-route-matcher.ts | 49 ----- .../dev-app-page-route-matcher.test.ts | 67 ------ .../dev-app-page-route-matcher.ts | 43 ---- .../dev-app-route-route-matcher.ts | 45 ---- .../route-matchers/dev-fs-route-matcher.ts | 112 ---------- .../dev-pages-api-route-matcher.ts | 72 ------- .../route-matchers/dev-pages-route-matcher.ts | 60 ------ .../route-matchers/pages-api-route-matcher.ts | 46 ---- .../pages-route-matcher.test.ts | 69 ------ .../route-matchers/pages-route-matcher.ts | 48 ----- .../src/server/route-matches/route-match.ts | 23 -- packages/next/src/server/router.ts | 47 +---- packages/next/src/server/web-server.ts | 8 +- 86 files changed, 2017 insertions(+), 1568 deletions(-) rename packages/next/src/server/{ => future/helpers}/module-loader/module-loader.ts (100%) rename packages/next/src/server/{ => future/helpers}/module-loader/node-module-loader.ts (100%) rename packages/next/src/server/{route-handlers/app-route/handlers.ts => future/helpers/response-handlers.ts} (100%) rename packages/next/src/server/{ => future}/normalizers/absolute-filename-normalizer.test.ts (100%) rename packages/next/src/server/{ => future}/normalizers/absolute-filename-normalizer.ts (77%) create mode 100644 packages/next/src/server/future/normalizers/locale-route-normalizer.ts rename packages/next/src/server/{ => future}/normalizers/normalizer.ts (100%) rename packages/next/src/server/{ => future}/normalizers/normalizers.ts (100%) rename packages/next/src/server/{ => future}/normalizers/prefixing-normalizer.ts (100%) rename packages/next/src/server/{ => future}/normalizers/wrap-normalizer-fn.ts (100%) create mode 100644 packages/next/src/server/future/route-definitions/app-page-route-definition.ts create mode 100644 packages/next/src/server/future/route-definitions/app-route-route-definition.ts create mode 100644 packages/next/src/server/future/route-definitions/pages-api-route-definition.ts create mode 100644 packages/next/src/server/future/route-definitions/pages-route-definition.ts rename packages/next/src/server/{route-matchers/route-matcher.ts => future/route-definitions/route-definition.ts} (51%) rename packages/next/src/server/{route-handlers/route-handlers.test.ts => future/route-handler-managers/route-handler-manager.test.ts} (59%) create mode 100644 packages/next/src/server/future/route-handler-managers/route-handler-manager.ts create mode 100644 packages/next/src/server/future/route-handlers/app-page-route-handler.ts rename packages/next/src/server/{route-handlers/app-route => future/route-handlers}/app-route-route-handler.ts (91%) create mode 100644 packages/next/src/server/future/route-handlers/pages-api-route-handler.ts create mode 100644 packages/next/src/server/future/route-handlers/pages-route-handler.ts create mode 100644 packages/next/src/server/future/route-handlers/route-handler.ts rename packages/next/src/server/{ => future}/route-kind.ts (100%) create mode 100644 packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts create mode 100644 packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts create mode 100644 packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts create mode 100644 packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts rename packages/next/src/server/{route-matchers/app-page-route-matcher.test.ts => future/route-matcher-providers/app-page-route-matcher-provider.test.ts} (58%) create mode 100644 packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts rename packages/next/src/server/{route-matchers/app-route-route-matcher.test.ts => future/route-matcher-providers/app-route-route-matcher-provider.test.ts} (68%) create mode 100644 packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts rename packages/next/src/server/{route-matchers/dev-app-route-route-matcher.test.ts => future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts} (51%) create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts rename packages/next/src/server/{route-matchers/dev-pages-api-route-matcher.test.ts => future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts} (55%) create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts rename packages/next/src/server/{route-matchers/dev-pages-route-matcher.test.ts => future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts} (54%) create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts rename packages/next/src/server/{route-matchers => future/route-matcher-providers/dev/helpers}/file-reader/default-file-reader.ts (66%) rename packages/next/src/server/{route-matchers => future/route-matcher-providers/dev/helpers}/file-reader/file-reader.ts (100%) rename packages/next/src/server/{ => future/route-matcher-providers/helpers}/manifest-loaders/manifest-loader.ts (56%) create mode 100644 packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts rename packages/next/src/server/{route-matchers/pages-api-route-matcher.test.ts => future/route-matcher-providers/pages-api-route-matcher-provider.test.ts} (68%) create mode 100644 packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts create mode 100644 packages/next/src/server/future/route-matchers/app-page-route-matcher.ts create mode 100644 packages/next/src/server/future/route-matchers/app-route-route-matcher.ts create mode 100644 packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts create mode 100644 packages/next/src/server/future/route-matchers/pages-route-matcher.ts create mode 100644 packages/next/src/server/future/route-matchers/route-matcher.ts create mode 100644 packages/next/src/server/future/route-matches/app-page-route-match.ts create mode 100644 packages/next/src/server/future/route-matches/app-route-route-match.ts create mode 100644 packages/next/src/server/future/route-matches/pages-api-route-match.ts create mode 100644 packages/next/src/server/future/route-matches/pages-route-match.ts create mode 100644 packages/next/src/server/future/route-matches/route-match.ts delete mode 100644 packages/next/src/server/manifest-loaders/node-manifest-loader.ts delete mode 100644 packages/next/src/server/normalizers/locale-route-normalizer.ts delete mode 100644 packages/next/src/server/route-handlers/route-handler.ts delete mode 100644 packages/next/src/server/route-handlers/route-handlers.ts delete mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts delete mode 100644 packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matcher-managers/route-matcher-manager.ts delete mode 100644 packages/next/src/server/route-matchers/app-page-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/app-route-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts delete mode 100644 packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/dev-fs-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/dev-pages-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/pages-api-route-matcher.ts delete mode 100644 packages/next/src/server/route-matchers/pages-route-matcher.test.ts delete mode 100644 packages/next/src/server/route-matchers/pages-route-matcher.ts delete mode 100644 packages/next/src/server/route-matches/route-match.ts diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index fd0fece212a75..7db3fb215abf7 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -215,7 +215,7 @@ export function getAppEntry(opts: { name: string pagePath: string appDir: string - appPaths: string[] | null + appPaths: ReadonlyArray | null pageExtensions: string[] assetPrefix: string isDev?: boolean diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e6337edfb0070..5d2c8ce52d227 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -38,7 +38,7 @@ import { STATIC_STATUS_PAGES, TEMPORARY_REDIRECT_STATUS, } from '../shared/lib/constants' -import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' +import { isDynamicRoute } from '../shared/lib/router/utils' import { setLazyProp, getCookieParser, @@ -68,8 +68,6 @@ import { normalizeAppPath, normalizeRscPath, } from '../shared/lib/router/utils/app-paths' -import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' -import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { getHostname } from '../shared/lib/get-hostname' import { parseUrl as parseUrlUtil } from '../shared/lib/router/utils/parse-url' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' @@ -80,8 +78,8 @@ import { FLIGHT_PARAMETERS, FETCH_CACHE_HEADER, } from '../client/components/app-router-headers' -import type { RouteHandlers } from './route-handlers/route-handlers' -import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' +import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' +import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -224,7 +222,6 @@ export default abstract class Server { protected serverOptions: ServerOptions private responseCache: ResponseCacheBase protected router: Router - // protected dynamicRoutes?: DynamicRoutes protected appPathRoutes?: Record protected customRoutes: CustomRoutes protected serverComponentManifest?: any @@ -246,7 +243,6 @@ export default abstract class Server { query: NextParsedUrlQuery params: Params isAppPath: boolean - appPaths?: string[] | null sriEnabled?: boolean }): Promise protected abstract getFontManifest(): FontManifest | undefined @@ -312,11 +308,11 @@ export default abstract class Server { }): void protected readonly matchers: RouteMatcherManager - protected readonly handlers: RouteHandlers + protected readonly handlers: RouteHandlerManager protected abstract getRoutes(): { matchers: RouteMatcherManager - handlers: RouteHandlers + handlers: RouteHandlerManager } public constructor(options: ServerOptions) { @@ -425,6 +421,11 @@ export default abstract class Server { this.matchers = matchers this.handlers = handlers + // Start route compilation. We don't wait for the routes to finish loading + // because we use the `waitTillReady` promise below in `handleRequest` to + // wait. Also we can't `await` in the constructor. + matchers.reload() + this.customRoutes = this.getCustomRoutes() this.router = new Router(this.generateRoutes()) this.setAssetPrefix(assetPrefix) @@ -443,6 +444,9 @@ export default abstract class Server { parsedUrl?: NextUrlWithParsedQuery ): Promise { try { + // Wait for the matchers to be ready. + await this.matchers.waitTillReady() + // ensure cookies set in middleware are merged and // not overridden by API routes/getServerSideProps const _res = (res as any).originalResponse || res @@ -580,23 +584,10 @@ export default abstract class Server { let srcPathname = matchedPath const match = await this.matchers.match(matchedPath) if (match) { - srcPathname = match.pathname + srcPathname = match.route.pathname } const pageIsDynamic = typeof match?.params !== 'undefined' - // NOTE: converted to the new match syntax - // if ( - // !isDynamicRoute(srcPathname) && - // !(await this.hasPage(removeTrailingSlash(srcPathname))) - // ) { - // for (const dynamicRoute of this.dynamicRoutes || []) { - // if (dynamicRoute.match(srcPathname)) { - // srcPathname = dynamicRoute.page - // break - // } - // } - // } - const utils = getUtils({ pageIsDynamic, page: srcPathname, @@ -811,29 +802,6 @@ export default abstract class Server { return false } - protected getDynamicRoutes(): Array { - const addedPages = new Set() - - return getSortedRoutes( - [ - ...Object.keys(this.appPathRoutes || {}), - ...Object.keys(this.pagesManifest!), - ].map( - (page) => - normalizeLocalePath(page, this.nextConfig.i18n?.locales).pathname - ) - ) - .map((page) => { - if (addedPages.has(page) || !isDynamicRoute(page)) return null - addedPages.add(page) - return { - page, - match: getRouteMatcher(getRouteRegex(page)), - } - }) - .filter((item): item is RoutingItem => Boolean(item)) - } - protected getAppPathRoutes(): Record { const appPathRoutes: Record = {} @@ -1699,7 +1667,6 @@ export default abstract class Server { query, params: ctx.renderOpts.params || {}, isAppPath, - appPaths, sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, }) if (result) { @@ -1725,12 +1692,11 @@ export default abstract class Server { delete query._nextBubbleNoFallback try { - const match = await this.matchers.match(pathname) - if (match) { + for await (const match of this.matchers.matchAll(pathname)) { const result = await this.renderPageComponent( { ...ctx, - pathname: match.pathname, + pathname: match.route.pathname, renderOpts: { ...ctx.renderOpts, params: match.params, diff --git a/packages/next/src/server/dev/hot-reloader.ts b/packages/next/src/server/dev/hot-reloader.ts index 2f7fe49abf382..ebc49f388699c 100644 --- a/packages/next/src/server/dev/hot-reloader.ts +++ b/packages/next/src/server/dev/hot-reloader.ts @@ -49,8 +49,7 @@ import ws from 'next/dist/compiled/ws' import { promises as fs } from 'fs' import { getPageStaticInfo } from '../../build/analysis/get-page-static-info' import { UnwrapPromise } from '../../lib/coalesced-function' -import { RouteKind } from '../route-kind' -import { RouteMatch } from '../route-matches/route-match' +import { RouteMatch } from '../future/route-matches/route-match' function diff(a: Set, b: Set) { return new Set([...a].filter((v) => !b.has(v))) @@ -1155,7 +1154,7 @@ export default class HotReloader { page: string clientOnly: boolean appPaths?: string[] | null - match?: RouteMatch + match?: RouteMatch }): Promise { // Make sure we don't re-build or dispose prebuilt pages if (page !== '/_error' && BLOCKED_PAGES.indexOf(page) !== -1) { diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index c56c2c2a6070a..3471819e8d09b 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -78,16 +78,14 @@ import { getDefineEnv } from '../../build/webpack-config' import loadJsConfig from '../../build/load-jsconfig' import { formatServerError } from '../../lib/format-server-error' import { pageFiles } from '../../build/webpack/plugins/flight-types-plugin' -import { DevPagesRouteMatcher } from '../route-matchers/dev-pages-route-matcher' -import { DevPagesAPIRouteMatcher } from '../route-matchers/dev-pages-api-route-matcher' -import { DevAppPageRouteMatcher } from '../route-matchers/dev-app-page-route-matcher' -import { DevAppRouteRouteMatcher } from '../route-matchers/dev-app-route-route-matcher' import { DevRouteMatcherManager, RouteEnsurer, -} from '../route-matcher-managers/dev-route-matcher-manager' -import { DefaultRouteMatcherManager } from '../route-matcher-managers/default-route-matcher-manager' -import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' +} from '../future/route-matcher-managers/dev-route-matcher-manager' +import { DevPagesRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-pages-route-matcher-provider' +import { DevPagesAPIRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider' +import { DevAppPageRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-page-route-matcher-provider' +import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-route-route-matcher-provider' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -229,7 +227,7 @@ export default class DevServer extends Server { ensure: async (match) => { await this.hotReloader!.ensurePage({ match, - page: match.pathname, + page: match.route.page, clientOnly: false, }) }, @@ -239,27 +237,17 @@ export default class DevServer extends Server { const matchers = new DevRouteMatcherManager(routes.matchers, ensurer) const handlers = routes.handlers - // Grab the locale normalizer if it's set. - const localeNormalizer = - routes.matchers instanceof DefaultRouteMatcherManager - ? routes.matchers.localeNormalizer - : new LocaleRouteNormalizer(this.nextConfig.i18n?.locales) - const extensions = this.nextConfig.pageExtensions // If the pages directory is available, then configure those matchers. if (pagesDir) { - matchers.push( - new DevPagesRouteMatcher(pagesDir, extensions, localeNormalizer) - ) - matchers.push( - new DevPagesAPIRouteMatcher(pagesDir, extensions, localeNormalizer) - ) + matchers.push(new DevPagesRouteMatcherProvider(pagesDir, extensions)) + matchers.push(new DevPagesAPIRouteMatcherProvider(pagesDir, extensions)) } if (appDir) { - matchers.push(new DevAppPageRouteMatcher(appDir, extensions)) - matchers.push(new DevAppRouteRouteMatcher(appDir, extensions)) + matchers.push(new DevAppPageRouteMatcherProvider(appDir, extensions)) + matchers.push(new DevAppRouteRouteMatcherProvider(appDir, extensions)) } return { matchers, handlers } @@ -692,9 +680,10 @@ export default class DevServer extends Server { } this.sortedRoutes = sortedRoutes - await this.matchers.compile() + // Reload the matchers. The filesystem would have been written to, + // and the matchers need to re-scan it to update the router. + await this.matchers.reload() - // this.router.setDynamicRoutes(dynamicRoutes) this.router.setCatchallMiddleware( this.generateCatchAllMiddlewareRoute(true) ) @@ -781,6 +770,7 @@ export default class DevServer extends Server { await this.addExportPathMapRoutes() await this.hotReloader.start() await this.startWatcher() + await this.matchers.reload() this.setDevReady!() if (this.nextConfig.experimental.nextScriptWorkers) { @@ -1370,11 +1360,6 @@ export default class DevServer extends Server { return [] } - // In development dynamic routes cannot be known ahead of time - protected getDynamicRoutes(): never[] { - return [] - } - _filterAmpDevelopmentScript( html: string, event: { line: number; col: number; code: string } @@ -1454,10 +1439,6 @@ export default class DevServer extends Server { } } - protected async ensureApiPage(pathname: string): Promise { - return this.hotReloader!.ensurePage({ page: pathname, clientOnly: false }) - } - private persistPatchedGlobals(): void { this.originalFetch = global.fetch } @@ -1471,13 +1452,11 @@ export default class DevServer extends Server { query, params, isAppPath, - appPaths, }: { pathname: string query: ParsedUrlQuery params: Params isAppPath: boolean - appPaths?: string[] | null }): Promise { await this.devReady const compilationErr = await this.getCompilationError(pathname) @@ -1486,12 +1465,6 @@ export default class DevServer extends Server { throw new WrappedBuildError(compilationErr) } try { - await this.hotReloader!.ensurePage({ - page: pathname, - appPaths, - clientOnly: false, - }) - // When the new page is compiled, we need to reload the server component // manifest. if (!!this.appDir) { diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 4e9e9abb840b8..3fa4623abc739 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -23,8 +23,9 @@ import { COMPILER_NAMES, RSC_MODULE_TYPES, } from '../../shared/lib/constants' -import { RouteKind } from '../route-kind' -import { RouteMatch } from '../route-matches/route-match' +import { RouteMatch } from '../future/route-matches/route-match' +import { RouteKind } from '../future/route-kind' +import { AppPageRouteMatch } from '../future/route-matches/app-page-route-match' const debug = origDebug('next:on-demand-entry-handler') @@ -152,7 +153,7 @@ interface Entry extends EntryType { * All parallel pages that match the same entry, for example: * ['/parallel/@bar/nested/@a/page', '/parallel/@bar/nested/@b/page', '/parallel/@foo/nested/@a/page', '/parallel/@foo/nested/@b/page'] */ - appPaths: string[] | null + appPaths: ReadonlyArray | null } interface ChildEntry extends EntryType { @@ -379,13 +380,15 @@ async function findRoutePathData( extensions: string[], pagesDir?: string, appDir?: string, - match?: RouteMatch + match?: RouteMatch ): ReturnType { if (match) { + // If the match is available, we don't have to discover the data from the + // filesystem. return { - absolutePagePath: match.filename, - page: match.page, - bundlePath: match.bundlePath, + absolutePagePath: match.route.filename, + page: match.route.page, + bundlePath: match.route.bundlePath, } } @@ -583,8 +586,8 @@ export function onDemandEntryHandler({ }: { page: string clientOnly: boolean - appPaths?: string[] | null - match?: RouteMatch + appPaths?: ReadonlyArray | null + match?: RouteMatch }): Promise { const stalledTime = 60 const stalledEnsureTimeout = setTimeout(() => { @@ -593,6 +596,13 @@ export function onDemandEntryHandler({ ) }, stalledTime * 1000) + // If the route is actually an app page route, then we should have access + // to the app route match, and therefore, the appPaths from it. + if (match?.route.kind === RouteKind.APP_PAGE) { + const { route } = match as AppPageRouteMatch + appPaths = route.appPaths + } + try { const pagePathData = await findRoutePathData( rootDir, diff --git a/packages/next/src/server/module-loader/module-loader.ts b/packages/next/src/server/future/helpers/module-loader/module-loader.ts similarity index 100% rename from packages/next/src/server/module-loader/module-loader.ts rename to packages/next/src/server/future/helpers/module-loader/module-loader.ts diff --git a/packages/next/src/server/module-loader/node-module-loader.ts b/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts similarity index 100% rename from packages/next/src/server/module-loader/node-module-loader.ts rename to packages/next/src/server/future/helpers/module-loader/node-module-loader.ts diff --git a/packages/next/src/server/route-handlers/app-route/handlers.ts b/packages/next/src/server/future/helpers/response-handlers.ts similarity index 100% rename from packages/next/src/server/route-handlers/app-route/handlers.ts rename to packages/next/src/server/future/helpers/response-handlers.ts diff --git a/packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts similarity index 100% rename from packages/next/src/server/normalizers/absolute-filename-normalizer.test.ts rename to packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts diff --git a/packages/next/src/server/normalizers/absolute-filename-normalizer.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts similarity index 77% rename from packages/next/src/server/normalizers/absolute-filename-normalizer.ts rename to packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts index 99b501ca3699f..fe3e0dd5a5800 100644 --- a/packages/next/src/server/normalizers/absolute-filename-normalizer.ts +++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts @@ -1,7 +1,7 @@ import path from 'path' -import { ensureLeadingSlash } from '../../shared/lib/page-path/ensure-leading-slash' -import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' -import { removePagePathTail } from '../../shared/lib/page-path/remove-page-path-tail' +import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' +import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' +import { removePagePathTail } from '../../../shared/lib/page-path/remove-page-path-tail' import { Normalizer } from './normalizer' /** diff --git a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts new file mode 100644 index 0000000000000..03f7bc2093391 --- /dev/null +++ b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts @@ -0,0 +1,50 @@ +import { Normalizer } from './normalizer' + +export interface LocaleRouteNormalizer extends Normalizer { + readonly locales: ReadonlyArray + readonly defaultLocale: string + match(pathname: string): { detectedLocale?: string; pathname: string } +} + +export class LocaleRouteNormalizer implements Normalizer { + private readonly lowerCase: ReadonlyArray + + constructor( + public readonly locales: ReadonlyArray, + public readonly defaultLocale: string + ) { + this.lowerCase = locales.map((locale) => locale.toLowerCase()) + } + + public match(pathname: string): { + detectedLocale?: string + pathname: string + } { + if (this.locales.length === 0) return { pathname } + + // The first segment will be empty, because it has a leading `/`. If + // there is no further segment, there is no locale. + const segments = pathname.split('/') + if (!segments[1]) return { pathname } + + // The second segment will contain the locale part if any. + const segment = segments[1].toLowerCase() + + // See if the segment matches one of the locales. + const index = this.lowerCase.indexOf(segment) + if (index < 0) return { pathname } + + // Return the case-sensitive locale. + const detectedLocale = this.locales[index] + + // Remove the `/${locale}` part of the pathname. + pathname = pathname.slice(detectedLocale.length + 1) || '/' + + return { detectedLocale, pathname } + } + + public normalize(pathname: string): string { + const match = this.match(pathname) + return match.pathname + } +} diff --git a/packages/next/src/server/normalizers/normalizer.ts b/packages/next/src/server/future/normalizers/normalizer.ts similarity index 100% rename from packages/next/src/server/normalizers/normalizer.ts rename to packages/next/src/server/future/normalizers/normalizer.ts diff --git a/packages/next/src/server/normalizers/normalizers.ts b/packages/next/src/server/future/normalizers/normalizers.ts similarity index 100% rename from packages/next/src/server/normalizers/normalizers.ts rename to packages/next/src/server/future/normalizers/normalizers.ts diff --git a/packages/next/src/server/normalizers/prefixing-normalizer.ts b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts similarity index 100% rename from packages/next/src/server/normalizers/prefixing-normalizer.ts rename to packages/next/src/server/future/normalizers/prefixing-normalizer.ts diff --git a/packages/next/src/server/normalizers/wrap-normalizer-fn.ts b/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts similarity index 100% rename from packages/next/src/server/normalizers/wrap-normalizer-fn.ts rename to packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts diff --git a/packages/next/src/server/future/route-definitions/app-page-route-definition.ts b/packages/next/src/server/future/route-definitions/app-page-route-definition.ts new file mode 100644 index 0000000000000..d76ea63a6f812 --- /dev/null +++ b/packages/next/src/server/future/route-definitions/app-page-route-definition.ts @@ -0,0 +1,7 @@ +import { RouteKind } from '../route-kind' +import { RouteDefinition } from './route-definition' + +export interface AppPageRouteDefinition + extends RouteDefinition { + readonly appPaths: ReadonlyArray +} diff --git a/packages/next/src/server/future/route-definitions/app-route-route-definition.ts b/packages/next/src/server/future/route-definitions/app-route-route-definition.ts new file mode 100644 index 0000000000000..f72e385dac743 --- /dev/null +++ b/packages/next/src/server/future/route-definitions/app-route-route-definition.ts @@ -0,0 +1,5 @@ +import { RouteKind } from '../route-kind' +import { RouteDefinition } from './route-definition' + +export interface AppRouteRouteDefinition + extends RouteDefinition {} diff --git a/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts new file mode 100644 index 0000000000000..f370476d591f6 --- /dev/null +++ b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts @@ -0,0 +1,5 @@ +import { RouteKind } from '../route-kind' +import { RouteDefinition } from './route-definition' + +export interface PagesAPIRouteDefinition + extends RouteDefinition {} diff --git a/packages/next/src/server/future/route-definitions/pages-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-route-definition.ts new file mode 100644 index 0000000000000..b2d2e5fdf5933 --- /dev/null +++ b/packages/next/src/server/future/route-definitions/pages-route-definition.ts @@ -0,0 +1,5 @@ +import { RouteKind } from '../route-kind' +import { RouteDefinition } from './route-definition' + +export interface PagesRouteDefinition + extends RouteDefinition {} diff --git a/packages/next/src/server/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-definitions/route-definition.ts similarity index 51% rename from packages/next/src/server/route-matchers/route-matcher.ts rename to packages/next/src/server/future/route-definitions/route-definition.ts index bada18bcee28e..91602791178da 100644 --- a/packages/next/src/server/route-matchers/route-matcher.ts +++ b/packages/next/src/server/future/route-definitions/route-definition.ts @@ -1,13 +1,9 @@ import { RouteKind } from '../route-kind' -export interface RouteDefinition { +export interface RouteDefinition { readonly kind: K readonly bundlePath: string readonly filename: string readonly page: string readonly pathname: string } - -export interface RouteMatcher { - routes(): Promise>> -} diff --git a/packages/next/src/server/route-handlers/route-handlers.test.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts similarity index 59% rename from packages/next/src/server/route-handlers/route-handlers.test.ts rename to packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts index a295548333de8..c8ccada6a3e65 100644 --- a/packages/next/src/server/route-handlers/route-handlers.test.ts +++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts @@ -1,22 +1,24 @@ -import { BaseNextRequest, BaseNextResponse } from '../base-http' +import { BaseNextRequest, BaseNextResponse } from '../../base-http' import { RouteKind } from '../route-kind' import { RouteMatch } from '../route-matches/route-match' -import { RouteHandlers } from './route-handlers' +import { RouteHandlerManager } from './route-handler-manager' const req = {} as BaseNextRequest const res = {} as BaseNextResponse -describe('RouteHandlers', () => { +describe('RouteHandlerManager', () => { it('will return false when there are no handlers', async () => { - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() expect( await handlers.handle( { - kind: RouteKind.PAGES, - filename: '/index.js', - pathname: '/', - bundlePath: '', - page: '', + route: { + kind: RouteKind.PAGES, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, }, req, res @@ -25,18 +27,20 @@ describe('RouteHandlers', () => { }) it('will return false when there is no matching handler', async () => { - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() const handler = { handle: jest.fn() } handlers.set(RouteKind.APP_PAGE, handler) expect( await handlers.handle( { - kind: RouteKind.PAGES, - filename: '/index.js', - pathname: '/', - bundlePath: '', - page: '', + route: { + kind: RouteKind.PAGES, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, }, req, res @@ -46,16 +50,18 @@ describe('RouteHandlers', () => { }) it('will return true when there is a matching handler', async () => { - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() const handler = { handle: jest.fn() } handlers.set(RouteKind.APP_PAGE, handler) - const route: RouteMatch = { - kind: RouteKind.APP_PAGE, - filename: '/index.js', - pathname: '/', - bundlePath: '', - page: '', + const route: RouteMatch = { + route: { + kind: RouteKind.APP_PAGE, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, } expect(await handlers.handle(route, req, res)).toEqual(true) @@ -63,7 +69,7 @@ describe('RouteHandlers', () => { }) it('will throw when multiple handlers are added for the same type', () => { - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() const handler = { handle: jest.fn() } expect(() => handlers.set(RouteKind.APP_PAGE, handler)).not.toThrow() expect(() => handlers.set(RouteKind.APP_ROUTE, handler)).not.toThrow() @@ -72,18 +78,20 @@ describe('RouteHandlers', () => { }) it('will call the correct handler', async () => { - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() const goodHandler = { handle: jest.fn() } const badHandler = { handle: jest.fn() } handlers.set(RouteKind.APP_PAGE, goodHandler) handlers.set(RouteKind.APP_ROUTE, badHandler) - const route: RouteMatch = { - kind: RouteKind.APP_PAGE, - filename: '/index.js', - pathname: '/', - bundlePath: '', - page: '', + const route: RouteMatch = { + route: { + kind: RouteKind.APP_PAGE, + filename: '/index.js', + pathname: '/', + bundlePath: '', + page: '', + }, } expect(await handlers.handle(route, req, res)).toEqual(true) diff --git a/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts new file mode 100644 index 0000000000000..3031078b7dabb --- /dev/null +++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts @@ -0,0 +1,36 @@ +import { BaseNextRequest, BaseNextResponse } from '../../base-http' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteDefinition } from '../route-definitions/route-definition' +import { RouteHandler } from '../route-handlers/route-handler' + +export class RouteHandlerManager { + private readonly handlers: Partial<{ + [K in RouteKind]: RouteHandler + }> = {} + + public set< + K extends RouteKind, + D extends RouteDefinition, + M extends RouteMatch, + H extends RouteHandler + >(kind: K, handler: H) { + if (kind in this.handlers) { + throw new Error('Invariant: duplicate route handler added for kind') + } + + this.handlers[kind] = handler + } + + public async handle( + match: RouteMatch, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise { + const handler = this.handlers[match.route.kind] + if (!handler) return false + + await handler.handle(match, req, res) + return true + } +} diff --git a/packages/next/src/server/future/route-handlers/app-page-route-handler.ts b/packages/next/src/server/future/route-handlers/app-page-route-handler.ts new file mode 100644 index 0000000000000..3f0430d1fd6e6 --- /dev/null +++ b/packages/next/src/server/future/route-handlers/app-page-route-handler.ts @@ -0,0 +1,8 @@ +import { AppPageRouteMatch } from '../route-matches/app-page-route-match' +import { RouteHandler } from './route-handler' + +export class AppPageRouteHandler implements RouteHandler { + public async handle(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts similarity index 91% rename from packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts rename to packages/next/src/server/future/route-handlers/app-route-route-handler.ts index f34de562372d0..ceec4e2af9388 100644 --- a/packages/next/src/server/route-handlers/app-route/app-route-route-handler.ts +++ b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts @@ -4,34 +4,33 @@ import { isRedirectError, } from '../../../client/components/redirect' import { - handleBadRequestResponse, - handleInternalServerErrorResponse, - handleMethodNotAllowedResponse, - handleNotFoundResponse, - handleTemporaryRedirectResponse, -} from './handlers' + RequestAsyncStorage, + RequestStore, +} from '../../../client/components/request-async-storage' +import { Params } from '../../../shared/lib/router/utils/route-matcher' +import { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' import { RequestAsyncStorageWrapper, RequestContext, } from '../../async-storage/request-async-storage-wrapper' -import type { BaseNextRequest, BaseNextResponse } from '../../base-http' -import type { NodeNextRequest, NodeNextResponse } from '../../base-http/node' +import { BaseNextRequest, BaseNextResponse } from '../../base-http' +import { NodeNextRequest, NodeNextResponse } from '../../base-http/node' import { getRequestMeta } from '../../request-meta' -import * as Log from '../../../build/output/log' +import { + handleBadRequestResponse, + handleInternalServerErrorResponse, + handleMethodNotAllowedResponse, + handleNotFoundResponse, + handleTemporaryRedirectResponse, +} from '../helpers/response-handlers' +import { AppRouteRouteMatch } from '../route-matches/app-route-route-match' import { HTTP_METHOD, isHTTPMethod } from '../../web/http' import { NextRequest } from '../../web/spec-extension/request' import { fromNodeHeaders } from '../../web/utils' -import type { RouteHandlerFn, RouteHandler } from '../route-handler' -import type { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' -import type { - RequestAsyncStorage, - RequestStore, -} from '../../../client/components/request-async-storage' -import type { RouteMatch } from '../../route-matches/route-match' -import type { ModuleLoader } from '../../module-loader/module-loader' -import { NodeModuleLoader } from '../../module-loader/node-module-loader' -import type { Params } from '../../../shared/lib/router/utils/route-matcher' -import type { RouteKind } from '../../route-kind' +import { ModuleLoader } from '../helpers/module-loader/module-loader' +import { NodeModuleLoader } from '../helpers/module-loader/node-module-loader' +import { RouteHandler } from './route-handler' +import * as Log from '../../../build/output/log' /** * Handler function for app routes. @@ -156,10 +155,7 @@ async function sendResponse( } } -/** - * - */ -export class AppRouteRouteHandler implements RouteHandler { +export class AppRouteRouteHandler implements RouteHandler { constructor( private readonly requestAsyncLocalStorageWrapper: AsyncStorageWrapper< RequestStore, @@ -229,7 +225,7 @@ export class AppRouteRouteHandler implements RouteHandler { } private async execute( - { params }: RouteMatch, + { params }: AppRouteRouteMatch, module: AppRouteModule, req: BaseNextRequest, res: BaseNextResponse @@ -294,15 +290,15 @@ export class AppRouteRouteHandler implements RouteHandler { return response } - public handle: RouteHandlerFn = async ( - match, - req, - res - ) => { + public async handle( + match: AppRouteRouteMatch, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise { try { // Load the module using the module loader. const module: AppRouteModule = await this.moduleLoader.load( - match.filename + match.route.filename ) // TODO: patch fetch diff --git a/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts b/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts new file mode 100644 index 0000000000000..7887161394d8d --- /dev/null +++ b/packages/next/src/server/future/route-handlers/pages-api-route-handler.ts @@ -0,0 +1,8 @@ +import { PagesAPIRouteMatch } from '../route-matches/pages-api-route-match' +import { RouteHandler } from './route-handler' + +export class PagesAPIRouteHandler implements RouteHandler { + public async handle(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/packages/next/src/server/future/route-handlers/pages-route-handler.ts b/packages/next/src/server/future/route-handlers/pages-route-handler.ts new file mode 100644 index 0000000000000..f33efb0850f4a --- /dev/null +++ b/packages/next/src/server/future/route-handlers/pages-route-handler.ts @@ -0,0 +1,8 @@ +import { PagesRouteMatch } from '../route-matches/pages-route-match' +import { RouteHandler } from './route-handler' + +export class PagesRouteHandler implements RouteHandler { + public async handle(): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/packages/next/src/server/future/route-handlers/route-handler.ts b/packages/next/src/server/future/route-handlers/route-handler.ts new file mode 100644 index 0000000000000..7a553fd872a2d --- /dev/null +++ b/packages/next/src/server/future/route-handlers/route-handler.ts @@ -0,0 +1,6 @@ +import { BaseNextRequest, BaseNextResponse } from '../../base-http' +import { RouteMatch } from '../route-matches/route-match' + +export interface RouteHandler { + handle(match: M, req: BaseNextRequest, res: BaseNextResponse): Promise +} diff --git a/packages/next/src/server/route-kind.ts b/packages/next/src/server/future/route-kind.ts similarity index 100% rename from packages/next/src/server/route-kind.ts rename to packages/next/src/server/future/route-kind.ts diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts new file mode 100644 index 0000000000000..d6468c2c7f1db --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts @@ -0,0 +1,167 @@ +import { DefaultRouteMatcherManager } from './default-route-matcher-manager' + +describe('DefaultRouteMatcherManager', () => { + it('will throw an error when used before it has been reloaded', async () => { + const manager = new DefaultRouteMatcherManager() + await expect(manager.match('/some/not/real/path')).resolves.toEqual(null) + manager.push({ matchers: jest.fn(async () => []) }) + await expect(manager.match('/some/not/real/path')).rejects.toThrow() + await manager.reload() + await expect(manager.match('/some/not/real/path')).resolves.toEqual(null) + }) + + it('will not error and not match when no matchers are provided', async () => { + const matchers = new DefaultRouteMatcherManager() + await matchers.reload() + await expect(matchers.match('/some/not/real/path')).resolves.toEqual(null) + }) +}) + +// TODO: port tests +/* eslint-disable jest/no-commented-out-tests */ + +// describe('DefaultRouteMatcherManager', () => { +// describe('static routes', () => { +// it.each([ +// ['/some/static/route', '/some/static/route.js'], +// ['/some/other/static/route', '/some/other/static/route.js'], +// ])('will match %s to %s', async (pathname, filename) => { +// const matchers = new DefaultRouteMatcherManager() + +// matchers.push({ +// routes: async () => [ +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/some/other/static/route', +// filename: '/some/other/static/route.js', +// bundlePath: '', +// page: '', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/some/static/route', +// filename: '/some/static/route.js', +// bundlePath: '', +// page: '', +// }, +// ], +// }) + +// await matchers.compile() + +// expect(await matchers.match(pathname)).toEqual({ +// kind: RouteKind.APP_ROUTE, +// pathname, +// filename, +// bundlePath: '', +// page: '', +// }) +// }) +// }) + +// describe('async generator', () => { +// it('will match', async () => { +// const matchers = new DefaultRouteMatcherManager() + +// matchers.push({ +// routes: async () => [ +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/account/[[...slug]]', +// filename: '/account/[[...slug]].js', +// bundlePath: '', +// page: '', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/blog/[[...slug]]', +// filename: '/blog/[[...slug]].js', +// bundlePath: '', +// page: '', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/[[...optional]]', +// filename: '/[[...optional]].js', +// bundlePath: '', +// page: '', +// }, +// ], +// }) + +// await matchers.compile() + +// const matches: string[] = [] + +// for await (const match of matchers.each('/blog/some-other-path')) { +// matches.push(match.definition.filename) +// } + +// expect(matches).toHaveLength(2) +// expect(matches[0]).toEqual('/blog/[[...slug]].js') +// expect(matches[1]).toEqual('/[[...optional]].js') +// }) +// }) + +// describe('dynamic routes', () => { +// it.each([ +// { +// pathname: '/users/123', +// route: { +// pathname: '/users/[id]', +// filename: '/users/[id].js', +// params: { id: '123' }, +// }, +// }, +// { +// pathname: '/account/123', +// route: { +// pathname: '/[...paths]', +// filename: '/[...paths].js', +// params: { paths: ['account', '123'] }, +// }, +// }, +// { +// pathname: '/dashboard/users/123', +// route: { +// pathname: '/[...paths]', +// filename: '/[...paths].js', +// params: { paths: ['dashboard', 'users', '123'] }, +// }, +// }, +// ])( +// "will match '$pathname' to '$route.filename'", +// async ({ pathname, route }) => { +// const matchers = new DefaultRouteMatcherManager() + +// matchers.push({ +// routes: async () => [ +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/[...paths]', +// filename: '/[...paths].js', +// bundlePath: '', +// page: '', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/users/[id]', +// filename: '/users/[id].js', +// bundlePath: '', +// page: '', +// }, +// ], +// }) + +// await matchers.compile() + +// expect(await matchers.match(pathname)).toEqual({ +// kind: RouteKind.APP_ROUTE, +// bundlePath: '', +// page: '', +// ...route, +// }) +// } +// ) +// }) +// }) diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts new file mode 100644 index 0000000000000..360165fa4029e --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -0,0 +1,199 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { isDynamicRoute } from '../../../shared/lib/router/utils' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteDefinition } from '../route-definitions/route-definition' +import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' +import { RouteMatcher } from '../route-matchers/route-matcher' +import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' +import { getSortedRoutes } from '../../../shared/lib/router/utils' + +export class DefaultRouteMatcherManager implements RouteMatcherManager { + private readonly providers: Array = [] + private readonly matchers: { + static: ReadonlyArray + dynamic: ReadonlyArray + } = { static: [], dynamic: [] } + private lastCompilationID = this.compilationID + + /** + * When this value changes, it indicates that a change has been introduced + * that requires recompilation. + */ + private get compilationID() { + return this.providers.length + } + + private waitTillReadyPromise?: Promise + public waitTillReady(): Promise { + return this.waitTillReadyPromise ?? Promise.resolve() + } + + public async reload() { + let callbacks: { resolve: Function; reject: Function } + this.waitTillReadyPromise = new Promise((resolve, reject) => { + callbacks = { resolve, reject } + }) + + try { + // Grab the compilation ID for this run, we'll verify it at the end to + // ensure that if any routes were added before reloading is finished that + // we error out. + const compilationID = this.compilationID + + // Collect all the matchers from each provider. + const matchers: Array = [] + + // Use this to detect duplicate pathnames. + const all = new Set() + for (const provider of this.providers) { + for (const matcher of await provider.matchers()) { + if (all.has(matcher.route.pathname)) { + // TODO: when a duplicate route is detected, what should we do? + throw new Error( + `Invariant: duplicate pathname detected '${matcher.route.pathname}', remove one of the conflicting files` + ) + } + + matchers.push(matcher) + + // Add the matcher's pathname to the set. + all.add(matcher.route.pathname) + } + } + + // For matchers that are for static routes, filter them now. + this.matchers.static = matchers.filter((matcher) => !matcher.isDynamic) + + // For matchers that are for dynamic routes, filter them and sort them now. + const dynamic = matchers.filter((matcher) => matcher.isDynamic) + + /** + * Because `getSortedRoutes` only accepts an array of strings, we need to + * build a reference between the pathnames used for dynamic routing and the + * underlying matchers used to perform the match for each route. We take the + * fact that the pathnames are unique to build a reference of their original + * index in the array so that when we call `getSortedRoutes`, we can lookup + * the associated matcher. + */ + + // Generate a filename to index map, this will be used to re-sort the array. + const indexes = new Map() + const pathnames = new Array(dynamic.length) + for (let index = 0; index < dynamic.length; index++) { + const pathname = dynamic[index].route.pathname + if (indexes.has(pathname)) { + throw new Error('Invariant: duplicate dynamic route detected') + } + + indexes.set(pathname, index) + pathnames[index] = pathname + } + + // Sort the array of pathnames. + const sorted = getSortedRoutes(pathnames) + const sortedDynamicMatchers = new Array(sorted.length) + for (let i = 0; i < sorted.length; i++) { + const pathname = sorted[i] + + const index = indexes.get(pathname) + if (typeof index !== 'number') { + throw new Error('Invariant: expected to find pathname in indexes map') + } + + sortedDynamicMatchers[i] = dynamic[index] + } + + this.matchers.dynamic = sortedDynamicMatchers + + // This means that there was a new matcher pushed while we were waiting + if (this.compilationID !== compilationID) { + throw new Error( + 'Invariant: expected compilation to finish before new matchers were added, possible missing await' + ) + } + + // The compilation ID matched, so mark the complication as finished. + this.lastCompilationID = compilationID + } catch (err) { + callbacks!.reject(err) + } finally { + callbacks!.resolve() + } + } + + public push(provider: RouteMatcherProvider): void { + this.providers.push(provider) + } + + public async test( + pathname: string, + options?: MatchOptions | undefined + ): Promise { + // See if there's a match for the pathname... + const match = await this.match(pathname, options) + + // This default implementation only needs to check to see if there _was_ a + // match. The development matcher actually changes it's behavior by not + // recompiling the routes. + return match !== null + } + + public async match( + pathname: string, + options?: MatchOptions + ): Promise> | null> { + // "Iterate" over the match options. Once we found a single match, exit with + // it, otherwise return null below. If no match is found, the inner block + // won't be called. + for await (const match of this.matchAll(pathname, options)) { + return match + } + + return null + } + + public async *matchAll( + pathname: string, + options?: MatchOptions | undefined + ): AsyncGenerator>, null, undefined> { + // Guard against the matcher manager from being run before it needs to be + // recompiled. This was preferred to re-running the compilation here because + // it should be re-ran only when it changes. If a match is attempted before + // this is done, it indicates that there is a case where a provider is added + // before it was recompiled (an error). We also don't want to affect request + // times. + if (this.lastCompilationID !== this.compilationID) { + throw new Error('Invariant: expected routes to be compiled before match') + } + + // If this pathname doesn't look like a dynamic route, and this pathname is + // listed in the normalized list of routes, then return it. This ensures + // that when a route like `/user/[id]` is encountered, it doesn't just match + // with the list of normalized routes. + if (!isDynamicRoute(pathname)) { + for (const matcher of this.matchers.static) { + const match = matcher.match(pathname) + if (!match) continue + + yield match + } + } + + // If we should skip handling dynamic routes, exit now. + if (options?.skipDynamic) return null + + // Loop over the dynamic matchers, yielding each match. + for (const matcher of this.matchers.dynamic) { + const match = matcher.match(pathname) + if (!match) continue + + yield match + } + + // We tried direct matching against the pathname and against all the dynamic + // paths, so there was no match. + return null + } +} diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts new file mode 100644 index 0000000000000..7d04e3c747a9b --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -0,0 +1,90 @@ +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteDefinition } from '../route-definitions/route-definition' +import { DefaultRouteMatcherManager } from './default-route-matcher-manager' +import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' + +export interface RouteEnsurer { + ensure(match: RouteMatch): Promise +} + +export class DevRouteMatcherManager extends DefaultRouteMatcherManager { + constructor( + private readonly production: RouteMatcherManager, + private readonly ensurer: RouteEnsurer + ) { + super() + } + + public async test( + pathname: string, + options?: MatchOptions + ): Promise { + // Try to find a match within the developer routes. + const match = await super.match(pathname, options) + + // Return if the match wasn't null. Unlike the implementation of `match` + // which uses `matchAll` here, this does not call `ensure` on the match + // found via the development matches. + return match !== null + } + + public async *matchAll( + pathname: string, + options?: MatchOptions + ): AsyncGenerator>, null, undefined> { + // Keep track of all the matches we've made. + const matches = new Set() + + // Iterate over the development matches to see if one of them match the + // request path. + for await (const development of super.matchAll(pathname, options)) { + // There was a development match! Let's check to see if we've already + // matched this one already (verified by comparing the bundlePath). + if (matches.has(development.route.bundlePath)) continue + + // We're here, which means that we haven't seen this match yet, so we + // should try to ensure it and recompile the production matcher. + await this.ensurer.ensure(development) + await this.production.reload() + + // Iterate over the production matches again, this time we should be able + // to match it against the production matcher. + let matchedProduction = false + for await (const production of this.production.matchAll( + pathname, + options + )) { + // We found a matching production match! It may have already been seen + // though, so let's skip if we have. + if (matches.has(production.route.bundlePath)) continue + + // Mark that we've matched in production. + matchedProduction = true + + // We found a matching production match! Add the match to the set of + // matches and yield this match to be used. + matches.add(production.route.bundlePath) + yield production + } + + if (!matchedProduction) { + throw new Error( + 'Invariant: development match was found, but not found after ensuring' + ) + } + } + + // We tried direct matching against the pathname and against all the dynamic + // paths, so there was no match. + return null + } + + public async reload(): Promise { + // Compile the production routes again. + await this.production.reload() + + // Compile the development routes. + await super.reload() + } +} diff --git a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts new file mode 100644 index 0000000000000..adc72b69765d0 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts @@ -0,0 +1,56 @@ +import { RouteMatch } from '../route-matches/route-match' +import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' + +export type MatchOptions = { skipDynamic?: boolean } + +export interface RouteMatcherManager { + /** + * Returns a promise that resolves when the matcher manager has finished + * reloading. + */ + waitTillReady(): Promise + + /** + * Pushes in a new matcher for this manager to manage. After all the + * providers have been pushed, the manager must be reloaded. + * + * @param provider the provider for this manager to also manage + */ + push(provider: RouteMatcherProvider): void + + /** + * Reloads the matchers from the providers. This should be done after all the + * providers have been added or the underlying providers should be refreshed. + */ + reload(): Promise + + /** + * Tests the underlying matchers to find a match. It does not return the + * match. + * + * @param pathname the pathname to test for matches + * @param options the options for the testing + */ + test(pathname: string, options?: MatchOptions): Promise + + /** + * Returns the first match for a given request. + * + * @param pathname the pathname to match against + * @param options the options for the matching + */ + match(pathname: string, options?: MatchOptions): Promise + + /** + * Returns a generator for each match for a given request. This should be + * consumed in a `for await (...)` loop, when finished, breaking or returning + * from the loop will terminate the matching operation. + * + * @param pathname the pathname to match against + * @param options the options for the matching + */ + matchAll( + pathname: string, + options?: MatchOptions + ): AsyncGenerator +} diff --git a/packages/next/src/server/route-matchers/app-page-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts similarity index 58% rename from packages/next/src/server/route-matchers/app-page-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts index 409039aa070c0..2b88e602cbc6c 100644 --- a/packages/next/src/server/route-matchers/app-page-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts @@ -1,20 +1,20 @@ -import { SERVER_DIRECTORY } from '../../shared/lib/constants' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' import { RouteKind } from '../route-kind' -import { AppPageRouteMatcher } from './app-page-route-matcher' -import { RouteDefinition } from './route-matcher' +import { AppPageRouteMatcherProvider } from './app-page-route-matcher-provider' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -describe('AppPageRouteMatcher', () => { +describe('AppPageRouteMatcherProvider', () => { it('returns no routes with an empty manifest', async () => { const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const matcher = new AppPageRouteMatcher('', loader) - expect(await matcher.routes()).toEqual([]) + const matcher = new AppPageRouteMatcherProvider('', loader) + await expect(matcher.matchers()).resolves.toEqual([]) }) describe('manifest matching', () => { it.each<{ manifest: Record - route: RouteDefinition + route: AppPageRouteDefinition }>([ { manifest: { @@ -26,6 +26,7 @@ describe('AppPageRouteMatcher', () => { filename: `/${SERVER_DIRECTORY}/app/page.js`, page: '/page', bundlePath: 'app/page', + appPaths: ['/page'], }, }, { @@ -38,6 +39,7 @@ describe('AppPageRouteMatcher', () => { filename: `/${SERVER_DIRECTORY}/app/(marketing)/about/page.js`, page: '/(marketing)/about/page', bundlePath: 'app/(marketing)/about/page', + appPaths: ['/(marketing)/about/page'], }, }, { @@ -50,6 +52,7 @@ describe('AppPageRouteMatcher', () => { filename: `/${SERVER_DIRECTORY}/app/dashboard/users/[id]/page.js`, page: '/dashboard/users/[id]/page', bundlePath: 'app/dashboard/users/[id]/page', + appPaths: ['/dashboard/users/[id]/page'], }, }, { @@ -60,6 +63,25 @@ describe('AppPageRouteMatcher', () => { filename: `/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, page: '/dashboard/users/page', bundlePath: 'app/dashboard/users/page', + appPaths: ['/dashboard/users/page'], + }, + }, + { + manifest: { + '/dashboard/users/page': 'app/dashboard/users/page.js', + '/(marketing)/dashboard/users/page': + 'app/(marketing)/dashboard/users/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/dashboard/users', + filename: `/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, + page: '/dashboard/users/page', + bundlePath: 'app/dashboard/users/page', + appPaths: [ + '/dashboard/users/page', + '/(marketing)/dashboard/users/page', + ], }, }, ])( @@ -72,12 +94,12 @@ describe('AppPageRouteMatcher', () => { ...manifest, })), } - const matcher = new AppPageRouteMatcher('', loader) - const routes = await matcher.routes() + const matcher = new AppPageRouteMatcherProvider('', loader) + const matchers = await matcher.matchers() expect(loader.load).toHaveBeenCalled() - expect(routes).toHaveLength(1) - expect(routes[0]).toEqual(route) + expect(matchers).toHaveLength(1) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts new file mode 100644 index 0000000000000..6e0f0b3a6b465 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts @@ -0,0 +1,64 @@ +import path from 'path' +import { isAppPageRoute } from '../../../lib/is-app-page-route' +import { + APP_PATHS_MANIFEST, + SERVER_DIRECTORY, +} from '../../../shared/lib/constants' +import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' +import { RouteKind } from '../route-kind' +import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' +import { RouteMatcherProvider } from './route-matcher-provider' + +export class AppPageRouteMatcherProvider + implements RouteMatcherProvider +{ + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async matchers(): Promise> { + const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + if (!manifest) return [] + + // This matcher only matches app pages. + const pages = Object.keys(manifest).filter((page) => isAppPageRoute(page)) + + // Collect all the app paths for each page. This could include any parallel + // routes. + const appPaths: Record = {} + for (const page of pages) { + const pathname = normalizeAppPath(page) + + if (pathname in appPaths) appPaths[pathname].push(page) + else appPaths[pathname] = [page] + } + + // Format the routes. + const matchers: Array = [] + for (const [pathname, paths] of Object.entries(appPaths)) { + // TODO-APP: (wyattjoh) this is a hack right now, should be more deterministic + const page = paths[0] + + const filename = path.join(this.distDir, SERVER_DIRECTORY, manifest[page]) + const bundlePath = path.join('app', page) + + matchers.push( + new AppPageRouteMatcher({ + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath, + filename, + appPaths: paths, + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/route-matchers/app-route-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts similarity index 68% rename from packages/next/src/server/route-matchers/app-route-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts index c9f451011cde5..1ec5fc22160bd 100644 --- a/packages/next/src/server/route-matchers/app-route-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts @@ -1,20 +1,20 @@ -import { SERVER_DIRECTORY } from '../../shared/lib/constants' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' import { RouteKind } from '../route-kind' -import { AppRouteRouteMatcher } from './app-route-route-matcher' -import { RouteDefinition } from './route-matcher' +import { AppRouteRouteMatcherProvider } from './app-route-route-matcher-provider' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -describe('AppRouteRouteMatcher', () => { +describe('AppRouteRouteMatcherProvider', () => { it('returns no routes with an empty manifest', async () => { const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const matcher = new AppRouteRouteMatcher('', loader) - expect(await matcher.routes()).toEqual([]) + const provider = new AppRouteRouteMatcherProvider('', loader) + expect(await provider.matchers()).toEqual([]) }) describe('manifest matching', () => { it.each<{ manifest: Record - route: RouteDefinition + route: AppRouteRouteDefinition }>([ { manifest: { @@ -58,11 +58,11 @@ describe('AppRouteRouteMatcher', () => { ...manifest, })), } - const matcher = new AppRouteRouteMatcher('', loader) - const routes = await matcher.routes() + const provider = new AppRouteRouteMatcherProvider('', loader) + const matchers = await provider.matchers() - expect(routes).toHaveLength(1) - expect(routes[0]).toEqual(route) + expect(matchers).toHaveLength(1) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts new file mode 100644 index 0000000000000..175555e3dcbeb --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts @@ -0,0 +1,51 @@ +import path from 'path' +import { isAppRouteRoute } from '../../../lib/is-app-route-route' +import { + APP_PATHS_MANIFEST, + SERVER_DIRECTORY, +} from '../../../shared/lib/constants' +import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' +import { RouteKind } from '../route-kind' +import { AppRouteRouteMatcher } from '../route-matchers/app-route-route-matcher' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' +import { RouteMatcherProvider } from './route-matcher-provider' + +export class AppRouteRouteMatcherProvider + implements RouteMatcherProvider +{ + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async matchers(): Promise> { + const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + if (!manifest) return [] + + // This matcher only matches app routes. + const pages = Object.keys(manifest).filter((page) => isAppRouteRoute(page)) + + // Format the routes. + const matchers: Array = [] + for (const page of pages) { + const pathname = normalizeAppPath(page) + const filename = path.join(this.distDir, SERVER_DIRECTORY, manifest[page]) + const bundlePath = path.join('app', page) + + matchers.push( + new AppRouteRouteMatcher({ + kind: RouteKind.APP_ROUTE, + pathname, + page, + bundlePath, + filename, + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts new file mode 100644 index 0000000000000..4f22c25ce2a66 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts @@ -0,0 +1,89 @@ +import { AppPageRouteDefinition } from '../../route-definitions/app-page-route-definition' +import { RouteKind } from '../../route-kind' +import { DevAppPageRouteMatcherProvider } from './dev-app-page-route-matcher-provider' +import { FileReader } from './helpers/file-reader/file-reader' + +describe('DevAppPageRouteMatcher', () => { + const dir = '' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const provider = new DevAppPageRouteMatcherProvider(dir, extensions, reader) + const matchers = await provider.matchers() + expect(matchers).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + }) + + describe('filename matching', () => { + it.each<{ + files: ReadonlyArray + route: AppPageRouteDefinition + }>([ + { + files: [`${dir}/(marketing)/about/page.ts`], + route: { + kind: RouteKind.APP_PAGE, + pathname: '/about', + filename: `${dir}/(marketing)/about/page.ts`, + page: '/(marketing)/about/page', + bundlePath: 'app/(marketing)/about/page', + appPaths: ['/(marketing)/about/page'], + }, + }, + { + files: [`${dir}/(marketing)/about/page.ts`], + route: { + kind: RouteKind.APP_PAGE, + pathname: '/about', + filename: `${dir}/(marketing)/about/page.ts`, + page: '/(marketing)/about/page', + bundlePath: 'app/(marketing)/about/page', + appPaths: ['/(marketing)/about/page'], + }, + }, + { + files: [`${dir}/some/other/page.ts`], + route: { + kind: RouteKind.APP_PAGE, + pathname: '/some/other', + filename: `${dir}/some/other/page.ts`, + page: '/some/other/page', + bundlePath: 'app/some/other/page', + appPaths: ['/some/other/page'], + }, + }, + { + files: [`${dir}/page.ts`], + route: { + kind: RouteKind.APP_PAGE, + pathname: '/', + filename: `${dir}/page.ts`, + page: '/page', + bundlePath: 'app/page', + appPaths: ['/page'], + }, + }, + ])( + "matches the '$route.page' route specified with the provided files", + async ({ files, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/some/route.${ext}`), + ...extensions.map((ext) => `${dir}/api/other.${ext}`), + ...files, + ]), + } + const provider = new DevAppPageRouteMatcherProvider( + dir, + extensions, + reader + ) + const matchers = await provider.matchers() + expect(matchers).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + expect(matchers[0].route).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts new file mode 100644 index 0000000000000..cef64832b67ae --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -0,0 +1,101 @@ +import { FileReader } from './helpers/file-reader/file-reader' +import { DefaultFileReader } from './helpers/file-reader/default-file-reader' +import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher' +import { RouteMatcherProvider } from '../route-matcher-provider' +import { Normalizer } from '../../normalizers/normalizer' +import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' +import { Normalizers } from '../../normalizers/normalizers' +import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' +import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' +import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' +import { RouteKind } from '../../route-kind' + +export class DevAppPageRouteMatcherProvider + implements RouteMatcherProvider +{ + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly appDir: string, + extensions: ReadonlyArray, + private readonly reader: FileReader = new DefaultFileReader() + ) { + // Match any page file that ends with `/page.${extension}` under the app + // directory. + this.expression = new RegExp(`\\/page\\.(?:${extensions.join('|')})$`) + + const pageNormalizer = new AbsoluteFilenameNormalizer(appDir, extensions) + + this.normalizers = { + page: pageNormalizer, + pathname: new Normalizers([ + pageNormalizer, + // The pathname to match should have the trailing `/page` and other route + // group information stripped from it. + wrapNormalizerFn(normalizeAppPath), + ]), + bundlePath: new Normalizers([ + pageNormalizer, + // Prefix the bundle path with `app/`. + new PrefixingNormalizer('app'), + ]), + } + } + + public async matchers(): Promise> { + // Read the files in the pages directory... + const files = await this.reader.read(this.appDir) + + // Collect all the app paths for each page. This could include any parallel + // routes. + const cache = new Map< + string, + { page: string; pathname: string; bundlePath: string } + >() + const appPaths: Record = {} + for (const filename of files) { + const page = this.normalizers.page.normalize(filename) + const pathname = this.normalizers.pathname.normalize(filename) + const bundlePath = this.normalizers.bundlePath.normalize(filename) + + // Save the normalization results. + cache.set(filename, { page, pathname, bundlePath }) + + if (pathname in appPaths) appPaths[pathname].push(page) + else appPaths[pathname] = [page] + } + + const matchers: Array = [] + for (const filename of files) { + // If the file isn't a match for this matcher, then skip it. + if (!this.expression.test(filename)) continue + + // Grab the cached values (and the appPaths). + const cached = cache.get(filename) + if (!cached) { + throw new Error('Invariant: expected filename to exist in cache') + } + const { pathname, page, bundlePath } = cached + + // TODO: what do we do if this route is a duplicate? + + matchers.push( + new AppPageRouteMatcher({ + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath, + filename, + appPaths: appPaths[pathname], + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts similarity index 51% rename from packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts index 40636cf9b7825..4cb84f8d5fae0 100644 --- a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts @@ -1,6 +1,7 @@ -import { RouteKind } from '../route-kind' -import { DevAppRouteRouteMatcher } from './dev-app-route-route-matcher' -import { FileReader } from './file-reader/file-reader' +import { AppRouteRouteDefinition } from '../../route-definitions/app-route-route-definition' +import { RouteKind } from '../../route-kind' +import { DevAppRouteRouteMatcherProvider } from './dev-app-route-route-matcher-provider' +import { FileReader } from './helpers/file-reader/file-reader' describe('DevAppRouteRouteMatcher', () => { const dir = '' @@ -8,16 +9,19 @@ describe('DevAppRouteRouteMatcher', () => { it('returns no routes with an empty filesystem', async () => { const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevAppRouteRouteMatcher(dir, extensions, reader) - const routes = await matcher.routes() - expect(routes).toHaveLength(0) + const matcher = new DevAppRouteRouteMatcherProvider(dir, extensions, reader) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(0) expect(reader.read).toBeCalledWith(dir) }) describe('filename matching', () => { - it.each([ + it.each<{ + files: ReadonlyArray + route: AppRouteRouteDefinition + }>([ { - filename: `${dir}/some/other/route.ts`, + files: [`${dir}/some/other/route.ts`], route: { kind: RouteKind.APP_ROUTE, pathname: '/some/other', @@ -27,7 +31,7 @@ describe('DevAppRouteRouteMatcher', () => { }, }, { - filename: `${dir}/route.ts`, + files: [`${dir}/route.ts`], route: { kind: RouteKind.APP_ROUTE, pathname: '/', @@ -37,20 +41,24 @@ describe('DevAppRouteRouteMatcher', () => { }, }, ])( - "matches the route specified with '$filename'", - async ({ filename, route }) => { + "matches the '$route.page' route specified with the provided files", + async ({ files, route }) => { const reader: FileReader = { read: jest.fn(() => [ ...extensions.map((ext) => `${dir}/some/page.${ext}`), ...extensions.map((ext) => `${dir}/api/other.${ext}`), - filename, + ...files, ]), } - const matcher = new DevAppRouteRouteMatcher(dir, extensions, reader) - const routes = await matcher.routes() - expect(routes).toHaveLength(1) + const matcher = new DevAppRouteRouteMatcherProvider( + dir, + extensions, + reader + ) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(routes[0]).toEqual(route) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts new file mode 100644 index 0000000000000..51ee046b33386 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -0,0 +1,78 @@ +import { FileReader } from './helpers/file-reader/file-reader' +import { DefaultFileReader } from './helpers/file-reader/default-file-reader' +import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher' +import { RouteMatcherProvider } from '../route-matcher-provider' +import { Normalizer } from '../../normalizers/normalizer' +import { Normalizers } from '../../normalizers/normalizers' +import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' +import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' +import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' +import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' +import { RouteKind } from '../../route-kind' + +export class DevAppRouteRouteMatcherProvider + implements RouteMatcherProvider +{ + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly appDir: string, + extensions: ReadonlyArray, + private readonly reader: FileReader = new DefaultFileReader() + ) { + // Match any route file that ends with `/route.${extension}` under the app + // directory. + this.expression = new RegExp(`\\/route\\.(?:${extensions.join('|')})$`) + + const pageNormalizer = new AbsoluteFilenameNormalizer(appDir, extensions) + + this.normalizers = { + page: pageNormalizer, + pathname: new Normalizers([ + pageNormalizer, + // The pathname to match should have the trailing `/route` and other route + // group information stripped from it. + wrapNormalizerFn(normalizeAppPath), + ]), + bundlePath: new Normalizers([ + pageNormalizer, + // Prefix the bundle path with `app/`. + new PrefixingNormalizer('app'), + ]), + } + } + + public async matchers(): Promise> { + // Read the files in the pages directory... + const files = await this.reader.read(this.appDir) + + const matchers: Array = [] + for (const filename of files) { + // If the file isn't a match for this matcher, then skip it. + if (!this.expression.test(filename)) continue + + const page = this.normalizers.page.normalize(filename) + const pathname = this.normalizers.pathname.normalize(filename) + const bundlePath = this.normalizers.bundlePath.normalize(filename) + + // TODO: what do we do if this route is a duplicate? + + matchers.push( + new AppRouteRouteMatcher({ + kind: RouteKind.APP_ROUTE, + pathname, + page, + bundlePath, + filename, + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts similarity index 55% rename from packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts index 75f6c7dab1ad5..5535f19436c0d 100644 --- a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts @@ -1,33 +1,27 @@ -import { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { FileReader } from './file-reader/file-reader' -import { DevPagesAPIRouteMatcher } from './dev-pages-api-route-matcher' +import { PagesAPIRouteDefinition } from '../../route-definitions/pages-api-route-definition' +import { RouteKind } from '../../route-kind' +import { DevPagesAPIRouteMatcherProvider } from './dev-pages-api-route-matcher-provider' +import { FileReader } from './helpers/file-reader/file-reader' -describe('DevPagesAPIRouteMatcher', () => { +describe('DevPagesAPIRouteMatcherProvider', () => { const dir = '' const extensions = ['ts', 'tsx', 'js', 'jsx'] it('returns no routes with an empty filesystem', async () => { const reader: FileReader = { read: jest.fn(() => []) } - const localeNormalizer: Normalizer = { - normalize: jest.fn((pathname) => pathname), - } - const matcher = new DevPagesAPIRouteMatcher( - dir, - extensions, - localeNormalizer, - reader - ) - const routes = await matcher.routes() - expect(routes).toHaveLength(0) + const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(0) expect(reader.read).toBeCalledWith(dir) - expect(localeNormalizer.normalize).not.toHaveBeenCalled() }) describe('filename matching', () => { - it.each([ + it.each<{ + files: ReadonlyArray + route: PagesAPIRouteDefinition + }>([ { - filename: `${dir}/api/other/route.ts`, + files: [`${dir}/api/other/route.ts`], route: { kind: RouteKind.PAGES_API, pathname: '/api/other/route', @@ -37,7 +31,7 @@ describe('DevPagesAPIRouteMatcher', () => { }, }, { - filename: `${dir}/api/other/index.ts`, + files: [`${dir}/api/other/index.ts`], route: { kind: RouteKind.PAGES_API, pathname: '/api/other', @@ -47,7 +41,7 @@ describe('DevPagesAPIRouteMatcher', () => { }, }, { - filename: `${dir}/api.ts`, + files: [`${dir}/api.ts`], route: { kind: RouteKind.PAGES_API, pathname: '/api', @@ -57,7 +51,7 @@ describe('DevPagesAPIRouteMatcher', () => { }, }, { - filename: `${dir}/api/index.ts`, + files: [`${dir}/api/index.ts`], route: { kind: RouteKind.PAGES_API, pathname: '/api', @@ -67,30 +61,25 @@ describe('DevPagesAPIRouteMatcher', () => { }, }, ])( - "matches the route specified with '$filename'", - async ({ filename, route }) => { + "matches the '$route.page' route specified with the provided files", + async ({ files, route }) => { const reader: FileReader = { read: jest.fn(() => [ ...extensions.map((ext) => `${dir}/some/other/page.${ext}`), ...extensions.map((ext) => `${dir}/some/other/route.${ext}`), `${dir}/some/api/route.ts`, - filename, + ...files, ]), } - const localeNormalizer: Normalizer = { - normalize: jest.fn((pathname) => pathname), - } - const matcher = new DevPagesAPIRouteMatcher( + const matcher = new DevPagesAPIRouteMatcherProvider( dir, extensions, - localeNormalizer, reader ) - const routes = await matcher.routes() - expect(routes).toHaveLength(1) - expect(localeNormalizer.normalize).toHaveBeenCalledTimes(1) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(routes[0]).toEqual(route) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts new file mode 100644 index 0000000000000..b1e144d249181 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -0,0 +1,100 @@ +import { Normalizer } from '../../normalizers/normalizer' +import { FileReader } from './helpers/file-reader/file-reader' +import { DefaultFileReader } from './helpers/file-reader/default-file-reader' +import { PagesAPIRouteMatcher } from '../../route-matchers/pages-api-route-matcher' +import { RouteMatcherProvider } from '../route-matcher-provider' +import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' +import { Normalizers } from '../../normalizers/normalizers' +import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' +import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-page-path' +import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' +import { RouteKind } from '../../route-kind' +import path from 'path' + +export class DevPagesAPIRouteMatcherProvider + implements RouteMatcherProvider +{ + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly pagesDir: string, + private readonly extensions: ReadonlyArray, + private readonly reader: FileReader = new DefaultFileReader() + ) { + // Match any route file that ends with `/${filename}.${extension}` under the + // pages directory. + this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) + + const pageNormalizer = new AbsoluteFilenameNormalizer(pagesDir, extensions) + + const bundlePathNormalizer = new Normalizers([ + pageNormalizer, + // If the bundle path would have ended in a `/`, add a `index` to it. + wrapNormalizerFn(normalizePagePath), + // Prefix the bundle path with `pages/`. + new PrefixingNormalizer('pages'), + ]) + + this.normalizers = { + page: pageNormalizer, + pathname: pageNormalizer, + bundlePath: bundlePathNormalizer, + } + } + + private test(filename: string): boolean { + // If the file does not end in the correct extension it's not a match. + if (!this.expression.test(filename)) return false + + // Pages API routes must exist in the pages directory with the `/api/` + // prefix. The pathnames being tested here though are the full filenames, + // so we need to include the pages directory. + + // TODO: could path separator normalization be needed here? + if (filename.startsWith(path.join(this.pagesDir, '/api/'))) return true + + for (const extension of this.extensions) { + // We can also match if we have `pages/api.${extension}`, so check to + // see if it's a match. + if (filename === path.join(this.pagesDir, `api.${extension}`)) { + return true + } + } + + return false + } + + public async matchers(): Promise> { + // Read the files in the pages directory... + const files = await this.reader.read(this.pagesDir) + + const matchers: Array = [] + for (const filename of files) { + // If the file isn't a match for this matcher, then skip it. + if (!this.test(filename)) continue + + const pathname = this.normalizers.pathname.normalize(filename) + const page = this.normalizers.page.normalize(filename) + const bundlePath = this.normalizers.bundlePath.normalize(filename) + + // TODO: what do we do if this route is a duplicate? + + matchers.push( + new PagesAPIRouteMatcher({ + kind: RouteKind.PAGES_API, + pathname, + page, + bundlePath, + filename, + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts similarity index 54% rename from packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts index 6a96121514746..bea71063bc026 100644 --- a/packages/next/src/server/route-matchers/dev-pages-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts @@ -1,33 +1,27 @@ -import { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { FileReader } from './file-reader/file-reader' -import { DevPagesRouteMatcher } from './dev-pages-route-matcher' +import { PagesRouteDefinition } from '../../route-definitions/pages-route-definition' +import { RouteKind } from '../../route-kind' +import { DevPagesRouteMatcherProvider } from './dev-pages-route-matcher-provider' +import { FileReader } from './helpers/file-reader/file-reader' -describe('DevPagesRouteMatcher', () => { +describe('DevPagesRouteMatcherProvider', () => { const dir = '' const extensions = ['ts', 'tsx', 'js', 'jsx'] it('returns no routes with an empty filesystem', async () => { const reader: FileReader = { read: jest.fn(() => []) } - const localeNormalizer: Normalizer = { - normalize: jest.fn((pathname) => pathname), - } - const matcher = new DevPagesRouteMatcher( - dir, - extensions, - localeNormalizer, - reader - ) - const routes = await matcher.routes() - expect(routes).toHaveLength(0) + const matcher = new DevPagesRouteMatcherProvider(dir, extensions, reader) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(0) expect(reader.read).toBeCalledWith(dir) - expect(localeNormalizer.normalize).not.toHaveBeenCalled() }) describe('filename matching', () => { - it.each([ + it.each<{ + files: ReadonlyArray + route: PagesRouteDefinition + }>([ { - filename: `${dir}/index.ts`, + files: [`${dir}/index.ts`], route: { kind: RouteKind.PAGES, pathname: '/', @@ -37,7 +31,7 @@ describe('DevPagesRouteMatcher', () => { }, }, { - filename: `${dir}/some/api/route.ts`, + files: [`${dir}/some/api/route.ts`], route: { kind: RouteKind.PAGES, pathname: '/some/api/route', @@ -47,7 +41,7 @@ describe('DevPagesRouteMatcher', () => { }, }, { - filename: `${dir}/some/other/route/index.ts`, + files: [`${dir}/some/other/route/index.ts`], route: { kind: RouteKind.PAGES, pathname: '/some/other/route', @@ -57,7 +51,7 @@ describe('DevPagesRouteMatcher', () => { }, }, { - filename: `${dir}/some/other/route/index/route.ts`, + files: [`${dir}/some/other/route/index/route.ts`], route: { kind: RouteKind.PAGES, pathname: '/some/other/route/index/route', @@ -67,28 +61,23 @@ describe('DevPagesRouteMatcher', () => { }, }, ])( - "matches the route specified with '$filename'", - async ({ filename, route }) => { + "matches the '$route.page' route specified with the provided files", + async ({ files, route }) => { const reader: FileReader = { read: jest.fn(() => [ ...extensions.map((ext) => `${dir}/api/other/page.${ext}`), - filename, + ...files, ]), } - const localeNormalizer: Normalizer = { - normalize: jest.fn((pathname) => pathname), - } - const matcher = new DevPagesRouteMatcher( + const matcher = new DevPagesRouteMatcherProvider( dir, extensions, - localeNormalizer, reader ) - const routes = await matcher.routes() - expect(routes).toHaveLength(1) - expect(localeNormalizer.normalize).toHaveBeenCalledTimes(1) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(routes[0]).toEqual(route) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts new file mode 100644 index 0000000000000..b75d5e852660d --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts @@ -0,0 +1,96 @@ +import { Normalizer } from '../../normalizers/normalizer' +import { FileReader } from './helpers/file-reader/file-reader' +import { DefaultFileReader } from './helpers/file-reader/default-file-reader' +import { PagesRouteMatcher } from '../../route-matchers/pages-route-matcher' +import { RouteMatcherProvider } from '../route-matcher-provider' +import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' +import { Normalizers } from '../../normalizers/normalizers' +import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' +import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-page-path' +import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' +import { RouteKind } from '../../route-kind' +import path from 'path' + +export class DevPagesRouteMatcherProvider + implements RouteMatcherProvider +{ + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly pagesDir: string, + private readonly extensions: ReadonlyArray, + private readonly reader: FileReader = new DefaultFileReader() + ) { + // Match any route file that ends with `/${filename}.${extension}` under the + // pages directory. + this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) + + const pageNormalizer = new AbsoluteFilenameNormalizer(pagesDir, extensions) + + this.normalizers = { + page: pageNormalizer, + pathname: pageNormalizer, + bundlePath: new Normalizers([ + pageNormalizer, + // If the bundle path would have ended in a `/`, add a `index` to it. + wrapNormalizerFn(normalizePagePath), + // Prefix the bundle path with `pages/`. + new PrefixingNormalizer('pages'), + ]), + } + } + + private test(filename: string): boolean { + // If the file does not end in the correct extension it's not a match. + if (!this.expression.test(filename)) return false + + // Pages routes must exist in the pages directory without the `/api/` + // prefix. The pathnames being tested here though are the full filenames, + // so we need to include the pages directory. + + // TODO: could path separator normalization be needed here? + if (filename.startsWith(`${this.pagesDir}/api/`)) return false + + for (const extension of this.extensions) { + // We can also match if we have `pages/api.${extension}`, so check to + // see if it's a match. + if (filename === path.join(this.pagesDir, `api.${extension}`)) { + return false + } + } + + return true + } + + public async matchers(): Promise> { + // Read the files in the pages directory... + const files = await this.reader.read(this.pagesDir) + + const matchers: Array = [] + for (const filename of files) { + // If the file isn't a match for this matcher, then skip it. + if (!this.test(filename)) continue + + const pathname = this.normalizers.pathname.normalize(filename) + const page = this.normalizers.page.normalize(filename) + const bundlePath = this.normalizers.bundlePath.normalize(filename) + + matchers.push( + new PagesRouteMatcher({ + kind: RouteKind.PAGES, + pathname, + page, + bundlePath, + filename, + }) + ) + } + + return matchers + } +} diff --git a/packages/next/src/server/route-matchers/file-reader/default-file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts similarity index 66% rename from packages/next/src/server/route-matchers/file-reader/default-file-reader.ts rename to packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts index ae122ea286285..1372d198778d8 100644 --- a/packages/next/src/server/route-matchers/file-reader/default-file-reader.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts @@ -1,3 +1,4 @@ +import { type Dirent } from 'fs' import fs from 'fs/promises' import path from 'path' import { FileReader } from './file-reader' @@ -11,10 +12,21 @@ export class DefaultFileReader implements FileReader { while (directories.length > 0) { // Load all the files in each directory at the same time. const results = await Promise.all( - directories.map(async (directory) => ({ - directory, - files: await fs.readdir(directory, { withFileTypes: true }), - })) + directories.map(async (directory) => { + let files: Dirent[] + try { + files = await fs.readdir(directory, { withFileTypes: true }) + } catch (err: any) { + // This can only happen when the underlying directory was removed. If + // anything other than this error occurs, re-throw it. + if (err.code !== 'ENOENT') throw err + + // The error occurred, so abandon reading this directory. + files = [] + } + + return { directory, files } + }) ) // Empty the directories, we'll fill it later if some of the files are diff --git a/packages/next/src/server/route-matchers/file-reader/file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts similarity index 100% rename from packages/next/src/server/route-matchers/file-reader/file-reader.ts rename to packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts diff --git a/packages/next/src/server/manifest-loaders/manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts similarity index 56% rename from packages/next/src/server/manifest-loaders/manifest-loader.ts rename to packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts index 7ea625556938f..8c800bf8a433e 100644 --- a/packages/next/src/server/manifest-loaders/manifest-loader.ts +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts @@ -1,5 +1,5 @@ export type Manifest = Record export interface ManifestLoader { - load(name: string): Promise | Manifest + load(name: string): Promise | Manifest | null } diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts new file mode 100644 index 0000000000000..82f2b0cc9aaa7 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts @@ -0,0 +1,21 @@ +import path from 'path' +import { SERVER_DIRECTORY } from '../../../../../shared/lib/constants' +import { Manifest, ManifestLoader } from './manifest-loader' + +export class NodeManifestLoader implements ManifestLoader { + constructor(private readonly distDir: string) {} + + public async load(name: string): Promise { + try { + return await require(path.join(this.distDir, SERVER_DIRECTORY, name)) + } catch (err: any) { + // If a manifest can't be found, we should gracefully fail by returning + // null. + if (err.code === 'MODULE_NOT_FOUND') { + return null + } + + throw err + } + } +} diff --git a/packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts similarity index 68% rename from packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts rename to packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts index 128dfd1466ac5..6254b6e9f0ee4 100644 --- a/packages/next/src/server/route-matchers/pages-api-route-matcher.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts @@ -1,21 +1,21 @@ -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' import { RouteKind } from '../route-kind' -import { PagesAPIRouteMatcher } from './pages-api-route-matcher' -import { RouteDefinition } from './route-matcher' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { PagesAPIRouteMatcherProvider } from './pages-api-route-matcher-provider' -describe('PagesAPIRouteMatcher', () => { +describe('PagesAPIRouteMatcherProvider', () => { it('returns no routes with an empty manifest', async () => { const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const matcher = new PagesAPIRouteMatcher('', loader) - expect(await matcher.routes()).toEqual([]) + const provider = new PagesAPIRouteMatcherProvider('', loader) + expect(await provider.matchers()).toEqual([]) expect(loader.load).toBeCalledWith(PAGES_MANIFEST) }) describe('manifest matching', () => { it.each<{ manifest: Record - route: RouteDefinition + route: PagesAPIRouteDefinition }>([ { manifest: { '/api/users/[id]': 'pages/api/users/[id].js' }, @@ -57,12 +57,12 @@ describe('PagesAPIRouteMatcher', () => { ...manifest, })), } - const matcher = new PagesAPIRouteMatcher('', loader) - const routes = await matcher.routes() + const provider = new PagesAPIRouteMatcherProvider('', loader) + const matchers = await provider.matchers() expect(loader.load).toBeCalledWith(PAGES_MANIFEST) - expect(routes).toHaveLength(1) - expect(routes[0]).toEqual(route) + expect(matchers).toHaveLength(1) + expect(matchers[0].route).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts new file mode 100644 index 0000000000000..b55b9ecb5251e --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -0,0 +1,49 @@ +import path from 'path' +import { isAPIRoute } from '../../../lib/is-api-route' +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' +import { RouteKind } from '../route-kind' +import { PagesAPIRouteMatcher } from '../route-matchers/pages-api-route-matcher' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' +import { RouteMatcherProvider } from './route-matcher-provider' + +export class PagesAPIRouteMatcherProvider + implements RouteMatcherProvider +{ + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ) + ) {} + + public async matchers(): Promise> { + const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + if (!manifest) return [] + + return ( + Object.keys(manifest) + // This matcher is only for Pages API routes. + .filter((page) => isAPIRoute(page)) + // Normalize the routes. + .reduce>((matchers, page) => { + matchers.push( + new PagesAPIRouteMatcher({ + kind: RouteKind.PAGES_API, + pathname: page, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join( + this.distDir, + SERVER_DIRECTORY, + manifest[page] + ), + }) + ) + + return matchers + }, []) + ) + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts new file mode 100644 index 0000000000000..0e02ff11ed61a --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts @@ -0,0 +1,180 @@ +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { RouteKind } from '../route-kind' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { PagesRouteMatcherProvider } from './pages-route-matcher-provider' + +describe('PagesRouteMatcherProvider', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const provider = new PagesRouteMatcherProvider('', loader) + expect(await provider.matchers()).toEqual([]) + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + }) + + describe('locale matching', () => { + describe.each<{ + manifest: Record + routes: ReadonlyArray + i18n: { locales: ReadonlyArray; defaultLocale: string } + }>([ + { + manifest: { + '/_app': 'pages/_app.js', + '/_error': 'pages/_error.js', + '/_document': 'pages/_document.js', + '/blog/[slug]': 'pages/blog/[slug].js', + '/en-US/404': 'pages/en-US/404.html', + '/fr/404': 'pages/fr/404.html', + '/nl-NL/404': 'pages/nl-NL/404.html', + '/en-US': 'pages/en-US.html', + '/fr': 'pages/fr.html', + '/nl-NL': 'pages/nl-NL.html', + }, + i18n: { locales: ['en-US', 'fr', 'nl-NL'], defaultLocale: 'en-US' }, + routes: [ + { + kind: RouteKind.PAGES, + pathname: '/blog/[slug]', + filename: `/${SERVER_DIRECTORY}/pages/blog/[slug].js`, + page: '/blog/[slug]', + bundlePath: 'pages/blog/[slug]', + }, + { + kind: RouteKind.PAGES, + pathname: '/', + filename: `/${SERVER_DIRECTORY}/pages/en-US.html`, + page: '/en-US', + bundlePath: 'pages/en-US', + }, + { + kind: RouteKind.PAGES, + pathname: '/en-US', + filename: `/${SERVER_DIRECTORY}/pages/en-US.html`, + page: '/en-US', + bundlePath: 'pages/en-US', + }, + { + kind: RouteKind.PAGES, + pathname: '/fr', + filename: `/${SERVER_DIRECTORY}/pages/fr.html`, + page: '/fr', + bundlePath: 'pages/fr', + }, + { + kind: RouteKind.PAGES, + pathname: '/nl-NL', + filename: `/${SERVER_DIRECTORY}/pages/nl-NL.html`, + page: '/nl-NL', + bundlePath: 'pages/nl-NL', + }, + { + kind: RouteKind.PAGES, + pathname: '/404', + filename: `/${SERVER_DIRECTORY}/pages/en-US/404.html`, + page: '/en-US/404', + bundlePath: 'pages/en-US/404', + }, + { + kind: RouteKind.PAGES, + pathname: '/en-US/404', + filename: `/${SERVER_DIRECTORY}/pages/en-US/404.html`, + page: '/en-US/404', + bundlePath: 'pages/en-US/404', + }, + { + kind: RouteKind.PAGES, + pathname: '/fr/404', + filename: `/${SERVER_DIRECTORY}/pages/fr/404.html`, + page: '/fr/404', + bundlePath: 'pages/fr/404', + }, + { + kind: RouteKind.PAGES, + pathname: '/nl-NL/404', + filename: `/${SERVER_DIRECTORY}/pages/nl-NL/404.html`, + page: '/nl-NL/404', + bundlePath: 'pages/nl-NL/404', + }, + ], + }, + ])('locale', ({ routes: expected, manifest, i18n }) => { + it.each(expected)('has the match for $pathname', async (route) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/api/users/[id]': 'pages/api/users/[id].js', + '/api/users': 'pages/api/users.js', + ...manifest, + })), + } + const provider = new PagesRouteMatcherProvider( + '', + loader, + new LocaleRouteNormalizer(i18n.locales, i18n.defaultLocale) + ) + const matchers = await provider.matchers() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + const routes = matchers.map((matcher) => matcher.route) + expect(routes).toHaveLength(expected.length) + expect(routes).toContainEqual(route) + }) + }) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record + route: PagesRouteDefinition + }>([ + { + manifest: { '/users/[id]': 'pages/users/[id].js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/users/[id]', + filename: `/${SERVER_DIRECTORY}/pages/users/[id].js`, + page: '/users/[id]', + bundlePath: 'pages/users/[id]', + }, + }, + { + manifest: { '/users': 'pages/users.js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/users', + filename: `/${SERVER_DIRECTORY}/pages/users.js`, + page: '/users', + bundlePath: 'pages/users', + }, + }, + { + manifest: { '/': 'pages/index.js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/', + filename: `/${SERVER_DIRECTORY}/pages/index.js`, + page: '/', + bundlePath: 'pages/index', + }, + }, + ])( + 'returns the correct routes for $route.pathname', + async ({ manifest, route }) => { + const loader: ManifestLoader = { + load: jest.fn(() => ({ + '/api/users/[id]': 'pages/api/users/[id].js', + '/api/users': 'pages/api/users.js', + ...manifest, + })), + } + const matcher = new PagesRouteMatcherProvider('', loader) + const matchers = await matcher.matchers() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + expect(matchers).toHaveLength(1) + expect(matchers[0].route).toEqual(route) + } + ) + }) +}) diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts new file mode 100644 index 0000000000000..3d6234303b1c1 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -0,0 +1,83 @@ +import path from 'path' +import { isAPIRoute } from '../../../lib/is-api-route' +import { + BLOCKED_PAGES, + PAGES_MANIFEST, + SERVER_DIRECTORY, +} from '../../../shared/lib/constants' +import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' +import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' +import { RouteKind } from '../route-kind' +import { PagesRouteMatcher } from '../route-matchers/pages-route-matcher' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' +import { RouteMatcherProvider } from './route-matcher-provider' + +export class PagesRouteMatcherProvider + implements RouteMatcherProvider +{ + constructor( + private readonly distDir: string, + private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( + distDir + ), + private readonly localeNormalizer?: LocaleRouteNormalizer + ) {} + + public async matchers(): Promise> { + const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + if (!manifest) return [] + + // This matcher is only for Pages routes, not Pages API routes which are + // included in this manifest. + let pathnames = Object.keys(manifest) + .filter((pathname) => !isAPIRoute(pathname)) + // Remove any blocked pages (page that can't be routed to, like error or + // internal pages). + .filter((pathname) => { + const normalized = + this.localeNormalizer?.normalize(pathname) ?? pathname + + // Skip any blocked pages. + if (BLOCKED_PAGES.includes(normalized)) return false + + return true + }) + + const matchers: Array = [] + for (const page of pathnames) { + matchers.push( + new PagesRouteMatcher({ + kind: RouteKind.PAGES, + pathname: page, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + }) + ) + } + + /** + * We need to include the default locale normalized pathname in the matcher + * as well. + */ + if (this.localeNormalizer) { + for (const page of pathnames) { + const { pathname, detectedLocale } = this.localeNormalizer.match(page) + if (detectedLocale !== this.localeNormalizer.defaultLocale) continue + + matchers.push( + new PagesRouteMatcher({ + kind: RouteKind.PAGES, + pathname, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + }) + ) + } + } + + return matchers + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts new file mode 100644 index 0000000000000..6263a9db5cf1b --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/route-matcher-provider.ts @@ -0,0 +1,5 @@ +import { RouteMatcher } from '../route-matchers/route-matcher' + +export interface RouteMatcherProvider { + matchers(): Promise> +} diff --git a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts new file mode 100644 index 0000000000000..159b064a3c941 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts @@ -0,0 +1,11 @@ +import { RouteMatcher } from './route-matcher' +import { AppPageRouteMatch } from '../route-matches/app-page-route-match' + +export class AppPageRouteMatcher extends RouteMatcher { + public match(pathname: string): AppPageRouteMatch | null { + const result = this.test(pathname) + if (!result) return null + + return { route: this.route, params: result.params } + } +} diff --git a/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts new file mode 100644 index 0000000000000..8c3d571896a12 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts @@ -0,0 +1,11 @@ +import { RouteMatcher } from './route-matcher' +import { AppRouteRouteMatch } from '../route-matches/app-route-route-match' + +export class AppRouteRouteMatcher extends RouteMatcher { + public match(pathname: string): AppRouteRouteMatch | null { + const result = this.test(pathname) + if (!result) return null + + return { route: this.route, params: result.params } + } +} diff --git a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts new file mode 100644 index 0000000000000..9ee6dcffba2b4 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts @@ -0,0 +1,11 @@ +import { RouteMatcher } from './route-matcher' +import { PagesAPIRouteMatch } from '../route-matches/pages-api-route-match' + +export class PagesAPIRouteMatcher extends RouteMatcher { + public match(pathname: string): PagesAPIRouteMatch | null { + const result = this.test(pathname) + if (!result) return null + + return { route: this.route, params: result.params } + } +} diff --git a/packages/next/src/server/future/route-matchers/pages-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts new file mode 100644 index 0000000000000..9e18789d81c36 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts @@ -0,0 +1,13 @@ +import { RouteMatcher } from './route-matcher' +import { PagesRouteMatch } from '../route-matches/pages-route-match' + +export class PagesRouteMatcher extends RouteMatcher { + public match(pathname: string): PagesRouteMatch | null { + const result = this.test(pathname) + if (!result) return null + + // TODO: could use this area to add locale information to the match + + return { route: this.route, params: result.params } + } +} diff --git a/packages/next/src/server/future/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-matchers/route-matcher.ts new file mode 100644 index 0000000000000..f3f658cd2b165 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/route-matcher.ts @@ -0,0 +1,39 @@ +import { isDynamicRoute } from '../../../shared/lib/router/utils' +import { + getRouteMatcher, + Params, + RouteMatchFn, +} from '../../../shared/lib/router/utils/route-matcher' +import { getRouteRegex } from '../../../shared/lib/router/utils/route-regex' +import { RouteMatch } from '../route-matches/route-match' + +export abstract class RouteMatcher { + private readonly dynamic?: RouteMatchFn + + constructor(public readonly route: M['route']) { + if (isDynamicRoute(route.pathname)) { + this.dynamic = getRouteMatcher(getRouteRegex(route.pathname)) + } + } + + public get isDynamic() { + return this.dynamic !== undefined + } + + public abstract match(pathname: string): M | null + + protected test(pathname: string): { params?: Params } | null { + if (this.dynamic) { + const params = this.dynamic(pathname) + if (!params) return null + + return { params } + } + + if (pathname === this.route.pathname) { + return {} + } + + return null + } +} diff --git a/packages/next/src/server/future/route-matches/app-page-route-match.ts b/packages/next/src/server/future/route-matches/app-page-route-match.ts new file mode 100644 index 0000000000000..dbf1a935c9be6 --- /dev/null +++ b/packages/next/src/server/future/route-matches/app-page-route-match.ts @@ -0,0 +1,4 @@ +import { RouteMatch } from './route-match' +import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' + +export interface AppPageRouteMatch extends RouteMatch {} diff --git a/packages/next/src/server/future/route-matches/app-route-route-match.ts b/packages/next/src/server/future/route-matches/app-route-route-match.ts new file mode 100644 index 0000000000000..5804826e5ade3 --- /dev/null +++ b/packages/next/src/server/future/route-matches/app-route-route-match.ts @@ -0,0 +1,5 @@ +import { RouteMatch } from './route-match' +import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' + +export interface AppRouteRouteMatch + extends RouteMatch {} diff --git a/packages/next/src/server/future/route-matches/pages-api-route-match.ts b/packages/next/src/server/future/route-matches/pages-api-route-match.ts new file mode 100644 index 0000000000000..ee842933de818 --- /dev/null +++ b/packages/next/src/server/future/route-matches/pages-api-route-match.ts @@ -0,0 +1,5 @@ +import { RouteMatch } from './route-match' +import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' + +export interface PagesAPIRouteMatch + extends RouteMatch {} diff --git a/packages/next/src/server/future/route-matches/pages-route-match.ts b/packages/next/src/server/future/route-matches/pages-route-match.ts new file mode 100644 index 0000000000000..048ae3ab0b220 --- /dev/null +++ b/packages/next/src/server/future/route-matches/pages-route-match.ts @@ -0,0 +1,4 @@ +import { RouteMatch } from './route-match' +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' + +export interface PagesRouteMatch extends RouteMatch {} diff --git a/packages/next/src/server/future/route-matches/route-match.ts b/packages/next/src/server/future/route-matches/route-match.ts new file mode 100644 index 0000000000000..f220648b27d1a --- /dev/null +++ b/packages/next/src/server/future/route-matches/route-match.ts @@ -0,0 +1,17 @@ +import { Params } from '../../../shared/lib/router/utils/route-matcher' +import { RouteDefinition } from '../route-definitions/route-definition' + +/** + * RouteMatch is the resolved match for a given request. This will contain all + * the dynamic parameters used for this route. + */ +export interface RouteMatch { + readonly route: R + + /** + * params when provided are the dynamic route parameters that were parsed from + * the incoming request pathname. If a route match is returned without any + * params, it should be considered a static route. + */ + readonly params?: Params +} diff --git a/packages/next/src/server/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/manifest-loaders/node-manifest-loader.ts deleted file mode 100644 index 030131725616e..0000000000000 --- a/packages/next/src/server/manifest-loaders/node-manifest-loader.ts +++ /dev/null @@ -1,11 +0,0 @@ -import path from 'path' -import { SERVER_DIRECTORY } from '../../shared/lib/constants' -import { Manifest, ManifestLoader } from './manifest-loader' - -export class NodeManifestLoader implements ManifestLoader { - constructor(private readonly distDir: string) {} - - public async load(name: string): Promise { - return await require(path.join(this.distDir, SERVER_DIRECTORY, name)) - } -} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 421467af69e51..9a2d8e1262d18 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -96,18 +96,19 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { setHttpClientAndAgentOptions } from './config' -import { RouteHandlers } from './route-handlers/route-handlers' -import { DefaultRouteMatcherManager } from './route-matcher-managers/default-route-matcher-manager' -import { LocaleRouteNormalizer } from './normalizers/locale-route-normalizer' -import { PagesRouteMatcher } from './route-matchers/pages-route-matcher' -import { PagesAPIRouteMatcher } from './route-matchers/pages-api-route-matcher' -import { AppRouteRouteMatcher } from './route-matchers/app-route-route-matcher' -import { RouteKind } from './route-kind' -import { AppRouteRouteHandler } from './route-handlers/app-route/app-route-route-handler' -import { AppPageRouteMatcher } from './route-matchers/app-page-route-matcher' -import { isRouteMatchKind, RouteMatch } from './route-matches/route-match' -import { NodeManifestLoader } from './manifest-loaders/node-manifest-loader' -import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' +import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' +import { RouteKind } from './future/route-kind' + +import { NodeManifestLoader } from './future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' +import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' +import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' +import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' +import { PagesRouteMatcherProvider } from './future/route-matcher-providers/pages-route-matcher-provider' +import { PagesAPIRouteMatcherProvider } from './future/route-matcher-providers/pages-api-route-matcher-provider' +import { AppPageRouteMatcherProvider } from './future/route-matcher-providers/app-page-route-matcher-provider' +import { AppRouteRouteMatcherProvider } from './future/route-matcher-providers/app-route-route-matcher-provider' +import { AppRouteRouteHandler } from './future/route-handlers/app-route-route-handler' +import { PagesAPIRouteMatch } from './future/route-matches/pages-api-route-match' export * from './base-server' @@ -271,43 +272,54 @@ export default class NextNodeServer extends BaseServer { protected getRoutes() { // Configure the locale normalizer, it's used for routes inside `pages/`. - const localeNormalizer = this.nextConfig.i18n?.locales - ? new LocaleRouteNormalizer(this.nextConfig.i18n.locales) - : undefined + const localeNormalizer = + this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale + ? new LocaleRouteNormalizer( + this.nextConfig.i18n.locales, + this.nextConfig.i18n.defaultLocale + ) + : undefined // Configure the matchers and handlers. - const matchers: RouteMatcherManager = new DefaultRouteMatcherManager( - localeNormalizer - ) - const handlers = new RouteHandlers() + const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() + const handlers = new RouteHandlerManager() const manifestLoader = new NodeManifestLoader(this.distDir) // Match pages under `pages/`. matchers.push( - new PagesRouteMatcher(this.distDir, manifestLoader, localeNormalizer) + new PagesRouteMatcherProvider( + this.distDir, + manifestLoader, + localeNormalizer + ) ) // NOTE: we don't have a handler for the pages route type yet // Match api routes under `pages/api/`. - matchers.push(new PagesAPIRouteMatcher(this.distDir, manifestLoader)) + matchers.push( + new PagesAPIRouteMatcherProvider(this.distDir, manifestLoader) + ) // NOTE: we don't have a handler for the pages api route type yet // If the app directory is enabled, then add the app matchers and handlers. if (this.hasAppDir) { // Match app pages under `app/`. - matchers.push(new AppPageRouteMatcher(this.distDir, manifestLoader)) + matchers.push( + new AppPageRouteMatcherProvider(this.distDir, manifestLoader) + ) // NOTE: we don't have a handler for the app page route type yet - matchers.push(new AppRouteRouteMatcher(this.distDir, manifestLoader)) + matchers.push( + new AppRouteRouteMatcherProvider(this.distDir, manifestLoader) + ) handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler()) } - // Compile the matchers. - matchers.compile() + // TODO: ensure that the matchers are reloaded return { matchers, handlers } } @@ -1249,10 +1261,16 @@ export default class NextNodeServer extends BaseServer { // If the route was detected as being a Pages API route, then handle // it. // TODO: move this behavior into a route handler. - if (isRouteMatchKind(match, RouteKind.PAGES_API)) { + if (match.route.kind === RouteKind.PAGES_API) { delete query._nextBubbleNoFallback - handled = await this.handleApiRequest(req, res, query, match) + handled = await this.handleApiRequest( + req, + res, + query, + // TODO: see if we can add a runtime check for this + match as PagesAPIRouteMatch + ) if (handled) return { finished: true } } } @@ -1278,7 +1296,6 @@ export default class NextNodeServer extends BaseServer { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - // this.dynamicRoutes = this.getDynamicRoutes() } return { @@ -1294,9 +1311,6 @@ export default class NextNodeServer extends BaseServer { } } - // Used to build API page in development - protected async ensureApiPage(_pathname: string): Promise {} - /** * Resolves `API` request, in development builds on demand * @param req http request @@ -1307,25 +1321,14 @@ export default class NextNodeServer extends BaseServer { req: BaseNextRequest, res: BaseNextResponse, query: ParsedUrlQuery, - match: RouteMatch + match: PagesAPIRouteMatch ): Promise { - const { pathname, params } = match - - // Make sure the page is built before getting the path - // or else it won't be in the manifest yet - await this.ensureApiPage(pathname) + const { + route: { pathname, filename }, + params, + } = match - let builtPagePath - try { - builtPagePath = this.getPagePath(pathname) - } catch (err) { - if (isError(err) && err.code === 'ENOENT') { - return false - } - throw err - } - - return this.runApi(req, res, query, params, pathname, builtPagePath) + return this.runApi(req, res, query, params, pathname, filename) } protected getCacheFilesystem(): CacheFs { @@ -1771,24 +1774,12 @@ export default class NextNodeServer extends BaseServer { const match = await this.matchers.match(normalizedPathname) if (match) { - page.name = match.params ? match.pathname : params.parsedUrl.pathname + page.name = match.params + ? match.route.pathname + : params.parsedUrl.pathname page.params = match.params } - // NOTE: converted to the new match syntax - // if (await this.hasPage(normalizedPathname)) { - // page.name = params.parsedUrl.pathname - // } else if (this.dynamicRoutes) { - // for (const dynamicRoute of this.dynamicRoutes) { - // const matchParams = dynamicRoute.match(normalizedPathname) - // if (matchParams) { - // page.name = dynamicRoute.page - // page.params = matchParams - // break - // } - // } - // } - const middleware = this.getMiddleware() if (!middleware) { return { finished: false } diff --git a/packages/next/src/server/normalizers/locale-route-normalizer.ts b/packages/next/src/server/normalizers/locale-route-normalizer.ts deleted file mode 100644 index 2bc1795bd811c..0000000000000 --- a/packages/next/src/server/normalizers/locale-route-normalizer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Normalizer } from './normalizer' - -export class LocaleRouteNormalizer implements Normalizer { - private readonly lowerCase: ReadonlyArray - - constructor(private readonly locales: ReadonlyArray | null = null) { - this.lowerCase = - locales && locales.length > 0 - ? locales.map((locale) => locale.toLowerCase()) - : [] - } - - private match(pathname: string): string | null { - if (!this.locales) return null - - // The first segment will be empty, because it has a leading `/`. If - // there is no further segment, there is no locale. - const segments = pathname.split('/') - if (!segments[1]) return null - - // The second segment will contain the locale part if any. - const segment = segments[1].toLowerCase() - - // See if the segment matches one of the locales. - const index = this.lowerCase.indexOf(segment) - if (index < 0) return null - - // Return the case-sensitive locale. - return this.locales[index] - } - - public normalize(pathname: string): string { - if (!this.locales) return pathname - - const locale = this.match(pathname) - if (!locale) return pathname - - // Remove the `/${locale}` part of the pathname. - return pathname.slice(locale.length + 1) || '/' - } -} diff --git a/packages/next/src/server/route-handlers/route-handler.ts b/packages/next/src/server/route-handlers/route-handler.ts deleted file mode 100644 index b162f80c2d648..0000000000000 --- a/packages/next/src/server/route-handlers/route-handler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { BaseNextRequest, BaseNextResponse } from '../base-http' -import { RouteKind } from '../route-kind' -import type { RouteMatch } from '../route-matches/route-match' - -export type RouteHandlerFn = ( - route: RouteMatch, - req: BaseNextRequest, - res: BaseNextResponse -) => Promise | void - -export interface RouteHandler { - /** - * Handler will return the handler for a given route given the route handler. - * - * @param route the route to execute with - */ - handle( - route: RouteMatch, - req: BaseNextRequest, - res: BaseNextResponse - ): Promise | void -} diff --git a/packages/next/src/server/route-handlers/route-handlers.ts b/packages/next/src/server/route-handlers/route-handlers.ts deleted file mode 100644 index e6f4a6baf9ad7..0000000000000 --- a/packages/next/src/server/route-handlers/route-handlers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BaseNextRequest, BaseNextResponse } from '../base-http' -import { RouteKind } from '../route-kind' -import { RouteMatch } from '../route-matches/route-match' -import { RouteHandler } from './route-handler' - -/** - * Handlers provides a single entrypoint to configuring the available handler - * for this application. - */ -export class RouteHandlers { - private readonly handlers: Partial<{ - [K in RouteKind]: RouteHandler - }> = {} - - public set(kind: RouteKind, handler: RouteHandler) { - if (kind in this.handlers) { - throw new Error( - 'Invariant: a route handler for this route type has already been configured' - ) - } - - this.handlers[kind] = handler - } - - public async handle( - route: RouteMatch, - req: BaseNextRequest, - res: BaseNextResponse - ) { - const handler = this.handlers[route.kind] - if (!handler) return false - - await handler.handle(route, req, res) - return true - } -} diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts deleted file mode 100644 index b7aa2a47ee788..0000000000000 --- a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { DefaultRouteMatcherManager } from './default-route-matcher-manager' - -describe('DefaultRouteMatcherManager', () => { - it('will throw an error when used before compiled', async () => { - const matchers = new DefaultRouteMatcherManager() - expect( - async () => await matchers.match('/some/not/real/path') - ).not.toThrow() - matchers.push({ routes: jest.fn(async () => []) }) - await expect(matchers.match('/some/not/real/path')).rejects.toThrow() - await matchers.compile() - await expect(matchers.match('/some/not/real/path')).resolves.toEqual(null) - }) - - it('will not error and not match when no matchers are provided', async () => { - const matchers = new DefaultRouteMatcherManager() - await matchers.compile() - expect(await matchers.match('/some/not/real/path')).toEqual(null) - }) - - it('tries to localize routes when provided', async () => { - const localeNormalizer: Normalizer = { - normalize: jest.fn((pathname) => pathname), - } - const matchers = new DefaultRouteMatcherManager(localeNormalizer) - await matchers.compile() - const pathname = '/some/not/real/path' - expect(await matchers.match(pathname)).toEqual(null) - expect(localeNormalizer.normalize).toHaveBeenCalledWith(pathname) - }) - - describe('static routes', () => { - it.each([ - ['/some/static/route', '/some/static/route.js'], - ['/some/other/static/route', '/some/other/static/route.js'], - ])('will match %s to %s', async (pathname, filename) => { - const matchers = new DefaultRouteMatcherManager() - - matchers.push({ - routes: async () => [ - { - kind: RouteKind.APP_PAGE, - pathname: '/some/other/static/route', - filename: '/some/other/static/route.js', - bundlePath: '', - page: '', - }, - { - kind: RouteKind.APP_PAGE, - pathname: '/some/static/route', - filename: '/some/static/route.js', - bundlePath: '', - page: '', - }, - ], - }) - - await matchers.compile() - - expect(await matchers.match(pathname)).toEqual({ - kind: RouteKind.APP_PAGE, - pathname, - filename, - bundlePath: '', - page: '', - }) - }) - }) - - describe('dynamic routes', () => { - it.each([ - { - pathname: '/users/123', - route: { - pathname: '/users/[id]', - filename: '/users/[id].js', - params: { id: '123' }, - }, - }, - { - pathname: '/account/123', - route: { - pathname: '/[...paths]', - filename: '/[...paths].js', - params: { paths: ['account', '123'] }, - }, - }, - { - pathname: '/dashboard/users/123', - route: { - pathname: '/[...paths]', - filename: '/[...paths].js', - params: { paths: ['dashboard', 'users', '123'] }, - }, - }, - ])( - "will match '$pathname' to '$route.filename'", - async ({ pathname, route }) => { - const matchers = new DefaultRouteMatcherManager() - - matchers.push({ - routes: async () => [ - { - kind: RouteKind.APP_PAGE, - pathname: '/[...paths]', - filename: '/[...paths].js', - bundlePath: '', - page: '', - }, - { - kind: RouteKind.APP_PAGE, - pathname: '/users/[id]', - filename: '/users/[id].js', - bundlePath: '', - page: '', - }, - ], - }) - - await matchers.compile() - - expect(await matchers.match(pathname)).toEqual({ - kind: RouteKind.APP_PAGE, - bundlePath: '', - page: '', - ...route, - }) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts deleted file mode 100644 index 2da4ca5b0f62b..0000000000000 --- a/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' -import { removeTrailingSlash } from '../../shared/lib/router/utils/remove-trailing-slash' -import { - getRouteMatcher, - RouteMatchFn, -} from '../../shared/lib/router/utils/route-matcher' -import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' -import { Normalizer } from '../normalizers/normalizer' -import { Normalizers } from '../normalizers/normalizers' -import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' -import { RouteKind } from '../route-kind' -import type { RouteMatch } from '../route-matches/route-match' -import { RouteDefinition, RouteMatcher } from '../route-matchers/route-matcher' -import { RouteMatcherManager } from './route-matcher-manager' - -interface DynamicRoute { - route: RouteDefinition - match: RouteMatchFn -} - -export class DefaultRouteMatcherManager implements RouteMatcherManager { - private readonly matchers: Array> = [] - private readonly normalizers: Normalizer - private normalized: Record> = {} - private dynamic: ReadonlyArray> = [] - private lastCompilationID = this.compilationID - - constructor(public readonly localeNormalizer?: Normalizer) { - const normalizers = new Normalizers([ - // Remove the trailing slash from incoming request pathnames as it may - // impact matching. - wrapNormalizerFn(removeTrailingSlash), - ]) - - // This will strip any locale code from the incoming path if configured. - if (localeNormalizer) normalizers.push(localeNormalizer) - - this.normalizers = normalizers - } - - /** - * When this value changes, it indicates that a change has been introduced - * that requires recompilation. - */ - private get compilationID() { - return this.matchers.length - } - - public push(matcher: RouteMatcher) { - this.matchers.push(matcher) - } - - /** - * Iterates over the matchers routes that have been provided and compiles all - * the dynamic routes. - */ - public async compile(): Promise { - // Grab the compilation ID for this run, we'll verify it at the end to - // ensure that if any routes were added before compilation is finished that - // we error out. - const compilationID = this.compilationID - const matcherRoutes = await Promise.all( - this.matchers.map((matcher) => matcher.routes()) - ) - - // Get all the pathnames (that have been normalized by the matcher) and - // associate them with the given route type. - this.normalized = matcherRoutes.reduce< - Record> - >((normalized, routes) => { - for (const route of routes) { - // Ensure we don't have duplicate routes in the normalized object. - // This can only happen when different matchers provide different - // routes as each matcher is expected to deduplicate routes returned. - if (route.pathname in normalized) { - // TODO: maybe just warn here and continue? - throw new Error( - 'Invariant: unexpected duplicate normalized pathname in matcher' - ) - } - - normalized[route.pathname] = route - } - - return normalized - }, {}) - - this.dynamic = - // Sort the routes according to their resolution order. - getSortedRoutes( - Object.keys(this.normalized) - // Only consider the routes with dynamic parameters. - .filter((pathname) => isDynamicRoute(pathname)) - ).map((pathname) => ({ - route: this.normalized[pathname], - match: getRouteMatcher(getRouteRegex(pathname)), - })) - - // This means that there was a new matcher pushed while we were waiting - if (this.compilationID !== compilationID) { - throw new Error( - 'Invariant: expected compilation to finish before new matchers were added, possible missing await' - ) - } - - // The compilation ID matched, so mark the complication as finished. - this.lastCompilationID = compilationID - } - - /** - * Matches a given request to a specific route match. If none could be found, - * it returns `null`. - * - * @param req the request to match a given route for - * @returns the route match if found - */ - public async match( - pathname: string, - options?: { skipDynamic?: boolean } - ): Promise | null> { - if (this.lastCompilationID !== this.compilationID) { - throw new Error('Invariant: expected routes to be compiled before match') - } - - // Normalize the pathname. - pathname = this.normalizers.normalize(pathname) - - // If this pathname doesn't look like a dynamic route, and this pathname is - // listed in the normalized list of routes, then return it. This ensures - // that when a route like `/user/[id]` is encountered, it doesn't just match - // with the list of normalized routes. - if (!isDynamicRoute(pathname) && pathname in this.normalized) { - return this.normalized[pathname] - } - - // If we should skip handling dynamic routes, exit now. - if (options?.skipDynamic) return null - - // For all the dynamic routes, try and match it. - for (const { route, match } of this.dynamic) { - const params = match(pathname) - - // Could not match the dynamic route, continue! - if (!params) continue - - // Matched! - return { ...route, params } - } - - // We tried direct matching against the pathname and against all the dynamic - // paths, so there was no match. - return null - } -} diff --git a/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts deleted file mode 100644 index 0b676a4fec40a..0000000000000 --- a/packages/next/src/server/route-matcher-managers/dev-route-matcher-manager.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { RouteMatcher } from '../route-matchers/route-matcher' -import { RouteMatch } from '../route-matches/route-match' -import { DefaultRouteMatcherManager } from './default-route-matcher-manager' -import { RouteMatcherManager } from './route-matcher-manager' - -export interface RouteEnsurer { - ensure(match: RouteMatch): Promise -} - -export class DevRouteMatcherManager implements RouteMatcherManager { - private readonly development: RouteMatcherManager - - constructor( - private readonly production: RouteMatcherManager, - private readonly ensurer: RouteEnsurer, - localeNormalizer?: Normalizer - ) { - this.development = new DefaultRouteMatcherManager(localeNormalizer) - } - - public push(matcher: RouteMatcher): void { - this.development.push(matcher) - } - - public async match( - pathname: string, - options?: { skipDynamic?: boolean } - ): Promise | null> { - let match = await this.production.match(pathname, options) - if (match) return match - - match = await this.development.match(pathname, options) - if (!match) return null - - // There was a match! This means that we didn't previously match the - // production matcher. Let's ensure the page so it gets built, and then - // recompile the production matcher. - await this.ensurer.ensure(match) - await this.production.compile() - - // Now that the production matcher has been recompiled, we should be able to - // match the pathname on it. If we can't that represents a disconnect - // between the development matchers and the production matchers, which would - // be a big problem! - match = await this.production.match(pathname) - if (!match) { - throw new Error( - 'Invariant: development match was found, but not found after ensuring' - ) - } - - return match - } - - public async compile(): Promise { - // Compile the production routes again. - await this.production.compile() - - // Compile the development routes. - await this.development.compile() - } -} diff --git a/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts deleted file mode 100644 index e4bc38984741a..0000000000000 --- a/packages/next/src/server/route-matcher-managers/route-matcher-manager.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { RouteKind } from '../route-kind' -import { RouteMatcher } from '../route-matchers/route-matcher' -import { RouteMatch } from '../route-matches/route-match' - -export interface RouteMatcherManager { - push(matcher: RouteMatcher): void - match( - pathname: string, - options?: { skipDynamic?: boolean } - ): Promise | null> - compile(): Promise -} diff --git a/packages/next/src/server/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/route-matchers/app-page-route-matcher.ts deleted file mode 100644 index 970033366f185..0000000000000 --- a/packages/next/src/server/route-matchers/app-page-route-matcher.ts +++ /dev/null @@ -1,55 +0,0 @@ -import path from 'path' -import { isAppPageRoute } from '../../lib/is-app-page-route' -import { - APP_PATHS_MANIFEST, - SERVER_DIRECTORY, -} from '../../shared/lib/constants' -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' -import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' -import { RouteKind } from '../route-kind' -import { RouteDefinition, RouteMatcher } from './route-matcher' - -export class AppPageRouteMatcher implements RouteMatcher { - constructor( - private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) - ) {} - - public async routes(): Promise< - ReadonlyArray> - > { - const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) - - return ( - Object.keys(manifest) - // This matcher only matches app pages. - .filter((page) => isAppPageRoute(page)) - // Normalize the routes. - .reduce>>((routes, page) => { - const pathname = normalizeAppPath(page) - - // If the route was already added, then don't add it again. - if (routes.find((r) => r.pathname === pathname)) return routes - - const filename = path.join( - this.distDir, - SERVER_DIRECTORY, - manifest[page] - ) - - routes.push({ - kind: RouteKind.APP_PAGE, - pathname, - filename, - page, - bundlePath: path.join('app', page), - }) - - return routes - }, []) - ) - } -} diff --git a/packages/next/src/server/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/route-matchers/app-route-route-matcher.ts deleted file mode 100644 index d4d2067de6196..0000000000000 --- a/packages/next/src/server/route-matchers/app-route-route-matcher.ts +++ /dev/null @@ -1,49 +0,0 @@ -import path from 'path' -import { isAppRouteRoute } from '../../lib/is-app-route-route' -import { - APP_PATHS_MANIFEST, - SERVER_DIRECTORY, -} from '../../shared/lib/constants' -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' -import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' -import { RouteKind } from '../route-kind' -import { RouteDefinition, RouteMatcher } from './route-matcher' - -export class AppRouteRouteMatcher implements RouteMatcher { - constructor( - private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) - ) {} - - public async routes(): Promise< - ReadonlyArray> - > { - const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) - - return ( - Object.keys(manifest) - // This matcher only matches app routes. - .filter((page) => isAppRouteRoute(page)) - // Normalize the routes. - .reduce>>((routes, page) => { - const pathname = normalizeAppPath(page) - - // If the route was already added, then don't add it again. - if (routes.find((r) => r.pathname === pathname)) return routes - - routes.push({ - kind: RouteKind.APP_ROUTE, - pathname, - filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), - page, - bundlePath: path.join('app', page), - }) - - return routes - }, []) - ) - } -} diff --git a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts deleted file mode 100644 index 38c316790dd63..0000000000000 --- a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { RouteKind } from '../route-kind' -import { DevAppPageRouteMatcher } from './dev-app-page-route-matcher' -import { FileReader } from './file-reader/file-reader' - -describe('DevAppPageRouteMatcher', () => { - const dir = '' - const extensions = ['ts', 'tsx', 'js', 'jsx'] - - it('returns no routes with an empty filesystem', async () => { - const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevAppPageRouteMatcher(dir, extensions, reader) - const routes = await matcher.routes() - expect(routes).toHaveLength(0) - expect(reader.read).toBeCalledWith(dir) - }) - - describe('filename matching', () => { - it.each([ - { - filename: `${dir}/(marketing)/about/page.ts`, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/about', - filename: `${dir}/(marketing)/about/page.ts`, - page: '/(marketing)/about/page', - bundlePath: 'app/(marketing)/about/page', - }, - }, - { - filename: `${dir}/some/other/page.ts`, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/some/other', - filename: `${dir}/some/other/page.ts`, - page: '/some/other/page', - bundlePath: 'app/some/other/page', - }, - }, - { - filename: `${dir}/page.ts`, - route: { - kind: RouteKind.APP_PAGE, - pathname: '/', - filename: `${dir}/page.ts`, - page: '/page', - bundlePath: 'app/page', - }, - }, - ])( - "matches the route specified with '$filename'", - async ({ filename, route }) => { - const reader: FileReader = { - read: jest.fn(() => [ - ...extensions.map((ext) => `${dir}/some/route.${ext}`), - ...extensions.map((ext) => `${dir}/api/other.${ext}`), - filename, - ]), - } - const matcher = new DevAppPageRouteMatcher(dir, extensions, reader) - const routes = await matcher.routes() - expect(routes).toHaveLength(1) - expect(reader.read).toBeCalledWith(dir) - expect(routes[0]).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts b/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts deleted file mode 100644 index 9424402082566..0000000000000 --- a/packages/next/src/server/route-matchers/dev-app-page-route-matcher.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' -import { Normalizers } from '../normalizers/normalizers' -import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' -import { RouteKind } from '../route-kind' -import { DevFSRouteMatcher } from './dev-fs-route-matcher' -import { FileReader } from './file-reader/file-reader' -import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' -import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' - -export class DevAppPageRouteMatcher extends DevFSRouteMatcher { - constructor(appDir: string, extensions: string[], reader?: FileReader) { - // Match any page file that ends with `/page.${extension}` under the app - // directory. - const matcher = new RegExp(`\\/page\\.(?:${extensions.join('|')})$`) - const filter = (pathname: string) => matcher.test(pathname) - - const filenameNormalizer = new AbsoluteFilenameNormalizer( - appDir, - extensions - ) - - const pathnameNormalizer = new Normalizers([ - filenameNormalizer, - // The pathname to match should have the trailing `/page` and other route - // group information stripped from it. - wrapNormalizerFn(normalizeAppPath), - ]) - - super({ - dir: appDir, - filter, - pageNormalizer: filenameNormalizer, - pathnameNormalizer, - bundlePathNormalizer: new Normalizers([ - filenameNormalizer, - // Prefix the bundle path with `app/`. - new PrefixingNormalizer('app'), - ]), - kind: RouteKind.APP_PAGE, - reader, - }) - } -} diff --git a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts b/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts deleted file mode 100644 index 67a3399148113..0000000000000 --- a/packages/next/src/server/route-matchers/dev-app-route-route-matcher.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' -import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' -import { Normalizers } from '../normalizers/normalizers' -import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' -import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' -import { RouteKind } from '../route-kind' -import { DevFSRouteMatcher } from './dev-fs-route-matcher' -import { FileReader } from './file-reader/file-reader' - -export class DevAppRouteRouteMatcher extends DevFSRouteMatcher { - constructor(appDir: string, extensions: string[], reader?: FileReader) { - // Match any route file that ends with `/route.${extension}` under the app - // directory. - const matcher = new RegExp(`\\/route\\.(?:${extensions.join('|')})$`) - const filter = (pathname: string) => matcher.test(pathname) - - const filenameNormalizer = new AbsoluteFilenameNormalizer( - appDir, - extensions - ) - - const pageNormalizer = new Normalizers([filenameNormalizer]) - - const pathnameNormalizer = new Normalizers([ - filenameNormalizer, - // The pathname to match should have the trailing `/route` and other route - // group information stripped from it. - wrapNormalizerFn(normalizeAppPath), - ]) - - super({ - dir: appDir, - filter, - pageNormalizer, - pathnameNormalizer, - bundlePathNormalizer: new Normalizers([ - filenameNormalizer, - // Prefix the bundle path with `app/`. - new PrefixingNormalizer('app'), - ]), - kind: RouteKind.APP_ROUTE, - reader, - }) - } -} diff --git a/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts b/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts deleted file mode 100644 index f706e45a68651..0000000000000 --- a/packages/next/src/server/route-matchers/dev-fs-route-matcher.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { FileReader } from './file-reader/file-reader' -import { DefaultFileReader } from './file-reader/default-file-reader' -import { RouteDefinition, RouteMatcher } from './route-matcher' - -type FilterFn = (filename: string) => boolean - -interface Options { - /** - * The directory to load the files from. - */ - dir: string - - /** - * The filter to include the files matched by this matcher. - */ - filter: FilterFn - - /** - * The normalizer that transforms the absolute filename to page. - */ - pageNormalizer: Normalizer - - /** - * The normalizer that transforms the absolute filename to a request pathname. - */ - pathnameNormalizer: Normalizer - - /** - * The normalizer that transforms the absolute filename to a bundle path. - */ - bundlePathNormalizer: Normalizer - - /** - * The kind of route definitions to emit. - */ - kind: K - - /** - * The reader implementation that provides the files from the directory. - * Defaults to a reader which uses the filesystem. - */ - reader?: FileReader -} - -export class DevFSRouteMatcher implements RouteMatcher { - private readonly dir: string - private readonly filter: FilterFn - private readonly pageNormalizer: Normalizer - private readonly pathnameNormalizer: Normalizer - private readonly bundlePathNormalizer: Normalizer - private readonly kind: K - private readonly reader: FileReader - - constructor({ - dir, - filter, - pageNormalizer, - pathnameNormalizer, - bundlePathNormalizer, - kind, - reader = new DefaultFileReader(), - }: Options) { - this.dir = dir - this.filter = filter - this.pageNormalizer = pageNormalizer - this.pathnameNormalizer = pathnameNormalizer - this.bundlePathNormalizer = bundlePathNormalizer - this.kind = kind - this.reader = reader - } - - public async routes(): Promise>> { - // Read the files in the directory... - let files: ReadonlyArray - - try { - files = await this.reader.read(this.dir) - } catch (err: any) { - if (err.code === 'ENOENT') { - // This can only happen when the underlying directory was removed. - return [] - } - - throw err - } - - return ( - files - // Filter the files by the provided filter... - .filter(this.filter) - .reduce>>((routes, filename) => { - // Normalize the filename into a pathname. - const pathname = this.pathnameNormalizer.normalize(filename) - - // If the route was already added, then don't add it again. - if (routes.find((r) => r.pathname === pathname)) return routes - - routes.push({ - kind: this.kind, - filename, - pathname, - page: this.pageNormalizer.normalize(filename), - bundlePath: this.bundlePathNormalizer.normalize(filename), - }) - - return routes - }, []) - ) - } -} diff --git a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts b/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts deleted file mode 100644 index fdc82c6881661..0000000000000 --- a/packages/next/src/server/route-matchers/dev-pages-api-route-matcher.ts +++ /dev/null @@ -1,72 +0,0 @@ -import path from '../../shared/lib/isomorphic/path' -import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' -import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' -import { Normalizer } from '../normalizers/normalizer' -import { Normalizers } from '../normalizers/normalizers' -import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' -import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' -import { RouteKind } from '../route-kind' -import { DevFSRouteMatcher } from './dev-fs-route-matcher' -import { FileReader } from './file-reader/file-reader' - -export class DevPagesAPIRouteMatcher extends DevFSRouteMatcher { - constructor( - pagesDir: string, - extensions: string[], - localeNormalizer?: Normalizer, - reader?: FileReader - ) { - // Match any route file that ends with `/${filename}.${extension}` under the - // pages directory. - const matcher = new RegExp(`\\.(?:${extensions.join('|')})$`) - const filter = (filename: string) => { - // If the file does not end in the correct extension, then it's not a - // match. - if (!matcher.test(filename)) return false - - // Pages API routes must exist in the pages directory with the `/api/` - // prefix. The pathnames being tested here though are the full filenames, - // so we need to include the pages directory. - if (filename.startsWith(path.join(pagesDir, '/api/'))) return true - - for (const extension of extensions) { - // We can also match if we have `pages/api.${extension}`, so check to - // see if it's a match. - if (filename === path.join(pagesDir, `api.${extension}`)) { - return true - } - } - - return false - } - - const filenameNormalizer = new AbsoluteFilenameNormalizer( - pagesDir, - extensions - ) - - const pathnameNormalizer = new Normalizers([filenameNormalizer]) - - const bundlePathNormalizer = new Normalizers([ - filenameNormalizer, - // If the bundle path would have ended in a `/`, add a `index` to it. - // new RootIndexNormalizer(), - wrapNormalizerFn(normalizePagePath), - // Prefix the bundle path with `pages/`. - new PrefixingNormalizer('pages'), - ]) - - // If configured, normalize the pathname for locales. - if (localeNormalizer) pathnameNormalizer.push(localeNormalizer) - - super({ - dir: pagesDir, - filter, - pageNormalizer: filenameNormalizer, - pathnameNormalizer, - bundlePathNormalizer, - kind: RouteKind.PAGES_API, - reader, - }) - } -} diff --git a/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts b/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts deleted file mode 100644 index a44d910606a3f..0000000000000 --- a/packages/next/src/server/route-matchers/dev-pages-route-matcher.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' -import { AbsoluteFilenameNormalizer } from '../normalizers/absolute-filename-normalizer' -import { Normalizer } from '../normalizers/normalizer' -import { Normalizers } from '../normalizers/normalizers' -import { PrefixingNormalizer } from '../normalizers/prefixing-normalizer' -import { wrapNormalizerFn } from '../normalizers/wrap-normalizer-fn' -import { RouteKind } from '../route-kind' -import { DevFSRouteMatcher } from './dev-fs-route-matcher' -import { FileReader } from './file-reader/file-reader' - -export class DevPagesRouteMatcher extends DevFSRouteMatcher { - constructor( - pagesDir: string, - extensions: string[], - localeNormalizer?: Normalizer, - reader?: FileReader - ) { - // Match any route file that ends with `/${filename}.${extension}` under the - // pages directory. - const matcher = new RegExp(`\\.(?:${extensions.join('|')})$`) - const filter = (filename: string) => { - // Pages routes must exist in the pages directory without the `/api/` - // prefix. The pathnames being tested here though are the full filenames, - // so we need to include the pages directory. - if (filename.startsWith(`${pagesDir}/api/`)) return false - - return matcher.test(filename) - } - - const absolutePathNormalizer = new AbsoluteFilenameNormalizer( - pagesDir, - extensions - ) - - const pageNormalizer = new Normalizers([absolutePathNormalizer]) - - const pathnameNormalizer = new Normalizers([absolutePathNormalizer]) - - const bundlePathNormalizer = new Normalizers([ - absolutePathNormalizer, - // If the bundle path would have ended in a `/`, add a `index` to it. - wrapNormalizerFn(normalizePagePath), - // Prefix the bundle path with `pages/`. - new PrefixingNormalizer('pages'), - ]) - - // If configured, normalize the pathname for locales. - if (localeNormalizer) pathnameNormalizer.push(localeNormalizer) - - super({ - dir: pagesDir, - filter, - pageNormalizer, - pathnameNormalizer, - bundlePathNormalizer, - kind: RouteKind.PAGES, - reader, - }) - } -} diff --git a/packages/next/src/server/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/route-matchers/pages-api-route-matcher.ts deleted file mode 100644 index 253a3299fe036..0000000000000 --- a/packages/next/src/server/route-matchers/pages-api-route-matcher.ts +++ /dev/null @@ -1,46 +0,0 @@ -import path from 'path' -import { isAPIRoute } from '../../lib/is-api-route' -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' -import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' -import { RouteKind } from '../route-kind' -import { RouteDefinition, RouteMatcher } from './route-matcher' - -export class PagesAPIRouteMatcher implements RouteMatcher { - constructor( - private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) - ) {} - - public async routes(): Promise< - ReadonlyArray> - > { - const manifest = await this.manifestLoader.load(PAGES_MANIFEST) - - return ( - Object.keys(manifest) - // This matcher is only for Pages API routes. - .filter((page) => isAPIRoute(page)) - // Normalize the routes. - .reduce>>((routes, page) => { - const pathname = page - - // If the route was already added, then don't add it again. - if (routes.find((r) => r.pathname === pathname)) return routes - - routes.push({ - kind: RouteKind.PAGES_API, - pathname, - filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), - page, - bundlePath: path.join('pages', page), - }) - - return routes - }, []) - ) - } -} diff --git a/packages/next/src/server/route-matchers/pages-route-matcher.test.ts b/packages/next/src/server/route-matchers/pages-route-matcher.test.ts deleted file mode 100644 index 15cc56f750f72..0000000000000 --- a/packages/next/src/server/route-matchers/pages-route-matcher.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' -import { RouteKind } from '../route-kind' -import { PagesRouteMatcher } from './pages-route-matcher' -import { RouteDefinition } from './route-matcher' - -describe('PagesRouteMatcher', () => { - it('returns no routes with an empty manifest', async () => { - const loader: ManifestLoader = { load: jest.fn(() => ({})) } - const matcher = new PagesRouteMatcher('', loader) - expect(await matcher.routes()).toEqual([]) - expect(loader.load).toBeCalledWith(PAGES_MANIFEST) - }) - - describe('manifest matching', () => { - it.each<{ - manifest: Record - route: RouteDefinition - }>([ - { - manifest: { '/users/[id]': 'pages/users/[id].js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/users/[id]', - filename: `/${SERVER_DIRECTORY}/pages/users/[id].js`, - page: '/users/[id]', - bundlePath: 'pages/users/[id]', - }, - }, - { - manifest: { '/users': 'pages/users.js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/users', - filename: `/${SERVER_DIRECTORY}/pages/users.js`, - page: '/users', - bundlePath: 'pages/users', - }, - }, - { - manifest: { '/': 'pages/index.js' }, - route: { - kind: RouteKind.PAGES, - pathname: '/', - filename: `/${SERVER_DIRECTORY}/pages/index.js`, - page: '/', - bundlePath: 'pages/index', - }, - }, - ])( - 'returns the correct routes for $route.pathname', - async ({ manifest, route }) => { - const loader: ManifestLoader = { - load: jest.fn(() => ({ - '/api/users/[id]': 'pages/api/users/[id].js', - '/api/users': 'pages/api/users.js', - ...manifest, - })), - } - const matcher = new PagesRouteMatcher('', loader) - const routes = await matcher.routes() - - expect(loader.load).toBeCalledWith(PAGES_MANIFEST) - expect(routes).toHaveLength(1) - expect(routes[0]).toEqual(route) - } - ) - }) -}) diff --git a/packages/next/src/server/route-matchers/pages-route-matcher.ts b/packages/next/src/server/route-matchers/pages-route-matcher.ts deleted file mode 100644 index a820b788f71df..0000000000000 --- a/packages/next/src/server/route-matchers/pages-route-matcher.ts +++ /dev/null @@ -1,48 +0,0 @@ -import path from 'path' -import { isAPIRoute } from '../../lib/is-api-route' -import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../shared/lib/constants' -import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' -import { ManifestLoader } from '../manifest-loaders/manifest-loader' -import { NodeManifestLoader } from '../manifest-loaders/node-manifest-loader' -import type { Normalizer } from '../normalizers/normalizer' -import { RouteKind } from '../route-kind' -import { RouteDefinition, RouteMatcher } from './route-matcher' - -export class PagesRouteMatcher implements RouteMatcher { - constructor( - private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ), - private readonly localeNormalizer?: Normalizer - ) {} - - public async routes(): Promise< - ReadonlyArray> - > { - const manifest = await this.manifestLoader.load(PAGES_MANIFEST) - - return ( - Object.keys(manifest) - // This matcher is only for Pages routes. - .filter((page) => !isAPIRoute(page)) - // Normalize the routes. - .reduce>>((routes, page) => { - const pathname = this.localeNormalizer?.normalize(page) ?? page - - // If the route was already added, then don't add it again. - if (routes.find((r) => r.pathname === pathname)) return routes - - routes.push({ - kind: RouteKind.PAGES, - pathname, - filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), - page, - bundlePath: path.join('pages', normalizePagePath(page)), - }) - - return routes - }, []) - ) - } -} diff --git a/packages/next/src/server/route-matches/route-match.ts b/packages/next/src/server/route-matches/route-match.ts deleted file mode 100644 index 870c42384ce06..0000000000000 --- a/packages/next/src/server/route-matches/route-match.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Params } from '../../shared/lib/router/utils/route-matcher' -import { RouteKind } from '../route-kind' -import { RouteDefinition } from '../route-matchers/route-matcher' - -/** - * RouteMatch is the resolved match for a given request. This will contain all - * the dynamic parameters used for this route. - */ -export interface RouteMatch extends RouteDefinition { - /** - * params when provided are the dynamic route parameters that were parsed from - * the incoming request pathname. If a route match is returned without any - * params, it should be considered a static route. - */ - readonly params?: Params -} - -export function isRouteMatchKind( - match: RouteMatch, - kind: K -): match is RouteMatch { - return match.kind === kind -} diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 6f5e8b550cee4..474b12992be03 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -19,9 +19,7 @@ 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' -import { RouteDefinition } from './route-matchers/route-matcher' -import { RouteKind } from './route-kind' -import { RouteMatcherManager } from './route-matcher-managers/route-matcher-manager' +import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' type RouteResult = { finished: boolean @@ -51,16 +49,6 @@ export type Route = { ) => Promise | RouteResult } -export interface DynamicRoute { - route: RouteDefinition - match: RouteMatchFn -} - -export type DynamicRoutes = ReadonlyArray<{ - route: { pathname: string } - match: RouteMatchFn -}> - export type RouterOptions = { headers: ReadonlyArray fsRoutes: ReadonlyArray @@ -91,26 +79,12 @@ export default class Router { fallback: ReadonlyArray } private readonly catchAllRoute: Route - private readonly matchers: RouteMatcherManager + private readonly matchers: Pick 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 = [], fsRoutes = [], @@ -141,10 +115,6 @@ export default class Router { this.needsRecompilation = false } - get locales() { - return this.nextConfig.i18n?.locales || [] - } - get basePath() { return this.nextConfig.basePath || '' } @@ -198,7 +168,7 @@ export default class Router { name: 'page checker', match: getPathMatch('/:path*'), fn: async (req, res, params, parsedUrl, upgradeHead) => { - const match = await this.matchers.match(parsedUrl.pathname!, { + const match = await this.matchers.test(parsedUrl.pathname!, { // We need to skip dynamic route matching because the next // step we're processing the afterFiles rewrites which must // not include dynamic matches. @@ -251,8 +221,7 @@ export default class Router { parsedUrl: NextUrlWithParsedQuery, upgradeHead?: Buffer ) { - const originalFsPathname = parsedUrl.pathname - const fsPathname = removePathPrefix(originalFsPathname!, this.basePath) + const fsPathname = removePathPrefix(parsedUrl.pathname!, this.basePath) for (const route of this.fsRoutes) { const params = route.match(fsPathname) @@ -267,10 +236,8 @@ export default class Router { } } - const match = await this.matchers.match(fsPathname) - if (!match) { - return false - } + const match = await this.matchers.test(fsPathname) + if (!match) return false // Matched a page or dynamic route so render it using catchAllRoute const params = this.catchAllRoute.match(parsedUrl.pathname) @@ -325,7 +292,7 @@ export default class Router { continue } - const originalPathname = parsedUrlUpdated.pathname as string + const originalPathname = parsedUrlUpdated.pathname! const pathnameInfo = getNextPathnameInfo(originalPathname, { nextConfig: this.nextConfig, parseData: false, diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 4d98f0684539b..6f6834ed0dc76 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -26,9 +26,8 @@ import { normalizeVercelUrl, } from '../build/webpack/loaders/next-serverless-loader/utils' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' -import { RouteHandlers } from './route-handlers/route-handlers' -import { DefaultRouteMatcherManager } from './route-matcher-managers/default-route-matcher-manager' - +import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' +import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' interface WebServerOptions extends Options { webServerConfig: { page: string @@ -53,7 +52,7 @@ export default class NextWebServer extends BaseServer { protected getRoutes() { const matchers = new DefaultRouteMatcherManager() - const handlers = new RouteHandlers() + const handlers = new RouteHandlerManager() // TODO: implement for edge runtime @@ -327,7 +326,6 @@ export default class NextWebServer extends BaseServer { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - // this.dynamicRoutes = this.getDynamicRoutes() } return { From 6295fca3326fcde7ec09f46056944404e32d907a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 10 Feb 2023 19:43:57 -0700 Subject: [PATCH 04/22] fix: fixed bug with edge render --- packages/next/src/server/base-server.ts | 71 +++++++++++++++++-- .../next/src/server/dev/next-dev-server.ts | 22 ++++-- .../absolute-filename-normalizer.ts | 2 +- .../route-handlers/app-route-route-handler.ts | 12 ++-- .../app-page-route-matcher-provider.ts | 9 +-- .../app-route-route-matcher-provider.ts | 9 +-- .../manifest-loaders/manifest-loader.ts | 2 +- .../manifest-loaders/node-manifest-loader.ts | 12 +++- .../server-manifest-loader.ts | 9 +++ .../pages-api-route-matcher-provider.ts | 9 +-- .../pages-route-matcher-provider.ts | 9 +-- packages/next/src/server/next-server.ts | 59 +-------------- packages/next/src/server/web-server.ts | 9 --- 13 files changed, 125 insertions(+), 109 deletions(-) create mode 100644 packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 5d2c8ce52d227..181185c4bfac3 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -34,7 +34,9 @@ import { format as formatUrl, parse as parseUrl } from 'url' import { getRedirectStatus } from '../lib/redirect-status' import { isEdgeRuntime } from '../lib/is-edge-runtime' import { + APP_PATHS_MANIFEST, NEXT_BUILTIN_DOCUMENT, + PAGES_MANIFEST, STATIC_STATUS_PAGES, TEMPORARY_REDIRECT_STATUS, } from '../shared/lib/constants' @@ -80,6 +82,13 @@ import { } from '../client/components/app-router-headers' import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' +import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' +import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' +import { AppPageRouteMatcherProvider } from './future/route-matcher-providers/app-page-route-matcher-provider' +import { AppRouteRouteMatcherProvider } from './future/route-matcher-providers/app-route-route-matcher-provider' +import { PagesAPIRouteMatcherProvider } from './future/route-matcher-providers/pages-api-route-matcher-provider' +import { PagesRouteMatcherProvider } from './future/route-matcher-providers/pages-route-matcher-provider' +import { ServerManifestLoader } from './future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -310,11 +319,6 @@ export default abstract class Server { protected readonly matchers: RouteMatcherManager protected readonly handlers: RouteHandlerManager - protected abstract getRoutes(): { - matchers: RouteMatcherManager - handlers: RouteHandlerManager - } - public constructor(options: ServerOptions) { const { dir = '.', @@ -433,6 +437,63 @@ export default abstract class Server { this.responseCache = this.getResponseCache({ dev }) } + protected getRoutes(): { + matchers: RouteMatcherManager + handlers: RouteHandlerManager + } { + // Create a new manifest loader that get's the manifests from the server. + const manifestLoader = new ServerManifestLoader((name) => { + switch (name) { + case PAGES_MANIFEST: + return this.getPagesManifest() ?? null + case APP_PATHS_MANIFEST: + return this.getAppPathsManifest() ?? null + default: + return null + } + }) + + // Configure the locale normalizer, it's used for routes inside `pages/`. + const localeNormalizer = + this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale + ? new LocaleRouteNormalizer( + this.nextConfig.i18n.locales, + this.nextConfig.i18n.defaultLocale + ) + : undefined + + // Configure the matchers and handlers. + const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() + const handlers = new RouteHandlerManager() + + // Match pages under `pages/`. + matchers.push( + new PagesRouteMatcherProvider( + this.distDir, + manifestLoader, + localeNormalizer + ) + ) + + // Match api routes under `pages/api/`. + matchers.push( + new PagesAPIRouteMatcherProvider(this.distDir, manifestLoader) + ) + + // If the app directory is enabled, then add the app matchers and handlers. + if (this.hasAppDir) { + // Match app pages under `app/`. + matchers.push( + new AppPageRouteMatcherProvider(this.distDir, manifestLoader) + ) + matchers.push( + new AppRouteRouteMatcherProvider(this.distDir, manifestLoader) + ) + } + + return { matchers, handlers } + } + public logError(err: Error): void { if (this.quiet) return console.error(err) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 3471819e8d09b..ddfdcb1014c1f 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -32,6 +32,8 @@ import { DEV_CLIENT_PAGES_MANIFEST, DEV_MIDDLEWARE_MANIFEST, COMPILER_NAMES, + PAGES_MANIFEST, + APP_PATHS_MANIFEST, } from '../../shared/lib/constants' import Server, { WrappedBuildError } from '../next-server' import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' @@ -86,6 +88,8 @@ import { DevPagesRouteMatcherProvider } from '../future/route-matcher-providers/ import { DevPagesAPIRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider' import { DevAppPageRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-page-route-matcher-provider' import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-route-route-matcher-provider' +import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' +import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -1219,12 +1223,22 @@ export default class DevServer extends Server { }) } - protected getPagesManifest(): undefined { - return undefined + protected getPagesManifest(): PagesManifest | undefined { + return ( + NodeManifestLoader.require( + pathJoin(this.serverDistDir, PAGES_MANIFEST) + ) ?? undefined + ) } - protected getAppPathsManifest(): undefined { - return undefined + protected getAppPathsManifest(): PagesManifest | undefined { + if (!this.hasAppDir) return undefined + + return ( + NodeManifestLoader.require( + pathJoin(this.serverDistDir, APP_PATHS_MANIFEST) + ) ?? undefined + ) } protected getMiddleware() { diff --git a/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts index fe3e0dd5a5800..55aaf869bf508 100644 --- a/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts +++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts @@ -1,4 +1,4 @@ -import path from 'path' +import path from '../../../shared/lib/isomorphic/path' import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' import { normalizePathSep } from '../../../shared/lib/page-path/normalize-path-sep' import { removePagePathTail } from '../../../shared/lib/page-path/remove-page-path-tail' diff --git a/packages/next/src/server/future/route-handlers/app-route-route-handler.ts b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts index ceec4e2af9388..0018e6784d022 100644 --- a/packages/next/src/server/future/route-handlers/app-route-route-handler.ts +++ b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts @@ -3,18 +3,18 @@ import { getURLFromRedirectError, isRedirectError, } from '../../../client/components/redirect' -import { +import type { RequestAsyncStorage, RequestStore, } from '../../../client/components/request-async-storage' -import { Params } from '../../../shared/lib/router/utils/route-matcher' -import { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' +import type { Params } from '../../../shared/lib/router/utils/route-matcher' +import type { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' import { RequestAsyncStorageWrapper, - RequestContext, + type RequestContext, } from '../../async-storage/request-async-storage-wrapper' -import { BaseNextRequest, BaseNextResponse } from '../../base-http' -import { NodeNextRequest, NodeNextResponse } from '../../base-http/node' +import type { BaseNextRequest, BaseNextResponse } from '../../base-http' +import type { NodeNextRequest, NodeNextResponse } from '../../base-http/node' import { getRequestMeta } from '../../request-meta' import { handleBadRequestResponse, diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts index 6e0f0b3a6b465..10f88cd5d02ef 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts @@ -1,14 +1,13 @@ -import path from 'path' import { isAppPageRoute } from '../../../lib/is-app-page-route' import { APP_PATHS_MANIFEST, SERVER_DIRECTORY, } from '../../../shared/lib/constants' +import path from '../../../shared/lib/isomorphic/path' import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' import { RouteKind } from '../route-kind' import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' import { RouteMatcherProvider } from './route-matcher-provider' export class AppPageRouteMatcherProvider @@ -16,13 +15,11 @@ export class AppPageRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) + private readonly manifestLoader: ManifestLoader ) {} public async matchers(): Promise> { - const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + const manifest = this.manifestLoader.load(APP_PATHS_MANIFEST) if (!manifest) return [] // This matcher only matches app pages. diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts index 175555e3dcbeb..cf2215c59b553 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts @@ -1,4 +1,4 @@ -import path from 'path' +import path from '../../../shared/lib/isomorphic/path' import { isAppRouteRoute } from '../../../lib/is-app-route-route' import { APP_PATHS_MANIFEST, @@ -8,7 +8,6 @@ import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' import { RouteKind } from '../route-kind' import { AppRouteRouteMatcher } from '../route-matchers/app-route-route-matcher' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' import { RouteMatcherProvider } from './route-matcher-provider' export class AppRouteRouteMatcherProvider @@ -16,13 +15,11 @@ export class AppRouteRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) + private readonly manifestLoader: ManifestLoader ) {} public async matchers(): Promise> { - const manifest = await this.manifestLoader.load(APP_PATHS_MANIFEST) + const manifest = this.manifestLoader.load(APP_PATHS_MANIFEST) if (!manifest) return [] // This matcher only matches app routes. diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts index 8c800bf8a433e..dbaf73cffb9d1 100644 --- a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts @@ -1,5 +1,5 @@ export type Manifest = Record export interface ManifestLoader { - load(name: string): Promise | Manifest | null + load(name: string): Manifest | null } diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts index 82f2b0cc9aaa7..e4654f42a1f51 100644 --- a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts @@ -1,13 +1,13 @@ -import path from 'path' import { SERVER_DIRECTORY } from '../../../../../shared/lib/constants' +import path from '../../../../../shared/lib/isomorphic/path' import { Manifest, ManifestLoader } from './manifest-loader' export class NodeManifestLoader implements ManifestLoader { constructor(private readonly distDir: string) {} - public async load(name: string): Promise { + static require(id: string) { try { - return await require(path.join(this.distDir, SERVER_DIRECTORY, name)) + return require(id) } catch (err: any) { // If a manifest can't be found, we should gracefully fail by returning // null. @@ -18,4 +18,10 @@ export class NodeManifestLoader implements ManifestLoader { throw err } } + + public load(name: string): Manifest | null { + return NodeManifestLoader.require( + path.join(this.distDir, SERVER_DIRECTORY, name) + ) + } } diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts new file mode 100644 index 0000000000000..6ecbcb4e1f0d0 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/server-manifest-loader.ts @@ -0,0 +1,9 @@ +import { Manifest, ManifestLoader } from './manifest-loader' + +export class ServerManifestLoader implements ManifestLoader { + constructor(private readonly getter: (name: string) => Manifest | null) {} + + public load(name: string): Manifest | null { + return this.getter(name) + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts index b55b9ecb5251e..05dc818f14865 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -1,11 +1,10 @@ -import path from 'path' +import path from '../../../shared/lib/isomorphic/path' import { isAPIRoute } from '../../../lib/is-api-route' import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { RouteKind } from '../route-kind' import { PagesAPIRouteMatcher } from '../route-matchers/pages-api-route-matcher' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' import { RouteMatcherProvider } from './route-matcher-provider' export class PagesAPIRouteMatcherProvider @@ -13,13 +12,11 @@ export class PagesAPIRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ) + private readonly manifestLoader: ManifestLoader ) {} public async matchers(): Promise> { - const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + const manifest = this.manifestLoader.load(PAGES_MANIFEST) if (!manifest) return [] return ( diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts index 3d6234303b1c1..a9bb1b03ddda9 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -1,4 +1,4 @@ -import path from 'path' +import path from '../../../shared/lib/isomorphic/path' import { isAPIRoute } from '../../../lib/is-api-route' import { BLOCKED_PAGES, @@ -10,7 +10,6 @@ import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' import { RouteKind } from '../route-kind' import { PagesRouteMatcher } from '../route-matchers/pages-route-matcher' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { NodeManifestLoader } from './helpers/manifest-loaders/node-manifest-loader' import { RouteMatcherProvider } from './route-matcher-provider' export class PagesRouteMatcherProvider @@ -18,14 +17,12 @@ export class PagesRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader = new NodeManifestLoader( - distDir - ), + private readonly manifestLoader: ManifestLoader, private readonly localeNormalizer?: LocaleRouteNormalizer ) {} public async matchers(): Promise> { - const manifest = await this.manifestLoader.load(PAGES_MANIFEST) + const manifest = this.manifestLoader.load(PAGES_MANIFEST) if (!manifest) return [] // This matcher is only for Pages routes, not Pages API routes which are diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 9a2d8e1262d18..80adc8dd9d418 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -96,17 +96,8 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { setHttpClientAndAgentOptions } from './config' -import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' import { RouteKind } from './future/route-kind' -import { NodeManifestLoader } from './future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' -import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' -import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' -import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' -import { PagesRouteMatcherProvider } from './future/route-matcher-providers/pages-route-matcher-provider' -import { PagesAPIRouteMatcherProvider } from './future/route-matcher-providers/pages-api-route-matcher-provider' -import { AppPageRouteMatcherProvider } from './future/route-matcher-providers/app-page-route-matcher-provider' -import { AppRouteRouteMatcherProvider } from './future/route-matcher-providers/app-route-route-matcher-provider' import { AppRouteRouteHandler } from './future/route-handlers/app-route-route-handler' import { PagesAPIRouteMatch } from './future/route-matches/pages-api-route-match' @@ -271,57 +262,13 @@ export default class NextNodeServer extends BaseServer { } protected getRoutes() { - // Configure the locale normalizer, it's used for routes inside `pages/`. - const localeNormalizer = - this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale - ? new LocaleRouteNormalizer( - this.nextConfig.i18n.locales, - this.nextConfig.i18n.defaultLocale - ) - : undefined - - // Configure the matchers and handlers. - const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() - const handlers = new RouteHandlerManager() - - const manifestLoader = new NodeManifestLoader(this.distDir) - - // Match pages under `pages/`. - matchers.push( - new PagesRouteMatcherProvider( - this.distDir, - manifestLoader, - localeNormalizer - ) - ) - - // NOTE: we don't have a handler for the pages route type yet - - // Match api routes under `pages/api/`. - matchers.push( - new PagesAPIRouteMatcherProvider(this.distDir, manifestLoader) - ) - - // NOTE: we don't have a handler for the pages api route type yet + const routes = super.getRoutes() - // If the app directory is enabled, then add the app matchers and handlers. if (this.hasAppDir) { - // Match app pages under `app/`. - matchers.push( - new AppPageRouteMatcherProvider(this.distDir, manifestLoader) - ) - - // NOTE: we don't have a handler for the app page route type yet - - matchers.push( - new AppRouteRouteMatcherProvider(this.distDir, manifestLoader) - ) - handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler()) + routes.handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler()) } - // TODO: ensure that the matchers are reloaded - - return { matchers, handlers } + return routes } protected loadEnvConfig({ diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 6f6834ed0dc76..4921445a09b3f 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -50,15 +50,6 @@ export default class NextWebServer extends BaseServer { Object.assign(this.renderOpts, options.webServerConfig.extendRenderOpts) } - protected getRoutes() { - const matchers = new DefaultRouteMatcherManager() - const handlers = new RouteHandlerManager() - - // TODO: implement for edge runtime - - return { matchers, handlers } - } - protected handleCompression() { // For the web server layer, compression is automatically handled by the // upstream proxy (edge runtime or node server) and we can simply skip here. From 6dcc7f05431192197fa495a231e3bc728dc7627b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 10 Feb 2023 22:43:10 -0700 Subject: [PATCH 05/22] fix: fixed case where error pages were not ensured --- packages/next/src/server/base-server.ts | 13 +++++++++++++ packages/next/src/server/dev/next-dev-server.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 181185c4bfac3..d54fdf0b917c0 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -253,6 +253,8 @@ export default abstract class Server { params: Params isAppPath: boolean sriEnabled?: boolean + appPaths?: string[] | null + shouldEnsure: boolean }): Promise protected abstract getFontManifest(): FontManifest | undefined protected abstract getPrerenderManifest(): PrerenderManifest @@ -1729,6 +1731,9 @@ export default abstract class Server { params: ctx.renderOpts.params || {}, isAppPath, sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, + appPaths, + // Ensuring for loading page component routes is done via the matcher. + shouldEnsure: false, }) if (result) { try { @@ -1923,6 +1928,8 @@ export default abstract class Server { query, params: {}, isAppPath: false, + // Ensuring can't be done here because you never "match" a 404 route. + shouldEnsure: true, }) using404Page = result !== null } @@ -1941,6 +1948,9 @@ export default abstract class Server { query, params: {}, isAppPath: false, + // Ensuring can't be done here because you never "match" a 500 + // route. + shouldEnsure: true, }) } } @@ -1951,6 +1961,9 @@ export default abstract class Server { query, params: {}, isAppPath: false, + // Ensuring can't be done here because you never "match" an error + // route. + shouldEnsure: true, }) statusPage = '/_error' } diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index ddfdcb1014c1f..33d95420864f0 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -1466,11 +1466,15 @@ export default class DevServer extends Server { query, params, isAppPath, + appPaths = null, + shouldEnsure, }: { pathname: string query: ParsedUrlQuery params: Params isAppPath: boolean + appPaths?: string[] | null + shouldEnsure: boolean }): Promise { await this.devReady const compilationErr = await this.getCompilationError(pathname) @@ -1479,6 +1483,14 @@ export default class DevServer extends Server { throw new WrappedBuildError(compilationErr) } try { + if (shouldEnsure) { + await this.hotReloader!.ensurePage({ + page: pathname, + appPaths, + clientOnly: false, + }) + } + // When the new page is compiled, we need to reload the server component // manifest. if (!!this.appDir) { From b35f1067d1ea5074b7b6d1fc5b39dc1b61970649 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 10 Feb 2023 23:44:10 -0700 Subject: [PATCH 06/22] fix: fixed case for trailing slash handling in the router --- packages/next/src/server/router.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 474b12992be03..4dd900a380ab1 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -20,6 +20,7 @@ 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' import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' +import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' type RouteResult = { finished: boolean @@ -168,7 +169,10 @@ export default class Router { name: 'page checker', match: getPathMatch('/:path*'), fn: async (req, res, params, parsedUrl, upgradeHead) => { - const match = await this.matchers.test(parsedUrl.pathname!, { + // Next.js performs all route matching without the trailing slash. + const pathname = removeTrailingSlash(parsedUrl.pathname || '/') + + const match = await this.matchers.test(pathname, { // We need to skip dynamic route matching because the next // step we're processing the afterFiles rewrites which must // not include dynamic matches. From 43c867ae4f7627431be0f6f848283de6e67a90f2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 01:01:31 -0700 Subject: [PATCH 07/22] fix: handle edge case where api routes also include localization information --- packages/next/src/server/base-server.ts | 6 ++- .../next/src/server/dev/next-dev-server.ts | 17 ++++++- ...v-pages-api-route-matcher-provider.test.ts | 8 +++- .../dev-pages-api-route-matcher-provider.ts | 18 ++++--- .../pages-api-route-matcher-provider.ts | 47 ++++++++++--------- .../pages-route-matcher-provider.ts | 2 +- .../route-matchers/pages-api-route-matcher.ts | 11 +++++ .../i18n-support-base-path/test/index.test.js | 7 +++ test/integration/i18n-support/test/shared.js | 8 ++-- 9 files changed, 87 insertions(+), 37 deletions(-) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index d54fdf0b917c0..85b56295abdfe 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -479,7 +479,11 @@ export default abstract class Server { // Match api routes under `pages/api/`. matchers.push( - new PagesAPIRouteMatcherProvider(this.distDir, manifestLoader) + new PagesAPIRouteMatcherProvider( + this.distDir, + manifestLoader, + localeNormalizer + ) ) // If the app directory is enabled, then add the app matchers and handlers. diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 33d95420864f0..0fa694e246241 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -90,6 +90,7 @@ import { DevAppPageRouteMatcherProvider } from '../future/route-matcher-provider import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-route-route-matcher-provider' import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' +import { LocaleRouteNormalizer } from '../future/normalizers/locale-route-normalizer' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -245,8 +246,22 @@ export default class DevServer extends Server { // If the pages directory is available, then configure those matchers. if (pagesDir) { + const localeNoramlizer = + this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale + ? new LocaleRouteNormalizer( + this.nextConfig.i18n.locales, + this.nextConfig.i18n.defaultLocale + ) + : undefined + matchers.push(new DevPagesRouteMatcherProvider(pagesDir, extensions)) - matchers.push(new DevPagesAPIRouteMatcherProvider(pagesDir, extensions)) + matchers.push( + new DevPagesAPIRouteMatcherProvider( + pagesDir, + extensions, + localeNoramlizer + ) + ) } if (appDir) { diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts index 5535f19436c0d..c26a01272b59f 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts @@ -9,7 +9,12 @@ describe('DevPagesAPIRouteMatcherProvider', () => { it('returns no routes with an empty filesystem', async () => { const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader) + const matcher = new DevPagesAPIRouteMatcherProvider( + dir, + extensions, + undefined, + reader + ) const matchers = await matcher.matchers() expect(matchers).toHaveLength(0) expect(reader.read).toBeCalledWith(dir) @@ -74,6 +79,7 @@ describe('DevPagesAPIRouteMatcherProvider', () => { const matcher = new DevPagesAPIRouteMatcherProvider( dir, extensions, + undefined, reader ) const matchers = await matcher.matchers() diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts index b1e144d249181..8591e00363d56 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -24,6 +24,7 @@ export class DevPagesAPIRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, + private readonly localeNormalizer?: Normalizer, private readonly reader: FileReader = new DefaultFileReader() ) { // Match any route file that ends with `/${filename}.${extension}` under the @@ -85,13 +86,16 @@ export class DevPagesAPIRouteMatcherProvider // TODO: what do we do if this route is a duplicate? matchers.push( - new PagesAPIRouteMatcher({ - kind: RouteKind.PAGES_API, - pathname, - page, - bundlePath, - filename, - }) + new PagesAPIRouteMatcher( + { + kind: RouteKind.PAGES_API, + pathname, + page, + bundlePath, + filename, + }, + this.localeNormalizer + ) ) } diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts index 05dc818f14865..666e0f059c5a8 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -6,41 +6,42 @@ import { RouteKind } from '../route-kind' import { PagesAPIRouteMatcher } from '../route-matchers/pages-api-route-matcher' import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' import { RouteMatcherProvider } from './route-matcher-provider' +import { Normalizer } from '../normalizers/normalizer' export class PagesAPIRouteMatcherProvider implements RouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader + private readonly manifestLoader: ManifestLoader, + private readonly localeNormalizer?: Normalizer ) {} public async matchers(): Promise> { const manifest = this.manifestLoader.load(PAGES_MANIFEST) if (!manifest) return [] - return ( - Object.keys(manifest) - // This matcher is only for Pages API routes. - .filter((page) => isAPIRoute(page)) - // Normalize the routes. - .reduce>((matchers, page) => { - matchers.push( - new PagesAPIRouteMatcher({ - kind: RouteKind.PAGES_API, - pathname: page, - page, - bundlePath: path.join('pages', normalizePagePath(page)), - filename: path.join( - this.distDir, - SERVER_DIRECTORY, - manifest[page] - ), - }) - ) - - return matchers - }, []) + // This matcher is only for Pages API routes. + const pathnames = Object.keys(manifest).filter((pathname) => + isAPIRoute(pathname) ) + + const matchers: Array = [] + for (const page of pathnames) { + matchers.push( + new PagesAPIRouteMatcher( + { + kind: RouteKind.PAGES_API, + pathname: page, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + }, + this.localeNormalizer + ) + ) + } + + return matchers } } diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts index a9bb1b03ddda9..177a7747f505f 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -27,7 +27,7 @@ export class PagesRouteMatcherProvider // This matcher is only for Pages routes, not Pages API routes which are // included in this manifest. - let pathnames = Object.keys(manifest) + const pathnames = Object.keys(manifest) .filter((pathname) => !isAPIRoute(pathname)) // Remove any blocked pages (page that can't be routed to, like error or // internal pages). diff --git a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts index 9ee6dcffba2b4..5c9798e4e7199 100644 --- a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts @@ -1,8 +1,19 @@ import { RouteMatcher } from './route-matcher' import { PagesAPIRouteMatch } from '../route-matches/pages-api-route-match' +import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' +import { Normalizer } from '../normalizers/normalizer' export class PagesAPIRouteMatcher extends RouteMatcher { + constructor( + route: PagesAPIRouteDefinition, + private readonly localeNormalizer?: Normalizer + ) { + super(route) + } + public match(pathname: string): PagesAPIRouteMatch | null { + pathname = this.localeNormalizer?.normalize(pathname) ?? pathname + const result = this.test(pathname) if (!result) return null diff --git a/test/integration/i18n-support-base-path/test/index.test.js b/test/integration/i18n-support-base-path/test/index.test.js index 55c9b99c9d8ce..0e99499c2b683 100644 --- a/test/integration/i18n-support-base-path/test/index.test.js +++ b/test/integration/i18n-support-base-path/test/index.test.js @@ -33,6 +33,13 @@ describe('i18n Support basePath', () => { ) }) }) + afterAll(async () => { + await new Promise((resolve, reject) => + ctx.externalApp.close((err) => { + err ? reject(err) : resolve() + }) + ) + }) describe('dev mode', () => { const curCtx = { diff --git a/test/integration/i18n-support/test/shared.js b/test/integration/i18n-support/test/shared.js index cfc69fdda8b15..c199bbeabb780 100644 --- a/test/integration/i18n-support/test/shared.js +++ b/test/integration/i18n-support/test/shared.js @@ -1355,8 +1355,9 @@ export function runTests(ctx) { }) }) - it('should rewrite to API route correctly', async () => { - for (const locale of locales) { + it.each(locales)( + 'should rewrite to API route correctly for %s locale', + async (locale) => { const res = await fetchViaHTTP( ctx.appPort, `${ctx.basePath || ''}${ @@ -1368,13 +1369,14 @@ export function runTests(ctx) { } ) + expect(res.headers.get('content-type')).toContain('application/json') const data = await res.json() expect(data).toEqual({ hello: true, query: {}, }) } - }) + ) it('should apply rewrites correctly', async () => { let res = await fetchViaHTTP( From 3b3d76deccbc8241f86ccd1309c287e8b7bb7c8f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 09:54:47 -0700 Subject: [PATCH 08/22] fix: catch loading error when manifest is not available --- .../helpers/manifest-loaders/node-manifest-loader.ts | 10 ++-------- packages/next/src/server/web-server.ts | 2 -- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts index e4654f42a1f51..a7bcd301439aa 100644 --- a/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts @@ -8,14 +8,8 @@ export class NodeManifestLoader implements ManifestLoader { static require(id: string) { try { return require(id) - } catch (err: any) { - // If a manifest can't be found, we should gracefully fail by returning - // null. - if (err.code === 'MODULE_NOT_FOUND') { - return null - } - - throw err + } catch { + return null } } diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 4921445a09b3f..278ea9923968e 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -26,8 +26,6 @@ import { normalizeVercelUrl, } from '../build/webpack/loaders/next-serverless-loader/utils' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' -import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' -import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' interface WebServerOptions extends Options { webServerConfig: { page: string From 00c89361945e8efb19abfd4ff9f3a58f22ba08bc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 11:12:35 -0700 Subject: [PATCH 09/22] feat: added provider caching for development --- .../default-route-matcher-manager.ts | 36 +++++++++++----- .../app-page-route-matcher-provider.ts | 24 ++++++----- .../app-route-route-matcher-provider.ts | 24 ++++++----- .../manifest-route-matcher-provider.ts | 41 +++++++++++++++++++ .../pages-api-route-matcher-provider.ts | 24 ++++++----- .../pages-route-matcher-provider.ts | 24 ++++++----- 6 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 360165fa4029e..c02a70d5678c5 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -15,6 +15,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { static: ReadonlyArray dynamic: ReadonlyArray } = { static: [], dynamic: [] } + private cache: ReadonlyArray = [] private lastCompilationID = this.compilationID /** @@ -45,10 +46,14 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // Collect all the matchers from each provider. const matchers: Array = [] + // Get all the providers matchers. + const providersMatchers: ReadonlyArray> = + await Promise.all(this.providers.map((provider) => provider.matchers())) + // Use this to detect duplicate pathnames. const all = new Set() - for (const provider of this.providers) { - for (const matcher of await provider.matchers()) { + for (const providerMatchers of providersMatchers) { + for (const matcher of providerMatchers) { if (all.has(matcher.route.pathname)) { // TODO: when a duplicate route is detected, what should we do? throw new Error( @@ -63,20 +68,31 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { } } + // If the cache is the same as what we just parsed, we can exit now. We + // can tell by using the `===` which compares object identity, which for + // the manifest matchers, will return the same matcher each time. + if ( + this.cache.length === matchers.length && + this.cache.every( + (cachedMatcher, index) => cachedMatcher === matchers[index] + ) + ) { + return + } + this.cache = matchers + // For matchers that are for static routes, filter them now. this.matchers.static = matchers.filter((matcher) => !matcher.isDynamic) // For matchers that are for dynamic routes, filter them and sort them now. const dynamic = matchers.filter((matcher) => matcher.isDynamic) - /** - * Because `getSortedRoutes` only accepts an array of strings, we need to - * build a reference between the pathnames used for dynamic routing and the - * underlying matchers used to perform the match for each route. We take the - * fact that the pathnames are unique to build a reference of their original - * index in the array so that when we call `getSortedRoutes`, we can lookup - * the associated matcher. - */ + // Because `getSortedRoutes` only accepts an array of strings, we need to + // build a reference between the pathnames used for dynamic routing and + // the underlying matchers used to perform the match for each route. We + // take the fact that the pathnames are unique to build a reference of + // their original index in the array so that when we call + // `getSortedRoutes`, we can lookup the associated matcher. // Generate a filename to index map, this will be used to re-sort the array. const indexes = new Map() diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts index 10f88cd5d02ef..f379e38af3f23 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts @@ -7,21 +7,23 @@ import path from '../../../shared/lib/isomorphic/path' import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' import { RouteKind } from '../route-kind' import { AppPageRouteMatcher } from '../route-matchers/app-page-route-matcher' -import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { RouteMatcherProvider } from './route-matcher-provider' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -export class AppPageRouteMatcherProvider - implements RouteMatcherProvider -{ +export class AppPageRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader - ) {} - - public async matchers(): Promise> { - const manifest = this.manifestLoader.load(APP_PATHS_MANIFEST) - if (!manifest) return [] + manifestLoader: ManifestLoader + ) { + super(APP_PATHS_MANIFEST, manifestLoader) + } + protected async transform( + manifest: Manifest + ): Promise> { // This matcher only matches app pages. const pages = Object.keys(manifest).filter((page) => isAppPageRoute(page)) diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts index cf2215c59b553..da2d2cdd1677d 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts @@ -7,21 +7,23 @@ import { import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths' import { RouteKind } from '../route-kind' import { AppRouteRouteMatcher } from '../route-matchers/app-route-route-matcher' -import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { RouteMatcherProvider } from './route-matcher-provider' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -export class AppRouteRouteMatcherProvider - implements RouteMatcherProvider -{ +export class AppRouteRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader - ) {} - - public async matchers(): Promise> { - const manifest = this.manifestLoader.load(APP_PATHS_MANIFEST) - if (!manifest) return [] + manifestLoader: ManifestLoader + ) { + super(APP_PATHS_MANIFEST, manifestLoader) + } + protected async transform( + manifest: Manifest + ): Promise> { // This matcher only matches app routes. const pages = Object.keys(manifest).filter((page) => isAppRouteRoute(page)) diff --git a/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts new file mode 100644 index 0000000000000..b08610fa73f38 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts @@ -0,0 +1,41 @@ +import { RouteMatcher } from '../route-matchers/route-matcher' +import { RouteMatcherProvider } from './route-matcher-provider' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' + +export abstract class ManifestRouteMatcherProvider< + M extends RouteMatcher = RouteMatcher +> implements RouteMatcherProvider +{ + private manifest?: Manifest + private cached: ReadonlyArray = [] + + constructor( + private readonly manifestName: string, + private readonly manifestLoader: ManifestLoader + ) {} + + protected abstract transform(manifest: Manifest): Promise> + + public async matchers(): Promise { + const manifest = this.manifestLoader.load(this.manifestName) + if (!manifest) return [] + + // Return the cached matchers if the manifest has not changed. The Manifest + // loader under the hood uses the `require` API, which has it's own cache. + // In production, this will always return the same value, in development, + // the cache is invalidated when the manifest is updated. + if (this.manifest === manifest) return this.cached + this.manifest = manifest + + // Transform the manifest into matchers. + const matchers = await this.transform(manifest) + + // Cache the matchers. + this.cached = matchers + + return matchers + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts index 666e0f059c5a8..afcb6d0b7ef5e 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -4,23 +4,25 @@ import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { RouteKind } from '../route-kind' import { PagesAPIRouteMatcher } from '../route-matchers/pages-api-route-matcher' -import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { RouteMatcherProvider } from './route-matcher-provider' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' import { Normalizer } from '../normalizers/normalizer' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -export class PagesAPIRouteMatcherProvider - implements RouteMatcherProvider -{ +export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader, + manifestLoader: ManifestLoader, private readonly localeNormalizer?: Normalizer - ) {} - - public async matchers(): Promise> { - const manifest = this.manifestLoader.load(PAGES_MANIFEST) - if (!manifest) return [] + ) { + super(PAGES_MANIFEST, manifestLoader) + } + protected async transform( + manifest: Manifest + ): Promise> { // This matcher is only for Pages API routes. const pathnames = Object.keys(manifest).filter((pathname) => isAPIRoute(pathname) diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts index 177a7747f505f..a042b80a3d097 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -9,22 +9,24 @@ import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page- import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' import { RouteKind } from '../route-kind' import { PagesRouteMatcher } from '../route-matchers/pages-route-matcher' -import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' -import { RouteMatcherProvider } from './route-matcher-provider' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' -export class PagesRouteMatcherProvider - implements RouteMatcherProvider -{ +export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, - private readonly manifestLoader: ManifestLoader, + manifestLoader: ManifestLoader, private readonly localeNormalizer?: LocaleRouteNormalizer - ) {} - - public async matchers(): Promise> { - const manifest = this.manifestLoader.load(PAGES_MANIFEST) - if (!manifest) return [] + ) { + super(PAGES_MANIFEST, manifestLoader) + } + protected async transform( + manifest: Manifest + ): Promise> { // This matcher is only for Pages routes, not Pages API routes which are // included in this manifest. const pathnames = Object.keys(manifest) From cebcc1e4e914ebc083a7482c08aec845e5ca2208 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 14:09:42 -0700 Subject: [PATCH 10/22] feat: added cached file scanner --- .../next/src/server/dev/next-dev-server.ts | 17 ++- .../dev-app-page-route-matcher-provider.ts | 3 +- .../dev-app-route-route-matcher-provider.ts | 3 +- ...v-pages-api-route-matcher-provider.test.ts | 8 +- .../dev-pages-api-route-matcher-provider.ts | 5 +- .../dev/dev-pages-route-matcher-provider.ts | 3 +- .../file-reader/cached-file-reader.test.ts | 66 +++++++++ .../helpers/file-reader/cached-file-reader.ts | 125 ++++++++++++++++++ 8 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 0fa694e246241..1b918d8ed10b8 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -91,6 +91,8 @@ import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-provide import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' import { LocaleRouteNormalizer } from '../future/normalizers/locale-route-normalizer' +import { CachedFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader' +import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/default-file-reader' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -244,6 +246,8 @@ export default class DevServer extends Server { const extensions = this.nextConfig.pageExtensions + const fileReader = new CachedFileReader(new DefaultFileReader()) + // If the pages directory is available, then configure those matchers. if (pagesDir) { const localeNoramlizer = @@ -254,19 +258,26 @@ export default class DevServer extends Server { ) : undefined - matchers.push(new DevPagesRouteMatcherProvider(pagesDir, extensions)) + matchers.push( + new DevPagesRouteMatcherProvider(pagesDir, extensions, fileReader) + ) matchers.push( new DevPagesAPIRouteMatcherProvider( pagesDir, extensions, + fileReader, localeNoramlizer ) ) } if (appDir) { - matchers.push(new DevAppPageRouteMatcherProvider(appDir, extensions)) - matchers.push(new DevAppRouteRouteMatcherProvider(appDir, extensions)) + matchers.push( + new DevAppPageRouteMatcherProvider(appDir, extensions, fileReader) + ) + matchers.push( + new DevAppRouteRouteMatcherProvider(appDir, extensions, fileReader) + ) } return { matchers, handlers } diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts index cef64832b67ae..d32e4b9fc2847 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -1,5 +1,4 @@ import { FileReader } from './helpers/file-reader/file-reader' -import { DefaultFileReader } from './helpers/file-reader/default-file-reader' import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { Normalizer } from '../../normalizers/normalizer' @@ -23,7 +22,7 @@ export class DevAppPageRouteMatcherProvider constructor( private readonly appDir: string, extensions: ReadonlyArray, - private readonly reader: FileReader = new DefaultFileReader() + private readonly reader: FileReader ) { // Match any page file that ends with `/page.${extension}` under the app // directory. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts index 51ee046b33386..b217c70a80cc0 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -1,5 +1,4 @@ import { FileReader } from './helpers/file-reader/file-reader' -import { DefaultFileReader } from './helpers/file-reader/default-file-reader' import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { Normalizer } from '../../normalizers/normalizer' @@ -23,7 +22,7 @@ export class DevAppRouteRouteMatcherProvider constructor( private readonly appDir: string, extensions: ReadonlyArray, - private readonly reader: FileReader = new DefaultFileReader() + private readonly reader: FileReader ) { // Match any route file that ends with `/route.${extension}` under the app // directory. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts index c26a01272b59f..5535f19436c0d 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts @@ -9,12 +9,7 @@ describe('DevPagesAPIRouteMatcherProvider', () => { it('returns no routes with an empty filesystem', async () => { const reader: FileReader = { read: jest.fn(() => []) } - const matcher = new DevPagesAPIRouteMatcherProvider( - dir, - extensions, - undefined, - reader - ) + const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader) const matchers = await matcher.matchers() expect(matchers).toHaveLength(0) expect(reader.read).toBeCalledWith(dir) @@ -79,7 +74,6 @@ describe('DevPagesAPIRouteMatcherProvider', () => { const matcher = new DevPagesAPIRouteMatcherProvider( dir, extensions, - undefined, reader ) const matchers = await matcher.matchers() diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts index 8591e00363d56..b85539701332e 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -1,6 +1,5 @@ import { Normalizer } from '../../normalizers/normalizer' import { FileReader } from './helpers/file-reader/file-reader' -import { DefaultFileReader } from './helpers/file-reader/default-file-reader' import { PagesAPIRouteMatcher } from '../../route-matchers/pages-api-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' @@ -24,8 +23,8 @@ export class DevPagesAPIRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, - private readonly localeNormalizer?: Normalizer, - private readonly reader: FileReader = new DefaultFileReader() + private readonly reader: FileReader, + private readonly localeNormalizer?: Normalizer ) { // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts index b75d5e852660d..0494c00b2f748 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts @@ -1,6 +1,5 @@ import { Normalizer } from '../../normalizers/normalizer' import { FileReader } from './helpers/file-reader/file-reader' -import { DefaultFileReader } from './helpers/file-reader/default-file-reader' import { PagesRouteMatcher } from '../../route-matchers/pages-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' @@ -24,7 +23,7 @@ export class DevPagesRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, - private readonly reader: FileReader = new DefaultFileReader() + private readonly reader: FileReader ) { // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts new file mode 100644 index 0000000000000..16f2aa32e54bb --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.test.ts @@ -0,0 +1,66 @@ +import { CachedFileReader } from './cached-file-reader' +import { FileReader } from './file-reader' + +describe('CachedFileReader', () => { + it('will only scan the filesystem a minimal amount of times', async () => { + const pages = ['1', '2', '3'] + const app = ['4', '5', '6'] + + const reader: FileReader = { + read: jest.fn(async (directory: string) => { + switch (directory) { + case '/pages': + return pages + case '/app': + return app + default: + throw new Error('unexpected') + } + }), + } + const cached = new CachedFileReader(reader) + + const results = await Promise.all([ + cached.read('/pages'), + cached.read('/pages'), + cached.read('/app'), + cached.read('/app'), + ]) + + expect(reader.read).toBeCalledTimes(2) + expect(results).toHaveLength(4) + expect(results[0]).toBe(pages) + expect(results[1]).toBe(pages) + expect(results[2]).toBe(app) + expect(results[3]).toBe(app) + }) + + it('will send an error back only to the correct reader', async () => { + const resolved: string[] = [] + const reader: FileReader = { + read: jest.fn(async (directory: string) => { + switch (directory) { + case 'reject': + throw new Error('rejected') + case 'resolve': + return resolved + default: + throw new Error('should not occur') + } + }), + } + const cached = new CachedFileReader(reader) + + await Promise.all( + ['reject', 'resolve', 'reject', 'resolve'].map(async (directory) => { + if (directory === 'reject') { + await expect(cached.read(directory)).rejects.toThrowError('rejected') + } else { + await expect(cached.read(directory)).resolves.toEqual(resolved) + } + }) + ) + + expect(reader.read).toBeCalledTimes(2) + }) +}) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts new file mode 100644 index 0000000000000..c9db00d97953b --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader.ts @@ -0,0 +1,125 @@ +import { FileReader } from './file-reader' + +interface CachedFileReaderBatch { + completed: boolean + directories: Array + callbacks: Array<{ + resolve: (value: ReadonlyArray) => void + reject: (err: any) => void + }> +} + +/** + * CachedFileReader will deduplicate requests made to the same folder structure + * to scan for files. + */ +export class CachedFileReader implements FileReader { + private batch?: CachedFileReaderBatch + + constructor(private readonly reader: FileReader) {} + + // This allows us to schedule the batches after all the promises associated + // with loading files. + private schedulePromise?: Promise + private schedule(callback: Function) { + if (!this.schedulePromise) { + this.schedulePromise = Promise.resolve() + } + this.schedulePromise.then(() => { + process.nextTick(callback) + }) + } + + private getOrCreateBatch(): CachedFileReaderBatch { + // If there is an existing batch and it's not completed, then reuse it. + if (this.batch && !this.batch.completed) { + return this.batch + } + + const batch: CachedFileReaderBatch = { + completed: false, + directories: [], + callbacks: [], + } + + this.batch = batch + + this.schedule(async () => { + batch.completed = true + if (batch.directories.length === 0) return + + // Collect all the results for each of the directories. If any error + // occurs, send the results back to the loaders. + let values: ReadonlyArray | Error> + try { + values = await this.load(batch.directories) + } catch (err) { + // Reject all the callbacks. + for (const { reject } of batch.callbacks) { + reject(err) + } + return + } + + // Loop over all the callbacks and send them their results. + for (let i = 0; i < batch.callbacks.length; i++) { + const value = values[i] + if (value instanceof Error) { + batch.callbacks[i].reject(value) + } else { + batch.callbacks[i].resolve(value) + } + } + }) + + return batch + } + + private async load( + directories: ReadonlyArray + ): Promise | Error>> { + // Make a unique array of directories. This is what lets us de-duplicate + // loads for the same directory. + const unique = [...new Set(directories)] + + const results = await Promise.all( + unique.map(async (directory) => { + let files: ReadonlyArray | undefined + let error: Error | undefined + try { + files = await this.reader.read(directory) + } catch (err) { + if (err instanceof Error) error = err + } + + return { directory, files, error } + }) + ) + + return directories.map((directory) => { + const found = results.find((result) => result.directory === directory) + if (!found) return [] + + if (found.files) return found.files + if (found.error) return found.error + + return [] + }) + } + + public async read(dir: string): Promise> { + // Get or create a new file reading batch. + const batch = this.getOrCreateBatch() + + // Push this directory into the batch to resolve. + batch.directories.push(dir) + + // Push the promise handles into the batch (under the same index) so it can + // be resolved later when it's scheduled. + const promise = new Promise>((resolve, reject) => { + batch.callbacks.push({ resolve, reject }) + }) + + return promise + } +} From d9ab5d0365ee7dd5d65c169e13bbf3e4ef9fbf58 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 14:10:09 -0700 Subject: [PATCH 11/22] fix: fixed issue where cached matchers resulted in not-reloaded message --- .../default-route-matcher-manager.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index c02a70d5678c5..be79cd5f9b92a 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -37,12 +37,12 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { callbacks = { resolve, reject } }) - try { - // Grab the compilation ID for this run, we'll verify it at the end to - // ensure that if any routes were added before reloading is finished that - // we error out. - const compilationID = this.compilationID + // Grab the compilation ID for this run, we'll verify it at the end to + // ensure that if any routes were added before reloading is finished that + // we error out. + const compilationID = this.compilationID + try { // Collect all the matchers from each provider. const matchers: Array = [] @@ -129,12 +129,11 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { 'Invariant: expected compilation to finish before new matchers were added, possible missing await' ) } - - // The compilation ID matched, so mark the complication as finished. - this.lastCompilationID = compilationID } catch (err) { callbacks!.reject(err) } finally { + // The compilation ID matched, so mark the complication as finished. + this.lastCompilationID = compilationID callbacks!.resolve() } } @@ -181,7 +180,9 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // before it was recompiled (an error). We also don't want to affect request // times. if (this.lastCompilationID !== this.compilationID) { - throw new Error('Invariant: expected routes to be compiled before match') + throw new Error( + 'Invariant: expected routes to have been loaded before match' + ) } // If this pathname doesn't look like a dynamic route, and this pathname is From f87272bcb79e776cbc059f16f8ba0adf3e0c72c9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 16:21:49 -0700 Subject: [PATCH 12/22] fix: support better duplicate detection --- .../next/src/server/dev/next-dev-server.ts | 6 ++- .../default-route-matcher-manager.ts | 40 ++++++++++++++----- .../dev-route-matcher-manager.ts | 19 ++++++++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 1b918d8ed10b8..48ae1989abae1 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -241,7 +241,11 @@ export default class DevServer extends Server { } const routes = super.getRoutes() - const matchers = new DevRouteMatcherManager(routes.matchers, ensurer) + const matchers = new DevRouteMatcherManager( + routes.matchers, + ensurer, + this.dir + ) const handlers = routes.handlers const extensions = this.nextConfig.pageExtensions diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index be79cd5f9b92a..16842a72453bb 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -9,12 +9,19 @@ import { RouteMatcher } from '../route-matchers/route-matcher' import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import { getSortedRoutes } from '../../../shared/lib/router/utils' +interface RouteMatchers { + static: ReadonlyArray + dynamic: ReadonlyArray + duplicates: Record> +} + export class DefaultRouteMatcherManager implements RouteMatcherManager { private readonly providers: Array = [] - private readonly matchers: { - static: ReadonlyArray - dynamic: ReadonlyArray - } = { static: [], dynamic: [] } + protected readonly matchers: RouteMatchers = { + static: [], + dynamic: [], + duplicates: {}, + } private cache: ReadonlyArray = [] private lastCompilationID = this.compilationID @@ -51,23 +58,34 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { await Promise.all(this.providers.map((provider) => provider.matchers())) // Use this to detect duplicate pathnames. - const all = new Set() + const all = new Map() + const duplicates: Record = {} for (const providerMatchers of providersMatchers) { for (const matcher of providerMatchers) { - if (all.has(matcher.route.pathname)) { - // TODO: when a duplicate route is detected, what should we do? - throw new Error( - `Invariant: duplicate pathname detected '${matcher.route.pathname}', remove one of the conflicting files` - ) + // Test to see if the matcher being added is a duplicate. + const duplicate = all.get(matcher.route.pathname) + if (duplicate) { + const others = duplicates[matcher.route.pathname] ?? [duplicate] + others.push(matcher) + duplicates[matcher.route.pathname] = others + + // Currently, this is a bit delicate, as the order for which we'll + // receive the matchers is not deterministic. + // TODO: see if we should error for duplicates in production? + continue } matchers.push(matcher) // Add the matcher's pathname to the set. - all.add(matcher.route.pathname) + all.set(matcher.route.pathname, matcher) } } + // Update the duplicate matchers. This is used in the development manager + // to warn about duplicates. + this.matchers.duplicates = duplicates + // If the cache is the same as what we just parsed, we can exit now. We // can tell by using the `===` which compares object identity, which for // the manifest matchers, will return the same matcher each time. diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index 7d04e3c747a9b..438a20440a46b 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -3,6 +3,9 @@ import { RouteMatch } from '../route-matches/route-match' import { RouteDefinition } from '../route-definitions/route-definition' import { DefaultRouteMatcherManager } from './default-route-matcher-manager' import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' +import path from '../../../shared/lib/isomorphic/path' +import { warn } from '../../../build/output/log' +import chalk from 'next/dist/compiled/chalk' export interface RouteEnsurer { ensure(match: RouteMatch): Promise @@ -11,7 +14,8 @@ export interface RouteEnsurer { export class DevRouteMatcherManager extends DefaultRouteMatcherManager { constructor( private readonly production: RouteMatcherManager, - private readonly ensurer: RouteEnsurer + private readonly ensurer: RouteEnsurer, + private readonly dir: string ) { super() } @@ -86,5 +90,18 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { // Compile the development routes. await super.reload() + + // Check for and warn of any duplicates. + for (const [pathname, matchers] of Object.entries( + this.matchers.duplicates + )) { + warn( + `Duplicate page detected. ${matchers + .map((matcher) => + chalk.cyan(path.relative(this.dir, matcher.route.filename)) + ) + .join(' and ')} resolve to ${chalk.cyan(pathname)}.` + ) + } } } From 5830d61b16f9b92e9a1fdc4d5df2d2cc4aafac45 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Sat, 11 Feb 2023 16:58:35 -0700 Subject: [PATCH 13/22] fix: improve handling for duplicate app/pages when detected --- .../default-route-matcher-manager.ts | 35 +++++++++++++++- .../dev-route-matcher-manager.ts | 40 +++++++++++++++++++ .../future/route-matchers/route-matcher.ts | 7 ++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 16842a72453bb..0e3407018a45b 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -65,10 +65,26 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // Test to see if the matcher being added is a duplicate. const duplicate = all.get(matcher.route.pathname) if (duplicate) { + // This looks a little weird, but essentially if the pathname + // already exists in the duplicates map, then we got that array + // reference. Otherwise, we create a new array with the original + // duplicate first. Then we push the new matcher into the duplicate + // array, and reset it to the duplicates object (which may be a + // no-op if the pathname already existed in the duplicates object). + // Then we set the array of duplicates on both the original + // duplicate object and the new one, so we can keep them in sync. + // If a new duplicate is found, and it matches an existing pathname, + // the retrieval of the `other` will actually return the array + // reference used by all other duplicates. This is why ReadonlyArray + // is so important! Array's are always references! const others = duplicates[matcher.route.pathname] ?? [duplicate] others.push(matcher) duplicates[matcher.route.pathname] = others + // Add duplicated details to each route. + duplicate.duplicated = others + matcher.duplicated = others + // Currently, this is a bit delicate, as the order for which we'll // receive the matchers is not deterministic. // TODO: see if we should error for duplicates in production? @@ -187,6 +203,21 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { return null } + /** + * This is a point for other managers to override to inject other checking + * behavior like duplicate route checking on a per-request basis. + * + * @param pathname the pathname to validate against + * @param matcher the matcher to validate/test with + * @returns the match if found + */ + protected validate( + pathname: string, + matcher: RouteMatcher + ): RouteMatch | null { + return matcher.match(pathname) + } + public async *matchAll( pathname: string, options?: MatchOptions | undefined @@ -209,7 +240,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // with the list of normalized routes. if (!isDynamicRoute(pathname)) { for (const matcher of this.matchers.static) { - const match = matcher.match(pathname) + const match = this.validate(pathname, matcher) if (!match) continue yield match @@ -221,7 +252,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // Loop over the dynamic matchers, yielding each match. for (const matcher of this.matchers.dynamic) { - const match = matcher.match(pathname) + const match = this.validate(pathname, matcher) if (!match) continue yield match diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index 438a20440a46b..5f8f1e30df16e 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -6,6 +6,7 @@ import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import path from '../../../shared/lib/isomorphic/path' import { warn } from '../../../build/output/log' import chalk from 'next/dist/compiled/chalk' +import { RouteMatcher } from '../route-matchers/route-matcher' export interface RouteEnsurer { ensure(match: RouteMatch): Promise @@ -33,6 +34,45 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { return match !== null } + protected validate( + pathname: string, + matcher: RouteMatcher + ): RouteMatch | null { + const match = matcher.match(pathname) + + // If a match was found, check to see if there were any conflicting app or + // pages files. + // TODO: maybe expand this to _any_ duplicated routes instead? + if ( + match && + matcher.duplicated && + matcher.duplicated.some( + (duplicate) => + duplicate.route.kind === RouteKind.APP_PAGE || + duplicate.route.kind === RouteKind.APP_ROUTE + ) && + matcher.duplicated.some( + (duplicate) => + duplicate.route.kind === RouteKind.PAGES || + duplicate.route.kind === RouteKind.PAGES_API + ) + ) { + throw new Error( + `Conflicting app and page file found: ${matcher.duplicated + // Sort the error output so that the app pages (starting with "app") + // are first. + .sort((a, b) => a.route.filename.localeCompare(b.route.filename)) + .map( + (duplicate) => + `"${path.relative(this.dir, duplicate.route.filename)}"` + ) + .join(' and ')}. Please remove one to continue.` + ) + } + + return match + } + public async *matchAll( pathname: string, options?: MatchOptions diff --git a/packages/next/src/server/future/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-matchers/route-matcher.ts index f3f658cd2b165..07fe54a54c5c3 100644 --- a/packages/next/src/server/future/route-matchers/route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/route-matcher.ts @@ -10,6 +10,13 @@ import { RouteMatch } from '../route-matches/route-match' export abstract class RouteMatcher { private readonly dynamic?: RouteMatchFn + /** + * When set, this is an array of all the other matchers that are duplicates of + * this one. This is used by the managers to warn the users about possible + * duplicate matches on routes. + */ + public duplicated?: Array + constructor(public readonly route: M['route']) { if (isDynamicRoute(route.pathname)) { this.dynamic = getRouteMatcher(getRouteRegex(route.pathname)) From a2e917ae3b1af682ff2e360e59fd6764854a7e67 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 00:10:24 -0700 Subject: [PATCH 14/22] fix: fixed test failures around locale detection in development --- packages/next/src/server/base-server.ts | 57 ++++---- .../next/src/server/dev/next-dev-server.ts | 20 ++- .../src/server/dev/on-demand-entry-handler.ts | 10 +- .../normalizers/locale-route-normalizer.ts | 23 +++- .../locale-route-definition.ts | 17 +++ .../pages-api-route-definition.ts | 4 +- .../pages-route-definition.ts | 4 +- .../route-handler-manager.test.ts | 8 +- .../route-handler-manager.ts | 2 +- .../route-handlers/app-route-route-handler.ts | 2 +- .../default-route-matcher-manager.test.ts | 128 +++++++++++++++++- .../default-route-matcher-manager.ts | 57 ++++---- .../dev-route-matcher-manager.ts | 40 +++--- .../route-matcher-manager.ts | 9 +- .../app-page-route-matcher-provider.test.ts | 2 +- .../app-route-route-matcher-provider.test.ts | 2 +- ...ev-app-page-route-matcher-provider.test.ts | 2 +- .../dev-app-page-route-matcher-provider.ts | 2 - ...v-app-route-route-matcher-provider.test.ts | 2 +- .../dev-app-route-route-matcher-provider.ts | 2 - ...v-pages-api-route-matcher-provider.test.ts | 2 +- .../dev-pages-api-route-matcher-provider.ts | 32 +++-- .../dev-pages-route-matcher-provider.test.ts | 2 +- .../dev/dev-pages-route-matcher-provider.ts | 40 ++++-- .../pages-api-route-matcher-provider.test.ts | 2 +- .../pages-api-route-matcher-provider.ts | 37 +++-- .../pages-route-matcher-provider.test.ts | 47 ++++--- .../pages-route-matcher-provider.ts | 42 +++--- .../route-matchers/app-page-route-matcher.ts | 11 +- .../route-matchers/app-route-route-matcher.ts | 11 +- .../route-matchers/locale-route-matcher.ts | 74 ++++++++++ .../route-matchers/pages-api-route-matcher.ts | 23 +--- .../route-matchers/pages-route-matcher.ts | 14 +- .../future/route-matchers/route-matcher.ts | 29 +++- .../route-matches/locale-route-match.ts | 7 + .../future/route-matches/pages-route-match.ts | 5 +- .../future/route-matches/route-match.ts | 4 +- packages/next/src/server/next-server.ts | 31 +++-- packages/next/src/server/router.ts | 31 ++++- packages/next/src/server/web-server.ts | 1 - 40 files changed, 561 insertions(+), 277 deletions(-) create mode 100644 packages/next/src/server/future/route-definitions/locale-route-definition.ts create mode 100644 packages/next/src/server/future/route-matchers/locale-route-matcher.ts create mode 100644 packages/next/src/server/future/route-matches/locale-route-match.ts diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 85b56295abdfe..ad81643a13f02 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -80,7 +80,10 @@ import { FLIGHT_PARAMETERS, FETCH_CACHE_HEADER, } from '../client/components/app-router-headers' -import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' +import { + MatchOptions, + RouteMatcherManager, +} from './future/route-matcher-managers/route-matcher-manager' import { RouteHandlerManager } from './future/route-handler-managers/route-handler-manager' import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' import { DefaultRouteMatcherManager } from './future/route-matcher-managers/default-route-matcher-manager' @@ -320,6 +323,7 @@ export default abstract class Server { protected readonly matchers: RouteMatcherManager protected readonly handlers: RouteHandlerManager + protected readonly localeNormalizer?: LocaleRouteNormalizer public constructor(options: ServerOptions) { const { @@ -352,6 +356,15 @@ export default abstract class Server { this.publicDir = this.getPublicDir() this.hasStaticDir = !minimalMode && this.getHasStaticDir() + // Configure the locale normalizer, it's used for routes inside `pages/`. + this.localeNormalizer = + this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale + ? new LocaleRouteNormalizer( + this.nextConfig.i18n.locales, + this.nextConfig.i18n.defaultLocale + ) + : undefined + // Only serverRuntimeConfig needs the default // publicRuntimeConfig gets it's default in client/index.js const { @@ -455,15 +468,6 @@ export default abstract class Server { } }) - // Configure the locale normalizer, it's used for routes inside `pages/`. - const localeNormalizer = - this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale - ? new LocaleRouteNormalizer( - this.nextConfig.i18n.locales, - this.nextConfig.i18n.defaultLocale - ) - : undefined - // Configure the matchers and handlers. const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() const handlers = new RouteHandlerManager() @@ -473,7 +477,7 @@ export default abstract class Server { new PagesRouteMatcherProvider( this.distDir, manifestLoader, - localeNormalizer + this.localeNormalizer ) ) @@ -482,7 +486,7 @@ export default abstract class Server { new PagesAPIRouteMatcherProvider( this.distDir, manifestLoader, - localeNormalizer + this.localeNormalizer ) ) @@ -635,23 +639,20 @@ export default abstract class Server { matchedPath = this.stripNextDataPath(matchedPath, false) // Perform locale detection and normalization. - if (this.nextConfig.i18n) { - const localeResult = normalizeLocalePath( - matchedPath, - this.nextConfig.i18n.locales - ) - matchedPath = localeResult.pathname - - if (localeResult.detectedLocale) { - parsedUrl.query.__nextLocale = localeResult.detectedLocale - } + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(matchedPath), + } + if (options.i18n?.detectedLocale) { + parsedUrl.query.__nextLocale = options.i18n.detectedLocale } + + // TODO: check if this is needed any more? matchedPath = denormalizePagePath(matchedPath) let srcPathname = matchedPath - const match = await this.matchers.match(matchedPath) + const match = await this.matchers.match(matchedPath, options) if (match) { - srcPathname = match.route.pathname + srcPathname = match.definition.pathname } const pageIsDynamic = typeof match?.params !== 'undefined' @@ -1761,12 +1762,16 @@ export default abstract class Server { const bubbleNoFallback = !!query._nextBubbleNoFallback delete query._nextBubbleNoFallback + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(pathname), + } + try { - for await (const match of this.matchers.matchAll(pathname)) { + for await (const match of this.matchers.matchAll(pathname, options)) { const result = await this.renderPageComponent( { ...ctx, - pathname: match.route.pathname, + pathname: match.definition.pathname, renderOpts: { ...ctx.renderOpts, params: match.params, diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 48ae1989abae1..5e26520e9f6b1 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -90,7 +90,6 @@ import { DevAppPageRouteMatcherProvider } from '../future/route-matcher-provider import { DevAppRouteRouteMatcherProvider } from '../future/route-matcher-providers/dev/dev-app-route-route-matcher-provider' import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' -import { LocaleRouteNormalizer } from '../future/normalizers/locale-route-normalizer' import { CachedFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/cached-file-reader' import { DefaultFileReader } from '../future/route-matcher-providers/dev/helpers/file-reader/default-file-reader' @@ -234,7 +233,7 @@ export default class DevServer extends Server { ensure: async (match) => { await this.hotReloader!.ensurePage({ match, - page: match.route.page, + page: match.definition.page, clientOnly: false, }) }, @@ -254,23 +253,20 @@ export default class DevServer extends Server { // If the pages directory is available, then configure those matchers. if (pagesDir) { - const localeNoramlizer = - this.nextConfig.i18n?.locales && this.nextConfig.i18n.defaultLocale - ? new LocaleRouteNormalizer( - this.nextConfig.i18n.locales, - this.nextConfig.i18n.defaultLocale - ) - : undefined - matchers.push( - new DevPagesRouteMatcherProvider(pagesDir, extensions, fileReader) + new DevPagesRouteMatcherProvider( + pagesDir, + extensions, + fileReader, + this.localeNormalizer + ) ) matchers.push( new DevPagesAPIRouteMatcherProvider( pagesDir, extensions, fileReader, - localeNoramlizer + this.localeNormalizer ) ) } diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 3fa4623abc739..643ae5b8dbadf 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -386,9 +386,9 @@ async function findRoutePathData( // If the match is available, we don't have to discover the data from the // filesystem. return { - absolutePagePath: match.route.filename, - page: match.route.page, - bundlePath: match.route.bundlePath, + absolutePagePath: match.definition.filename, + page: match.definition.page, + bundlePath: match.definition.bundlePath, } } @@ -598,8 +598,8 @@ export function onDemandEntryHandler({ // If the route is actually an app page route, then we should have access // to the app route match, and therefore, the appPaths from it. - if (match?.route.kind === RouteKind.APP_PAGE) { - const { route } = match as AppPageRouteMatch + if (match?.definition.kind === RouteKind.APP_PAGE) { + const { definition: route } = match as AppPageRouteMatch appPaths = route.appPaths } diff --git a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts index 03f7bc2093391..323247501b04e 100644 --- a/packages/next/src/server/future/normalizers/locale-route-normalizer.ts +++ b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts @@ -3,7 +3,10 @@ import { Normalizer } from './normalizer' export interface LocaleRouteNormalizer extends Normalizer { readonly locales: ReadonlyArray readonly defaultLocale: string - match(pathname: string): { detectedLocale?: string; pathname: string } + match( + pathname: string, + options?: { inferDefaultLocale: boolean } + ): { detectedLocale?: string; pathname: string } } export class LocaleRouteNormalizer implements Normalizer { @@ -16,26 +19,32 @@ export class LocaleRouteNormalizer implements Normalizer { this.lowerCase = locales.map((locale) => locale.toLowerCase()) } - public match(pathname: string): { - detectedLocale?: string + public match( + pathname: string, + options?: { inferDefaultLocale: boolean } + ): { pathname: string + detectedLocale?: string } { - if (this.locales.length === 0) return { pathname } + let detectedLocale: string | undefined = options?.inferDefaultLocale + ? this.defaultLocale + : undefined + if (this.locales.length === 0) return { detectedLocale, pathname } // The first segment will be empty, because it has a leading `/`. If // there is no further segment, there is no locale. const segments = pathname.split('/') - if (!segments[1]) return { pathname } + if (!segments[1]) return { detectedLocale, pathname } // The second segment will contain the locale part if any. const segment = segments[1].toLowerCase() // See if the segment matches one of the locales. const index = this.lowerCase.indexOf(segment) - if (index < 0) return { pathname } + if (index < 0) return { detectedLocale, pathname } // Return the case-sensitive locale. - const detectedLocale = this.locales[index] + detectedLocale = this.locales[index] // Remove the `/${locale}` part of the pathname. pathname = pathname.slice(detectedLocale.length + 1) || '/' diff --git a/packages/next/src/server/future/route-definitions/locale-route-definition.ts b/packages/next/src/server/future/route-definitions/locale-route-definition.ts new file mode 100644 index 0000000000000..68978e506836e --- /dev/null +++ b/packages/next/src/server/future/route-definitions/locale-route-definition.ts @@ -0,0 +1,17 @@ +import { RouteKind } from '../route-kind' +import { RouteDefinition } from './route-definition' + +export interface LocaleRouteDefinition + extends RouteDefinition { + /** + * When defined it means that this route is locale aware. When undefined, + * it means no special handling has to occur to process locales. + */ + i18n?: { + /** + * Describes the locale for the route. If this is undefined, then it + * indicates that this route can handle _any_ locale. + */ + locale?: string + } +} diff --git a/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts index f370476d591f6..9510b0419198a 100644 --- a/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts +++ b/packages/next/src/server/future/route-definitions/pages-api-route-definition.ts @@ -1,5 +1,5 @@ import { RouteKind } from '../route-kind' -import { RouteDefinition } from './route-definition' +import { LocaleRouteDefinition } from './locale-route-definition' export interface PagesAPIRouteDefinition - extends RouteDefinition {} + extends LocaleRouteDefinition {} diff --git a/packages/next/src/server/future/route-definitions/pages-route-definition.ts b/packages/next/src/server/future/route-definitions/pages-route-definition.ts index b2d2e5fdf5933..8e5055d8c0afa 100644 --- a/packages/next/src/server/future/route-definitions/pages-route-definition.ts +++ b/packages/next/src/server/future/route-definitions/pages-route-definition.ts @@ -1,5 +1,5 @@ import { RouteKind } from '../route-kind' -import { RouteDefinition } from './route-definition' +import { LocaleRouteDefinition } from './locale-route-definition' export interface PagesRouteDefinition - extends RouteDefinition {} + extends LocaleRouteDefinition {} diff --git a/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts index c8ccada6a3e65..8df7e9a0e62d4 100644 --- a/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts +++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts @@ -12,7 +12,7 @@ describe('RouteHandlerManager', () => { expect( await handlers.handle( { - route: { + definition: { kind: RouteKind.PAGES, filename: '/index.js', pathname: '/', @@ -34,7 +34,7 @@ describe('RouteHandlerManager', () => { expect( await handlers.handle( { - route: { + definition: { kind: RouteKind.PAGES, filename: '/index.js', pathname: '/', @@ -55,7 +55,7 @@ describe('RouteHandlerManager', () => { handlers.set(RouteKind.APP_PAGE, handler) const route: RouteMatch = { - route: { + definition: { kind: RouteKind.APP_PAGE, filename: '/index.js', pathname: '/', @@ -85,7 +85,7 @@ describe('RouteHandlerManager', () => { handlers.set(RouteKind.APP_ROUTE, badHandler) const route: RouteMatch = { - route: { + definition: { kind: RouteKind.APP_PAGE, filename: '/index.js', pathname: '/', diff --git a/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts index 3031078b7dabb..b8a8b8df79018 100644 --- a/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts +++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.ts @@ -27,7 +27,7 @@ export class RouteHandlerManager { req: BaseNextRequest, res: BaseNextResponse ): Promise { - const handler = this.handlers[match.route.kind] + const handler = this.handlers[match.definition.kind] if (!handler) return false await handler.handle(match, req, res) diff --git a/packages/next/src/server/future/route-handlers/app-route-route-handler.ts b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts index 0018e6784d022..210967447233f 100644 --- a/packages/next/src/server/future/route-handlers/app-route-route-handler.ts +++ b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts @@ -298,7 +298,7 @@ export class AppRouteRouteHandler implements RouteHandler { try { // Load the module using the module loader. const module: AppRouteModule = await this.moduleLoader.load( - match.route.filename + match.definition.filename ) // TODO: patch fetch diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts index d6468c2c7f1db..0346c1f23377d 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts @@ -1,19 +1,135 @@ +import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition' +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { RouteKind } from '../route-kind' +import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' +import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' import { DefaultRouteMatcherManager } from './default-route-matcher-manager' +import { MatchOptions } from './route-matcher-manager' describe('DefaultRouteMatcherManager', () => { it('will throw an error when used before it has been reloaded', async () => { const manager = new DefaultRouteMatcherManager() - await expect(manager.match('/some/not/real/path')).resolves.toEqual(null) + await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( + null + ) manager.push({ matchers: jest.fn(async () => []) }) - await expect(manager.match('/some/not/real/path')).rejects.toThrow() + await expect(manager.match('/some/not/real/path', {})).rejects.toThrow() await manager.reload() - await expect(manager.match('/some/not/real/path')).resolves.toEqual(null) + await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( + null + ) }) it('will not error and not match when no matchers are provided', async () => { - const matchers = new DefaultRouteMatcherManager() - await matchers.reload() - await expect(matchers.match('/some/not/real/path')).resolves.toEqual(null) + const manager = new DefaultRouteMatcherManager() + await manager.reload() + await expect(manager.match('/some/not/real/path', {})).resolves.toEqual( + null + ) + }) + + it.each<{ + pathname: string + options: MatchOptions + definition: LocaleRouteDefinition + }>([ + { + pathname: '/nl-NL/some/path', + options: { + i18n: { + detectedLocale: 'nl-NL', + pathname: '/some/path', + }, + }, + definition: { + kind: RouteKind.PAGES, + filename: '', + bundlePath: '', + page: '', + pathname: '/some/path', + i18n: { + locale: 'nl-NL', + }, + }, + }, + { + pathname: '/en-US/some/path', + options: { + i18n: { + detectedLocale: 'en-US', + pathname: '/some/path', + }, + }, + definition: { + kind: RouteKind.PAGES, + filename: '', + bundlePath: '', + page: '', + pathname: '/some/path', + i18n: { + locale: 'en-US', + }, + }, + }, + { + pathname: '/some/path', + options: { + i18n: { + pathname: '/some/path', + }, + }, + definition: { + kind: RouteKind.PAGES, + filename: '', + bundlePath: '', + page: '', + pathname: '/some/path', + i18n: { + locale: 'en-US', + }, + }, + }, + ])( + 'can handle locale aware matchers for $pathname and locale $options.i18n.detectedLocale', + async ({ pathname, options, definition }) => { + const manager = new DefaultRouteMatcherManager() + + const matcher = new LocaleRouteMatcher(definition) + const provider: RouteMatcherProvider = { + matchers: jest.fn(async () => [matcher]), + } + manager.push(provider) + await manager.reload() + + const match = await manager.match(pathname, options) + expect(match?.definition).toBe(definition) + } + ) + + it('calls the locale route matcher when one is provided', async () => { + const manager = new DefaultRouteMatcherManager() + const definition: PagesRouteDefinition = { + kind: RouteKind.PAGES, + filename: '', + bundlePath: '', + page: '', + pathname: '/some/path', + i18n: { + locale: 'en-US', + }, + } + const matcher = new LocaleRouteMatcher(definition) + const provider: RouteMatcherProvider = { + matchers: jest.fn(async () => [matcher]), + } + manager.push(provider) + await manager.reload() + + const options = { + i18n: { detectedLocale: undefined, pathname: '/some/path' }, + } + const match = await manager.match('/en-US/some/path', options) + expect(match?.definition).toBe(definition) }) }) diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 0e3407018a45b..80748d94cb8e3 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -8,6 +8,7 @@ import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-p import { RouteMatcher } from '../route-matchers/route-matcher' import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import { getSortedRoutes } from '../../../shared/lib/router/utils' +import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' interface RouteMatchers { static: ReadonlyArray @@ -22,7 +23,6 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { dynamic: [], duplicates: {}, } - private cache: ReadonlyArray = [] private lastCompilationID = this.compilationID /** @@ -38,6 +38,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { return this.waitTillReadyPromise ?? Promise.resolve() } + private previousMatchers: ReadonlyArray = [] public async reload() { let callbacks: { resolve: Function; reject: Function } this.waitTillReadyPromise = new Promise((resolve, reject) => { @@ -63,7 +64,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { for (const providerMatchers of providersMatchers) { for (const matcher of providerMatchers) { // Test to see if the matcher being added is a duplicate. - const duplicate = all.get(matcher.route.pathname) + const duplicate = all.get(matcher.identity) if (duplicate) { // This looks a little weird, but essentially if the pathname // already exists in the duplicates map, then we got that array @@ -77,9 +78,9 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // the retrieval of the `other` will actually return the array // reference used by all other duplicates. This is why ReadonlyArray // is so important! Array's are always references! - const others = duplicates[matcher.route.pathname] ?? [duplicate] + const others = duplicates[matcher.identity] ?? [duplicate] others.push(matcher) - duplicates[matcher.route.pathname] = others + duplicates[matcher.identity] = others // Add duplicated details to each route. duplicate.duplicated = others @@ -94,7 +95,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { matchers.push(matcher) // Add the matcher's pathname to the set. - all.set(matcher.route.pathname, matcher) + all.set(matcher.definition.pathname, matcher) } } @@ -106,14 +107,14 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // can tell by using the `===` which compares object identity, which for // the manifest matchers, will return the same matcher each time. if ( - this.cache.length === matchers.length && - this.cache.every( + this.previousMatchers.length === matchers.length && + this.previousMatchers.every( (cachedMatcher, index) => cachedMatcher === matchers[index] ) ) { return } - this.cache = matchers + this.previousMatchers = matchers // For matchers that are for static routes, filter them now. this.matchers.static = matchers.filter((matcher) => !matcher.isDynamic) @@ -130,26 +131,28 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // Generate a filename to index map, this will be used to re-sort the array. const indexes = new Map() - const pathnames = new Array(dynamic.length) + const identities = new Array(dynamic.length) for (let index = 0; index < dynamic.length; index++) { - const pathname = dynamic[index].route.pathname - if (indexes.has(pathname)) { + // Because locale aware definitions do not have the locale parts + // connected to the pathnames, we have to use the identity. + const identity = dynamic[index].identity + if (indexes.has(identity)) { throw new Error('Invariant: duplicate dynamic route detected') } - indexes.set(pathname, index) - pathnames[index] = pathname + indexes.set(identity, index) + identities[index] = identity } // Sort the array of pathnames. - const sorted = getSortedRoutes(pathnames) + const sorted = getSortedRoutes(identities) const sortedDynamicMatchers = new Array(sorted.length) for (let i = 0; i < sorted.length; i++) { - const pathname = sorted[i] + const identity = sorted[i] - const index = indexes.get(pathname) + const index = indexes.get(identity) if (typeof index !== 'number') { - throw new Error('Invariant: expected to find pathname in indexes map') + throw new Error('Invariant: expected to find identity in indexes map') } sortedDynamicMatchers[i] = dynamic[index] @@ -176,10 +179,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { this.providers.push(provider) } - public async test( - pathname: string, - options?: MatchOptions | undefined - ): Promise { + public async test(pathname: string, options: MatchOptions): Promise { // See if there's a match for the pathname... const match = await this.match(pathname, options) @@ -191,7 +191,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { public async match( pathname: string, - options?: MatchOptions + options: MatchOptions ): Promise> | null> { // "Iterate" over the match options. Once we found a single match, exit with // it, otherwise return null below. If no match is found, the inner block @@ -213,14 +213,19 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { */ protected validate( pathname: string, - matcher: RouteMatcher + matcher: RouteMatcher, + options: MatchOptions ): RouteMatch | null { + if (matcher instanceof LocaleRouteMatcher) { + return matcher.match(pathname, options) + } + return matcher.match(pathname) } public async *matchAll( pathname: string, - options?: MatchOptions | undefined + options: MatchOptions ): AsyncGenerator>, null, undefined> { // Guard against the matcher manager from being run before it needs to be // recompiled. This was preferred to re-running the compilation here because @@ -240,7 +245,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // with the list of normalized routes. if (!isDynamicRoute(pathname)) { for (const matcher of this.matchers.static) { - const match = this.validate(pathname, matcher) + const match = this.validate(pathname, matcher, options) if (!match) continue yield match @@ -252,7 +257,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // Loop over the dynamic matchers, yielding each match. for (const matcher of this.matchers.dynamic) { - const match = this.validate(pathname, matcher) + const match = this.validate(pathname, matcher, options) if (!match) continue yield match diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index 5f8f1e30df16e..df186a2835544 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -21,10 +21,7 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { super() } - public async test( - pathname: string, - options?: MatchOptions - ): Promise { + public async test(pathname: string, options: MatchOptions): Promise { // Try to find a match within the developer routes. const match = await super.match(pathname, options) @@ -36,9 +33,10 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { protected validate( pathname: string, - matcher: RouteMatcher + matcher: RouteMatcher, + options: MatchOptions ): RouteMatch | null { - const match = matcher.match(pathname) + const match = super.validate(pathname, matcher, options) // If a match was found, check to see if there were any conflicting app or // pages files. @@ -48,23 +46,25 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { matcher.duplicated && matcher.duplicated.some( (duplicate) => - duplicate.route.kind === RouteKind.APP_PAGE || - duplicate.route.kind === RouteKind.APP_ROUTE + duplicate.definition.kind === RouteKind.APP_PAGE || + duplicate.definition.kind === RouteKind.APP_ROUTE ) && matcher.duplicated.some( (duplicate) => - duplicate.route.kind === RouteKind.PAGES || - duplicate.route.kind === RouteKind.PAGES_API + duplicate.definition.kind === RouteKind.PAGES || + duplicate.definition.kind === RouteKind.PAGES_API ) ) { throw new Error( `Conflicting app and page file found: ${matcher.duplicated // Sort the error output so that the app pages (starting with "app") // are first. - .sort((a, b) => a.route.filename.localeCompare(b.route.filename)) + .sort((a, b) => + a.definition.filename.localeCompare(b.definition.filename) + ) .map( (duplicate) => - `"${path.relative(this.dir, duplicate.route.filename)}"` + `"${path.relative(this.dir, duplicate.definition.filename)}"` ) .join(' and ')}. Please remove one to continue.` ) @@ -75,7 +75,7 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { public async *matchAll( pathname: string, - options?: MatchOptions + options: MatchOptions ): AsyncGenerator>, null, undefined> { // Keep track of all the matches we've made. const matches = new Set() @@ -85,7 +85,7 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { for await (const development of super.matchAll(pathname, options)) { // There was a development match! Let's check to see if we've already // matched this one already (verified by comparing the bundlePath). - if (matches.has(development.route.bundlePath)) continue + if (matches.has(development.definition.bundlePath)) continue // We're here, which means that we haven't seen this match yet, so we // should try to ensure it and recompile the production matcher. @@ -101,14 +101,14 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { )) { // We found a matching production match! It may have already been seen // though, so let's skip if we have. - if (matches.has(production.route.bundlePath)) continue + if (matches.has(production.definition.bundlePath)) continue // Mark that we've matched in production. matchedProduction = true // We found a matching production match! Add the match to the set of // matches and yield this match to be used. - matches.add(production.route.bundlePath) + matches.add(production.definition.bundlePath) yield production } @@ -132,13 +132,13 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { await super.reload() // Check for and warn of any duplicates. - for (const [pathname, matchers] of Object.entries( - this.matchers.duplicates - )) { + for (const matchers of Object.values(this.matchers.duplicates)) { + // Pull the pathname off the first one. + const pathname = matchers[0].definition.pathname warn( `Duplicate page detected. ${matchers .map((matcher) => - chalk.cyan(path.relative(this.dir, matcher.route.filename)) + chalk.cyan(path.relative(this.dir, matcher.definition.filename)) ) .join(' and ')} resolve to ${chalk.cyan(pathname)}.` ) diff --git a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts index adc72b69765d0..146cda52e17a4 100644 --- a/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts @@ -1,7 +1,8 @@ import { RouteMatch } from '../route-matches/route-match' import { RouteMatcherProvider } from '../route-matcher-providers/route-matcher-provider' +import { LocaleMatcherMatchOptions } from '../route-matchers/locale-route-matcher' -export type MatchOptions = { skipDynamic?: boolean } +export type MatchOptions = { skipDynamic?: boolean } & LocaleMatcherMatchOptions export interface RouteMatcherManager { /** @@ -31,7 +32,7 @@ export interface RouteMatcherManager { * @param pathname the pathname to test for matches * @param options the options for the testing */ - test(pathname: string, options?: MatchOptions): Promise + test(pathname: string, options: MatchOptions): Promise /** * Returns the first match for a given request. @@ -39,7 +40,7 @@ export interface RouteMatcherManager { * @param pathname the pathname to match against * @param options the options for the matching */ - match(pathname: string, options?: MatchOptions): Promise + match(pathname: string, options: MatchOptions): Promise /** * Returns a generator for each match for a given request. This should be @@ -51,6 +52,6 @@ export interface RouteMatcherManager { */ matchAll( pathname: string, - options?: MatchOptions + options: MatchOptions ): AsyncGenerator } diff --git a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts index 2b88e602cbc6c..bc3a15b4fac48 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts @@ -99,7 +99,7 @@ describe('AppPageRouteMatcherProvider', () => { expect(loader.load).toHaveBeenCalled() expect(matchers).toHaveLength(1) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts index 1ec5fc22160bd..335322111f17e 100644 --- a/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts @@ -62,7 +62,7 @@ describe('AppRouteRouteMatcherProvider', () => { const matchers = await provider.matchers() expect(matchers).toHaveLength(1) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts index 4f22c25ce2a66..2226b565d075e 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.test.ts @@ -82,7 +82,7 @@ describe('DevAppPageRouteMatcher', () => { const matchers = await provider.matchers() expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts index d32e4b9fc2847..caf9fe2145f7d 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -81,8 +81,6 @@ export class DevAppPageRouteMatcherProvider } const { pathname, page, bundlePath } = cached - // TODO: what do we do if this route is a duplicate? - matchers.push( new AppPageRouteMatcher({ kind: RouteKind.APP_PAGE, diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts index 4cb84f8d5fae0..477b22400b65a 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts @@ -58,7 +58,7 @@ describe('DevAppRouteRouteMatcher', () => { const matchers = await matcher.matchers() expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts index b217c70a80cc0..a9f6a8dd946ce 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -59,8 +59,6 @@ export class DevAppRouteRouteMatcherProvider const pathname = this.normalizers.pathname.normalize(filename) const bundlePath = this.normalizers.bundlePath.normalize(filename) - // TODO: what do we do if this route is a duplicate? - matchers.push( new AppRouteRouteMatcher({ kind: RouteKind.APP_ROUTE, diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts index 5535f19436c0d..993855c4fb89f 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts @@ -79,7 +79,7 @@ describe('DevPagesAPIRouteMatcherProvider', () => { const matchers = await matcher.matchers() expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts index b85539701332e..7db5c1f759174 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -1,6 +1,9 @@ import { Normalizer } from '../../normalizers/normalizer' import { FileReader } from './helpers/file-reader/file-reader' -import { PagesAPIRouteMatcher } from '../../route-matchers/pages-api-route-matcher' +import { + PagesAPILocaleRouteMatcher, + PagesAPIRouteMatcher, +} from '../../route-matchers/pages-api-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' import { Normalizers } from '../../normalizers/normalizers' @@ -9,6 +12,7 @@ import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-pa import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' import path from 'path' +import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' export class DevPagesAPIRouteMatcherProvider implements RouteMatcherProvider @@ -24,7 +28,7 @@ export class DevPagesAPIRouteMatcherProvider private readonly pagesDir: string, private readonly extensions: ReadonlyArray, private readonly reader: FileReader, - private readonly localeNormalizer?: Normalizer + private readonly localeNormalizer?: LocaleRouteNormalizer ) { // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. @@ -82,20 +86,28 @@ export class DevPagesAPIRouteMatcherProvider const page = this.normalizers.page.normalize(filename) const bundlePath = this.normalizers.bundlePath.normalize(filename) - // TODO: what do we do if this route is a duplicate? - - matchers.push( - new PagesAPIRouteMatcher( - { + if (this.localeNormalizer) { + matchers.push( + new PagesAPILocaleRouteMatcher({ + kind: RouteKind.PAGES_API, + pathname, + page, + bundlePath, + filename, + i18n: {}, + }) + ) + } else { + matchers.push( + new PagesAPIRouteMatcher({ kind: RouteKind.PAGES_API, pathname, page, bundlePath, filename, - }, - this.localeNormalizer + }) ) - ) + } } return matchers diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts index bea71063bc026..576cc7e52072d 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts @@ -77,7 +77,7 @@ describe('DevPagesRouteMatcherProvider', () => { const matchers = await matcher.matchers() expect(matchers).toHaveLength(1) expect(reader.read).toBeCalledWith(dir) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts index 0494c00b2f748..1882ebded8bbd 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts @@ -1,6 +1,9 @@ import { Normalizer } from '../../normalizers/normalizer' import { FileReader } from './helpers/file-reader/file-reader' -import { PagesRouteMatcher } from '../../route-matchers/pages-route-matcher' +import { + PagesRouteMatcher, + PagesLocaleRouteMatcher, +} from '../../route-matchers/pages-route-matcher' import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' import { Normalizers } from '../../normalizers/normalizers' @@ -9,6 +12,7 @@ import { normalizePagePath } from '../../../../shared/lib/page-path/normalize-pa import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' import path from 'path' +import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' export class DevPagesRouteMatcherProvider implements RouteMatcherProvider @@ -23,7 +27,8 @@ export class DevPagesRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, - private readonly reader: FileReader + private readonly reader: FileReader, + private readonly localeNormalizer?: LocaleRouteNormalizer ) { // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. @@ -79,15 +84,28 @@ export class DevPagesRouteMatcherProvider const page = this.normalizers.page.normalize(filename) const bundlePath = this.normalizers.bundlePath.normalize(filename) - matchers.push( - new PagesRouteMatcher({ - kind: RouteKind.PAGES, - pathname, - page, - bundlePath, - filename, - }) - ) + if (this.localeNormalizer) { + matchers.push( + new PagesLocaleRouteMatcher({ + kind: RouteKind.PAGES, + pathname, + page, + bundlePath, + filename, + i18n: {}, + }) + ) + } else { + matchers.push( + new PagesRouteMatcher({ + kind: RouteKind.PAGES, + pathname, + page, + bundlePath, + filename, + }) + ) + } } return matchers diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts index 6254b6e9f0ee4..fa860ed795bd3 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts @@ -62,7 +62,7 @@ describe('PagesAPIRouteMatcherProvider', () => { expect(loader.load).toBeCalledWith(PAGES_MANIFEST) expect(matchers).toHaveLength(1) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts index afcb6d0b7ef5e..0f736116d0eaf 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -3,19 +3,22 @@ import { isAPIRoute } from '../../../lib/is-api-route' import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { RouteKind } from '../route-kind' -import { PagesAPIRouteMatcher } from '../route-matchers/pages-api-route-matcher' +import { + PagesAPILocaleRouteMatcher, + PagesAPIRouteMatcher, +} from '../route-matchers/pages-api-route-matcher' import { Manifest, ManifestLoader, } from './helpers/manifest-loaders/manifest-loader' -import { Normalizer } from '../normalizers/normalizer' import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' +import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider { constructor( private readonly distDir: string, manifestLoader: ManifestLoader, - private readonly localeNormalizer?: Normalizer + private readonly localeNormalizer?: LocaleRouteNormalizer ) { super(PAGES_MANIFEST, manifestLoader) } @@ -29,19 +32,35 @@ export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider

= [] + for (const page of pathnames) { - matchers.push( - new PagesAPIRouteMatcher( - { + if (this.localeNormalizer) { + // Match the locale on the page name, or default to the default locale. + const { detectedLocale, pathname } = this.localeNormalizer.match(page) + + matchers.push( + new PagesAPILocaleRouteMatcher({ + kind: RouteKind.PAGES_API, + pathname, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + i18n: { + locale: detectedLocale, + }, + }) + ) + } else { + matchers.push( + new PagesAPIRouteMatcher({ kind: RouteKind.PAGES_API, pathname: page, page, bundlePath: path.join('pages', normalizePagePath(page)), filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), - }, - this.localeNormalizer + }) ) - ) + } } return matchers diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts index 0e02ff11ed61a..fd45776ac730e 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts @@ -40,6 +40,7 @@ describe('PagesRouteMatcherProvider', () => { filename: `/${SERVER_DIRECTORY}/pages/blog/[slug].js`, page: '/blog/[slug]', bundlePath: 'pages/blog/[slug]', + i18n: {}, }, { kind: RouteKind.PAGES, @@ -47,27 +48,29 @@ describe('PagesRouteMatcherProvider', () => { filename: `/${SERVER_DIRECTORY}/pages/en-US.html`, page: '/en-US', bundlePath: 'pages/en-US', + i18n: { + locale: 'en-US', + }, }, { kind: RouteKind.PAGES, - pathname: '/en-US', - filename: `/${SERVER_DIRECTORY}/pages/en-US.html`, - page: '/en-US', - bundlePath: 'pages/en-US', - }, - { - kind: RouteKind.PAGES, - pathname: '/fr', + pathname: '/', filename: `/${SERVER_DIRECTORY}/pages/fr.html`, page: '/fr', bundlePath: 'pages/fr', + i18n: { + locale: 'fr', + }, }, { kind: RouteKind.PAGES, - pathname: '/nl-NL', + pathname: '/', filename: `/${SERVER_DIRECTORY}/pages/nl-NL.html`, page: '/nl-NL', bundlePath: 'pages/nl-NL', + i18n: { + locale: 'nl-NL', + }, }, { kind: RouteKind.PAGES, @@ -75,27 +78,29 @@ describe('PagesRouteMatcherProvider', () => { filename: `/${SERVER_DIRECTORY}/pages/en-US/404.html`, page: '/en-US/404', bundlePath: 'pages/en-US/404', + i18n: { + locale: 'en-US', + }, }, { kind: RouteKind.PAGES, - pathname: '/en-US/404', - filename: `/${SERVER_DIRECTORY}/pages/en-US/404.html`, - page: '/en-US/404', - bundlePath: 'pages/en-US/404', - }, - { - kind: RouteKind.PAGES, - pathname: '/fr/404', + pathname: '/404', filename: `/${SERVER_DIRECTORY}/pages/fr/404.html`, page: '/fr/404', bundlePath: 'pages/fr/404', + i18n: { + locale: 'fr', + }, }, { kind: RouteKind.PAGES, - pathname: '/nl-NL/404', + pathname: '/404', filename: `/${SERVER_DIRECTORY}/pages/nl-NL/404.html`, page: '/nl-NL/404', bundlePath: 'pages/nl-NL/404', + i18n: { + locale: 'nl-NL', + }, }, ], }, @@ -116,9 +121,9 @@ describe('PagesRouteMatcherProvider', () => { const matchers = await provider.matchers() expect(loader.load).toBeCalledWith(PAGES_MANIFEST) - const routes = matchers.map((matcher) => matcher.route) - expect(routes).toHaveLength(expected.length) + const routes = matchers.map((matcher) => matcher.definition) expect(routes).toContainEqual(route) + expect(routes).toHaveLength(expected.length) }) }) }) @@ -173,7 +178,7 @@ describe('PagesRouteMatcherProvider', () => { expect(loader.load).toBeCalledWith(PAGES_MANIFEST) expect(matchers).toHaveLength(1) - expect(matchers[0].route).toEqual(route) + expect(matchers[0].definition).toEqual(route) } ) }) diff --git a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts index a042b80a3d097..061a53ba41d66 100644 --- a/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -8,7 +8,10 @@ import { import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' import { RouteKind } from '../route-kind' -import { PagesRouteMatcher } from '../route-matchers/pages-route-matcher' +import { + PagesLocaleRouteMatcher, + PagesRouteMatcher, +} from '../route-matchers/pages-route-matcher' import { Manifest, ManifestLoader, @@ -45,33 +48,30 @@ export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider = [] for (const page of pathnames) { - matchers.push( - new PagesRouteMatcher({ - kind: RouteKind.PAGES, - pathname: page, - page, - bundlePath: path.join('pages', normalizePagePath(page)), - filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), - }) - ) - } - - /** - * We need to include the default locale normalized pathname in the matcher - * as well. - */ - if (this.localeNormalizer) { - for (const page of pathnames) { - const { pathname, detectedLocale } = this.localeNormalizer.match(page) - if (detectedLocale !== this.localeNormalizer.defaultLocale) continue + if (this.localeNormalizer) { + // Match the locale on the page name, or default to the default locale. + const { detectedLocale, pathname } = this.localeNormalizer.match(page) matchers.push( - new PagesRouteMatcher({ + new PagesLocaleRouteMatcher({ kind: RouteKind.PAGES, pathname, page, bundlePath: path.join('pages', normalizePagePath(page)), filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), + i18n: { + locale: detectedLocale, + }, + }) + ) + } else { + matchers.push( + new PagesRouteMatcher({ + kind: RouteKind.PAGES, + pathname: page, + page, + bundlePath: path.join('pages', normalizePagePath(page)), + filename: path.join(this.distDir, SERVER_DIRECTORY, manifest[page]), }) ) } diff --git a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts index 159b064a3c941..795b4b44ec93a 100644 --- a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts @@ -1,11 +1,4 @@ import { RouteMatcher } from './route-matcher' -import { AppPageRouteMatch } from '../route-matches/app-page-route-match' +import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' -export class AppPageRouteMatcher extends RouteMatcher { - public match(pathname: string): AppPageRouteMatch | null { - const result = this.test(pathname) - if (!result) return null - - return { route: this.route, params: result.params } - } -} +export class AppPageRouteMatcher extends RouteMatcher {} diff --git a/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts index 8c3d571896a12..0bc3720ff9d2d 100644 --- a/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts @@ -1,11 +1,4 @@ import { RouteMatcher } from './route-matcher' -import { AppRouteRouteMatch } from '../route-matches/app-route-route-match' +import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' -export class AppRouteRouteMatcher extends RouteMatcher { - public match(pathname: string): AppRouteRouteMatch | null { - const result = this.test(pathname) - if (!result) return null - - return { route: this.route, params: result.params } - } -} +export class AppRouteRouteMatcher extends RouteMatcher {} diff --git a/packages/next/src/server/future/route-matchers/locale-route-matcher.ts b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts new file mode 100644 index 0000000000000..dc72787a0f79e --- /dev/null +++ b/packages/next/src/server/future/route-matchers/locale-route-matcher.ts @@ -0,0 +1,74 @@ +import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition' +import { LocaleRouteMatch } from '../route-matches/locale-route-match' +import { RouteMatcher } from './route-matcher' + +export type LocaleMatcherMatchOptions = { + /** + * If defined, this indicates to the matcher that the request should be + * treated as locale-aware. If this is undefined, it means that this + * application was not configured for additional locales. + */ + i18n?: { + /** + * The locale that was detected on the incoming route. If undefined it means + * that the locale should be considered to be the default one. + */ + detectedLocale?: string + + /** + * The pathname that has had it's locale information stripped from. + */ + pathname: string + } +} + +export class LocaleRouteMatcher< + D extends LocaleRouteDefinition = LocaleRouteDefinition +> extends RouteMatcher { + /** + * Identity returns the identity part of the matcher. This is used to compare + * a unique matcher to another. This is also used when sorting dynamic routes, + * so it must contain the pathname part as well. + */ + public get identity(): string { + return `${this.definition.pathname}?__nextLocale=${this.definition.i18n?.locale}` + } + + public match( + pathname: string, + options?: LocaleMatcherMatchOptions + ): LocaleRouteMatch | null { + // This is like the parent `match` method but instead this injects the + // additional `options` into the + const result = this.test(pathname, options) + if (!result) return null + + return { + definition: this.definition, + params: result.params, + detectedLocale: + options?.i18n?.detectedLocale ?? this.definition.i18n?.locale, + } + } + + public test(pathname: string, options?: LocaleMatcherMatchOptions) { + // If this route has locale information... + if (this.definition.i18n && options?.i18n) { + // If we have detected a locale and it does not match this route's locale, + // then this isn't a match! + if ( + this.definition.i18n.locale && + options.i18n.detectedLocale && + this.definition.i18n.locale !== options.i18n.detectedLocale + ) { + return null + } + + // Perform regular matching against the locale stripped pathname now, the + // locale information matches! + return super.test(options.i18n.pathname) + } + + return super.test(pathname) + } +} diff --git a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts index 5c9798e4e7199..84cd8eec412af 100644 --- a/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts @@ -1,22 +1,7 @@ -import { RouteMatcher } from './route-matcher' -import { PagesAPIRouteMatch } from '../route-matches/pages-api-route-match' import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' -import { Normalizer } from '../normalizers/normalizer' - -export class PagesAPIRouteMatcher extends RouteMatcher { - constructor( - route: PagesAPIRouteDefinition, - private readonly localeNormalizer?: Normalizer - ) { - super(route) - } - - public match(pathname: string): PagesAPIRouteMatch | null { - pathname = this.localeNormalizer?.normalize(pathname) ?? pathname +import { LocaleRouteMatcher } from './locale-route-matcher' +import { RouteMatcher } from './route-matcher' - const result = this.test(pathname) - if (!result) return null +export class PagesAPIRouteMatcher extends RouteMatcher {} - return { route: this.route, params: result.params } - } -} +export class PagesAPILocaleRouteMatcher extends LocaleRouteMatcher {} diff --git a/packages/next/src/server/future/route-matchers/pages-route-matcher.ts b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts index 9e18789d81c36..aacf69d419ccb 100644 --- a/packages/next/src/server/future/route-matchers/pages-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts @@ -1,13 +1,7 @@ +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { LocaleRouteMatcher } from './locale-route-matcher' import { RouteMatcher } from './route-matcher' -import { PagesRouteMatch } from '../route-matches/pages-route-match' -export class PagesRouteMatcher extends RouteMatcher { - public match(pathname: string): PagesRouteMatch | null { - const result = this.test(pathname) - if (!result) return null +export class PagesRouteMatcher extends RouteMatcher {} - // TODO: could use this area to add locale information to the match - - return { route: this.route, params: result.params } - } -} +export class PagesLocaleRouteMatcher extends LocaleRouteMatcher {} diff --git a/packages/next/src/server/future/route-matchers/route-matcher.ts b/packages/next/src/server/future/route-matchers/route-matcher.ts index 07fe54a54c5c3..8be68356d5277 100644 --- a/packages/next/src/server/future/route-matchers/route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/route-matcher.ts @@ -5,9 +5,10 @@ import { RouteMatchFn, } from '../../../shared/lib/router/utils/route-matcher' import { getRouteRegex } from '../../../shared/lib/router/utils/route-regex' +import { RouteDefinition } from '../route-definitions/route-definition' import { RouteMatch } from '../route-matches/route-match' -export abstract class RouteMatcher { +export class RouteMatcher { private readonly dynamic?: RouteMatchFn /** @@ -17,19 +18,33 @@ export abstract class RouteMatcher { */ public duplicated?: Array - constructor(public readonly route: M['route']) { - if (isDynamicRoute(route.pathname)) { - this.dynamic = getRouteMatcher(getRouteRegex(route.pathname)) + constructor(public readonly definition: D) { + if (isDynamicRoute(definition.pathname)) { + this.dynamic = getRouteMatcher(getRouteRegex(definition.pathname)) } } + /** + * Identity returns the identity part of the matcher. This is used to compare + * a unique matcher to another. This is also used when sorting dynamic routes, + * so it must contain the pathname part. + */ + public get identity(): string { + return this.definition.pathname + } + public get isDynamic() { return this.dynamic !== undefined } - public abstract match(pathname: string): M | null + public match(pathname: string): RouteMatch | null { + const result = this.test(pathname) + if (!result) return null + + return { definition: this.definition, params: result.params } + } - protected test(pathname: string): { params?: Params } | null { + public test(pathname: string): { params?: Params } | null { if (this.dynamic) { const params = this.dynamic(pathname) if (!params) return null @@ -37,7 +52,7 @@ export abstract class RouteMatcher { return { params } } - if (pathname === this.route.pathname) { + if (pathname === this.definition.pathname) { return {} } diff --git a/packages/next/src/server/future/route-matches/locale-route-match.ts b/packages/next/src/server/future/route-matches/locale-route-match.ts new file mode 100644 index 0000000000000..99004a614812b --- /dev/null +++ b/packages/next/src/server/future/route-matches/locale-route-match.ts @@ -0,0 +1,7 @@ +import { LocaleRouteDefinition } from '../route-definitions/locale-route-definition' +import { RouteMatch } from './route-match' + +export interface LocaleRouteMatch + extends RouteMatch { + readonly detectedLocale?: string +} diff --git a/packages/next/src/server/future/route-matches/pages-route-match.ts b/packages/next/src/server/future/route-matches/pages-route-match.ts index 048ae3ab0b220..73551caed1317 100644 --- a/packages/next/src/server/future/route-matches/pages-route-match.ts +++ b/packages/next/src/server/future/route-matches/pages-route-match.ts @@ -1,4 +1,5 @@ -import { RouteMatch } from './route-match' import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { LocaleRouteMatch } from './locale-route-match' -export interface PagesRouteMatch extends RouteMatch {} +export interface PagesRouteMatch + extends LocaleRouteMatch {} diff --git a/packages/next/src/server/future/route-matches/route-match.ts b/packages/next/src/server/future/route-matches/route-match.ts index f220648b27d1a..758bb58545ec4 100644 --- a/packages/next/src/server/future/route-matches/route-match.ts +++ b/packages/next/src/server/future/route-matches/route-match.ts @@ -5,8 +5,8 @@ import { RouteDefinition } from '../route-definitions/route-definition' * RouteMatch is the resolved match for a given request. This will contain all * the dynamic parameters used for this route. */ -export interface RouteMatch { - readonly route: R +export interface RouteMatch { + readonly definition: D /** * params when provided are the dynamic route parameters that were parsed from diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 80adc8dd9d418..b7fe105fd9850 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -100,6 +100,7 @@ import { RouteKind } from './future/route-kind' import { AppRouteRouteHandler } from './future/route-handlers/app-route-route-handler' import { PagesAPIRouteMatch } from './future/route-matches/pages-api-route-match' +import { MatchOptions } from './future/route-matcher-managers/route-matcher-manager' export * from './base-server' @@ -1185,20 +1186,16 @@ export default class NextNodeServer extends BaseServer { // next.js core assumes page path without trailing slash pathname = removeTrailingSlash(pathname) - if (this.nextConfig.i18n) { - const localePathResult = normalizeLocalePath( - pathname, - this.nextConfig.i18n?.locales - ) - - if (localePathResult.detectedLocale) { - pathname = localePathResult.pathname - parsedUrl.query.__nextLocale = localePathResult.detectedLocale - } + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(pathname), } + if (options.i18n?.detectedLocale) { + parsedUrl.query.__nextLocale = options.i18n.detectedLocale + } + const bubbleNoFallback = !!query._nextBubbleNoFallback - const match = await this.matchers.match(pathname) + const match = await this.matchers.match(pathname, options) // Try to handle the given route with the configured handlers. if (match) { @@ -1208,7 +1205,7 @@ export default class NextNodeServer extends BaseServer { // If the route was detected as being a Pages API route, then handle // it. // TODO: move this behavior into a route handler. - if (match.route.kind === RouteKind.PAGES_API) { + if (match.definition.kind === RouteKind.PAGES_API) { delete query._nextBubbleNoFallback handled = await this.handleApiRequest( @@ -1255,6 +1252,7 @@ export default class NextNodeServer extends BaseServer { useFileSystemPublicRoutes, matchers: this.matchers, nextConfig: this.nextConfig, + localeNormalizer: this.localeNormalizer, } } @@ -1271,7 +1269,7 @@ export default class NextNodeServer extends BaseServer { match: PagesAPIRouteMatch ): Promise { const { - route: { pathname, filename }, + definition: { pathname, filename }, params, } = match @@ -1697,6 +1695,9 @@ export default class NextNodeServer extends BaseServer { let url: string + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(normalizedPathname), + } if (this.nextConfig.skipMiddlewareUrlNormalize) { url = getRequestMeta(params.request, '__NEXT_INIT_URL')! } else { @@ -1719,10 +1720,10 @@ export default class NextNodeServer extends BaseServer { const page: { name?: string; params?: { [key: string]: string } } = {} - const match = await this.matchers.match(normalizedPathname) + const match = await this.matchers.match(normalizedPathname, options) if (match) { page.name = match.params - ? match.route.pathname + ? match.definition.pathname : params.parsedUrl.pathname page.params = match.params } diff --git a/packages/next/src/server/router.ts b/packages/next/src/server/router.ts index 4dd900a380ab1..01b8466565448 100644 --- a/packages/next/src/server/router.ts +++ b/packages/next/src/server/router.ts @@ -19,8 +19,12 @@ 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' -import { RouteMatcherManager } from './future/route-matcher-managers/route-matcher-manager' +import { + MatchOptions, + RouteMatcherManager, +} from './future/route-matcher-managers/route-matcher-manager' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' +import { LocaleRouteNormalizer } from './future/normalizers/locale-route-normalizer' type RouteResult = { finished: boolean @@ -64,6 +68,7 @@ export type RouterOptions = { matchers: RouteMatcherManager useFileSystemPublicRoutes: boolean nextConfig: NextConfig + localeNormalizer?: LocaleRouteNormalizer } export type PageChecker = (pathname: string) => Promise @@ -83,6 +88,7 @@ export default class Router { private readonly matchers: Pick private readonly useFileSystemPublicRoutes: boolean private readonly nextConfig: NextConfig + private readonly localeNormalizer?: LocaleRouteNormalizer private compiledRoutes: ReadonlyArray private needsRecompilation: boolean @@ -100,6 +106,7 @@ export default class Router { matchers, useFileSystemPublicRoutes, nextConfig, + localeNormalizer, }: RouterOptions) { this.nextConfig = nextConfig this.headers = headers @@ -110,6 +117,7 @@ export default class Router { this.catchAllMiddleware = catchAllMiddleware this.matchers = matchers this.useFileSystemPublicRoutes = useFileSystemPublicRoutes + this.localeNormalizer = localeNormalizer // Perform the initial route compilation. this.compiledRoutes = this.compileRoutes() @@ -172,12 +180,19 @@ export default class Router { // Next.js performs all route matching without the trailing slash. const pathname = removeTrailingSlash(parsedUrl.pathname || '/') - const match = await this.matchers.test(pathname, { + // Normalize and detect the locale on the pathname. + const options: MatchOptions = { // We need to skip dynamic route matching because the next // step we're processing the afterFiles rewrites which must // not include dynamic matches. skipDynamic: true, - }) + i18n: this.localeNormalizer?.match(pathname, { + // TODO: verify changing the default locale + inferDefaultLocale: true, + }), + } + + const match = await this.matchers.test(pathname, options) if (!match) return { finished: false } return this.catchAllRoute.fn( @@ -240,7 +255,15 @@ export default class Router { } } - const match = await this.matchers.test(fsPathname) + // Normalize and detect the locale on the pathname. + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(fsPathname, { + // TODO: verify changing the default locale + inferDefaultLocale: true, + }), + } + + const match = await this.matchers.test(fsPathname, options) if (!match) return false // Matched a page or dynamic route so render it using catchAllRoute diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 278ea9923968e..cad09fed3502f 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -284,7 +284,6 @@ export default class NextWebServer extends BaseServer { ) if (localePathResult.detectedLocale) { - pathname = localePathResult.pathname parsedUrl.query.__nextLocale = localePathResult.detectedLocale } } From d0fd7087f34dae1b9315012f4b24cfdfa291bbb8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 01:41:08 -0700 Subject: [PATCH 15/22] fix: fixed error with error message detection --- .../dev-route-matcher-manager.ts | 26 +------------------ .../custom-error/test/index.test.js | 10 ++++++- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index df186a2835544..c2d658caa0a3f 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -77,46 +77,22 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { pathname: string, options: MatchOptions ): AsyncGenerator>, null, undefined> { - // Keep track of all the matches we've made. - const matches = new Set() - // Iterate over the development matches to see if one of them match the // request path. for await (const development of super.matchAll(pathname, options)) { - // There was a development match! Let's check to see if we've already - // matched this one already (verified by comparing the bundlePath). - if (matches.has(development.definition.bundlePath)) continue - // We're here, which means that we haven't seen this match yet, so we // should try to ensure it and recompile the production matcher. await this.ensurer.ensure(development) await this.production.reload() // Iterate over the production matches again, this time we should be able - // to match it against the production matcher. - let matchedProduction = false + // to match it against the production matcher unless there's an error. for await (const production of this.production.matchAll( pathname, options )) { - // We found a matching production match! It may have already been seen - // though, so let's skip if we have. - if (matches.has(production.definition.bundlePath)) continue - - // Mark that we've matched in production. - matchedProduction = true - - // We found a matching production match! Add the match to the set of - // matches and yield this match to be used. - matches.add(production.definition.bundlePath) yield production } - - if (!matchedProduction) { - throw new Error( - 'Invariant: development match was found, but not found after ensuring' - ) - } } // We tried direct matching against the pathname and against all the dynamic diff --git a/test/integration/custom-error/test/index.test.js b/test/integration/custom-error/test/index.test.js index a519dbd86dd12..d4261b9aad0e3 100644 --- a/test/integration/custom-error/test/index.test.js +++ b/test/integration/custom-error/test/index.test.js @@ -9,6 +9,7 @@ import { nextStart, killApp, launchApp, + waitFor, } from 'next-test-utils' const appDir = join(__dirname, '..') @@ -38,6 +39,9 @@ describe('Custom _error', () => { }, }) }) + beforeEach(async () => { + await waitFor(1000) + }) afterAll(() => killApp(app)) it('should not warn with /_error and /404 when rendering error first', async () => { @@ -45,6 +49,7 @@ describe('Custom _error', () => { await fs.writeFile(page404, 'export default

') const html = await renderViaHTTP(appPort, '/404') await fs.remove(page404) + console.log('WYATT REMOVED FILE') expect(html).toContain('Unexpected eof') expect(stderr).not.toMatch(customErrNo404Match) }) @@ -61,6 +66,9 @@ describe('Custom _error', () => { }, }) }) + beforeEach(async () => { + await waitFor(1000) + }) afterAll(() => killApp(app)) it('should not warn with /_error and /404', async () => { @@ -75,8 +83,8 @@ describe('Custom _error', () => { it('should warn on custom /_error without custom /404', async () => { stderr = '' const html = await renderViaHTTP(appPort, '/404') - expect(html).toContain('An error 404 occurred on server') expect(stderr).toMatch(customErrNo404Match) + expect(html).toContain('An error 404 occurred on server') }) }) From db58e0824306d104c6ed1abe9b5e2e67b8de28aa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 03:29:01 -0700 Subject: [PATCH 16/22] fix: fixed issue with route sorting --- packages/next/src/server/base-server.ts | 5 ++ .../default-route-matcher-manager.ts | 60 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index ad81643a13f02..a23125e871945 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -656,6 +656,11 @@ export default abstract class Server { } const pageIsDynamic = typeof match?.params !== 'undefined' + // The rest of this function can't handle i18n properly, so ensure we + // restore the pathname with the locale information stripped from it + // now that we're done matching. + matchedPath = options.i18n?.pathname ?? matchedPath + const utils = getUtils({ pageIsDynamic, page: srcPathname, diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 80748d94cb8e3..579ea950612a4 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -122,40 +122,48 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // For matchers that are for dynamic routes, filter them and sort them now. const dynamic = matchers.filter((matcher) => matcher.isDynamic) - // Because `getSortedRoutes` only accepts an array of strings, we need to - // build a reference between the pathnames used for dynamic routing and - // the underlying matchers used to perform the match for each route. We - // take the fact that the pathnames are unique to build a reference of - // their original index in the array so that when we call - // `getSortedRoutes`, we can lookup the associated matcher. - - // Generate a filename to index map, this will be used to re-sort the array. - const indexes = new Map() - const identities = new Array(dynamic.length) + // As `getSortedRoutes` only takes an array of strings, we need to create + // a map of the pathnames (used for sorting) and the matchers. When we + // have locales, there may be multiple matches for the same pathname. To + // handle this, we keep a map of all the indexes (in `reference`) and + // merge them in later. + + const reference = new Map() + const pathnames = new Array() for (let index = 0; index < dynamic.length; index++) { - // Because locale aware definitions do not have the locale parts - // connected to the pathnames, we have to use the identity. - const identity = dynamic[index].identity - if (indexes.has(identity)) { - throw new Error('Invariant: duplicate dynamic route detected') - } + // Grab the pathname from the definition. + const pathname = dynamic[index].definition.pathname + + // Grab the index in the dynamic array, push it into the reference. + const indexes = reference.get(pathname) ?? [] + indexes.push(index) - indexes.set(identity, index) - identities[index] = identity + // If this is the first one set it. If it isn't, we don't need to + // because pushing above on the array will mutate the array already + // stored there because array's are always a reference! + if (indexes.length === 1) reference.set(pathname, indexes) + // Otherwise, continue, we've already added this pathname before. + else continue + + pathnames.push(pathname) } // Sort the array of pathnames. - const sorted = getSortedRoutes(identities) - const sortedDynamicMatchers = new Array(sorted.length) - for (let i = 0; i < sorted.length; i++) { - const identity = sorted[i] - - const index = indexes.get(identity) - if (typeof index !== 'number') { + const sorted = getSortedRoutes(pathnames) + + // For each of the sorted pathnames, iterate over them, grabbing the list + // of indexes and merging them back into the new `sortedDynamicMatchers` + // array. The order of the same matching pathname doesn't matter because + // they will have other matching characteristics (like the locale) that + // is considered. + const sortedDynamicMatchers: Array = [] + for (const pathname of sorted) { + const indexes = reference.get(pathname) + if (!Array.isArray(indexes)) { throw new Error('Invariant: expected to find identity in indexes map') } - sortedDynamicMatchers[i] = dynamic[index] + for (const index of indexes) sortedDynamicMatchers.push(dynamic[index]) } this.matchers.dynamic = sortedDynamicMatchers From 0ea93cc1eed719f7b630043939a99ea55a6462c4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 04:29:46 -0700 Subject: [PATCH 17/22] fix: ensure leading slash when matching, polyfilled request --- packages/next/src/server/dev/next-dev-server.ts | 2 +- .../route-matcher-managers/default-route-matcher-manager.ts | 4 ++++ test/integration/custom-server/server.js | 1 + test/integration/custom-server/test/index.test.js | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 5e26520e9f6b1..06d64f6178ecb 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -1509,7 +1509,7 @@ export default class DevServer extends Server { throw new WrappedBuildError(compilationErr) } try { - if (shouldEnsure) { + if (shouldEnsure || this.renderOpts.customServer) { await this.hotReloader!.ensurePage({ page: pathname, appPaths, diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 579ea950612a4..4f3452840a66a 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -9,6 +9,7 @@ import { RouteMatcher } from '../route-matchers/route-matcher' import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' +import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' interface RouteMatchers { static: ReadonlyArray @@ -247,6 +248,9 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { ) } + // Ensure that path matching is done with a leading slash. + pathname = ensureLeadingSlash(pathname) + // If this pathname doesn't look like a dynamic route, and this pathname is // listed in the normalized list of routes, then return it. This ensures // that when a route like `/user/[id]` is encountered, it doesn't just match diff --git a/test/integration/custom-server/server.js b/test/integration/custom-server/server.js index 1aa4f7141fdba..06ec26985bd0a 100644 --- a/test/integration/custom-server/server.js +++ b/test/integration/custom-server/server.js @@ -1,5 +1,6 @@ if (process.env.POLYFILL_FETCH) { global.fetch = require('node-fetch').default + global.Request = require('node-fetch').Request } const { readFileSync } = require('fs') diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js index c702c11e8fd36..8fa302852e999 100644 --- a/test/integration/custom-server/test/index.test.js +++ b/test/integration/custom-server/test/index.test.js @@ -280,7 +280,7 @@ describe.each([ expect(stderr).toContain( 'error - unhandledRejection: Error: unhandled rejection' ) - expect(stderr).toContain('server.js:31:22') + expect(stderr).toContain('server.js:32:22') }) }) From bd453c9bd06e611b4e210a27c06218e20c993ea4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 05:55:22 -0700 Subject: [PATCH 18/22] fix: handle cases where the dev matchers are too slow during testing --- .../next/src/server/dev/next-dev-server.ts | 8 ++-- .../dev-route-matcher-manager.ts | 4 ++ .../dev-app-page-route-matcher-provider.ts | 19 +++++---- .../dev-app-route-route-matcher-provider.ts | 19 +++++---- .../dev-pages-api-route-matcher-provider.ts | 17 ++++---- .../dev/dev-pages-route-matcher-provider.ts | 17 ++++---- .../dev/file-cache-route-matcher-provider.ts | 26 ++++++++++++ .../helpers/cached-route-matcher-provider.ts | 40 +++++++++++++++++++ .../manifest-route-matcher-provider.ts | 38 ++++-------------- 9 files changed, 115 insertions(+), 73 deletions(-) create mode 100644 packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts create mode 100644 packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 06d64f6178ecb..e17ff274c82d4 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -710,10 +710,6 @@ export default class DevServer extends Server { } this.sortedRoutes = sortedRoutes - // Reload the matchers. The filesystem would have been written to, - // and the matchers need to re-scan it to update the router. - await this.matchers.reload() - this.router.setCatchallMiddleware( this.generateCatchAllMiddlewareRoute(true) ) @@ -729,6 +725,10 @@ export default class DevServer extends Server { } else { Log.warn('Failed to reload dynamic routes:', e) } + } finally { + // Reload the matchers. The filesystem would have been written to, + // and the matchers need to re-scan it to update the router. + await this.matchers.reload() } }) }) diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index c2d658caa0a3f..7b119cadcc0b2 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -77,6 +77,10 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { pathname: string, options: MatchOptions ): AsyncGenerator>, null, undefined> { + // Compile the development routes. + // TODO: we may want to only run this during testing, users won't be fast enough to require this many dir scans + await super.reload() + // Iterate over the development matches to see if one of them match the // request path. for await (const development of super.matchAll(pathname, options)) { diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts index caf9fe2145f7d..e5a19adb23087 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -1,6 +1,5 @@ import { FileReader } from './helpers/file-reader/file-reader' import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher' -import { RouteMatcherProvider } from '../route-matcher-provider' import { Normalizer } from '../../normalizers/normalizer' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' import { Normalizers } from '../../normalizers/normalizers' @@ -8,10 +7,9 @@ import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -export class DevAppPageRouteMatcherProvider - implements RouteMatcherProvider -{ +export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvider { private readonly expression: RegExp private readonly normalizers: { page: Normalizer @@ -20,10 +18,12 @@ export class DevAppPageRouteMatcherProvider } constructor( - private readonly appDir: string, + appDir: string, extensions: ReadonlyArray, - private readonly reader: FileReader + reader: FileReader ) { + super(appDir, reader) + // Match any page file that ends with `/page.${extension}` under the app // directory. this.expression = new RegExp(`\\/page\\.(?:${extensions.join('|')})$`) @@ -46,10 +46,9 @@ export class DevAppPageRouteMatcherProvider } } - public async matchers(): Promise> { - // Read the files in the pages directory... - const files = await this.reader.read(this.appDir) - + protected async transform( + files: ReadonlyArray + ): Promise> { // Collect all the app paths for each page. This could include any parallel // routes. const cache = new Map< diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts index a9f6a8dd946ce..d40b677a781c6 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -1,6 +1,5 @@ import { FileReader } from './helpers/file-reader/file-reader' import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher' -import { RouteMatcherProvider } from '../route-matcher-provider' import { Normalizer } from '../../normalizers/normalizer' import { Normalizers } from '../../normalizers/normalizers' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' @@ -8,10 +7,9 @@ import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' import { normalizeAppPath } from '../../../../shared/lib/router/utils/app-paths' import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -export class DevAppRouteRouteMatcherProvider - implements RouteMatcherProvider -{ +export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvider { private readonly expression: RegExp private readonly normalizers: { page: Normalizer @@ -20,10 +18,12 @@ export class DevAppRouteRouteMatcherProvider } constructor( - private readonly appDir: string, + appDir: string, extensions: ReadonlyArray, - private readonly reader: FileReader + reader: FileReader ) { + super(appDir, reader) + // Match any route file that ends with `/route.${extension}` under the app // directory. this.expression = new RegExp(`\\/route\\.(?:${extensions.join('|')})$`) @@ -46,10 +46,9 @@ export class DevAppRouteRouteMatcherProvider } } - public async matchers(): Promise> { - // Read the files in the pages directory... - const files = await this.reader.read(this.appDir) - + protected async transform( + files: ReadonlyArray + ): Promise> { const matchers: Array = [] for (const filename of files) { // If the file isn't a match for this matcher, then skip it. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts index 7db5c1f759174..5ba1b186f4e15 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -4,7 +4,6 @@ import { PagesAPILocaleRouteMatcher, PagesAPIRouteMatcher, } from '../../route-matchers/pages-api-route-matcher' -import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' import { Normalizers } from '../../normalizers/normalizers' import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' @@ -13,10 +12,9 @@ import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' import path from 'path' import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -export class DevPagesAPIRouteMatcherProvider - implements RouteMatcherProvider -{ +export class DevPagesAPIRouteMatcherProvider extends FileCacheRouteMatcherProvider { private readonly expression: RegExp private readonly normalizers: { page: Normalizer @@ -27,9 +25,11 @@ export class DevPagesAPIRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, - private readonly reader: FileReader, + reader: FileReader, private readonly localeNormalizer?: LocaleRouteNormalizer ) { + super(pagesDir, reader) + // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) @@ -73,10 +73,9 @@ export class DevPagesAPIRouteMatcherProvider return false } - public async matchers(): Promise> { - // Read the files in the pages directory... - const files = await this.reader.read(this.pagesDir) - + protected async transform( + files: ReadonlyArray + ): Promise> { const matchers: Array = [] for (const filename of files) { // If the file isn't a match for this matcher, then skip it. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts index 1882ebded8bbd..85d1fd3b7660c 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts @@ -4,7 +4,6 @@ import { PagesRouteMatcher, PagesLocaleRouteMatcher, } from '../../route-matchers/pages-route-matcher' -import { RouteMatcherProvider } from '../route-matcher-provider' import { AbsoluteFilenameNormalizer } from '../../normalizers/absolute-filename-normalizer' import { Normalizers } from '../../normalizers/normalizers' import { wrapNormalizerFn } from '../../normalizers/wrap-normalizer-fn' @@ -13,10 +12,9 @@ import { PrefixingNormalizer } from '../../normalizers/prefixing-normalizer' import { RouteKind } from '../../route-kind' import path from 'path' import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' -export class DevPagesRouteMatcherProvider - implements RouteMatcherProvider -{ +export class DevPagesRouteMatcherProvider extends FileCacheRouteMatcherProvider { private readonly expression: RegExp private readonly normalizers: { page: Normalizer @@ -27,9 +25,11 @@ export class DevPagesRouteMatcherProvider constructor( private readonly pagesDir: string, private readonly extensions: ReadonlyArray, - private readonly reader: FileReader, + reader: FileReader, private readonly localeNormalizer?: LocaleRouteNormalizer ) { + super(pagesDir, reader) + // Match any route file that ends with `/${filename}.${extension}` under the // pages directory. this.expression = new RegExp(`\\.(?:${extensions.join('|')})$`) @@ -71,10 +71,9 @@ export class DevPagesRouteMatcherProvider return true } - public async matchers(): Promise> { - // Read the files in the pages directory... - const files = await this.reader.read(this.pagesDir) - + protected async transform( + files: ReadonlyArray + ): Promise> { const matchers: Array = [] for (const filename of files) { // If the file isn't a match for this matcher, then skip it. diff --git a/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts new file mode 100644 index 0000000000000..bc212ee825e58 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/file-cache-route-matcher-provider.ts @@ -0,0 +1,26 @@ +import { RouteMatcher } from '../../route-matchers/route-matcher' +import { CachedRouteMatcherProvider } from '../helpers/cached-route-matcher-provider' +import { FileReader } from './helpers/file-reader/file-reader' + +/** + * This will memoize the matchers when the file contents are the same. + */ +export abstract class FileCacheRouteMatcherProvider< + M extends RouteMatcher = RouteMatcher +> extends CachedRouteMatcherProvider> { + constructor(dir: string, reader: FileReader) { + super({ + load: async () => reader.read(dir), + compare: (left, right) => { + if (left.length !== right.length) return false + + // Assuming the file traversal order is deterministic... + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false + } + + return true + }, + }) + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts new file mode 100644 index 0000000000000..e5e27b4e991a9 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/helpers/cached-route-matcher-provider.ts @@ -0,0 +1,40 @@ +import { RouteMatcher } from '../../route-matchers/route-matcher' +import { RouteMatcherProvider } from '../route-matcher-provider' + +interface LoaderComparable { + load(): Promise + compare(left: D, right: D): boolean +} + +/** + * This will memoize the matchers if the loaded data is comparable. + */ +export abstract class CachedRouteMatcherProvider< + M extends RouteMatcher = RouteMatcher, + D = any +> implements RouteMatcherProvider +{ + private data?: D + private cached: ReadonlyArray = [] + + constructor(private readonly loader: LoaderComparable) {} + + protected abstract transform(data: D): Promise> + + public async matchers(): Promise { + const data = await this.loader.load() + if (!data) return [] + + // Return the cached matchers if the data has not changed. + if (this.data && this.loader.compare(this.data, data)) return this.cached + this.data = data + + // Transform the manifest into matchers. + const matchers = await this.transform(data) + + // Cache the matchers. + this.cached = matchers + + return matchers + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts index b08610fa73f38..e9dffe1997359 100644 --- a/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts @@ -1,41 +1,17 @@ import { RouteMatcher } from '../route-matchers/route-matcher' -import { RouteMatcherProvider } from './route-matcher-provider' import { Manifest, ManifestLoader, } from './helpers/manifest-loaders/manifest-loader' +import { CachedRouteMatcherProvider } from './helpers/cached-route-matcher-provider' export abstract class ManifestRouteMatcherProvider< M extends RouteMatcher = RouteMatcher -> implements RouteMatcherProvider -{ - private manifest?: Manifest - private cached: ReadonlyArray = [] - - constructor( - private readonly manifestName: string, - private readonly manifestLoader: ManifestLoader - ) {} - - protected abstract transform(manifest: Manifest): Promise> - - public async matchers(): Promise { - const manifest = this.manifestLoader.load(this.manifestName) - if (!manifest) return [] - - // Return the cached matchers if the manifest has not changed. The Manifest - // loader under the hood uses the `require` API, which has it's own cache. - // In production, this will always return the same value, in development, - // the cache is invalidated when the manifest is updated. - if (this.manifest === manifest) return this.cached - this.manifest = manifest - - // Transform the manifest into matchers. - const matchers = await this.transform(manifest) - - // Cache the matchers. - this.cached = matchers - - return matchers +> extends CachedRouteMatcherProvider { + constructor(manifestName: string, manifestLoader: ManifestLoader) { + super({ + load: async () => manifestLoader.load(manifestName), + compare: (left, right) => left === right, + }) } } From 36245948c32f779e3d2753a5d896b4478b429c36 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 06:28:44 -0700 Subject: [PATCH 19/22] chore: removed comments + old waits --- test/integration/custom-error/test/index.test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/integration/custom-error/test/index.test.js b/test/integration/custom-error/test/index.test.js index d4261b9aad0e3..48d2ed6dd4bc9 100644 --- a/test/integration/custom-error/test/index.test.js +++ b/test/integration/custom-error/test/index.test.js @@ -9,7 +9,6 @@ import { nextStart, killApp, launchApp, - waitFor, } from 'next-test-utils' const appDir = join(__dirname, '..') @@ -39,9 +38,6 @@ describe('Custom _error', () => { }, }) }) - beforeEach(async () => { - await waitFor(1000) - }) afterAll(() => killApp(app)) it('should not warn with /_error and /404 when rendering error first', async () => { @@ -49,7 +45,6 @@ describe('Custom _error', () => { await fs.writeFile(page404, 'export default

') const html = await renderViaHTTP(appPort, '/404') await fs.remove(page404) - console.log('WYATT REMOVED FILE') expect(html).toContain('Unexpected eof') expect(stderr).not.toMatch(customErrNo404Match) }) @@ -66,9 +61,6 @@ describe('Custom _error', () => { }, }) }) - beforeEach(async () => { - await waitFor(1000) - }) afterAll(() => killApp(app)) it('should not warn with /_error and /404', async () => { From c5c8feee71e5141acb565ba83b22ba2eb63f1a2d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 07:25:49 -0700 Subject: [PATCH 20/22] fix: improve parallel route support --- .../future/route-matchers/app-page-route-matcher.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts index 795b4b44ec93a..54727515c681b 100644 --- a/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts +++ b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts @@ -1,4 +1,10 @@ import { RouteMatcher } from './route-matcher' import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' -export class AppPageRouteMatcher extends RouteMatcher {} +export class AppPageRouteMatcher extends RouteMatcher { + public get identity(): string { + return `${ + this.definition.pathname + }?__nextParallelPaths=${this.definition.appPaths.join(',')}}` + } +} From 47be100b0aa41dfc4eb302a70c85813a7ad978fb Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 13 Feb 2023 13:47:54 -0700 Subject: [PATCH 21/22] fix: enhance duplicate matcher warning --- .../default-route-matcher-manager.ts | 11 +++++------ .../dev-route-matcher-manager.ts | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts index 4f3452840a66a..b8d88976b936f 100644 --- a/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -65,7 +65,7 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { for (const providerMatchers of providersMatchers) { for (const matcher of providerMatchers) { // Test to see if the matcher being added is a duplicate. - const duplicate = all.get(matcher.identity) + const duplicate = all.get(matcher.definition.pathname) if (duplicate) { // This looks a little weird, but essentially if the pathname // already exists in the duplicates map, then we got that array @@ -79,18 +79,17 @@ export class DefaultRouteMatcherManager implements RouteMatcherManager { // the retrieval of the `other` will actually return the array // reference used by all other duplicates. This is why ReadonlyArray // is so important! Array's are always references! - const others = duplicates[matcher.identity] ?? [duplicate] + const others = duplicates[matcher.definition.pathname] ?? [ + duplicate, + ] others.push(matcher) - duplicates[matcher.identity] = others + duplicates[matcher.definition.pathname] = others // Add duplicated details to each route. duplicate.duplicated = others matcher.duplicated = others - // Currently, this is a bit delicate, as the order for which we'll - // receive the matchers is not deterministic. // TODO: see if we should error for duplicates in production? - continue } matchers.push(matcher) diff --git a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts index 7b119cadcc0b2..be1cdd87d2236 100644 --- a/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -4,7 +4,7 @@ import { RouteDefinition } from '../route-definitions/route-definition' import { DefaultRouteMatcherManager } from './default-route-matcher-manager' import { MatchOptions, RouteMatcherManager } from './route-matcher-manager' import path from '../../../shared/lib/isomorphic/path' -import { warn } from '../../../build/output/log' +import * as Log from '../../../build/output/log' import chalk from 'next/dist/compiled/chalk' import { RouteMatcher } from '../route-matchers/route-matcher' @@ -112,15 +112,22 @@ export class DevRouteMatcherManager extends DefaultRouteMatcherManager { await super.reload() // Check for and warn of any duplicates. - for (const matchers of Object.values(this.matchers.duplicates)) { - // Pull the pathname off the first one. - const pathname = matchers[0].definition.pathname - warn( + for (const [pathname, matchers] of Object.entries( + this.matchers.duplicates + )) { + // We only want to warn about matchers resolving to the same path if their + // identities are different. + const identity = matchers[0].identity + if (matchers.slice(1).some((matcher) => matcher.identity !== identity)) { + continue + } + + Log.warn( `Duplicate page detected. ${matchers .map((matcher) => chalk.cyan(path.relative(this.dir, matcher.definition.filename)) ) - .join(' and ')} resolve to ${chalk.cyan(pathname)}.` + .join(' and ')} resolve to ${chalk.cyan(pathname)}` ) } } From 87c13431741e444a11a5a6b97b42b569a69d4b51 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 14 Feb 2023 15:43:33 -0800 Subject: [PATCH 22/22] fix prefixing normalizer on windows --- .../next/src/server/future/normalizers/prefixing-normalizer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/future/normalizers/prefixing-normalizer.ts b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts index a19e194394ebf..e4dade43429c8 100644 --- a/packages/next/src/server/future/normalizers/prefixing-normalizer.ts +++ b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts @@ -5,6 +5,6 @@ export class PrefixingNormalizer implements Normalizer { constructor(private readonly prefix: string) {} public normalize(pathname: string): string { - return path.join(this.prefix, pathname) + return path.posix.join(this.prefix, pathname) } }