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/examples/app-dir-mdx/mdx-components.tsx b/examples/app-dir-mdx/mdx-components.tsx index b980a0bcd8348..5e157b1df7236 100644 --- a/examples/app-dir-mdx/mdx-components.tsx +++ b/examples/app-dir-mdx/mdx-components.tsx @@ -1,7 +1,7 @@ +import type { MDXComponents } from 'mdx/types' + // This file is required to use MDX in `app` directory. -export function useMDXComponents(components: { - [component: string]: React.ComponentType -}) { +export function useMDXComponents(components: MDXComponents): MDXComponents { return { // Allows customizing built-in components, e.g. to add styling. // h1: ({ children }) => <h1 style={{ fontSize: "100px" }}>{children}</h1>, diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index b436ff711e620..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<string> | null pageExtensions: string[] assetPrefix: string isDev?: boolean @@ -310,7 +310,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { let appPathsPerRoute: Record<string, string[]> = {} 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 66506ab13aecd..28a69f68530d4 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -49,7 +49,7 @@ import { PAGES_MANIFEST, PHASE_PRODUCTION_BUILD, PRERENDER_MANIFEST, - FLIGHT_MANIFEST, + CLIENT_REFERENCE_MANIFEST, REACT_LOADABLE_MANIFEST, ROUTES_MANIFEST, SERVER_DIRECTORY, @@ -501,7 +501,9 @@ export default async function build( .traceAsyncFn(() => recursiveReadDir( appDir, - new RegExp(`^page\\.(?:${config.pageExtensions.join('|')})$`) + new RegExp( + `^(page|route)\\.(?:${config.pageExtensions.join('|')})$` + ) ) ) } @@ -578,7 +580,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] @@ -904,8 +906,14 @@ export default async function build( : []), path.join(SERVER_DIRECTORY, APP_PATHS_MANIFEST), APP_BUILD_MANIFEST, - path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.js'), - path.join(SERVER_DIRECTORY, FLIGHT_MANIFEST + '.json'), + path.join( + SERVER_DIRECTORY, + CLIENT_REFERENCE_MANIFEST + '.js' + ), + path.join( + SERVER_DIRECTORY, + CLIENT_REFERENCE_MANIFEST + '.json' + ), path.join( SERVER_DIRECTORY, FLIGHT_SERVER_CSS_MANIFEST + '.js' @@ -1091,7 +1099,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), @@ -1382,7 +1390,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<string | undefined> export type ComponentsType = { readonly [componentKey in ValueOf<typeof FILE_TYPES>]?: ModuleReference } & { @@ -33,6 +38,35 @@ export type ComponentsType = { readonly metadata?: CollectedMetadata } +async function createAppRouteCode({ + pagePath, + resolver, +}: { + pagePath: string + resolver: PathResolver +}): Promise<string> { + // 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<typeof getNamedRouteRegex> | undefined - let dynamicRouteMatcher: RouteMatch | undefined + let dynamicRouteMatcher: RouteMatchFn | undefined let defaultRouteMatches: ParsedUrlQuery | undefined if (pageIsDynamic) { diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index 16160a11f4f2a..805f6fa9bc77e 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -19,7 +19,7 @@ import { APP_CLIENT_INTERNALS, COMPILER_NAMES, EDGE_RUNTIME_WEBPACK, - ACTIONS_MANIFEST, + SERVER_REFERENCE_MANIFEST, FLIGHT_SERVER_CSS_MANIFEST, } from '../../../shared/lib/constants' import { ASYNC_CLIENT_MODULES } from './flight-manifest-plugin' @@ -756,9 +756,8 @@ export class FlightClientEntryPlugin { } } - const file = ACTIONS_MANIFEST const json = JSON.stringify(serverActions, null, this.dev ? 2 : undefined) - assets[file + '.json'] = new sources.RawSource( + assets[SERVER_REFERENCE_MANIFEST + '.json'] = new sources.RawSource( json ) as unknown as webpack.sources.RawSource } diff --git a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts index 26192a312a9d0..44e9c7af02357 100644 --- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts @@ -6,7 +6,7 @@ */ import { webpack, sources } from 'next/dist/compiled/webpack/webpack' -import { FLIGHT_MANIFEST } from '../../../shared/lib/constants' +import { CLIENT_REFERENCE_MANIFEST } from '../../../shared/lib/constants' import { relative, sep } from 'path' import { isClientComponentModule, regexCSS } from '../loaders/utils' @@ -369,7 +369,7 @@ export class FlightManifestPlugin { manifest.__entry_css_files__ = entryCSSFiles }) - const file = 'server/' + FLIGHT_MANIFEST + const file = 'server/' + CLIENT_REFERENCE_MANIFEST const json = JSON.stringify(manifest, null, this.dev ? 2 : undefined) ASYNC_CLIENT_MODULES.clear() diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts index 0b957799c0069..7f23b41d4cdfd 100644 --- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts @@ -13,7 +13,7 @@ import { EDGE_RUNTIME_WEBPACK, EDGE_UNSUPPORTED_NODE_APIS, MIDDLEWARE_BUILD_MANIFEST, - FLIGHT_MANIFEST, + CLIENT_REFERENCE_MANIFEST, MIDDLEWARE_MANIFEST, MIDDLEWARE_REACT_LOADABLE_MANIFEST, NEXT_CLIENT_SSR_ENTRY_SUFFIX, @@ -95,7 +95,7 @@ function getEntryFiles( const files: string[] = [] if (meta.edgeSSR) { if (meta.edgeSSR.isServerComponent) { - files.push(`server/${FLIGHT_MANIFEST}.js`) + files.push(`server/${CLIENT_REFERENCE_MANIFEST}.js`) files.push(`server/${FLIGHT_SERVER_CSS_MANIFEST}.js`) if (opts.sriEnabled) { files.push(`server/${SUBRESOURCE_INTEGRITY_MANIFEST}.js`) 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/container/RuntimeError/GroupedStackFrames.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx index 1bd38c1a6dfc9..9a8232d95b253 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/GroupedStackFrames.tsx @@ -12,39 +12,34 @@ function FrameworkGroup({ stackFrames: StackFramesGroup['stackFrames'] all: boolean }) { - const [open, setOpen] = React.useState(false) - const toggleOpen = React.useCallback(() => setOpen((v) => !v), []) - return ( <> - <button - data-nextjs-call-stack-framework-button - data-state={open ? 'open' : 'closed'} - onClick={toggleOpen} - tabIndex={10} // Match CallStackFrame tabIndex - > - <svg - data-nextjs-call-stack-chevron-icon - fill="none" - height="20" - width="20" - shapeRendering="geometricPrecision" - stroke="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth="2" - viewBox="0 0 24 24" + <details data-nextjs-collapsed-call-stack-details> + <summary + tabIndex={10} // Match CallStackFrame tabIndex > - <path d="M9 18l6-6-6-6" /> - </svg> - <FrameworkIcon framework={framework} /> - {framework === 'react' ? 'React' : 'Next.js'} - </button> - {open - ? stackFrames.map((frame, index) => ( - <CallStackFrame key={`call-stack-${index}-${all}`} frame={frame} /> - )) - : null} + <svg + data-nextjs-call-stack-chevron-icon + fill="none" + height="20" + width="20" + shapeRendering="geometricPrecision" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth="2" + viewBox="0 0 24 24" + > + <path d="M9 18l6-6-6-6" /> + </svg> + <FrameworkIcon framework={framework} /> + {framework === 'react' ? 'React' : 'Next.js'} + </summary> + + {stackFrames.map((frame, index) => ( + <CallStackFrame key={`call-stack-${index}-${all}`} frame={frame} /> + ))} + </details> </> ) } diff --git a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx index bc62d5bb59bf6..3d9d00d1a8f35 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/container/RuntimeError/index.tsx @@ -178,14 +178,6 @@ export const styles = css` display: unset; } - [data-nextjs-call-stack-framework-button] { - border: none; - background: none; - display: flex; - align-items: center; - padding: 0; - margin: var(--size-gap-double) 0; - } [data-nextjs-call-stack-framework-icon] { margin-right: var(--size-gap); } @@ -195,10 +187,26 @@ export const styles = css` [data-nextjs-call-stack-framework-icon='react'] { color: rgb(20, 158, 202); } - [data-nextjs-call-stack-framework-button][data-state='open'] - > [data-nextjs-call-stack-chevron-icon] { + [data-nextjs-collapsed-call-stack-details][open] + [data-nextjs-call-stack-chevron-icon] { transform: rotate(90deg); } + [data-nextjs-collapsed-call-stack-details] summary { + display: flex; + align-items: center; + margin: var(--size-gap-double) 0; + list-style: none; + } + [data-nextjs-collapsed-call-stack-details] summary::-webkit-details-marker { + display: none; + } + + [data-nextjs-collapsed-call-stack-details] h6 { + color: #666; + } + [data-nextjs-collapsed-call-stack-details] [data-nextjs-call-stack-frame] { + margin-bottom: var(--size-gap-double); + } ` export { RuntimeError } 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<U extends string> = 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<typeof url>).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<U extends string>( + error: any +): error is RedirectError<U> { + 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<U extends string>( + error: RedirectError<U> +): 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/index.ts b/packages/next/src/export/index.ts index 79a92a37dd876..4ae64610624e5 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -22,7 +22,7 @@ import { CLIENT_STATIC_FILES_PATH, EXPORT_DETAIL, EXPORT_MARKER, - FLIGHT_MANIFEST, + CLIENT_REFERENCE_MANIFEST, FLIGHT_SERVER_CSS_MANIFEST, FONT_LOADER_MANIFEST, MIDDLEWARE_MANIFEST, @@ -441,7 +441,7 @@ export default async function exportApp( renderOpts.serverComponentManifest = require(join( distDir, SERVER_DIRECTORY, - `${FLIGHT_MANIFEST}.json` + `${CLIENT_REFERENCE_MANIFEST}.json` )) as PagesManifest // @ts-expect-error untyped renderOpts.serverCSSManifest = require(join( 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 545e1aafff2f5..9f5971b0627b4 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 } @@ -2028,11 +2028,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<Store extends {}, Context extends {}> { + /** + * 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<Result>( + storage: AsyncLocalStorage<Store>, + 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<RequestStore, RequestContext> +{ + /** + * 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<Result>( + storage: AsyncLocalStorage<RequestStore>, + 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<Result>( + storage: AsyncLocalStorage<RequestStore>, + { 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<StaticGenerationStore, RequestContext> +{ + public wrap<Result>( + storage: AsyncLocalStorage<StaticGenerationStore>, + 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<Result>( + storage: AsyncLocalStorage<StaticGenerationStore>, + { 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..a23125e871945 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' @@ -34,11 +34,13 @@ 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' -import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' +import { isDynamicRoute } from '../shared/lib/router/utils' import { setLazyProp, getCookieParser, @@ -68,8 +70,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,6 +80,18 @@ import { FLIGHT_PARAMETERS, FETCH_CACHE_HEADER, } from '../client/components/app-router-headers' +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' +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 @@ -88,7 +100,7 @@ export type FindComponentsResult = { export interface RoutingItem { page: string - match: RouteMatch + match: RouteMatchFn re?: RegExp } @@ -173,18 +185,18 @@ type ResponsePayload = { } export default abstract class Server<ServerOptions extends Options = Options> { - 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 +234,6 @@ export default abstract class Server<ServerOptions extends Options = Options> { protected serverOptions: ServerOptions private responseCache: ResponseCacheBase protected router: Router - protected dynamicRoutes?: DynamicRoutes protected appPathRoutes?: Record<string, string[]> protected customRoutes: CustomRoutes protected serverComponentManifest?: any @@ -244,8 +255,9 @@ export default abstract class Server<ServerOptions extends Options = Options> { query: NextParsedUrlQuery params: Params isAppPath: boolean - appPaths?: string[] | null sriEnabled?: boolean + appPaths?: string[] | null + shouldEnsure: boolean }): Promise<FindComponentsResult | null> protected abstract getFontManifest(): FontManifest | undefined protected abstract getPrerenderManifest(): PrerenderManifest @@ -260,22 +272,7 @@ export default abstract class Server<ServerOptions extends Options = Options> { protected abstract getCustomRoutes(): CustomRoutes protected abstract hasPage(pathname: string): Promise<boolean> - 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 +321,10 @@ export default abstract class Server<ServerOptions extends Options = Options> { forceReload?: boolean }): void + protected readonly matchers: RouteMatcherManager + protected readonly handlers: RouteHandlerManager + protected readonly localeNormalizer?: LocaleRouteNormalizer + public constructor(options: ServerOptions) { const { dir = '.', @@ -355,6 +356,15 @@ export default abstract class Server<ServerOptions extends Options = Options> { 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 { @@ -408,12 +418,12 @@ export default abstract class Server<ServerOptions extends Options = Options> { ? 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 +435,16 @@ export default abstract class Server<ServerOptions extends Options = Options> { this.pagesManifest = this.getPagesManifest() this.appPathsManifest = this.getAppPathsManifest() + // Configure the routes. + const { matchers, handlers } = this.getRoutes() + 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) @@ -432,6 +452,58 @@ export default abstract class Server<ServerOptions extends Options = Options> { 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 matchers and handlers. + const matchers: RouteMatcherManager = new DefaultRouteMatcherManager() + const handlers = new RouteHandlerManager() + + // Match pages under `pages/`. + matchers.push( + new PagesRouteMatcherProvider( + this.distDir, + manifestLoader, + this.localeNormalizer + ) + ) + + // Match api routes under `pages/api/`. + matchers.push( + new PagesAPIRouteMatcherProvider( + this.distDir, + manifestLoader, + this.localeNormalizer + ) + ) + + // 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) @@ -443,6 +515,9 @@ export default abstract class Server<ServerOptions extends Options = Options> { parsedUrl?: NextUrlWithParsedQuery ): Promise<void> { 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 @@ -559,36 +634,33 @@ export default abstract class Server<ServerOptions extends Options = Options> { if (urlPathname.startsWith(`/_next/data/`)) { parsedUrl.query.__nextDataReq = '1' } + const normalizedUrlPath = this.stripNextDataPath(urlPathname) matchedPath = this.stripNextDataPath(matchedPath, false) - if (this.nextConfig.i18n) { - const localeResult = normalizeLocalePath( - matchedPath, - this.nextConfig.i18n.locales - ) - matchedPath = localeResult.pathname - - if (localeResult.detectedLocale) { - parsedUrl.query.__nextLocale = localeResult.detectedLocale - } + // Perform locale detection and normalization. + 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 - 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, options) + if (match) { + srcPathname = match.definition.pathname } + 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 pageIsDynamic = isDynamicRoute(srcPathname) const utils = getUtils({ pageIsDynamic, page: srcPathname, @@ -803,34 +875,11 @@ export default abstract class Server<ServerOptions extends Options = Options> { return false } - protected getDynamicRoutes(): Array<RoutingItem> { - const addedPages = new Set<string>() - - 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<string, string[]> { const appPathRoutes: Record<string, string[]> = {} Object.keys(this.appPathsManifest || {}).forEach((entry) => { - const normalizedPath = normalizeAppPath(entry) || '/' + const normalizedPath = normalizeAppPath(entry) if (!appPathRoutes[normalizedPath]) { appPathRoutes[normalizedPath] = [] } @@ -1691,8 +1740,10 @@ export default abstract class Server<ServerOptions extends Options = Options> { query, params: ctx.renderOpts.params || {}, isAppPath, - appPaths, sriEnabled: !!this.nextConfig.experimental.sri?.algorithm, + appPaths, + // Ensuring for loading page component routes is done via the matcher. + shouldEnsure: false, }) if (result) { try { @@ -1716,34 +1767,24 @@ export default abstract class Server<ServerOptions extends Options = Options> { const bubbleNoFallback = !!query._nextBubbleNoFallback 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 - } + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(pathname), + } - 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, - }, + try { + for await (const match of this.matchers.matchAll(pathname, options)) { + const result = await this.renderPageComponent( + { + ...ctx, + pathname: match.definition.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 @@ -1901,6 +1942,8 @@ export default abstract class Server<ServerOptions extends Options = Options> { query, params: {}, isAppPath: false, + // Ensuring can't be done here because you never "match" a 404 route. + shouldEnsure: true, }) using404Page = result !== null } @@ -1919,6 +1962,9 @@ export default abstract class Server<ServerOptions extends Options = Options> { query, params: {}, isAppPath: false, + // Ensuring can't be done here because you never "match" a 500 + // route. + shouldEnsure: true, }) } } @@ -1929,6 +1975,9 @@ export default abstract class Server<ServerOptions extends Options = Options> { 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/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<T extends IncomingMessage>( return base } -export interface ClonableBody { +export interface CloneableBody { finalize(): Promise<void> cloneBodyStream(): Readable } -export function getClonableBody<T extends IncomingMessage>( +export function getCloneableBody<T extends IncomingMessage>( readable: T -): ClonableBody { +): CloneableBody { let buffered: Readable | null = null const endPromise = new Promise<void | { error?: unknown }>( diff --git a/packages/next/src/server/dev/hot-reloader.ts b/packages/next/src/server/dev/hot-reloader.ts index 3a62bbb60d696..ebc49f388699c 100644 --- a/packages/next/src/server/dev/hot-reloader.ts +++ b/packages/next/src/server/dev/hot-reloader.ts @@ -49,6 +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 { RouteMatch } from '../future/route-matches/route-match' function diff(a: Set<any>, b: Set<any>) { return new Set([...a].filter((v) => !b.has(v))) @@ -1148,10 +1149,12 @@ export default class HotReloader { page, clientOnly, appPaths, + match, }: { page: string clientOnly: boolean appPaths?: string[] | null + match?: RouteMatch }): Promise<void> { // Make sure we don't re-build or dispose prebuilt pages if (page !== '/_error' && BLOCKED_PAGES.indexOf(page) !== -1) { @@ -1167,6 +1170,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 1db31504bb3b4..3cdee0fe39186 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' @@ -63,7 +65,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 +80,18 @@ 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 { + DevRouteMatcherManager, + RouteEnsurer, +} 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' +import { PagesManifest } from '../../build/webpack/plugins/pages-manifest-plugin' +import { NodeManifestLoader } from '../future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader' +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 @@ -209,6 +223,66 @@ 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.definition.page, + clientOnly: false, + }) + }, + } + + const routes = super.getRoutes() + const matchers = new DevRouteMatcherManager( + routes.matchers, + ensurer, + this.dir + ) + const handlers = routes.handlers + + const extensions = this.nextConfig.pageExtensions + + const fileReader = new CachedFileReader(new DefaultFileReader()) + + // If the pages directory is available, then configure those matchers. + if (pagesDir) { + matchers.push( + new DevPagesRouteMatcherProvider( + pagesDir, + extensions, + fileReader, + this.localeNormalizer + ) + ) + matchers.push( + new DevPagesAPIRouteMatcherProvider( + pagesDir, + extensions, + fileReader, + this.localeNormalizer + ) + ) + } + + if (appDir) { + matchers.push( + new DevAppPageRouteMatcherProvider(appDir, extensions, fileReader) + ) + matchers.push( + new DevAppRouteRouteMatcherProvider(appDir, extensions, fileReader) + ) + } + + return { matchers, handlers } + } + protected getBuildId(): string { return 'development' } @@ -423,7 +497,7 @@ export default class DevServer extends Server { } const originalPageName = pageName - pageName = normalizeAppPath(pageName) || '/' + pageName = normalizeAppPath(pageName) if (!appPaths[pageName]) { appPaths[pageName] = [] } @@ -636,14 +710,6 @@ export default class DevServer extends Server { } this.sortedRoutes = sortedRoutes - this.dynamicRoutes = this.sortedRoutes - .filter(isDynamicRoute) - .map((page) => ({ - page, - match: getRouteMatcher(getRouteRegex(page)), - })) - - this.router.setDynamicRoutes(this.dynamicRoutes) this.router.setCatchallMiddleware( this.generateCatchAllMiddlewareRoute(true) ) @@ -659,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() } }) }) @@ -731,6 +801,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) { @@ -852,7 +923,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` ) @@ -1178,12 +1250,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() { @@ -1230,9 +1312,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', @@ -1245,7 +1330,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.unshift({ + routes.unshift({ match: getPathMatch( `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_CLIENT_PAGES_MANIFEST}` ), @@ -1269,7 +1354,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.unshift({ + routes.unshift({ match: getPathMatch( `/_next/${CLIENT_STATIC_FILES_PATH}/${this.buildId}/${DEV_MIDDLEWARE_MANIFEST}` ), @@ -1285,7 +1370,7 @@ export default class DevServer extends Server { }, }) - fsRoutes.push({ + routes.push({ match: getPathMatch('/:path*'), type: 'route', name: 'catchall public directory route', @@ -1308,7 +1393,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 @@ -1316,11 +1401,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 } @@ -1400,10 +1480,6 @@ export default class DevServer extends Server { } } - protected async ensureApiPage(pathname: string): Promise<void> { - return this.hotReloader!.ensurePage({ page: pathname, clientOnly: false }) - } - private persistPatchedGlobals(): void { this.originalFetch = global.fetch } @@ -1417,13 +1493,15 @@ export default class DevServer extends Server { query, params, isAppPath, - appPaths, + appPaths = null, + shouldEnsure, }: { pathname: string query: ParsedUrlQuery params: Params isAppPath: boolean appPaths?: string[] | null + shouldEnsure: boolean }): Promise<FindComponentsResult | null> { await this.devReady const compilationErr = await this.getCompilationError(pathname) @@ -1432,11 +1510,13 @@ export default class DevServer extends Server { throw new WrappedBuildError(compilationErr) } try { - await this.hotReloader!.ensurePage({ - page: pathname, - appPaths, - clientOnly: false, - }) + if (shouldEnsure || this.renderOpts.customServer) { + await this.hotReloader!.ensurePage({ + page: pathname, + appPaths, + clientOnly: false, + }) + } // When the new page is compiled, we need to reload the server component // manifest. @@ -1503,7 +1583,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..643ae5b8dbadf 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,9 @@ import { COMPILER_NAMES, RSC_MODULE_TYPES, } from '../../shared/lib/constants' +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') @@ -150,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<string> | null } interface ChildEntry extends EntryType { @@ -331,7 +334,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 +374,27 @@ async function findPagePathData( } } +async function findRoutePathData( + rootDir: string, + page: string, + extensions: string[], + pagesDir?: string, + appDir?: string, + match?: RouteMatch +): ReturnType<typeof findPagePathData> { + if (match) { + // If the match is available, we don't have to discover the data from the + // filesystem. + return { + absolutePagePath: match.definition.filename, + page: match.definition.page, + bundlePath: match.definition.bundlePath, + } + } + + return findPagePathData(rootDir, page, extensions, pagesDir, appDir) +} + export function onDemandEntryHandler({ maxInactiveAge, multiCompiler, @@ -558,10 +582,12 @@ export function onDemandEntryHandler({ page, clientOnly, appPaths = null, + match, }: { page: string clientOnly: boolean - appPaths?: string[] | null + appPaths?: ReadonlyArray<string> | null + match?: RouteMatch }): Promise<void> { const stalledTime = 60 const stalledEnsureTimeout = setTimeout(() => { @@ -570,13 +596,21 @@ 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?.definition.kind === RouteKind.APP_PAGE) { + const { definition: route } = match as AppPageRouteMatch + appPaths = route.appPaths + } + 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/future/helpers/module-loader/module-loader.ts b/packages/next/src/server/future/helpers/module-loader/module-loader.ts new file mode 100644 index 0000000000000..35376aebbb03b --- /dev/null +++ b/packages/next/src/server/future/helpers/module-loader/module-loader.ts @@ -0,0 +1,6 @@ +/** + * Loads a given module for a given ID. + */ +export interface ModuleLoader { + load<M = any>(id: string): M +} diff --git a/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts b/packages/next/src/server/future/helpers/module-loader/node-module-loader.ts new file mode 100644 index 0000000000000..8218c6692544f --- /dev/null +++ b/packages/next/src/server/future/helpers/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<M>(id: string): M { + return require(id) + } +} diff --git a/packages/next/src/server/future/helpers/response-handlers.ts b/packages/next/src/server/future/helpers/response-handlers.ts new file mode 100644 index 0000000000000..7fc2948e23ce8 --- /dev/null +++ b/packages/next/src/server/future/helpers/response-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/future/normalizers/absolute-filename-normalizer.test.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts new file mode 100644 index 0000000000000..df253729b38c7 --- /dev/null +++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.test.ts @@ -0,0 +1,33 @@ +import { AbsoluteFilenameNormalizer } from './absolute-filename-normalizer' + +describe('AbsoluteFilenameNormalizer', () => { + it.each([ + { + name: 'app', + pathname: '<root>/app/basic/(grouped)/endpoint/nested/route.ts', + expected: '/basic/(grouped)/endpoint/nested/route', + }, + { + name: 'pages', + pathname: '<root>/pages/basic/endpoint/nested.ts', + expected: '/basic/endpoint/nested', + }, + { + name: 'pages', + pathname: '<root>/pages/basic/endpoint/index.ts', + expected: '/basic/endpoint', + }, + ])( + "normalizes '$pathname' to '$expected'", + ({ pathname, expected, name }) => { + const normalizer = new AbsoluteFilenameNormalizer(`<root>/${name}`, [ + 'ts', + 'tsx', + 'js', + 'jsx', + ]) + + expect(normalizer.normalize(pathname)).toEqual(expected) + } + ) +}) diff --git a/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts new file mode 100644 index 0000000000000..55aaf869bf508 --- /dev/null +++ b/packages/next/src/server/future/normalizers/absolute-filename-normalizer.ts @@ -0,0 +1,32 @@ +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' +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<string> + ) {} + + 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/future/normalizers/locale-route-normalizer.ts b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts new file mode 100644 index 0000000000000..323247501b04e --- /dev/null +++ b/packages/next/src/server/future/normalizers/locale-route-normalizer.ts @@ -0,0 +1,59 @@ +import { Normalizer } from './normalizer' + +export interface LocaleRouteNormalizer extends Normalizer { + readonly locales: ReadonlyArray<string> + readonly defaultLocale: string + match( + pathname: string, + options?: { inferDefaultLocale: boolean } + ): { detectedLocale?: string; pathname: string } +} + +export class LocaleRouteNormalizer implements Normalizer { + private readonly lowerCase: ReadonlyArray<string> + + constructor( + public readonly locales: ReadonlyArray<string>, + public readonly defaultLocale: string + ) { + this.lowerCase = locales.map((locale) => locale.toLowerCase()) + } + + public match( + pathname: string, + options?: { inferDefaultLocale: boolean } + ): { + pathname: string + detectedLocale?: string + } { + 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 { 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 { detectedLocale, pathname } + + // Return the case-sensitive locale. + 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/future/normalizers/normalizer.ts b/packages/next/src/server/future/normalizers/normalizer.ts new file mode 100644 index 0000000000000..98b6e06048acf --- /dev/null +++ b/packages/next/src/server/future/normalizers/normalizer.ts @@ -0,0 +1,3 @@ +export interface Normalizer { + normalize(pathname: string): string +} diff --git a/packages/next/src/server/future/normalizers/normalizers.ts b/packages/next/src/server/future/normalizers/normalizers.ts new file mode 100644 index 0000000000000..11226a80daeb2 --- /dev/null +++ b/packages/next/src/server/future/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<Normalizer> = []) {} + + public push(normalizer: Normalizer) { + this.normalizers.push(normalizer) + } + + public normalize(pathname: string): string { + return this.normalizers.reduce<string>( + (normalized, normalizer) => normalizer.normalize(normalized), + pathname + ) + } +} diff --git a/packages/next/src/server/future/normalizers/prefixing-normalizer.ts b/packages/next/src/server/future/normalizers/prefixing-normalizer.ts new file mode 100644 index 0000000000000..e4dade43429c8 --- /dev/null +++ b/packages/next/src/server/future/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.posix.join(this.prefix, pathname) + } +} diff --git a/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts b/packages/next/src/server/future/normalizers/wrap-normalizer-fn.ts new file mode 100644 index 0000000000000..e8b785661e2fa --- /dev/null +++ b/packages/next/src/server/future/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/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<RouteKind.APP_PAGE> { + readonly appPaths: ReadonlyArray<string> +} 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<RouteKind.APP_ROUTE> {} 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<K extends RouteKind = RouteKind> + extends RouteDefinition<K> { + /** + * 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 new file mode 100644 index 0000000000000..9510b0419198a --- /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 { LocaleRouteDefinition } from './locale-route-definition' + +export interface PagesAPIRouteDefinition + extends LocaleRouteDefinition<RouteKind.PAGES_API> {} 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..8e5055d8c0afa --- /dev/null +++ b/packages/next/src/server/future/route-definitions/pages-route-definition.ts @@ -0,0 +1,5 @@ +import { RouteKind } from '../route-kind' +import { LocaleRouteDefinition } from './locale-route-definition' + +export interface PagesRouteDefinition + extends LocaleRouteDefinition<RouteKind.PAGES> {} diff --git a/packages/next/src/server/future/route-definitions/route-definition.ts b/packages/next/src/server/future/route-definitions/route-definition.ts new file mode 100644 index 0000000000000..91602791178da --- /dev/null +++ b/packages/next/src/server/future/route-definitions/route-definition.ts @@ -0,0 +1,9 @@ +import { RouteKind } from '../route-kind' + +export interface RouteDefinition<K extends RouteKind = RouteKind> { + readonly kind: K + readonly bundlePath: string + readonly filename: string + readonly page: string + readonly pathname: string +} 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 new file mode 100644 index 0000000000000..8df7e9a0e62d4 --- /dev/null +++ b/packages/next/src/server/future/route-handler-managers/route-handler-manager.test.ts @@ -0,0 +1,101 @@ +import { BaseNextRequest, BaseNextResponse } from '../../base-http' +import { RouteKind } from '../route-kind' +import { RouteMatch } from '../route-matches/route-match' +import { RouteHandlerManager } from './route-handler-manager' + +const req = {} as BaseNextRequest +const res = {} as BaseNextResponse + +describe('RouteHandlerManager', () => { + it('will return false when there are no handlers', async () => { + const handlers = new RouteHandlerManager() + expect( + await handlers.handle( + { + definition: { + kind: RouteKind.PAGES, + filename: '<root>/index.js', + pathname: '/', + bundlePath: '<bundle path>', + page: '<page>', + }, + }, + req, + res + ) + ).toEqual(false) + }) + + it('will return false when there is no matching handler', async () => { + const handlers = new RouteHandlerManager() + const handler = { handle: jest.fn() } + handlers.set(RouteKind.APP_PAGE, handler) + + expect( + await handlers.handle( + { + definition: { + kind: RouteKind.PAGES, + filename: '<root>/index.js', + pathname: '/', + bundlePath: '<bundle path>', + page: '<page>', + }, + }, + req, + res + ) + ).toEqual(false) + expect(handler.handle).not.toHaveBeenCalled() + }) + + it('will return true when there is a matching handler', async () => { + const handlers = new RouteHandlerManager() + const handler = { handle: jest.fn() } + handlers.set(RouteKind.APP_PAGE, handler) + + const route: RouteMatch = { + definition: { + kind: RouteKind.APP_PAGE, + filename: '<root>/index.js', + pathname: '/', + bundlePath: '<bundle path>', + page: '<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 RouteHandlerManager() + 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 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 = { + definition: { + kind: RouteKind.APP_PAGE, + filename: '<root>/index.js', + pathname: '/', + bundlePath: '<bundle path>', + page: '<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/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..b8a8b8df79018 --- /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<K>, + M extends RouteMatch<D>, + H extends RouteHandler<M> + >(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<boolean> { + const handler = this.handlers[match.definition.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<AppPageRouteMatch> { + public async handle(): Promise<void> { + throw new Error('Method not implemented.') + } +} 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 new file mode 100644 index 0000000000000..210967447233f --- /dev/null +++ b/packages/next/src/server/future/route-handlers/app-route-route-handler.ts @@ -0,0 +1,316 @@ +import { isNotFoundError } from '../../../client/components/not-found' +import { + getURLFromRedirectError, + isRedirectError, +} from '../../../client/components/redirect' +import type { + RequestAsyncStorage, + RequestStore, +} from '../../../client/components/request-async-storage' +import type { Params } from '../../../shared/lib/router/utils/route-matcher' +import type { AsyncStorageWrapper } from '../../async-storage/async-storage-wrapper' +import { + RequestAsyncStorageWrapper, + type 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 { + 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 { 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. + */ +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<HTTP_METHOD, AppRouteHandlerFn> + + /** + * 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<void> { + // 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<AppRouteRouteMatch> { + 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 }: AppRouteRouteMatch, + module: AppRouteModule, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise<Response> { + // 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 async handle( + match: AppRouteRouteMatch, + req: BaseNextRequest, + res: BaseNextResponse + ): Promise<void> { + try { + // Load the module using the module loader. + const module: AppRouteModule = await this.moduleLoader.load( + match.definition.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/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<PagesAPIRouteMatch> { + public async handle(): Promise<void> { + 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<PagesRouteMatch> { + public async handle(): Promise<void> { + 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<M extends RouteMatch = RouteMatch> { + handle(match: M, req: BaseNextRequest, res: BaseNextResponse): Promise<void> +} diff --git a/packages/next/src/server/future/route-kind.ts b/packages/next/src/server/future/route-kind.ts new file mode 100644 index 0000000000000..25ede65e520bf --- /dev/null +++ b/packages/next/src/server/future/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/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..0346c1f23377d --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.test.ts @@ -0,0 +1,283 @@ +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 + ) + 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 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) + }) +}) + +// TODO: port tests +/* eslint-disable jest/no-commented-out-tests */ + +// describe('DefaultRouteMatcherManager', () => { +// describe('static routes', () => { +// it.each([ +// ['/some/static/route', '<root>/some/static/route.js'], +// ['/some/other/static/route', '<root>/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: '<root>/some/other/static/route.js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/some/static/route', +// filename: '<root>/some/static/route.js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// ], +// }) + +// await matchers.compile() + +// expect(await matchers.match(pathname)).toEqual({ +// kind: RouteKind.APP_ROUTE, +// pathname, +// filename, +// bundlePath: '<bundle path>', +// page: '<page>', +// }) +// }) +// }) + +// describe('async generator', () => { +// it('will match', async () => { +// const matchers = new DefaultRouteMatcherManager() + +// matchers.push({ +// routes: async () => [ +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/account/[[...slug]]', +// filename: '<root>/account/[[...slug]].js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/blog/[[...slug]]', +// filename: '<root>/blog/[[...slug]].js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/[[...optional]]', +// filename: '<root>/[[...optional]].js', +// bundlePath: '<bundle path>', +// page: '<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('<root>/blog/[[...slug]].js') +// expect(matches[1]).toEqual('<root>/[[...optional]].js') +// }) +// }) + +// describe('dynamic routes', () => { +// it.each([ +// { +// pathname: '/users/123', +// route: { +// pathname: '/users/[id]', +// filename: '<root>/users/[id].js', +// params: { id: '123' }, +// }, +// }, +// { +// pathname: '/account/123', +// route: { +// pathname: '/[...paths]', +// filename: '<root>/[...paths].js', +// params: { paths: ['account', '123'] }, +// }, +// }, +// { +// pathname: '/dashboard/users/123', +// route: { +// pathname: '/[...paths]', +// filename: '<root>/[...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: '<root>/[...paths].js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// { +// kind: RouteKind.APP_ROUTE, +// pathname: '/users/[id]', +// filename: '<root>/users/[id].js', +// bundlePath: '<bundle path>', +// page: '<page>', +// }, +// ], +// }) + +// await matchers.compile() + +// expect(await matchers.match(pathname)).toEqual({ +// kind: RouteKind.APP_ROUTE, +// bundlePath: '<bundle path>', +// page: '<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..b8d88976b936f --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/default-route-matcher-manager.ts @@ -0,0 +1,281 @@ +/* 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' +import { LocaleRouteMatcher } from '../route-matchers/locale-route-matcher' +import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash' + +interface RouteMatchers { + static: ReadonlyArray<RouteMatcher> + dynamic: ReadonlyArray<RouteMatcher> + duplicates: Record<string, ReadonlyArray<RouteMatcher>> +} + +export class DefaultRouteMatcherManager implements RouteMatcherManager { + private readonly providers: Array<RouteMatcherProvider> = [] + protected readonly matchers: RouteMatchers = { + static: [], + dynamic: [], + duplicates: {}, + } + 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<void> + public waitTillReady(): Promise<void> { + return this.waitTillReadyPromise ?? Promise.resolve() + } + + private previousMatchers: ReadonlyArray<RouteMatcher> = [] + public async reload() { + let callbacks: { resolve: Function; reject: Function } + this.waitTillReadyPromise = new Promise((resolve, reject) => { + callbacks = { resolve, reject } + }) + + // 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<RouteMatcher> = [] + + // Get all the providers matchers. + const providersMatchers: ReadonlyArray<ReadonlyArray<RouteMatcher>> = + await Promise.all(this.providers.map((provider) => provider.matchers())) + + // Use this to detect duplicate pathnames. + const all = new Map<string, RouteMatcher>() + const duplicates: Record<string, RouteMatcher[]> = {} + 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.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 + // 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.definition.pathname] ?? [ + duplicate, + ] + others.push(matcher) + duplicates[matcher.definition.pathname] = others + + // Add duplicated details to each route. + duplicate.duplicated = others + matcher.duplicated = others + + // TODO: see if we should error for duplicates in production? + } + + matchers.push(matcher) + + // Add the matcher's pathname to the set. + all.set(matcher.definition.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. + if ( + this.previousMatchers.length === matchers.length && + this.previousMatchers.every( + (cachedMatcher, index) => cachedMatcher === matchers[index] + ) + ) { + return + } + this.previousMatchers = 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) + + // 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<string, number[]>() + const pathnames = new Array<string>() + for (let index = 0; index < dynamic.length; index++) { + // 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) + + // 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(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<RouteMatcher> = [] + 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') + } + + for (const index of indexes) sortedDynamicMatchers.push(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' + ) + } + } catch (err) { + callbacks!.reject(err) + } finally { + // The compilation ID matched, so mark the complication as finished. + this.lastCompilationID = compilationID + callbacks!.resolve() + } + } + + public push(provider: RouteMatcherProvider): void { + this.providers.push(provider) + } + + public async test(pathname: string, options: MatchOptions): Promise<boolean> { + // 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<RouteMatch<RouteDefinition<RouteKind>> | 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 + } + + /** + * 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, + options: MatchOptions + ): RouteMatch | null { + if (matcher instanceof LocaleRouteMatcher) { + return matcher.match(pathname, options) + } + + return matcher.match(pathname) + } + + public async *matchAll( + pathname: string, + options: MatchOptions + ): AsyncGenerator<RouteMatch<RouteDefinition<RouteKind>>, 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 have been loaded before match' + ) + } + + // 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 + // with the list of normalized routes. + if (!isDynamicRoute(pathname)) { + for (const matcher of this.matchers.static) { + const match = this.validate(pathname, matcher, options) + 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 = this.validate(pathname, matcher, options) + 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..be1cdd87d2236 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/dev-route-matcher-manager.ts @@ -0,0 +1,134 @@ +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' +import path from '../../../shared/lib/isomorphic/path' +import * as Log 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<void> +} + +export class DevRouteMatcherManager extends DefaultRouteMatcherManager { + constructor( + private readonly production: RouteMatcherManager, + private readonly ensurer: RouteEnsurer, + private readonly dir: string + ) { + super() + } + + public async test(pathname: string, options: MatchOptions): Promise<boolean> { + // 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 + } + + protected validate( + pathname: string, + matcher: RouteMatcher, + options: MatchOptions + ): RouteMatch | null { + const match = super.validate(pathname, matcher, options) + + // 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.definition.kind === RouteKind.APP_PAGE || + duplicate.definition.kind === RouteKind.APP_ROUTE + ) && + matcher.duplicated.some( + (duplicate) => + 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.definition.filename.localeCompare(b.definition.filename) + ) + .map( + (duplicate) => + `"${path.relative(this.dir, duplicate.definition.filename)}"` + ) + .join(' and ')}. Please remove one to continue.` + ) + } + + return match + } + + public async *matchAll( + pathname: string, + options: MatchOptions + ): AsyncGenerator<RouteMatch<RouteDefinition<RouteKind>>, 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)) { + // 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 unless there's an error. + for await (const production of this.production.matchAll( + pathname, + options + )) { + yield production + } + } + + // We tried direct matching against the pathname and against all the dynamic + // paths, so there was no match. + return null + } + + public async reload(): Promise<void> { + // Compile the production routes again. + await this.production.reload() + + // Compile the development routes. + await super.reload() + + // Check for and warn of any duplicates. + 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)}` + ) + } + } +} 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..146cda52e17a4 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-managers/route-matcher-manager.ts @@ -0,0 +1,57 @@ +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 } & LocaleMatcherMatchOptions + +export interface RouteMatcherManager { + /** + * Returns a promise that resolves when the matcher manager has finished + * reloading. + */ + waitTillReady(): Promise<void> + + /** + * 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<void> + + /** + * 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<boolean> + + /** + * 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<RouteMatch | null> + + /** + * 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<RouteMatch, null, undefined> +} 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 new file mode 100644 index 0000000000000..bc3a15b4fac48 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.test.ts @@ -0,0 +1,106 @@ +import { SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' +import { RouteKind } from '../route-kind' +import { AppPageRouteMatcherProvider } from './app-page-route-matcher-provider' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' + +describe('AppPageRouteMatcherProvider', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const matcher = new AppPageRouteMatcherProvider('<root>', loader) + await expect(matcher.matchers()).resolves.toEqual([]) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record<string, string> + route: AppPageRouteDefinition + }>([ + { + manifest: { + '/page': 'app/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/', + filename: `<root>/${SERVER_DIRECTORY}/app/page.js`, + page: '/page', + bundlePath: 'app/page', + appPaths: ['/page'], + }, + }, + { + manifest: { + '/(marketing)/about/page': 'app/(marketing)/about/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/about', + filename: `<root>/${SERVER_DIRECTORY}/app/(marketing)/about/page.js`, + page: '/(marketing)/about/page', + bundlePath: 'app/(marketing)/about/page', + appPaths: ['/(marketing)/about/page'], + }, + }, + { + manifest: { + '/dashboard/users/[id]/page': 'app/dashboard/users/[id]/page.js', + }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/dashboard/users/[id]', + filename: `<root>/${SERVER_DIRECTORY}/app/dashboard/users/[id]/page.js`, + page: '/dashboard/users/[id]/page', + bundlePath: 'app/dashboard/users/[id]/page', + appPaths: ['/dashboard/users/[id]/page'], + }, + }, + { + manifest: { '/dashboard/users/page': 'app/dashboard/users/page.js' }, + route: { + kind: RouteKind.APP_PAGE, + pathname: '/dashboard/users', + filename: `<root>/${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: `<root>/${SERVER_DIRECTORY}/app/dashboard/users/page.js`, + page: '/dashboard/users/page', + bundlePath: 'app/dashboard/users/page', + appPaths: [ + '/dashboard/users/page', + '/(marketing)/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 AppPageRouteMatcherProvider('<root>', loader) + const matchers = await matcher.matchers() + + expect(loader.load).toHaveBeenCalled() + expect(matchers).toHaveLength(1) + expect(matchers[0].definition).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..f379e38af3f23 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-page-route-matcher-provider.ts @@ -0,0 +1,63 @@ +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 { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' + +export class AppPageRouteMatcherProvider extends ManifestRouteMatcherProvider<AppPageRouteMatcher> { + constructor( + private readonly distDir: string, + manifestLoader: ManifestLoader + ) { + super(APP_PATHS_MANIFEST, manifestLoader) + } + + protected async transform( + manifest: Manifest + ): Promise<ReadonlyArray<AppPageRouteMatcher>> { + // 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<string, string[]> = {} + 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<AppPageRouteMatcher> = [] + 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/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 new file mode 100644 index 0000000000000..335322111f17e --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.test.ts @@ -0,0 +1,69 @@ +import { SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' +import { RouteKind } from '../route-kind' +import { AppRouteRouteMatcherProvider } from './app-route-route-matcher-provider' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' + +describe('AppRouteRouteMatcherProvider', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const provider = new AppRouteRouteMatcherProvider('<root>', loader) + expect(await provider.matchers()).toEqual([]) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record<string, string> + route: AppRouteRouteDefinition + }>([ + { + manifest: { + '/route': 'app/route.js', + }, + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/', + filename: `<root>/${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: `<root>/${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: `<root>/${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 provider = new AppRouteRouteMatcherProvider('<root>', loader) + const matchers = await provider.matchers() + + expect(matchers).toHaveLength(1) + expect(matchers[0].definition).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..da2d2cdd1677d --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/app-route-route-matcher-provider.ts @@ -0,0 +1,50 @@ +import path from '../../../shared/lib/isomorphic/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 { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' + +export class AppRouteRouteMatcherProvider extends ManifestRouteMatcherProvider<AppRouteRouteMatcher> { + constructor( + private readonly distDir: string, + manifestLoader: ManifestLoader + ) { + super(APP_PATHS_MANIFEST, manifestLoader) + } + + protected async transform( + manifest: Manifest + ): Promise<ReadonlyArray<AppRouteRouteMatcher>> { + // This matcher only matches app routes. + const pages = Object.keys(manifest).filter((page) => isAppRouteRoute(page)) + + // Format the routes. + const matchers: Array<AppRouteRouteMatcher> = [] + 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..2226b565d075e --- /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 = '<root>' + 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<string> + 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].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 new file mode 100644 index 0000000000000..e5a19adb23087 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -0,0 +1,97 @@ +import { FileReader } from './helpers/file-reader/file-reader' +import { AppPageRouteMatcher } from '../../route-matchers/app-page-route-matcher' +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' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' + +export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvider<AppPageRouteMatcher> { + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + appDir: string, + extensions: ReadonlyArray<string>, + 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('|')})$`) + + 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'), + ]), + } + } + + protected async transform( + files: ReadonlyArray<string> + ): Promise<ReadonlyArray<AppPageRouteMatcher>> { + // 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<string, string[]> = {} + 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<AppPageRouteMatcher> = [] + 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 + + matchers.push( + new AppPageRouteMatcher({ + kind: RouteKind.APP_PAGE, + pathname, + page, + bundlePath, + filename, + appPaths: appPaths[pathname], + }) + ) + } + + return matchers + } +} 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 new file mode 100644 index 0000000000000..477b22400b65a --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.test.ts @@ -0,0 +1,65 @@ +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 = '<root>' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + 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<{ + files: ReadonlyArray<string> + route: AppRouteRouteDefinition + }>([ + { + files: [`${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', + }, + }, + { + files: [`${dir}/route.ts`], + route: { + kind: RouteKind.APP_ROUTE, + pathname: '/', + filename: `${dir}/route.ts`, + page: '/route', + bundlePath: 'app/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}`), + ...files, + ]), + } + const matcher = new DevAppRouteRouteMatcherProvider( + dir, + extensions, + reader + ) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + 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 new file mode 100644 index 0000000000000..d40b677a781c6 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-route-route-matcher-provider.ts @@ -0,0 +1,74 @@ +import { FileReader } from './helpers/file-reader/file-reader' +import { AppRouteRouteMatcher } from '../../route-matchers/app-route-route-matcher' +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' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' + +export class DevAppRouteRouteMatcherProvider extends FileCacheRouteMatcherProvider<AppRouteRouteMatcher> { + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + appDir: string, + extensions: ReadonlyArray<string>, + 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('|')})$`) + + 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'), + ]), + } + } + + protected async transform( + files: ReadonlyArray<string> + ): Promise<ReadonlyArray<AppRouteRouteMatcher>> { + const matchers: Array<AppRouteRouteMatcher> = [] + 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) + + 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-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 new file mode 100644 index 0000000000000..993855c4fb89f --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.test.ts @@ -0,0 +1,86 @@ +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('DevPagesAPIRouteMatcherProvider', () => { + const dir = '<root>' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const matcher = new DevPagesAPIRouteMatcherProvider(dir, extensions, reader) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + }) + + describe('filename matching', () => { + it.each<{ + files: ReadonlyArray<string> + route: PagesAPIRouteDefinition + }>([ + { + files: [`${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', + }, + }, + { + files: [`${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', + }, + }, + { + files: [`${dir}/api.ts`], + route: { + kind: RouteKind.PAGES_API, + pathname: '/api', + filename: `${dir}/api.ts`, + page: '/api', + bundlePath: 'pages/api', + }, + }, + { + files: [`${dir}/api/index.ts`], + route: { + kind: RouteKind.PAGES_API, + pathname: '/api', + filename: `${dir}/api/index.ts`, + page: '/api', + bundlePath: 'pages/api', + }, + }, + ])( + "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`, + ...files, + ]), + } + const matcher = new DevPagesAPIRouteMatcherProvider( + dir, + extensions, + reader + ) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + 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 new file mode 100644 index 0000000000000..5ba1b186f4e15 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-api-route-matcher-provider.ts @@ -0,0 +1,114 @@ +import { Normalizer } from '../../normalizers/normalizer' +import { FileReader } from './helpers/file-reader/file-reader' +import { + PagesAPILocaleRouteMatcher, + PagesAPIRouteMatcher, +} from '../../route-matchers/pages-api-route-matcher' +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' +import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' + +export class DevPagesAPIRouteMatcherProvider extends FileCacheRouteMatcherProvider<PagesAPIRouteMatcher> { + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly pagesDir: string, + private readonly extensions: ReadonlyArray<string>, + 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('|')})$`) + + 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 + } + + protected async transform( + files: ReadonlyArray<string> + ): Promise<ReadonlyArray<PagesAPIRouteMatcher>> { + const matchers: Array<PagesAPIRouteMatcher> = [] + 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) + + 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, + }) + ) + } + } + + 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 new file mode 100644 index 0000000000000..576cc7e52072d --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.test.ts @@ -0,0 +1,84 @@ +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('DevPagesRouteMatcherProvider', () => { + const dir = '<root>' + const extensions = ['ts', 'tsx', 'js', 'jsx'] + + it('returns no routes with an empty filesystem', async () => { + const reader: FileReader = { read: jest.fn(() => []) } + const matcher = new DevPagesRouteMatcherProvider(dir, extensions, reader) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(0) + expect(reader.read).toBeCalledWith(dir) + }) + + describe('filename matching', () => { + it.each<{ + files: ReadonlyArray<string> + route: PagesRouteDefinition + }>([ + { + files: [`${dir}/index.ts`], + route: { + kind: RouteKind.PAGES, + pathname: '/', + filename: `${dir}/index.ts`, + page: '/', + bundlePath: 'pages/index', + }, + }, + { + files: [`${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', + }, + }, + { + files: [`${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', + }, + }, + { + files: [`${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.page' route specified with the provided files", + async ({ files, route }) => { + const reader: FileReader = { + read: jest.fn(() => [ + ...extensions.map((ext) => `${dir}/api/other/page.${ext}`), + ...files, + ]), + } + const matcher = new DevPagesRouteMatcherProvider( + dir, + extensions, + reader + ) + const matchers = await matcher.matchers() + expect(matchers).toHaveLength(1) + expect(reader.read).toBeCalledWith(dir) + 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 new file mode 100644 index 0000000000000..85d1fd3b7660c --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-pages-route-matcher-provider.ts @@ -0,0 +1,112 @@ +import { Normalizer } from '../../normalizers/normalizer' +import { FileReader } from './helpers/file-reader/file-reader' +import { + PagesRouteMatcher, + PagesLocaleRouteMatcher, +} from '../../route-matchers/pages-route-matcher' +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' +import { LocaleRouteNormalizer } from '../../normalizers/locale-route-normalizer' +import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider' + +export class DevPagesRouteMatcherProvider extends FileCacheRouteMatcherProvider<PagesRouteMatcher> { + private readonly expression: RegExp + private readonly normalizers: { + page: Normalizer + pathname: Normalizer + bundlePath: Normalizer + } + + constructor( + private readonly pagesDir: string, + private readonly extensions: ReadonlyArray<string>, + 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('|')})$`) + + 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 + } + + protected async transform( + files: ReadonlyArray<string> + ): Promise<ReadonlyArray<PagesRouteMatcher>> { + const matchers: Array<PagesRouteMatcher> = [] + 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) + + 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/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<M, ReadonlyArray<string>> { + 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/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 '<root>/pages': + return pages + case '<root>/app': + return app + default: + throw new Error('unexpected') + } + }), + } + const cached = new CachedFileReader(reader) + + const results = await Promise.all([ + cached.read('<root>/pages'), + cached.read('<root>/pages'), + cached.read('<root>/app'), + cached.read('<root>/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<string> + callbacks: Array<{ + resolve: (value: ReadonlyArray<string>) => 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<void> + 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<ReadonlyArray<string> | 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<string> + ): Promise<ReadonlyArray<ReadonlyArray<string> | 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<string> | 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<ReadonlyArray<string>> { + // 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<ReadonlyArray<string>>((resolve, reject) => { + batch.callbacks.push({ resolve, reject }) + }) + + return promise + } +} diff --git a/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts new file mode 100644 index 0000000000000..1372d198778d8 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/default-file-reader.ts @@ -0,0 +1,56 @@ +import { type Dirent } from 'fs' +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<ReadonlyArray<string>> { + 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) => { + 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 + // 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/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts b/packages/next/src/server/future/route-matcher-providers/dev/helpers/file-reader/file-reader.ts new file mode 100644 index 0000000000000..ece4fd3d65cf8 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/dev/helpers/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<string>> | ReadonlyArray<string> +} 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<D> { + load(): Promise<D> + 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<M> +{ + private data?: D + private cached: ReadonlyArray<M> = [] + + constructor(private readonly loader: LoaderComparable<D>) {} + + protected abstract transform(data: D): Promise<ReadonlyArray<M>> + + public async matchers(): Promise<readonly M[]> { + 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/helpers/manifest-loaders/manifest-loader.ts b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts new file mode 100644 index 0000000000000..dbaf73cffb9d1 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/manifest-loader.ts @@ -0,0 +1,5 @@ +export type Manifest = Record<string, string> + +export interface ManifestLoader { + 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 new file mode 100644 index 0000000000000..a7bcd301439aa --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/helpers/manifest-loaders/node-manifest-loader.ts @@ -0,0 +1,21 @@ +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) {} + + static require(id: string) { + try { + return require(id) + } catch { + return null + } + } + + 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/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..e9dffe1997359 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/manifest-route-matcher-provider.ts @@ -0,0 +1,17 @@ +import { RouteMatcher } from '../route-matchers/route-matcher' +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 +> extends CachedRouteMatcherProvider<M, Manifest | null> { + constructor(manifestName: string, manifestLoader: ManifestLoader) { + super({ + load: async () => manifestLoader.load(manifestName), + compare: (left, right) => left === right, + }) + } +} 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 new file mode 100644 index 0000000000000..fa860ed795bd3 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.test.ts @@ -0,0 +1,69 @@ +import { PAGES_MANIFEST, SERVER_DIRECTORY } from '../../../shared/lib/constants' +import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' +import { RouteKind } from '../route-kind' +import { ManifestLoader } from './helpers/manifest-loaders/manifest-loader' +import { PagesAPIRouteMatcherProvider } from './pages-api-route-matcher-provider' + +describe('PagesAPIRouteMatcherProvider', () => { + it('returns no routes with an empty manifest', async () => { + const loader: ManifestLoader = { load: jest.fn(() => ({})) } + const provider = new PagesAPIRouteMatcherProvider('<root>', loader) + expect(await provider.matchers()).toEqual([]) + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record<string, string> + route: PagesAPIRouteDefinition + }>([ + { + manifest: { '/api/users/[id]': 'pages/api/users/[id].js' }, + route: { + kind: RouteKind.PAGES_API, + pathname: '/api/users/[id]', + filename: `<root>/${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: `<root>/${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: `<root>/${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 provider = new PagesAPIRouteMatcherProvider('<root>', loader) + const matchers = await provider.matchers() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + expect(matchers).toHaveLength(1) + 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 new file mode 100644 index 0000000000000..0f736116d0eaf --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-api-route-matcher-provider.ts @@ -0,0 +1,68 @@ +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 { + PagesAPILocaleRouteMatcher, + PagesAPIRouteMatcher, +} from '../route-matchers/pages-api-route-matcher' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' +import { LocaleRouteNormalizer } from '../normalizers/locale-route-normalizer' + +export class PagesAPIRouteMatcherProvider extends ManifestRouteMatcherProvider<PagesAPIRouteMatcher> { + constructor( + private readonly distDir: string, + manifestLoader: ManifestLoader, + private readonly localeNormalizer?: LocaleRouteNormalizer + ) { + super(PAGES_MANIFEST, manifestLoader) + } + + protected async transform( + manifest: Manifest + ): Promise<ReadonlyArray<PagesAPIRouteMatcher>> { + // This matcher is only for Pages API routes. + const pathnames = Object.keys(manifest).filter((pathname) => + isAPIRoute(pathname) + ) + + const matchers: Array<PagesAPIRouteMatcher> = [] + + for (const page of pathnames) { + 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]), + }) + ) + } + } + + 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..fd45776ac730e --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.test.ts @@ -0,0 +1,185 @@ +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('<root>', loader) + expect(await provider.matchers()).toEqual([]) + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + }) + + describe('locale matching', () => { + describe.each<{ + manifest: Record<string, string> + routes: ReadonlyArray<PagesRouteDefinition> + i18n: { locales: ReadonlyArray<string>; 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: `<root>/${SERVER_DIRECTORY}/pages/blog/[slug].js`, + page: '/blog/[slug]', + bundlePath: 'pages/blog/[slug]', + i18n: {}, + }, + { + kind: RouteKind.PAGES, + pathname: '/', + filename: `<root>/${SERVER_DIRECTORY}/pages/en-US.html`, + page: '/en-US', + bundlePath: 'pages/en-US', + i18n: { + locale: 'en-US', + }, + }, + { + kind: RouteKind.PAGES, + pathname: '/', + filename: `<root>/${SERVER_DIRECTORY}/pages/fr.html`, + page: '/fr', + bundlePath: 'pages/fr', + i18n: { + locale: 'fr', + }, + }, + { + kind: RouteKind.PAGES, + pathname: '/', + filename: `<root>/${SERVER_DIRECTORY}/pages/nl-NL.html`, + page: '/nl-NL', + bundlePath: 'pages/nl-NL', + i18n: { + locale: 'nl-NL', + }, + }, + { + kind: RouteKind.PAGES, + pathname: '/404', + filename: `<root>/${SERVER_DIRECTORY}/pages/en-US/404.html`, + page: '/en-US/404', + bundlePath: 'pages/en-US/404', + i18n: { + locale: 'en-US', + }, + }, + { + kind: RouteKind.PAGES, + pathname: '/404', + filename: `<root>/${SERVER_DIRECTORY}/pages/fr/404.html`, + page: '/fr/404', + bundlePath: 'pages/fr/404', + i18n: { + locale: 'fr', + }, + }, + { + kind: RouteKind.PAGES, + pathname: '/404', + filename: `<root>/${SERVER_DIRECTORY}/pages/nl-NL/404.html`, + page: '/nl-NL/404', + bundlePath: 'pages/nl-NL/404', + i18n: { + locale: 'nl-NL', + }, + }, + ], + }, + ])('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( + '<root>', + loader, + new LocaleRouteNormalizer(i18n.locales, i18n.defaultLocale) + ) + const matchers = await provider.matchers() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + const routes = matchers.map((matcher) => matcher.definition) + expect(routes).toContainEqual(route) + expect(routes).toHaveLength(expected.length) + }) + }) + }) + + describe('manifest matching', () => { + it.each<{ + manifest: Record<string, string> + route: PagesRouteDefinition + }>([ + { + manifest: { '/users/[id]': 'pages/users/[id].js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/users/[id]', + filename: `<root>/${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: `<root>/${SERVER_DIRECTORY}/pages/users.js`, + page: '/users', + bundlePath: 'pages/users', + }, + }, + { + manifest: { '/': 'pages/index.js' }, + route: { + kind: RouteKind.PAGES, + pathname: '/', + filename: `<root>/${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('<root>', loader) + const matchers = await matcher.matchers() + + expect(loader.load).toBeCalledWith(PAGES_MANIFEST) + expect(matchers).toHaveLength(1) + 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 new file mode 100644 index 0000000000000..061a53ba41d66 --- /dev/null +++ b/packages/next/src/server/future/route-matcher-providers/pages-route-matcher-provider.ts @@ -0,0 +1,82 @@ +import path from '../../../shared/lib/isomorphic/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 { + PagesLocaleRouteMatcher, + PagesRouteMatcher, +} from '../route-matchers/pages-route-matcher' +import { + Manifest, + ManifestLoader, +} from './helpers/manifest-loaders/manifest-loader' +import { ManifestRouteMatcherProvider } from './manifest-route-matcher-provider' + +export class PagesRouteMatcherProvider extends ManifestRouteMatcherProvider<PagesRouteMatcher> { + constructor( + private readonly distDir: string, + manifestLoader: ManifestLoader, + private readonly localeNormalizer?: LocaleRouteNormalizer + ) { + super(PAGES_MANIFEST, manifestLoader) + } + + protected async transform( + manifest: Manifest + ): Promise<ReadonlyArray<PagesRouteMatcher>> { + // This matcher is only for Pages routes, not Pages API routes which are + // included in this 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). + .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<PagesRouteMatcher> = [] + for (const page of pathnames) { + 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 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]), + }) + ) + } + } + + 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<M extends RouteMatcher = RouteMatcher> { + matchers(): Promise<ReadonlyArray<M>> +} 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..54727515c681b --- /dev/null +++ b/packages/next/src/server/future/route-matchers/app-page-route-matcher.ts @@ -0,0 +1,10 @@ +import { RouteMatcher } from './route-matcher' +import { AppPageRouteDefinition } from '../route-definitions/app-page-route-definition' + +export class AppPageRouteMatcher extends RouteMatcher<AppPageRouteDefinition> { + public get identity(): string { + return `${ + this.definition.pathname + }?__nextParallelPaths=${this.definition.appPaths.join(',')}}` + } +} 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..0bc3720ff9d2d --- /dev/null +++ b/packages/next/src/server/future/route-matchers/app-route-route-matcher.ts @@ -0,0 +1,4 @@ +import { RouteMatcher } from './route-matcher' +import { AppRouteRouteDefinition } from '../route-definitions/app-route-route-definition' + +export class AppRouteRouteMatcher extends RouteMatcher<AppRouteRouteDefinition> {} 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<D> { + /** + * 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<D> | 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 new file mode 100644 index 0000000000000..84cd8eec412af --- /dev/null +++ b/packages/next/src/server/future/route-matchers/pages-api-route-matcher.ts @@ -0,0 +1,7 @@ +import { PagesAPIRouteDefinition } from '../route-definitions/pages-api-route-definition' +import { LocaleRouteMatcher } from './locale-route-matcher' +import { RouteMatcher } from './route-matcher' + +export class PagesAPIRouteMatcher extends RouteMatcher<PagesAPIRouteDefinition> {} + +export class PagesAPILocaleRouteMatcher extends LocaleRouteMatcher<PagesAPIRouteDefinition> {} 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..aacf69d419ccb --- /dev/null +++ b/packages/next/src/server/future/route-matchers/pages-route-matcher.ts @@ -0,0 +1,7 @@ +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { LocaleRouteMatcher } from './locale-route-matcher' +import { RouteMatcher } from './route-matcher' + +export class PagesRouteMatcher extends RouteMatcher<PagesRouteDefinition> {} + +export class PagesLocaleRouteMatcher extends LocaleRouteMatcher<PagesRouteDefinition> {} 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..8be68356d5277 --- /dev/null +++ b/packages/next/src/server/future/route-matchers/route-matcher.ts @@ -0,0 +1,61 @@ +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 { RouteDefinition } from '../route-definitions/route-definition' +import { RouteMatch } from '../route-matches/route-match' + +export class RouteMatcher<D extends RouteDefinition = RouteDefinition> { + 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<RouteMatcher> + + 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 match(pathname: string): RouteMatch<D> | null { + const result = this.test(pathname) + if (!result) return null + + return { definition: this.definition, params: result.params } + } + + public test(pathname: string): { params?: Params } | null { + if (this.dynamic) { + const params = this.dynamic(pathname) + if (!params) return null + + return { params } + } + + if (pathname === this.definition.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<AppPageRouteDefinition> {} 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<AppRouteRouteDefinition> {} 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<R extends LocaleRouteDefinition> + extends RouteMatch<R> { + readonly detectedLocale?: string +} 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<PagesAPIRouteDefinition> {} 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..73551caed1317 --- /dev/null +++ b/packages/next/src/server/future/route-matches/pages-route-match.ts @@ -0,0 +1,5 @@ +import { PagesRouteDefinition } from '../route-definitions/pages-route-definition' +import { LocaleRouteMatch } from './locale-route-match' + +export interface PagesRouteMatch + extends LocaleRouteMatch<PagesRouteDefinition> {} 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..758bb58545ec4 --- /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<D extends RouteDefinition = RouteDefinition> { + readonly definition: D + + /** + * 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/load-components.ts b/packages/next/src/server/load-components.ts index eb68fd09cc0df..11039706ed4c7 100644 --- a/packages/next/src/server/load-components.ts +++ b/packages/next/src/server/load-components.ts @@ -12,8 +12,8 @@ import type { import { BUILD_MANIFEST, REACT_LOADABLE_MANIFEST, - FLIGHT_MANIFEST, - ACTIONS_MANIFEST, + CLIENT_REFERENCE_MANIFEST, + SERVER_REFERENCE_MANIFEST, } from '../shared/lib/constants' import { join } from 'path' import { requirePage } from './require' @@ -108,12 +108,14 @@ export async function loadComponents({ loadManifest<BuildManifest>(join(distDir, BUILD_MANIFEST)), loadManifest<ReactLoadableManifest>(join(distDir, REACT_LOADABLE_MANIFEST)), hasServerComponents - ? loadManifest(join(distDir, 'server', FLIGHT_MANIFEST + '.json')) + ? loadManifest( + join(distDir, 'server', CLIENT_REFERENCE_MANIFEST + '.json') + ) : null, hasServerComponents - ? loadManifest(join(distDir, 'server', ACTIONS_MANIFEST + '.json')).catch( - () => null - ) + ? loadManifest( + join(distDir, 'server', SERVER_REFERENCE_MANIFEST + '.json') + ).catch(() => null) : null, ]) diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 62d64924c7478..92555feb8d3a0 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, @@ -41,7 +37,7 @@ import { CLIENT_STATIC_FILES_RUNTIME, PRERENDER_MANIFEST, ROUTES_MANIFEST, - FLIGHT_MANIFEST, + CLIENT_REFERENCE_MANIFEST, CLIENT_PUBLIC_FILES_PATH, APP_PATHS_MANIFEST, FLIGHT_SERVER_CSS_MANIFEST, @@ -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,11 @@ import { normalizeAppPath } from '../shared/lib/router/utils/app-paths' import { renderToHTMLOrFlight as appRenderToHTMLOrFlight } from './app-render' import { setHttpClientAndAgentOptions } from './config' +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' @@ -124,7 +125,7 @@ const MiddlewareMatcherCache = new WeakMap< const EdgeMatcherCache = new WeakMap< MiddlewareManifest['functions'][string], - RouteMatch + RouteMatchFn >() function getMiddlewareMatcher( @@ -183,7 +184,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 +262,16 @@ export default class NextNodeServer extends BaseServer { setHttpClientAndAgentOptions(this.nextConfig) } + protected getRoutes() { + const routes = super.getRoutes() + + if (this.hasAppDir) { + routes.handlers.set(RouteKind.APP_ROUTE, new AppRouteRouteHandler()) + } + + return routes + } + protected loadEnvConfig({ dev, forceReload, @@ -326,10 +337,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<boolean> { @@ -1004,7 +1015,11 @@ export default class NextNodeServer extends BaseServer { protected getServerComponentManifest() { if (!this.hasAppDir) return undefined - return require(join(this.distDir, 'server', FLIGHT_MANIFEST + '.json')) + return require(join( + this.distDir, + 'server', + CLIENT_REFERENCE_MANIFEST + '.json' + )) } protected getServerCSSManifest() { @@ -1027,22 +1042,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() @@ -1190,25 +1190,36 @@ 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 - if (isAPIRoute(pathname)) { - delete query._nextBubbleNoFallback + const match = await this.matchers.match(pathname, options) - 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 (match.definition.kind === RouteKind.PAGES_API) { + delete query._nextBubbleNoFallback + + 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 } } } @@ -1233,7 +1244,6 @@ export default class NextNodeServer extends BaseServer { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - this.dynamicRoutes = this.getDynamicRoutes() } return { @@ -1244,15 +1254,12 @@ export default class NextNodeServer extends BaseServer { catchAllRoute, catchAllMiddleware, useFileSystemPublicRoutes, - dynamicRoutes: this.dynamicRoutes, - pageChecker: this.hasPage.bind(this), + matchers: this.matchers, nextConfig: this.nextConfig, + localeNormalizer: this.localeNormalizer, } } - // Used to build API page in development - protected async ensureApiPage(_pathname: string): Promise<void> {} - /** * Resolves `API` request, in development builds on demand * @param req http request @@ -1262,42 +1269,15 @@ export default class NextNodeServer extends BaseServer { protected async handleApiRequest( req: BaseNextRequest, res: BaseNextResponse, - pathname: string, - query: ParsedUrlQuery + query: ParsedUrlQuery, + match: PagesAPIRouteMatch ): Promise<boolean> { - 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 - } - } - } - - 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) - - let builtPagePath - try { - builtPagePath = this.getPagePath(page) - } catch (err) { - if (isError(err) && err.code === 'ENOENT') { - return false - } - throw err - } + const { + definition: { pathname, filename }, + params, + } = match - return this.runApi(req, res, query, params, page, builtPagePath) + return this.runApi(req, res, query, params, pathname, filename) } protected getCacheFilesystem(): CacheFs { @@ -1415,7 +1395,7 @@ export default class NextNodeServer extends BaseServer { path: string, parsedUrl?: UrlWithParsedQuery ): Promise<void> { - if (!this.isServeableUrl(path)) { + if (!this.isServableUrl(path)) { return this.render404(req, res, parsedUrl) } @@ -1470,7 +1450,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 @@ -1719,6 +1699,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 { @@ -1740,17 +1723,13 @@ 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, options) + if (match) { + page.name = match.params + ? match.definition.pathname + : params.parsedUrl.pathname + page.params = match.params } const middleware = this.getMiddleware() @@ -2077,7 +2056,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/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/router.ts b/packages/next/src/server/router.ts index 91ddd1090c93f..01b8466565448 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,17 @@ 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 { + 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 @@ -29,7 +33,7 @@ type RouteResult = { } export type Route = { - match: RouteMatch + match: RouteMatchFn has?: RouteHas[] missing?: RouteHas[] type: string @@ -50,7 +54,22 @@ export type Route = { ) => Promise<RouteResult> | RouteResult } -export type DynamicRoutes = Array<{ page: string; match: RouteMatch }> +export type RouterOptions = { + headers: ReadonlyArray<Route> + fsRoutes: ReadonlyArray<Route> + rewrites: { + beforeFiles: ReadonlyArray<Route> + afterFiles: ReadonlyArray<Route> + fallback: ReadonlyArray<Route> + } + redirects: ReadonlyArray<Route> + catchAllRoute: Route + catchAllMiddleware: ReadonlyArray<Route> + matchers: RouteMatcherManager + useFileSystemPublicRoutes: boolean + nextConfig: NextConfig + localeNormalizer?: LocaleRouteNormalizer +} export type PageChecker = (pathname: string) => Promise<boolean> @@ -66,27 +85,13 @@ export default class Router { fallback: ReadonlyArray<Route> } private readonly catchAllRoute: Route - private readonly pageChecker: PageChecker - private dynamicRoutes: DynamicRoutes + private readonly matchers: Pick<RouteMatcherManager, 'test'> private readonly useFileSystemPublicRoutes: boolean private readonly nextConfig: NextConfig + private readonly localeNormalizer?: LocaleRouteNormalizer private compiledRoutes: ReadonlyArray<Route> private needsRecompilation: boolean - /** - * context stores information used by the router. - */ - private readonly context = new WeakMap< - BaseNextRequest, - { - /** - * pageChecks is the memoized record of all checks made against pages to - * help de-duplicate work. - */ - pageChecks: Record<string, boolean> - } - >() - constructor({ headers = [], fsRoutes = [], @@ -98,76 +103,31 @@ export default class Router { redirects = [], catchAllRoute, catchAllMiddleware = [], - dynamicRoutes = [], - pageChecker, + matchers, useFileSystemPublicRoutes, nextConfig, - }: { - headers: ReadonlyArray<Route> - fsRoutes: ReadonlyArray<Route> - rewrites: { - beforeFiles: ReadonlyArray<Route> - afterFiles: ReadonlyArray<Route> - fallback: ReadonlyArray<Route> - } - redirects: ReadonlyArray<Route> - catchAllRoute: Route - catchAllMiddleware: ReadonlyArray<Route> - dynamicRoutes: DynamicRoutes | undefined - pageChecker: PageChecker - useFileSystemPublicRoutes: boolean - nextConfig: NextConfig - }) { + localeNormalizer, + }: 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 + this.localeNormalizer = localeNormalizer // Perform the initial route compilation. this.compiledRoutes = this.compileRoutes() this.needsRecompilation = false } - private async checkPage( - req: BaseNextRequest, - pathname: string - ): Promise<boolean> { - pathname = normalizeLocalePath(pathname, this.locales).pathname - - const context = this.context.get(req) - if (!context) { - throw new Error( - 'Invariant: request is not available inside the context, this is an internal error please open an issue.' - ) - } - - if (context.pageChecks[pathname] !== undefined) { - return context.pageChecks[pathname] - } - - const result = await this.pageChecker(pathname) - context.pageChecks[pathname] = result - return result - } - - get locales() { - return this.nextConfig.i18n?.locales || [] - } - get basePath() { return this.nextConfig.basePath || '' } - public setDynamicRoutes(dynamicRoutes: DynamicRoutes) { - this.dynamicRoutes = dynamicRoutes - this.needsRecompilation = true - } public setCatchallMiddleware(catchAllMiddleware: ReadonlyArray<Route>) { this.catchAllMiddleware = catchAllMiddleware this.needsRecompilation = true @@ -217,22 +177,31 @@ export default class Router { name: 'page checker', match: getPathMatch('/:path*'), fn: async (req, res, params, parsedUrl, upgradeHead) => { + // Next.js performs all route matching without the trailing slash. 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 - ) + // 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, + }), } - return { finished: false } + const match = await this.matchers.test(pathname, options) + if (!match) return { finished: false } + + return this.catchAllRoute.fn( + req, + res, + params, + parsedUrl, + upgradeHead + ) }, } as Route, ] @@ -271,64 +240,56 @@ 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) + 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 - } - } + // Normalize and detect the locale on the pathname. + const options: MatchOptions = { + i18n: this.localeNormalizer?.match(fsPathname, { + // TODO: verify changing the default locale + inferDefaultLocale: true, + }), } - // Matched a page or dynamic route so render it using catchAllRoute - if (matchedPage) { - const params = this.catchAllRoute.match(parsedUrl.pathname) - if (!params) { - throw new Error( - `Invariant: could not match params, this is an internal error please open an issue.` - ) - } - - parsedUrl.pathname = fsPathname - parsedUrl.query._nextBubbleNoFallback = '1' + const match = await this.matchers.test(fsPathname, options) + if (!match) return false - const { finished } = await this.catchAllRoute.fn( - req, - res, - params, - parsedUrl, - upgradeHead + // Matched a page or dynamic route so render it using catchAllRoute + 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 +305,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! + 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 + } + + if ( + route.matchesTrailingSlash && + getRequestMeta(req, '__nextHadTrailingSlash') + ) { + pathnameInfo.trailingSlash = true + } - const basePath = pathnameInfo.basePath - if (!route.matchesBasePath) { - pathnameInfo.basePath = '' + 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..cad09fed3502f 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,7 +26,6 @@ import { normalizeVercelUrl, } from '../build/webpack/loaders/next-serverless-loader/utils' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' - interface WebServerOptions extends Options { webServerConfig: { page: string @@ -149,22 +147,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> { .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*'), @@ -301,7 +284,6 @@ export default class NextWebServer extends BaseServer<WebServerOptions> { ) if (localePathResult.detectedLocale) { - pathname = localePathResult.pathname parsedUrl.query.__nextLocale = localePathResult.detectedLocale } } @@ -332,7 +314,6 @@ export default class NextWebServer extends BaseServer<WebServerOptions> { if (useFileSystemPublicRoutes) { this.appPathRoutes = this.getAppPathRoutes() - this.dynamicRoutes = this.getDynamicRoutes() } return { @@ -347,8 +328,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> { catchAllRoute, catchAllMiddleware: [], useFileSystemPublicRoutes, - dynamicRoutes: this.dynamicRoutes, - pageChecker: this.hasPage.bind(this), + matchers: this.matchers, nextConfig: this.nextConfig, } } @@ -357,6 +337,7 @@ export default class NextWebServer extends BaseServer<WebServerOptions> { 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<RequestData, 'body'> & { - body?: ClonableBody + body?: CloneableBody } export interface FetchEventResult { diff --git a/packages/next/src/shared/lib/constants.ts b/packages/next/src/shared/lib/constants.ts index 555ac48c848b6..9ec464023070c 100644 --- a/packages/next/src/shared/lib/constants.ts +++ b/packages/next/src/shared/lib/constants.ts @@ -63,12 +63,12 @@ export const MODERN_BROWSERSLIST_TARGET = [ export const NEXT_BUILTIN_DOCUMENT = '__NEXT_BUILTIN_DOCUMENT__' export const NEXT_CLIENT_SSR_ENTRY_SUFFIX = '.__sc_client__' -// server/flight-manifest.js -export const FLIGHT_MANIFEST = 'flight-manifest' -// server/flight-server-css-manifest.json +// server/client-reference-manifest +export const CLIENT_REFERENCE_MANIFEST = 'client-reference-manifest' +// server/flight-server-css-manifest export const FLIGHT_SERVER_CSS_MANIFEST = 'flight-server-css-manifest' -// server/actions-manifest.json -export const ACTIONS_MANIFEST = 'actions-manifest' +// server/server-reference-manifest +export const SERVER_REFERENCE_MANIFEST = 'server-reference-manifest' // server/middleware-build-manifest.js export const MIDDLEWARE_BUILD_MANIFEST = 'middleware-build-manifest' // server/middleware-react-loadable-manifest.js 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<string> 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/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts index ddac2c3807fd6..c486851159821 100644 --- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts @@ -785,18 +785,6 @@ for (const variant of ['default', 'turbo']) { .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]') .click() - const collapsedFrameworkGroups = await browser.elementsByCss( - "[data-nextjs-call-stack-framework-button][data-state='closed']" - ) - for (const collapsedFrameworkButton of collapsedFrameworkGroups) { - // Open the collapsed framework groups, the callstack count should increase with each opened group - const callStackCountBeforeGroupOpened = await getCallStackCount() - await collapsedFrameworkButton.click() - expect(await getCallStackCount()).toBeGreaterThan( - callStackCountBeforeGroupOpened - ) - } - // Expect more than the default amount of frames // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements expect(await getCallStackCount()).toBeGreaterThan(9) 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..398e3f08bb590 --- /dev/null +++ b/test/e2e/app-dir/app-routes/app-custom-routes.test.ts @@ -0,0 +1,300 @@ +import { createNextDescribe } from 'e2e-utils' +import { + withRequestMeta, + getRequestMeta, + cookieWithRequestMeta, +} from './helpers' +import { Readable } from 'stream' + +createNextDescribe( + 'app-custom-routes', + { + files: __dirname, + }, + ({ next }) => { + 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<Response> { + 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<string, string | string[]> } +): Promise<Response> => { + 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<string, any>, + headers: Record<string, string> = {} +): Record<string, string> { + 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<string, any>, + { cookie = '', ...headers }: Record<string, string> = {} +): Record<string, string> { + 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<string, any> { + 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, + }, +} diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index cabb4b9a378be..9dea077ba9bf5 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -837,22 +837,19 @@ createNextDescribe( async (method) => { const browser = await next.browser('/internal') - try { - // Wait for and click the navigation element, this should trigger - // the flight request that'll be caught by the middleware. If the - // middleware sees any flight data on the request it'll redirect to - // a page with an element of #failure, otherwise, we'll see the - // element for #success. - await browser - .waitForElementByCss(`#navigate-${method}`) - .elementById(`navigate-${method}`) - .click() - expect( - await browser.waitForElementByCss('#success', 3000).text() - ).toBe('Success') - } finally { - await browser.close() - } + // Wait for and click the navigation element, this should trigger + // the flight request that'll be caught by the middleware. If the + // middleware sees any flight data on the request it'll redirect to + // a page with an element of #failure, otherwise, we'll see the + // element for #success. + await browser + .waitForElementByCss(`#navigate-${method}`) + .elementById(`navigate-${method}`) + .click() + await check( + async () => await browser.elementByCss('#success').text(), + /Success/ + ) } ) }) diff --git a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts index 314e932e7bef3..2faa0ec42c0d5 100644 --- a/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts +++ b/test/e2e/app-dir/rsc-basic/rsc-basic.test.ts @@ -460,7 +460,7 @@ describe('app dir - rsc basics', () => { const files = [ 'middleware-build-manifest.js', 'middleware-manifest.json', - 'flight-manifest.json', + 'client-reference-manifest.json', ] files.forEach((file) => { diff --git a/test/integration/custom-error/test/index.test.js b/test/integration/custom-error/test/index.test.js index a519dbd86dd12..48d2ed6dd4bc9 100644 --- a/test/integration/custom-error/test/index.test.js +++ b/test/integration/custom-error/test/index.test.js @@ -75,8 +75,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') }) }) 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') }) }) 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(