diff --git a/docs/advanced-features/i18n-routing.md b/docs/advanced-features/i18n-routing.md index 2dbe239805..032c152381 100644 --- a/docs/advanced-features/i18n-routing.md +++ b/docs/advanced-features/i18n-routing.md @@ -164,7 +164,7 @@ module.exports = { Next, we can use [Middleware](/docs/middleware.md) to add custom routing rules: ```js -// pages/_middleware.ts +// middleware.ts import { NextRequest, NextResponse } from 'next/server' diff --git a/docs/advanced-features/middleware.md b/docs/advanced-features/middleware.md index 857e71ba92..70ac061225 100644 --- a/docs/advanced-features/middleware.md +++ b/docs/advanced-features/middleware.md @@ -24,12 +24,12 @@ Middleware enables you to use code over configuration. This gives you full flexi npm install next@latest ``` -2. Then, create a `_middleware.ts` file under your `/pages` directory. +2. Then, create a `middleware.ts` file under your project root directory. -3. Finally, export a middleware function from the `_middleware.ts` file. +3. Finally, export a middleware function from the `middleware.ts` file. ```jsx -// pages/_middleware.ts +// middleware.ts import type { NextFetchEvent, NextRequest } from 'next/server' @@ -42,7 +42,7 @@ In this example, we use the standard Web API Response ([MDN](https://developer.m ## API -Middleware is created by using a `middleware` function that lives inside a `_middleware` file. Its API is based upon the native [`FetchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), and [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) objects. +Middleware is created by using a `middleware` function that lives inside a `middleware` file. Its API is based upon the native [`FetchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), and [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) objects. These native Web API objects are extended to give you more control over how you manipulate and configure a response, based on the incoming requests. @@ -75,31 +75,6 @@ Middleware can be used for anything that shares logic for a set of pages, includ ## Execution Order -If your Middleware is created in `/pages/_middleware.ts`, it will run on all routes within the `/pages` directory. The below example assumes you have `about.tsx` and `teams.tsx` routes. - -```bash -- package.json -- /pages - _middleware.ts # Will run on all routes under /pages - index.tsx - about.tsx - teams.tsx -``` - -If you _do_ have sub-directories with nested routes, Middleware will run from the top down. For example, if you have `/pages/about/_middleware.ts` and `/pages/about/team/_middleware.ts`, `/about` will run first and then `/about/team`. The below example shows how this works with a nested routing structure. - -```bash -- package.json -- /pages - index.tsx - - /about - _middleware.ts # Will run first - about.tsx - - /teams - _middleware.ts # Will run second - teams.tsx -``` - Middleware runs directly after `redirects` and `headers`, before the first filesystem lookup. This excludes `/_next` files. ## Deployment diff --git a/docs/api-reference/next.config.js/custom-page-extensions.md b/docs/api-reference/next.config.js/custom-page-extensions.md index a3f0d61324..75949781f9 100644 --- a/docs/api-reference/next.config.js/custom-page-extensions.md +++ b/docs/api-reference/next.config.js/custom-page-extensions.md @@ -16,7 +16,7 @@ module.exports = { > **Note**: The default value of `pageExtensions` is [`['tsx', 'ts', 'jsx', 'js']`](https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161). -> **Note**: configuring `pageExtensions` also affects `_document.js`, `_app.js`, `_middleware.js` as well as files under `pages/api/`. For example, setting `pageExtensions: ['page.tsx', 'page.ts']` means the following files: `_document.tsx`, `_app.tsx`, `_middleware.ts`, `pages/users.tsx` and `pages/api/users.ts` will have to be renamed to `_document.page.tsx`, `_app.page.tsx`, `_middleware.page.ts`, `pages/users.page.tsx` and `pages/api/users.page.ts` respectively. +> **Note**: configuring `pageExtensions` also affects `_document.js`, `_app.js`, `middleware.js` as well as files under `pages/api/`. For example, setting `pageExtensions: ['page.tsx', 'page.ts']` means the following files: `_document.tsx`, `_app.tsx`, `middleware.ts`, `pages/users.tsx` and `pages/api/users.ts` will have to be renamed to `_document.page.tsx`, `_app.page.tsx`, `middleware.page.ts`, `pages/users.page.tsx` and `pages/api/users.page.ts` respectively. ## Including non-page files in the `pages` directory @@ -32,7 +32,7 @@ module.exports = { Then rename your pages to have a file extension that includes `.page` (ex. rename `MyPage.tsx` to `MyPage.page.tsx`). -> **Note**: Make sure you also rename `_document.js`, `_app.js`, `_middleware.js`, as well as files under `pages/api/`. +> **Note**: Make sure you also rename `_document.js`, `_app.js`, `middleware.js`, as well as files under `pages/api/`. Without this config, Next.js assumes every tsx/ts/jsx/js file in the `pages` directory is a page or API route, and may expose unintended routes vulnerable to denial of service attacks, or throw an error like the following when building the production bundle: diff --git a/docs/api-reference/next/server.md b/docs/api-reference/next/server.md index 66bd4dc0a1..d84c2759a1 100644 --- a/docs/api-reference/next/server.md +++ b/docs/api-reference/next/server.md @@ -8,7 +8,7 @@ The `next/server` module provides several exports for server-only helpers, such ## NextMiddleware -Middleware is created by using a `middleware` function that lives inside a `_middleware` file. The Middleware API is based upon the native [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`FetchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent), and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects. +Middleware is created by using a `middleware` function that lives inside a `middleware` file. The Middleware API is based upon the native [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`FetchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent), and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) objects. These native Web API objects are extended to give you more control over how you manipulate and configure a response, based on the incoming requests. diff --git a/errors/manifest.json b/errors/manifest.json index 00ec91db0c..82ae1b4f93 100644 --- a/errors/manifest.json +++ b/errors/manifest.json @@ -626,6 +626,10 @@ "title": "middleware-relative-urls", "path": "/errors/middleware-relative-urls.md" }, + { + "title": "nested-middleware", + "path": "/errors/nested-middleware.md" + }, { "title": "deleting-query-params-in-middlewares", "path": "/errors/deleting-query-params-in-middlewares.md" diff --git a/errors/middleware-new-signature.md b/errors/middleware-new-signature.md index b7bb43307b..3f8aac1cd4 100644 --- a/errors/middleware-new-signature.md +++ b/errors/middleware-new-signature.md @@ -5,7 +5,7 @@ Your application is using a Middleware function that is using parameters from the deprecated API. ```typescript -// _middleware.js +// middleware.js import { NextResponse } from 'next/server' export function middleware(event) { @@ -24,7 +24,7 @@ export function middleware(event) { Update to use the new API for Middleware: ```typescript -// _middleware.js +// middleware.js import { NextResponse } from 'next/server' export function middleware(request) { diff --git a/errors/nested-middleware.md b/errors/nested-middleware.md new file mode 100644 index 0000000000..e39c2ebfee --- /dev/null +++ b/errors/nested-middleware.md @@ -0,0 +1,26 @@ +# Nested Middleware + +#### Why This Error Occurred + +You are defining a middleware file in a location different from `/middleware` which is not allowed. + +While in beta, a middleware file under specific pages implied that it would _only_ be executed when pages below its declaration were matched. +This execution model allowed the nesting of multiple middleware, which is hard to reason about and led to consequences such as dragging effects between different middleware executions. + +The API has been removed in favor of a simpler model with a single root middleware. + +#### Possible Ways to Fix It + +To fix this error, declare your middleware in the root folder and use `NextRequest` parsed URL to define which path the middleware code should be executed for. For example, a middleware declared under `pages/about/_middleware.js` can be moved to `middleware`. A conditional can be used to ensure the middleware executes only when it matches the `about/*` path: + +```typescript +import type { NextRequest } from 'next/server' + +export function middleware(request: NextRequest) { + if (request.nextUrl.pathname.startsWith('/about')) { + // Execute pages/about/_middleware.js + } +} +``` + +If you have more than one middleware, you will need to combine them into a single file and model their execution depending on the request. diff --git a/errors/no-server-import-in-page.md b/errors/no-server-import-in-page.md index c8fc45069a..b7639682d3 100644 --- a/errors/no-server-import-in-page.md +++ b/errors/no-server-import-in-page.md @@ -2,14 +2,14 @@ ### Why This Error Occurred -`next/server` was imported outside of `pages/**/_middleware.{js,ts}`. +`next/server` was imported outside of `middleware.{js,ts}`. ### Possible Ways to Fix It -Only import and use `next/server` in a file located within the pages directory: `pages/**/_middleware.{js,ts}`. +Only import and use `next/server` in a file located within the project root directory: `middleware.{js,ts}`. ```ts -// pages/_middleware.ts +// middleware.ts import type { NextFetchEvent, NextRequest } from 'next/server' diff --git a/packages/eslint-plugin-next/lib/rules/no-server-import-in-page.js b/packages/eslint-plugin-next/lib/rules/no-server-import-in-page.js index 3cec735da7..c61248349e 100644 --- a/packages/eslint-plugin-next/lib/rules/no-server-import-in-page.js +++ b/packages/eslint-plugin-next/lib/rules/no-server-import-in-page.js @@ -3,8 +3,7 @@ const path = require('path') module.exports = { meta: { docs: { - description: - 'Disallow importing next/server outside of pages/_middleware.js', + description: 'Disallow importing next/server outside of middleware.js', recommended: true, url: 'https://nextjs.org/docs/messages/no-server-import-in-page', }, @@ -16,20 +15,18 @@ module.exports = { return } - const paths = context.getFilename().split('pages') - const page = paths[paths.length - 1] - + const filename = context.getFilename() if ( - !page || - page.includes(`${path.sep}_middleware`) || - page.includes(`${path.posix.sep}_middleware`) + filename.startsWith('middleware.') || + filename.startsWith(`${path.sep}middleware.`) || + filename.startsWith(`${path.posix.sep}middleware.`) ) { return } context.report({ node, - message: `next/server should not be imported outside of pages/_middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page`, + message: `next/server should not be imported outside of middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page`, }) }, } diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 524027a148..2cb71fe949 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -13,7 +13,10 @@ import { stringify } from 'querystring' import { API_ROUTE, DOT_NEXT_ALIAS, + MIDDLEWARE_FILE, + MIDDLEWARE_FILENAME, PAGES_DIR_ALIAS, + ROOT_DIR_ALIAS, VIEWS_DIR_ALIAS, } from '../lib/constants' import { @@ -23,7 +26,6 @@ import { CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH, EDGE_RUNTIME_WEBPACK, } from '../shared/lib/constants' -import { MIDDLEWARE_ROUTE } from '../lib/constants' import { __ApiPreviewProps } from '../server/api-utils' import { isTargetLikeServerless } from '../server/utils' import { warn } from './output/log' @@ -57,18 +59,17 @@ export function getPageFromPath(pagePath: string, pageExtensions: string[]) { export function createPagesMapping({ hasServerComponents, isDev, - isViews, pageExtensions, pagePaths, + pagesType, }: { hasServerComponents: boolean isDev: boolean - isViews?: boolean pageExtensions: string[] pagePaths: string[] + pagesType: 'pages' | 'root' | 'views' }): { [page: string]: string } { const previousPages: { [key: string]: string } = {} - const pathAlias = isViews ? VIEWS_DIR_ALIAS : PAGES_DIR_ALIAS const pages = pagePaths.reduce<{ [key: string]: string }>( (result, pagePath) => { // Do not process .d.ts files inside the `pages` folder @@ -97,17 +98,22 @@ export function createPagesMapping({ previousPages[pageKey] = pagePath } - result[pageKey] = normalizePathSep(join(pathAlias, pagePath)) + result[pageKey] = normalizePathSep( + join( + pagesType === 'pages' + ? PAGES_DIR_ALIAS + : pagesType === 'views' + ? VIEWS_DIR_ALIAS + : ROOT_DIR_ALIAS, + pagePath + ) + ) return result }, {} ) - // In development we always alias these to allow Webpack to fallback to - // the correct source file so that HMR can work properly when a file is - // added or removed. - - if (isViews) { + if (pagesType !== 'pages') { return pages } @@ -118,7 +124,11 @@ export function createPagesMapping({ delete pages['/_document'] } + // In development we always alias these to allow Webpack to fallback to + // the correct source file so that HMR can work properly when a file is + // added or removed. const root = isDev ? PAGES_DIR_ALIAS : 'next/dist/pages' + return { '/_app': `${root}/_app`, '/_error': `${root}/_error`, @@ -259,6 +269,8 @@ interface CreateEntrypointsParams { pages: { [page: string]: string } pagesDir: string previewMode: __ApiPreviewProps + rootDir: string + rootPaths?: Record target: 'server' | 'serverless' | 'experimental-serverless-trace' viewsDir?: string viewPaths?: Record @@ -275,7 +287,7 @@ export function getEdgeServerEntry(opts: { page: string pages: { [page: string]: string } }) { - if (opts.page.match(MIDDLEWARE_ROUTE)) { + if (opts.page === MIDDLEWARE_FILE) { const loaderParams: MiddlewareLoaderOptions = { absolutePagePath: opts.absolutePagePath, page: opts.page, @@ -388,6 +400,8 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { pages, pagesDir, isDev, + rootDir, + rootPaths, target, viewsDir, viewPaths, @@ -398,14 +412,16 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { const client: webpack5.EntryObject = {} const getEntryHandler = - (mappings: Record, isViews: boolean) => + (mappings: Record, pagesType: 'views' | 'pages' | 'root') => async (page: string) => { const bundleFile = normalizePagePath(page) const clientBundlePath = posix.join('pages', bundleFile) - const serverBundlePath = posix.join( - isViews ? 'views' : 'pages', - bundleFile - ) + const serverBundlePath = + pagesType === 'pages' + ? posix.join('pages', bundleFile) + : pagesType === 'views' + ? posix.join('views', bundleFile) + : bundleFile.slice(1) const absolutePagePath = mappings[page] // Handle paths that have aliases @@ -418,9 +434,27 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { return absolutePagePath.replace(VIEWS_DIR_ALIAS, viewsDir) } + if (absolutePagePath.startsWith(ROOT_DIR_ALIAS)) { + return absolutePagePath.replace(ROOT_DIR_ALIAS, rootDir) + } + return require.resolve(absolutePagePath) })() + /** + * When we find a middleware file that is not in the ROOT_DIR we fail. + * There is no need to check on `dev` as this should only happen when + * building for production. + */ + if ( + !absolutePagePath.startsWith(ROOT_DIR_ALIAS) && + /[\\\\/]_middleware$/.test(page) + ) { + throw new Error( + `nested Middleware is not allowed (found pages${page}) - https://nextjs.org/docs/messages/nested-middleware` + ) + } + const isServerComponent = serverComponentRegex.test(absolutePagePath) runDependingOnPageType({ @@ -439,7 +473,7 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { } }, onServer: () => { - if (isViews && viewsDir) { + if (pagesType === 'views' && viewsDir) { server[serverBundlePath] = getViewsEntry({ name: serverBundlePath, pagePath: mappings[page], @@ -477,10 +511,15 @@ export async function createEntrypoints(params: CreateEntrypointsParams) { } if (viewsDir && viewPaths) { - const entryHandler = getEntryHandler(viewPaths, true) + const entryHandler = getEntryHandler(viewPaths, 'views') await Promise.all(Object.keys(viewPaths).map(entryHandler)) } - await Promise.all(Object.keys(pages).map(getEntryHandler(pages, false))) + if (rootPaths) { + await Promise.all( + Object.keys(rootPaths).map(getEntryHandler(rootPaths, 'root')) + ) + } + await Promise.all(Object.keys(pages).map(getEntryHandler(pages, 'pages'))) return { client, @@ -496,7 +535,7 @@ export function runDependingOnPageType(params: { page: string pageRuntime: PageRuntime }) { - if (params.page.match(MIDDLEWARE_ROUTE)) { + if (params.page === MIDDLEWARE_FILE) { return [params.onEdgeServer()] } else if (params.page.match(API_ROUTE)) { return [params.onServer()] @@ -545,7 +584,7 @@ export function finalizeEntrypoint({ if (compilerType === 'edge-server') { return { - layer: MIDDLEWARE_ROUTE.test(name) ? 'middleware' : undefined, + layer: name === MIDDLEWARE_FILENAME ? 'middleware' : undefined, library: { name: ['_ENTRIES', `middleware_[name]`], type: 'assign' }, runtime: EDGE_RUNTIME_WEBPACK, asyncChunks: false, diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index a564e107a4..cd7cc927ac 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -15,7 +15,8 @@ import formatWebpackMessages from '../client/dev/error-overlay/format-webpack-me import { STATIC_STATUS_PAGE_GET_INITIAL_PROPS_ERROR, PUBLIC_DIR_MIDDLEWARE_CONFLICT, - MIDDLEWARE_ROUTE, + MIDDLEWARE_FILENAME, + MIDDLEWARE_FILE, PAGES_DIR_ALIAS, } from '../lib/constants' import { fileExists } from '../lib/file-exists' @@ -54,11 +55,7 @@ import { STATIC_STATUS_PAGES, MIDDLEWARE_MANIFEST, } from '../shared/lib/constants' -import { - getRouteRegex, - getSortedRoutes, - isDynamicRoute, -} from '../shared/lib/router/utils' +import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' import { __ApiPreviewProps } from '../server/api-utils' import loadConfig from '../server/config' import { isTargetLikeServerless } from '../server/utils' @@ -115,6 +112,8 @@ import { recursiveCopy } from '../lib/recursive-copy' import { recursiveReadDir } from '../lib/recursive-readdir' import { lockfilePatchPromise, teardownTraceSubscriber } from './swc' import { injectedClientEntries } from './webpack/plugins/flight-manifest-plugin' +import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' +import { flatReaddir } from '../lib/flat-readdir' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -326,6 +325,13 @@ export default async function build( ) } + const rootPaths = await flatReaddir( + dir, + new RegExp( + `^${MIDDLEWARE_FILENAME}\\.(?:${config.pageExtensions.join('|')})$` + ) + ) + // needed for static exporting since we want to replace with HTML // files @@ -345,11 +351,12 @@ export default async function build( hasServerComponents, isDev: false, pageExtensions: config.pageExtensions, - pagePaths, + pagesType: 'pages', + pagePaths: pagePaths, }) ) - let mappedViewPaths: ReturnType | undefined + let mappedViewPaths: { [page: string]: string } | undefined if (viewPaths && viewsDir) { mappedViewPaths = nextBuildSpan @@ -359,12 +366,23 @@ export default async function build( pagePaths: viewPaths!, hasServerComponents, isDev: false, - isViews: true, + pagesType: 'views', pageExtensions: config.pageExtensions, }) ) } + let mappedRootPaths: { [page: string]: string } = {} + if (rootPaths.length > 0) { + mappedRootPaths = createPagesMapping({ + hasServerComponents, + isDev: false, + pageExtensions: config.pageExtensions, + pagePaths: rootPaths, + pagesType: 'root', + }) + } + const entrypoints = await nextBuildSpan .traceChild('create-entrypoints') .traceAsyncFn(() => @@ -377,6 +395,8 @@ export default async function build( pagesDir, previewMode: previewProps, target, + rootDir: dir, + rootPaths: mappedRootPaths, viewsDir, viewPaths: mappedViewPaths, pageExtensions: config.pageExtensions, @@ -389,7 +409,7 @@ export default async function build( const hasCustomErrorPage = mappedPages['/_error'].startsWith(PAGES_DIR_ALIAS) - if (pageKeys.some((page) => MIDDLEWARE_ROUTE.test(page))) { + if (mappedRootPaths?.[MIDDLEWARE_FILE]) { Log.warn( `using beta Middleware (not covered by semver) - https://nextjs.org/docs/messages/beta-middleware` ) @@ -538,17 +558,10 @@ export default async function build( redirects: redirects.map((r: any) => buildCustomRoute(r, 'redirect')), headers: headers.map((r: any) => buildCustomRoute(r, 'header')), dynamicRoutes: getSortedRoutes(pageKeys) - .filter( - (page) => isDynamicRoute(page) && !page.match(MIDDLEWARE_ROUTE) - ) + .filter(isDynamicRoute) .map(pageToRoute), staticRoutes: getSortedRoutes(pageKeys) - .filter( - (page) => - !isDynamicRoute(page) && - !page.match(MIDDLEWARE_ROUTE) && - !isReservedPage(page) - ) + .filter((page) => !isDynamicRoute(page) && !isReservedPage(page)) .map(pageToRoute), dataRoutes: [], i18n: config.i18n || undefined, @@ -1069,7 +1082,6 @@ export default async function build( let isServerComponent = false let isHybridAmp = false let ssgPageRoutes: string[] | null = null - let isMiddlewareRoute = !!page.match(MIDDLEWARE_ROUTE) const pagePath = pagePaths.find( (p) => @@ -1088,7 +1100,6 @@ export default async function build( } if ( - !isMiddlewareRoute && !isReservedPage(page) && // We currently don't support static optimization in the Edge runtime. pageRuntime !== 'edge' @@ -1483,7 +1494,9 @@ export default async function build( let routeKeys: { [named: string]: string } | undefined if (isDynamicRoute(page)) { - const routeRegex = getRouteRegex(dataRoute.replace(/\.json$/, '')) + const routeRegex = getNamedRouteRegex( + dataRoute.replace(/\.json$/, '') + ) dataRouteRegex = normalizeRouteRegex( routeRegex.re.source.replace(/\(\?:\\\/\)\?\$$/, `\\.json$`) @@ -2091,9 +2104,7 @@ export default async function build( rewritesWithHasCount: combinedRewrites.filter((r: any) => !!r.has) .length, redirectsWithHasCount: redirects.filter((r: any) => !!r.has).length, - middlewareCount: pageKeys.filter((page) => - MIDDLEWARE_ROUTE.test(page) - ).length, + middlewareCount: Object.keys(rootPaths).length > 0 ? 1 : 0, }) ) @@ -2114,7 +2125,9 @@ export default async function build( ) finalDynamicRoutes[tbdRoute] = { - routeRegex: normalizeRouteRegex(getRouteRegex(tbdRoute).re.source), + routeRegex: normalizeRouteRegex( + getNamedRouteRegex(tbdRoute).re.source + ), dataRoute, fallback: ssgBlockingFallbackPages.has(tbdRoute) ? null @@ -2122,10 +2135,9 @@ export default async function build( ? `${normalizedRoute}.html` : false, dataRouteRegex: normalizeRouteRegex( - getRouteRegex(dataRoute.replace(/\.json$/, '')).re.source.replace( - /\(\?:\\\/\)\?\$$/, - '\\.json$' - ) + getNamedRouteRegex( + dataRoute.replace(/\.json$/, '') + ).re.source.replace(/\(\?:\\\/\)\?\$$/, '\\.json$') ), } }) @@ -2257,6 +2269,7 @@ export default async function build( useStatic404, pageExtensions: config.pageExtensions, buildManifest, + middlewareManifest, gzipSize: config.experimental.gzipSize, } ) @@ -2334,7 +2347,7 @@ function isTelemetryPlugin(plugin: unknown): plugin is TelemetryPlugin { } function pageToRoute(page: string) { - const routeRegex = getRouteRegex(page) + const routeRegex = getNamedRouteRegex(page) return { page, regex: normalizeRouteRegex(routeRegex.re.source), diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index ad69430173..2ce5efc338 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -23,11 +23,12 @@ import { SSG_GET_INITIAL_PROPS_CONFLICT, SERVER_PROPS_GET_INIT_PROPS_CONFLICT, SERVER_PROPS_SSG_CONFLICT, - MIDDLEWARE_ROUTE, + MIDDLEWARE_FILENAME, } from '../lib/constants' import { EDGE_RUNTIME_WEBPACK } from '../shared/lib/constants' import prettyBytes from '../lib/pretty-bytes' -import { getRouteMatcher, getRouteRegex } from '../shared/lib/router/utils' +import { getRouteRegex } from '../shared/lib/router/utils/route-regex' +import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic' import escapePathDelimiters from '../shared/lib/router/utils/escape-path-delimiters' import { findPageFile } from '../server/lib/find-page-file' @@ -89,6 +90,7 @@ export async function printTreeView( pagesDir, pageExtensions, buildManifest, + middlewareManifest, useStatic404, gzipSize = true, }: { @@ -97,6 +99,7 @@ export async function printTreeView( pagesDir: string pageExtensions: string[] buildManifest: BuildManifest + middlewareManifest: MiddlewareManifest useStatic404: boolean gzipSize?: boolean } @@ -194,8 +197,6 @@ export async function printTreeView( const symbol = item === '/_app' || item === '/_app.server' ? ' ' - : item.endsWith('/_middleware') - ? 'ƒ' : pageInfo?.static ? '○' : pageInfo?.isSsg @@ -351,6 +352,18 @@ export async function printTreeView( ]) }) + const middlewareInfo = middlewareManifest.middleware?.['/'] + if (middlewareInfo?.files.length > 0) { + const sizes = await Promise.all( + middlewareInfo.files + .map((dep) => `${distPath}/${dep}`) + .map(gzipSize ? fsStatGzip : fsStat) + ) + + messages.push(['', '', '']) + messages.push(['ƒ Middleware', getPrettySize(sum(sizes)), '']) + } + console.log( textTable(messages, { align: ['l', 'l', 'r'], @@ -362,11 +375,6 @@ export async function printTreeView( console.log( textTable( [ - usedSymbols.has('ƒ') && [ - 'ƒ', - '(Middleware)', - `intercepts requests (uses ${chalk.cyan('_middleware')})`, - ], usedSymbols.has('ℇ') && [ 'ℇ', '(Streaming)', @@ -1182,12 +1190,9 @@ export async function copyTracedFiles( ) } - for (const page of pageKeys) { - if (MIDDLEWARE_ROUTE.test(page)) { - const { files } = - middlewareManifest.middleware[page.replace(/\/_middleware$/, '') || '/'] - - for (const file of files) { + for (const middleware of Object.values(middlewareManifest.middleware) || []) { + if (middleware.name === MIDDLEWARE_FILENAME) { + for (const file of middleware.files) { const originalPath = path.join(distDir, file) const fileOutputPath = path.join( outputPath, @@ -1197,9 +1202,10 @@ export async function copyTracedFiles( await fs.mkdir(path.dirname(fileOutputPath), { recursive: true }) await fs.copyFile(originalPath, fileOutputPath) } - continue } + } + for (const page of pageKeys) { const pageFile = path.join( distDir, 'server', diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 597dbe9bcf..8079c36dec 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -10,6 +10,7 @@ import { NEXT_PROJECT_ROOT, NEXT_PROJECT_ROOT_DIST_CLIENT, PAGES_DIR_ALIAS, + ROOT_DIR_ALIAS, VIEWS_DIR_ALIAS, } from '../lib/constants' import { fileExists } from '../lib/file-exists' @@ -671,6 +672,7 @@ export default async function getBaseWebpackConfig( [VIEWS_DIR_ALIAS]: viewsDir, } : {}), + [ROOT_DIR_ALIAS]: dir, [DOT_NEXT_ALIAS]: distDir, ...(isClient || isEdgeServer ? getOptimizedAliases() : {}), ...getReactProfilingInProduction(), diff --git a/packages/next/build/webpack/loaders/next-middleware-loader.ts b/packages/next/build/webpack/loaders/next-middleware-loader.ts index cccfbd4cc0..668d6106c5 100644 --- a/packages/next/build/webpack/loaders/next-middleware-loader.ts +++ b/packages/next/build/webpack/loaders/next-middleware-loader.ts @@ -1,5 +1,6 @@ import { getModuleBuildInfo } from './get-module-build-info' import { stringifyRequest } from '../stringify-request' +import { MIDDLEWARE_FILE } from '../../../lib/constants' export type MiddlewareLoaderOptions = { absolutePagePath: string @@ -11,7 +12,7 @@ export default function middlewareLoader(this: any) { const stringifiedPagePath = stringifyRequest(this, absolutePagePath) const buildInfo = getModuleBuildInfo(this._module) buildInfo.nextEdgeMiddleware = { - page: page.replace(/\/_middleware$/, '') || '/', + page: page.replace(new RegExp(`${MIDDLEWARE_FILE}$`), '') || '/', } return ` diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index 3aedda0407..f49141a117 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -1,6 +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 { NextConfig } from '../../../../server/config' import type { GetServerSideProps, @@ -13,7 +14,7 @@ import { format as formatUrl, UrlWithParsedQuery, parse as parseUrl } from 'url' import { parse as parseQs, ParsedUrlQuery } from 'querystring' import { normalizeLocalePath } from '../../../../shared/lib/i18n/normalize-locale-path' import { getPathMatch } from '../../../../shared/lib/router/utils/path-match' -import { getRouteRegex } from '../../../../shared/lib/router/utils/route-regex' +import { getNamedRouteRegex } from '../../../../shared/lib/router/utils/route-regex' import { getRouteMatcher } from '../../../../shared/lib/router/utils/route-matcher' import { matchHas, @@ -79,12 +80,12 @@ export function getUtils({ rewrites: ServerlessHandlerCtx['rewrites'] pageIsDynamic: ServerlessHandlerCtx['pageIsDynamic'] }) { - let defaultRouteRegex: ReturnType | undefined - let dynamicRouteMatcher: ReturnType | undefined + let defaultRouteRegex: ReturnType | undefined + let dynamicRouteMatcher: RouteMatch | undefined let defaultRouteMatches: ParsedUrlQuery | undefined if (pageIsDynamic) { - defaultRouteRegex = getRouteRegex(page) + defaultRouteRegex = getNamedRouteRegex(page) dynamicRouteMatcher = getRouteMatcher(defaultRouteRegex) defaultRouteMatches = dynamicRouteMatcher(page) as ParsedUrlQuery } diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index aa923c3671..dd2d6eda63 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -1,6 +1,6 @@ import type { EdgeMiddlewareMeta } from '../loaders/get-module-build-info' import type { EdgeSSRMeta, WasmBinding } from '../loaders/get-module-build-info' -import { getMiddlewareRegex } from '../../../shared/lib/router/utils' +import { getNamedMiddlewareRegex } from '../../../shared/lib/router/utils/route-regex' import { getModuleBuildInfo } from '../loaders/get-module-build-info' import { getSortedRoutes } from '../../../shared/lib/router/utils' import { webpack, sources, webpack5 } from 'next/dist/compiled/webpack/webpack' @@ -358,12 +358,16 @@ function getCreateAssets(params: { continue } + const { namedRegex } = getNamedMiddlewareRegex(page, { + catchAll: !metadata.edgeSSR, + }) + middlewareManifest.middleware[page] = { env: Array.from(metadata.env), files: getEntryFiles(entrypoint.getFiles(), metadata), name: entrypoint.name, page: page, - regexp: getMiddlewareRegex(page, !metadata.edgeSSR).namedRegex!, + regexp: namedRegex, wasm: Array.from(metadata.wasmBindings), } } diff --git a/packages/next/build/webpack/plugins/middleware-source-maps-plugin.ts b/packages/next/build/webpack/plugins/middleware-source-maps-plugin.ts index 9ca1f771c7..048669dccd 100644 --- a/packages/next/build/webpack/plugins/middleware-source-maps-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-source-maps-plugin.ts @@ -1,4 +1,5 @@ import { webpack } from 'next/dist/compiled/webpack/webpack' +import { MIDDLEWARE_FILENAME } from '../../../lib/constants' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' /** @@ -9,12 +10,7 @@ export const getMiddlewareSourceMapPlugins = () => { return [ new webpack.SourceMapDevToolPlugin({ filename: '[file].map', - include: [ - // Middlewares are the only ones who have `server/pages/[name]` as their filename - /^pages\//, - // All middleware chunks - /^edge-chunks\//, - ], + include: [new RegExp(`^${MIDDLEWARE_FILENAME}.`), /^edge-chunks\//], }), new MiddlewareSourceMapsPlugin(), ] diff --git a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js index ca6ff6b83b..7b988d9214 100644 --- a/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js +++ b/packages/next/build/webpack/plugins/terser-webpack-plugin/src/index.js @@ -101,7 +101,7 @@ export class TerserPlugin { // and doesn't provide too much of a benefit as it's server-side if ( name.match( - /(edge-runtime-webpack\.js|edge-chunks|_middleware\.js$)/ + /(edge-runtime-webpack\.js|edge-chunks|middleware\.js$)/ ) ) { return false diff --git a/packages/next/lib/constants.ts b/packages/next/lib/constants.ts index 354b625817..937f77d693 100644 --- a/packages/next/lib/constants.ts +++ b/packages/next/lib/constants.ts @@ -19,12 +19,15 @@ export const NEXT_PROJECT_ROOT_DIST_SERVER = join( export const API_ROUTE = /^\/api(?:\/|$)/ // Regex for middleware -export const MIDDLEWARE_ROUTE = /_middleware$/ +export const MIDDLEWARE_ROUTE = /middleware$/ +export const MIDDLEWARE_FILENAME = 'middleware' +export const MIDDLEWARE_FILE = `/${MIDDLEWARE_FILENAME}` // Because on Windows absolute paths in the generated code can break because of numbers, eg 1 in the path, // we have to use a private alias export const PAGES_DIR_ALIAS = 'private-next-pages' export const DOT_NEXT_ALIAS = 'private-dot-next' +export const ROOT_DIR_ALIAS = 'private-next-root-dir' export const VIEWS_DIR_ALIAS = 'private-next-views-dir' export const PUBLIC_DIR_MIDDLEWARE_CONFLICT = `You can not have a '_next' folder inside of your public folder. This conflicts with the internal '/_next' route. https://nextjs.org/docs/messages/public-next-folder-conflict` diff --git a/packages/next/lib/flat-readdir.ts b/packages/next/lib/flat-readdir.ts new file mode 100644 index 0000000000..7343ee1964 --- /dev/null +++ b/packages/next/lib/flat-readdir.ts @@ -0,0 +1,26 @@ +import { join } from 'path' +import { nonNullable } from './non-nullable' +import { promises } from 'fs' + +export async function flatReaddir(dir: string, include: RegExp) { + const dirents = await promises.readdir(dir, { withFileTypes: true }) + const result = await Promise.all( + dirents.map(async (part) => { + const absolutePath = join(dir, part.name) + if (part.isSymbolicLink()) { + const stats = await promises.stat(absolutePath) + if (stats.isDirectory()) { + return null + } + } + + if (part.isDirectory() || !include.test(part.name)) { + return null + } + + return absolutePath.replace(dir, '') + }) + ) + + return result.filter(nonNullable) +} diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 05afa09b9c..36a226b812 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -1,14 +1,11 @@ import type { NextConfig } from '../server/config' +import type { Token } from 'next/dist/compiled/path-to-regexp' import chalk from './chalk' -import { parse as parseUrl } from 'url' -import * as pathToRegexp from 'next/dist/compiled/path-to-regexp' import { escapeStringRegexp } from '../shared/lib/escape-regexp' -import { - PERMANENT_REDIRECT_STATUS, - TEMPORARY_REDIRECT_STATUS, -} from '../shared/lib/constants' -import isError from './is-error' +import { PERMANENT_REDIRECT_STATUS } from '../shared/lib/constants' +import { TEMPORARY_REDIRECT_STATUS } from '../shared/lib/constants' +import { tryToParsePath } from './try-to-parse-path' export type RouteHas = | { @@ -133,54 +130,6 @@ function checkHeader(route: Header): string[] { return invalidParts } -type ParseAttemptResult = { - error?: boolean - tokens?: pathToRegexp.Token[] - regexStr?: string -} - -function tryParsePath(route: string, handleUrl?: boolean): ParseAttemptResult { - const result: ParseAttemptResult = {} - let routePath = route - - try { - if (handleUrl) { - const parsedDestination = parseUrl(route, true) - routePath = `${parsedDestination.pathname!}${ - parsedDestination.hash || '' - }` - } - - // Make sure we can parse the source properly - result.tokens = pathToRegexp.parse(routePath) - - const regex = pathToRegexp.tokensToRegexp(result.tokens) - result.regexStr = regex.source - } catch (err) { - // If there is an error show our error link but still show original error or a formatted one if we can - let errMatches - - if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { - const position = parseInt(errMatches[1], 10) - console.error( - `\nError parsing \`${route}\` ` + - `https://nextjs.org/docs/messages/invalid-route-source\n` + - `Reason: ${err.message}\n\n` + - ` ${routePath}\n` + - ` ${new Array(position).fill(' ').join('')}^\n` - ) - } else { - console.error( - `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, - err - ) - } - result.error = true - } - - return result -} - export type RouteType = 'rewrite' | 'redirect' | 'header' function checkCustomRoutes( @@ -331,12 +280,12 @@ function checkCustomRoutes( invalidParts.push(...result.invalidParts) } - let sourceTokens: pathToRegexp.Token[] | undefined + let sourceTokens: Token[] | undefined if (typeof route.source === 'string' && route.source.startsWith('/')) { // only show parse error if we didn't already show error // for not being a string - const { tokens, error, regexStr } = tryParsePath(route.source) + const { tokens, error, regexStr } = tryToParsePath(route.source) if (error) { invalidParts.push('`source` parse failed') @@ -399,7 +348,9 @@ function checkCustomRoutes( tokens: destTokens, regexStr: destRegexStr, error: destinationParseFailed, - } = tryParsePath((route as Rewrite).destination, true) + } = tryToParsePath((route as Rewrite).destination, { + handleUrl: true, + }) if (destRegexStr && destRegexStr.length > 4096) { invalidParts.push('`destination` exceeds max built length of 4096') diff --git a/packages/next/lib/try-to-parse-path.ts b/packages/next/lib/try-to-parse-path.ts new file mode 100644 index 0000000000..fac03f16db --- /dev/null +++ b/packages/next/lib/try-to-parse-path.ts @@ -0,0 +1,65 @@ +import type { Token } from 'next/dist/compiled/path-to-regexp' +import { parse, tokensToRegexp } from 'next/dist/compiled/path-to-regexp' +import { parse as parseURL } from 'url' +import isError from './is-error' + +interface ParseResult { + error?: any + parsedPath: string + regexStr?: string + route: string + tokens?: Token[] +} + +/** + * Attempts to parse a given route with `path-to-regexp` and returns an object + * with the result. Whenever an error happens on parse, it will print an error + * attempting to find the error position and showing a link to the docs. When + * `handleUrl` is set to `true` it will also attempt to parse the route + * and use the resulting pathname to parse with `path-to-regexp`. + */ +export function tryToParsePath( + route: string, + options?: { + handleUrl?: boolean + } +): ParseResult { + const result: ParseResult = { route, parsedPath: route } + try { + if (options?.handleUrl) { + const parsed = parseURL(route, true) + result.parsedPath = `${parsed.pathname!}${parsed.hash || ''}` + } + + result.tokens = parse(result.parsedPath) + result.regexStr = tokensToRegexp(result.tokens).source + } catch (err) { + reportError(result, err) + result.error = err + } + + return result +} + +/** + * If there is an error show our error link but still show original error or + * a formatted one if we can + */ +function reportError({ route, parsedPath }: ParseResult, err: any) { + let errMatches + if (isError(err) && (errMatches = err.message.match(/at (\d{0,})/))) { + const position = parseInt(errMatches[1], 10) + console.error( + `\nError parsing \`${route}\` ` + + `https://nextjs.org/docs/messages/invalid-route-source\n` + + `Reason: ${err.message}\n\n` + + ` ${parsedPath}\n` + + ` ${new Array(position).fill(' ').join('')}^\n` + ) + } else { + console.error( + `\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`, + err + ) + } +} diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 4fd42d56f9..796281298a 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -1,10 +1,11 @@ import { __ApiPreviewProps } from './api-utils' import type { CustomRoutes } from '../lib/load-custom-routes' import type { DomainLocale } from './config' -import type { DynamicRoutes, PageChecker, Params, Route } from './router' +import type { DynamicRoutes, PageChecker, Route } from './router' import type { FontManifest } from './font-utils' import type { LoadComponentsReturnType } from './load-components' -import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' +import type { RouteMatch } from '../shared/lib/router/utils/route-matcher' +import type { Params } from '../shared/lib/router/utils/route-matcher' import type { NextConfig, NextConfigComplete } from './config-shared' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' import type { ParsedUrlQuery } from 'querystring' @@ -33,12 +34,7 @@ import { STATIC_STATUS_PAGES, TEMPORARY_REDIRECT_STATUS, } from '../shared/lib/constants' -import { - getRouteMatcher, - getRouteRegex, - getSortedRoutes, - isDynamicRoute, -} from '../shared/lib/router/utils' +import { getSortedRoutes, isDynamicRoute } from '../shared/lib/router/utils' import { setLazyProp, getCookieParser, @@ -64,22 +60,23 @@ import { getUtils } from '../build/webpack/loaders/next-serverless-loader/utils' import ResponseCache from './response-cache' import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url' import isError, { getProperError } from '../lib/is-error' -import { MIDDLEWARE_ROUTE } from '../lib/constants' import { addRequestMeta, getRequestMeta } from './request-meta' import { createHeaderRoute, createRedirectRoute } from './server-route-utils' import { PrerenderManifest } from '../build' import { ImageConfigComplete } from '../shared/lib/image-config' import { replaceBasePath } from './router-utils' import { normalizeViewPath } from '../shared/lib/router/utils/view-paths' +import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' +import { getRouteRegex } from '../shared/lib/router/utils/route-regex' export type FindComponentsResult = { components: LoadComponentsReturnType query: NextParsedUrlQuery } -interface RoutingItem { +export interface RoutingItem { page: string - match: ReturnType + match: RouteMatch ssr?: boolean } @@ -184,8 +181,6 @@ export default abstract class Server { protected dynamicRoutes?: DynamicRoutes protected viewPathRoutes?: Record protected customRoutes: CustomRoutes - protected middlewareManifest?: MiddlewareManifest - protected middleware?: RoutingItem[] protected serverComponentManifest?: any public readonly hostname?: string public readonly port?: number @@ -210,26 +205,13 @@ export default abstract class Server { fallback: Route[] } protected abstract getFilesystemPaths(): Set - protected abstract getMiddleware(): { - match: (pathname: string | null | undefined) => - | false - | { - [paramName: string]: string | string[] - } - page: string - }[] protected abstract findPageComponents( pathname: string, query?: NextParsedUrlQuery, params?: Params | null ): Promise - protected abstract hasMiddleware( - pathname: string, - _isSSR?: boolean - ): Promise protected abstract getPagePath(pathname: string, locales?: string[]): string protected abstract getFontManifest(): FontManifest | undefined - protected abstract getMiddlewareManifest(): MiddlewareManifest | undefined protected abstract getRoutesManifest(): CustomRoutes protected abstract getPrerenderManifest(): PrerenderManifest protected abstract getServerComponentManifest(): any @@ -357,7 +339,6 @@ export default abstract class Server { this.pagesManifest = this.getPagesManifest() this.viewPathsManifest = this.getViewPathsManifest() - this.middlewareManifest = this.getMiddlewareManifest() this.customRoutes = this.getCustomRoutes() this.router = new Router(this.generateRoutes()) @@ -677,8 +658,6 @@ export default abstract class Server { return this.getPrerenderManifest().preview } - protected async ensureMiddleware(_pathname: string, _isSSR?: boolean) {} - protected generateRoutes(): { basePath: string headers: Route[] @@ -840,13 +819,6 @@ export default abstract class Server { } const bubbleNoFallback = !!query._nextBubbleNoFallback - if (pathname.match(MIDDLEWARE_ROUTE)) { - await this.render404(req, res, parsedUrl) - return { - finished: true, - } - } - if (pathname === '/api' || pathname.startsWith('/api/')) { delete query._nextBubbleNoFallback @@ -878,9 +850,6 @@ export default abstract class Server { if (useFileSystemPublicRoutes) { this.viewPathRoutes = this.getViewPathRoutes() this.dynamicRoutes = this.getDynamicRoutes() - if (!this.minimalMode) { - this.middleware = this.getMiddleware() - } } return { diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index f9d3f2b0c4..736aa19556 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -18,7 +18,7 @@ import { watchCompilers } from '../../build/output' import getBaseWebpackConfig from '../../build/webpack-config' import { API_ROUTE, - MIDDLEWARE_ROUTE, + MIDDLEWARE_FILENAME, VIEWS_DIR_ALIAS, } from '../../lib/constants' import { recursiveDelete } from '../../lib/recursive-delete' @@ -405,6 +405,7 @@ export default class HotReloader { hasServerComponents: this.hasServerComponents, isDev: true, pageExtensions: this.config.pageExtensions, + pagesType: 'pages', pagePaths: pagePaths.filter( (i): i is string => typeof i === 'string' ), @@ -422,6 +423,7 @@ export default class HotReloader { pages: this.pagesMapping, pagesDir: this.pagesDir, previewMode: this.previewProps, + rootDir: this.dir, target: 'server', pageExtensions: this.config.pageExtensions, }) @@ -490,6 +492,7 @@ export default class HotReloader { }, pagesDir: this.pagesDir, previewMode: this.previewProps, + rootDir: this.dir, target: 'server', pageExtensions: this.config.pageExtensions, }) @@ -674,7 +677,7 @@ export default class HotReloader { (stats: webpack5.Compilation) => { try { stats.entrypoints.forEach((entry, key) => { - if (key.startsWith('pages/')) { + if (key.startsWith('pages/') || key === MIDDLEWARE_FILENAME) { // TODO this doesn't handle on demand loaded chunks entry.chunks.forEach((chunk) => { if (chunk.id === key) { @@ -793,7 +796,7 @@ export default class HotReloader { changedClientPages ) const middlewareChanges = Array.from(changedEdgeServerPages).filter( - (name) => name.match(MIDDLEWARE_ROUTE) + (name) => name === MIDDLEWARE_FILENAME ) changedClientPages.clear() changedServerPages.clear() @@ -884,6 +887,7 @@ export default class HotReloader { watcher: this.watcher, pagesDir: this.pagesDir, viewsDir: this.viewsDir, + rootDir: this.dir, nextConfig: this.config, ...(this.config.onDemandEntries as { maxInactiveAge: number diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 00661ac31b..ef64202f52 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -4,12 +4,13 @@ import type { FetchEventResult } from '../web/types' import type { FindComponentsResult } from '../next-server' import type { LoadComponentsReturnType } from '../load-components' import type { Options as ServerOptions } from '../next-server' -import type { Params } from '../router' +import type { Params } from '../../shared/lib/router/utils/route-matcher' import type { ParsedNextUrl } from '../../shared/lib/router/utils/parse-next-url' import type { ParsedUrlQuery } from 'querystring' import type { Server as HTTPServer } from 'http' import type { UrlWithParsedQuery } from 'url' import type { BaseNextRequest, BaseNextResponse } from '../base-http' +import type { RoutingItem } from '../base-server' import crypto from 'crypto' import fs from 'fs' @@ -21,6 +22,7 @@ import { join as pathJoin, relative, resolve as pathResolve, sep } from 'path' import React from 'react' import Watchpack from 'next/dist/compiled/watchpack' import { ampValidation } from '../../build/output' +import { MIDDLEWARE_FILENAME } from '../../lib/constants' import { PUBLIC_DIR_MIDDLEWARE_CONFLICT } from '../../lib/constants' import { fileExists } from '../../lib/file-exists' import { findPagesDir } from '../../lib/find-pages-dir' @@ -33,13 +35,8 @@ import { DEV_CLIENT_PAGES_MANIFEST, DEV_MIDDLEWARE_MANIFEST, } from '../../shared/lib/constants' -import { - getRouteMatcher, - getRouteRegex, - getSortedRoutes, - isDynamicRoute, -} from '../../shared/lib/router/utils' import Server, { WrappedBuildError } from '../next-server' +import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' import { normalizePagePath } from '../../shared/lib/page-path/normalize-page-path' import { absolutePathToPage } from '../../shared/lib/page-path/absolute-path-to-page' import Router from '../router' @@ -61,8 +58,12 @@ import { } from 'next/dist/compiled/@next/react-dev-overlay/middleware' import * as Log from '../../build/output/log' import isError, { getProperError } from '../../lib/is-error' -import { getMiddlewareRegex } from '../../shared/lib/router/utils/get-middleware-regex' -import { isCustomErrorPage, isReservedPage } from '../../build/utils' +import { + getMiddlewareRegex, + getRouteRegex, +} from '../../shared/lib/router/utils/route-regex' +import { getSortedRoutes, isDynamicRoute } from '../../shared/lib/router/utils' +import { runDependingOnPageType } from '../../build/entries' import { NodeNextResponse, NodeNextRequest } from '../base-http/node' import { getPageStaticInfo, @@ -70,6 +71,7 @@ import { } from '../../build/entries' import { normalizePathSep } from '../../shared/lib/page-path/normalize-path-sep' import { normalizeViewPath } from '../../shared/lib/router/utils/view-paths' +import { MIDDLEWARE_FILE } from '../../lib/constants' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: React.FunctionComponent @@ -103,6 +105,13 @@ export default class DevServer extends Server { private pagesDir: string private viewsDir?: string + /** + * Since the dev server is stateful and middleware routes can be added and + * removed over time, we need to keep a list of all of the middleware + * routing items to be returned in `getMiddleware()` + */ + private middleware?: RoutingItem[] + protected staticPathsWorker?: { [key: string]: any } & { loadStaticPaths: typeof import('./static-paths-worker').loadStaticPaths } @@ -245,10 +254,6 @@ export default class DevServer extends Server { return } - const regexMiddleware = new RegExp( - `[\\\\/](_middleware.(?:${this.nextConfig.pageExtensions.join('|')}))$` - ) - const regexPageExtension = new RegExp( `\\.+(?:${this.nextConfig.pageExtensions.join('|')})$` ) @@ -268,48 +273,53 @@ export default class DevServer extends Server { }) let wp = (this.webpackWatcher = new Watchpack()) - const toWatch = [this.pagesDir!] + const pages = [this.pagesDir] + const views = this.viewsDir ? [this.viewsDir] : [] + const directories = [...pages, ...views] + const files = this.nextConfig.pageExtensions.map((extension) => + pathJoin(this.dir, `${MIDDLEWARE_FILENAME}.${extension}`) + ) - if (this.viewsDir) { - toWatch.push(this.viewsDir) - } - wp.watch([], toWatch, 0) + wp.watch(files, directories, 0) wp.on('aggregated', async () => { - const routedMiddleware = [] + const routedMiddleware: string[] = [] const routedPages: string[] = [] const knownFiles = wp.getTimeInfoEntries() const viewPaths: Record = {} const ssrMiddleware = new Set() - for (const [fileName, { accuracy, safeTime }] of knownFiles) { - if (accuracy === undefined || !regexPageExtension.test(fileName)) { + for (const [fileName, meta] of knownFiles) { + if ( + meta?.accuracy === undefined || + !regexPageExtension.test(fileName) + ) { continue } - let pageName: string = '' - let isViewPath = false - if ( + const isViewPath = Boolean( this.viewsDir && - normalizePathSep(fileName).startsWith( - normalizePathSep(this.viewsDir) - ) - ) { - isViewPath = true - pageName = absolutePathToPage( - this.viewsDir, - fileName, - this.nextConfig.pageExtensions, - false - ) - } else { - pageName = absolutePathToPage( - this.pagesDir, - fileName, - this.nextConfig.pageExtensions - ) + normalizePathSep(fileName).startsWith( + normalizePathSep(this.viewsDir) + ) + ) + + const rootFile = absolutePathToPage(fileName, { + pagesDir: this.dir, + extensions: this.nextConfig.pageExtensions, + }) + + if (rootFile === MIDDLEWARE_FILE) { + routedMiddleware.push(`/`) + continue } + let pageName = absolutePathToPage(fileName, { + pagesDir: isViewPath ? this.viewsDir! : this.pagesDir, + extensions: this.nextConfig.pageExtensions, + keepIndex: isViewPath, + }) + if (isViewPath) { // TODO: should only routes ending in /index.js be route-able? const originalPageName = pageName @@ -324,36 +334,36 @@ export default class DevServer extends Server { pageName = pageName.replace(/\/index$/, '') || '/' } - if (regexMiddleware.test(fileName)) { - routedMiddleware.push( - `/${relative(this.pagesDir, fileName).replace(/\\+/g, '/')}` - .replace(/^\/+/g, '/') - .replace(regexMiddleware, '/') + /** + * If there is a middleware that is not declared in the root we will + * warn without adding it so it doesn't make its way into the system. + */ + if (/[\\\\/]_middleware$/.test(pageName)) { + Log.error( + `nested Middleware is not allowed (found pages${pageName}) - https://nextjs.org/docs/messages/nested-middleware` ) continue } - invalidatePageRuntimeCache(fileName, safeTime) - const pageRuntimeConfig = ( - await getPageStaticInfo(fileName, this.nextConfig) - ).runtime - const isEdgeRuntime = pageRuntimeConfig === 'edge' - - if ( - isEdgeRuntime && - !(isReservedPage(pageName) || isCustomErrorPage(pageName)) - ) { - routedMiddleware.push(pageName) - ssrMiddleware.add(pageName) - } - + invalidatePageRuntimeCache(fileName, meta.safeTime) + runDependingOnPageType({ + page: pageName, + pageRuntime: (await getPageStaticInfo(fileName, this.nextConfig)) + .runtime, + onClient: () => {}, + onServer: () => {}, + onEdgeServer: () => { + routedMiddleware.push(pageName) + ssrMiddleware.add(pageName) + }, + }) routedPages.push(pageName) } this.viewPathRoutes = viewPaths this.middleware = getSortedRoutes(routedMiddleware).map((page) => ({ match: getRouteMatcher( - getMiddlewareRegex(page, !ssrMiddleware.has(page)) + getMiddlewareRegex(page, { catchAll: !ssrMiddleware.has(page) }) ), page, ssr: ssrMiddleware.has(page), @@ -497,6 +507,14 @@ export default class DevServer extends Server { return false } + if (normalizedPath === MIDDLEWARE_FILE) { + return findPageFile( + this.dir, + normalizedPath, + this.nextConfig.pageExtensions + ).then(Boolean) + } + // check viewsDir first if enabled if (this.viewsDir) { const pageFile = await findPageFile( @@ -792,12 +810,8 @@ export default class DevServer extends Server { return undefined } - protected getMiddleware(): never[] { - return [] - } - - protected getMiddlewareManifest(): undefined { - return undefined + protected getMiddleware() { + return this.middleware ?? [] } protected getServerComponentManifest() { @@ -808,13 +822,11 @@ export default class DevServer extends Server { pathname: string, isSSR?: boolean ): Promise { - return this.hasPage(isSSR ? pathname : getMiddlewareFilepath(pathname)) + return this.hasPage(isSSR ? pathname : MIDDLEWARE_FILE) } protected async ensureMiddleware(pathname: string, isSSR?: boolean) { - return this.hotReloader!.ensurePage( - isSSR ? pathname : getMiddlewareFilepath(pathname) - ) + return this.hotReloader!.ensurePage(isSSR ? pathname : MIDDLEWARE_FILE) } generateRoutes() { @@ -869,10 +881,10 @@ export default class DevServer extends Server { res .body( JSON.stringify( - this.middleware?.map((middleware) => [ + this.getMiddleware().map((middleware) => [ middleware.page, !!middleware.ssr, - ]) || [] + ]) ) ) .send() @@ -1102,9 +1114,3 @@ export default class DevServer extends Server { return false } } - -function getMiddlewareFilepath(pathname: string) { - return pathname.endsWith('/') - ? `${pathname}_middleware` - : `${pathname}/_middleware` -} diff --git a/packages/next/server/dev/on-demand-entry-handler.ts b/packages/next/server/dev/on-demand-entry-handler.ts index c6b1333005..6e877315e3 100644 --- a/packages/next/server/dev/on-demand-entry-handler.ts +++ b/packages/next/server/dev/on-demand-entry-handler.ts @@ -13,6 +13,7 @@ import { pageNotFoundError } from '../require' import { reportTrigger } from '../../build/output' import getRouteFromEntrypoint from '../get-route-from-entrypoint' import { serverComponentRegex } from '../../build/webpack/loaders/utils' +import { MIDDLEWARE_FILE, MIDDLEWARE_FILENAME } from '../../lib/constants' export const ADDED = Symbol('added') export const BUILDING = Symbol('building') @@ -62,6 +63,7 @@ export function onDemandEntryHandler({ nextConfig, pagesBufferLength, pagesDir, + rootDir, viewsDir, watcher, }: { @@ -70,6 +72,7 @@ export function onDemandEntryHandler({ nextConfig: NextConfigComplete pagesBufferLength: number pagesDir: string + rootDir: string viewsDir?: string watcher: any }) { @@ -96,6 +99,8 @@ export function onDemandEntryHandler({ pagePaths.push(`${type}${page}`) } else if (root && entrypoint.name === 'root') { pagePaths.push(`${type}/${entrypoint.name}`) + } else if (entrypoint.name === MIDDLEWARE_FILENAME) { + pagePaths.push(`${type}/${entrypoint.name}`) } } @@ -185,6 +190,7 @@ export function onDemandEntryHandler({ return { async ensurePage(page: string, clientOnly: boolean) { const pagePathData = await findPagePathData( + rootDir, pagesDir, page, nextConfig.pageExtensions, @@ -346,11 +352,13 @@ class Invalidator { * a page and allowed extensions. If the page can't be found it will throw an * error. It defaults the `/_error` page to Next.js internal error page. * + * @param rootDir Absolute path to the project root. * @param pagesDir Absolute path to the pages folder with trailing `/pages`. * @param normalizedPagePath The page normalized (it will be denormalized). * @param pageExtensions Array of page extensions. */ async function findPagePathData( + rootDir: string, pagesDir: string, page: string, extensions: string[], @@ -358,14 +366,43 @@ async function findPagePathData( ) { const normalizedPagePath = tryToNormalizePagePath(page) let pagePath: string | null = null - let isView = false - // check viewsDir first + if (normalizedPagePath === MIDDLEWARE_FILE) { + pagePath = await findPageFile(rootDir, normalizedPagePath, extensions) + + if (!pagePath) { + throw pageNotFoundError(normalizedPagePath) + } + + const pageUrl = ensureLeadingSlash( + removePagePathTail(normalizePathSep(pagePath), { + extensions, + }) + ) + + return { + absolutePagePath: join(rootDir, pagePath), + bundlePath: normalizedPagePath.slice(1), + page: posix.normalize(pageUrl), + } + } + + // Check viewsDir first falling back to pagesDir if (viewsDir) { pagePath = await findPageFile(viewsDir, normalizedPagePath, extensions) - if (pagePath) { - isView = true + const pageUrl = ensureLeadingSlash( + removePagePathTail(normalizePathSep(pagePath), { + keepIndex: true, + extensions, + }) + ) + + return { + absolutePagePath: join(viewsDir, pagePath), + bundlePath: posix.join('views', normalizePagePath(pageUrl)), + page: posix.normalize(pageUrl), + } } } @@ -375,15 +412,14 @@ async function findPagePathData( if (pagePath !== null) { const pageUrl = ensureLeadingSlash( - removePagePathTail(normalizePathSep(pagePath), extensions, !isView) + removePagePathTail(normalizePathSep(pagePath), { + extensions, + }) ) - const bundleFile = normalizePagePath(pageUrl) - const bundlePath = posix.join(isView ? 'views' : 'pages', bundleFile) - const absolutePagePath = join(isView ? viewsDir! : pagesDir, pagePath) return { - absolutePagePath, - bundlePath, + absolutePagePath: join(pagesDir, pagePath), + bundlePath: posix.join('pages', normalizePagePath(pageUrl)), page: posix.normalize(pageUrl), } } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 3ce99c042e..7b25d1deb5 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -1,7 +1,7 @@ import './node-polyfill-fetch' import './node-polyfill-web-streams' -import type { Params, Route } from './router' +import type { Route } from './router' import type { CacheFs } from '../shared/lib/utils' import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' import type RenderResult from './render-result' @@ -13,6 +13,7 @@ import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import type { PayloadOptions } from './send-payload' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' +import type { Params } from '../shared/lib/router/utils/route-matcher' import fs from 'fs' import { join, relative, resolve, sep } from 'path' @@ -55,8 +56,10 @@ import BaseServer, { FindComponentsResult, prepareServerlessUrl, stringifyQuery, + RoutingItem, } from './base-server' -import { getMiddlewareInfo, getPagePath, requireFontManifest } from './require' +import { getPagePath, requireFontManifest, pageNotFoundError } from './require' +import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { loadComponents } from './load-components' import isError, { getProperError } from '../lib/is-error' @@ -66,14 +69,15 @@ import { relativizeURL } from '../shared/lib/router/utils/relativize-url' import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url' import { prepareDestination } from '../shared/lib/router/utils/prepare-destination' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' -import { getMiddlewareRegex, getRouteMatcher } from '../shared/lib/router/utils' -import { MIDDLEWARE_ROUTE } from '../lib/constants' +import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' +import { MIDDLEWARE_FILENAME } from '../lib/constants' import { loadEnvConfig } from '@next/env' import { getCustomRoute } from './server-route-utils' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import ResponseCache from '../server/response-cache' import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import { clonableBodyForRequest } from './body-streams' +import { getMiddlewareRegex } from '../shared/lib/router/utils/route-regex' export * from './base-server' @@ -91,6 +95,12 @@ export interface NodeRequestHandler { ): Promise } +const middlewareBetaWarning = execOnce(() => { + Log.warn( + `using beta Middleware (not covered by semver) - https://nextjs.org/docs/messages/beta-middleware` + ) +}) + export default class NextNodeServer extends BaseServer { private imageResponseCache?: ResponseCache @@ -830,24 +840,6 @@ export default class NextNodeServer extends BaseServer { ) } - protected async hasMiddleware( - pathname: string, - _isSSR?: boolean - ): Promise { - try { - return ( - getMiddlewareInfo({ - dev: this.renderOpts.dev, - distDir: this.distDir, - page: pathname, - serverless: this._isLikeServerless, - }).paths.length > 0 - ) - } catch (_) {} - - return false - } - public async serveStatic( req: BaseNextRequest | IncomingMessage, res: BaseNextResponse | ServerResponse, @@ -946,21 +938,6 @@ export default class NextNodeServer extends BaseServer { return filesystemUrls.has(resolved) } - protected getMiddlewareInfo(page: string) { - return getMiddlewareInfo({ - dev: this.renderOpts.dev, - page, - distDir: this.distDir, - serverless: this._isLikeServerless, - }) - } - - protected getMiddlewareManifest(): MiddlewareManifest | undefined { - return !this.minimalMode - ? require(join(this.serverDistDir, MIDDLEWARE_MANIFEST)) - : undefined - } - protected generateRewrites({ restrictedRedirectPaths, }: { @@ -1034,6 +1011,203 @@ export default class NextNodeServer extends BaseServer { } } + /** + * Return a list of middleware routing items. This method exists to be later + * overridden by the development server in order to use a different source + * to get the list. + */ + protected getMiddleware(): RoutingItem[] { + if (this.minimalMode) { + return [] + } + + const manifest: MiddlewareManifest = require(join( + this.serverDistDir, + MIDDLEWARE_MANIFEST + )) + + return manifest.sortedMiddleware.map((page) => ({ + match: getRouteMatcher( + getMiddlewareRegex(page, { + catchAll: manifest?.middleware?.[page].name === MIDDLEWARE_FILENAME, + }) + ), + page, + })) + } + + /** + * Get information for the middleware located in the provided page + * folder. If the middleware info can't be found it will throw + * an error. + */ + protected getMiddlewareInfo(page: string) { + const manifest: MiddlewareManifest = require(join( + this.serverDistDir, + MIDDLEWARE_MANIFEST + )) + + let foundPage: string + + try { + foundPage = denormalizePagePath(normalizePagePath(page)) + } catch (err) { + throw pageNotFoundError(page) + } + + let pageInfo = manifest.middleware[foundPage] + if (!pageInfo) { + throw pageNotFoundError(foundPage) + } + + return { + name: pageInfo.name, + paths: pageInfo.files.map((file) => join(this.distDir, file)), + env: pageInfo.env ?? [], + wasm: (pageInfo.wasm ?? []).map((binding) => ({ + ...binding, + filePath: join(this.distDir, binding.filePath), + })), + } + } + + /** + * Checks if a middleware exists. This method is useful for the development + * server where we need to check the filesystem. Here we just check the + * middleware manifest. + */ + protected async hasMiddleware( + pathname: string, + _isSSR?: boolean + ): Promise { + try { + return this.getMiddlewareInfo(pathname).paths.length > 0 + } catch (_) {} + + return false + } + + /** + * A placeholder for a function to be defined in the development server. + * It will make sure that the middleware has been compiled so that we + * can run it. + */ + protected async ensureMiddleware(_pathname: string, _isSSR?: boolean) {} + + /** + * This method gets all middleware matchers and execute them when the request + * matches. It will make sure that each middleware exists and is compiled and + * ready to be invoked. The development server will decorate it to add warns + * and errors with rich traces. + */ + protected async runMiddleware(params: { + request: BaseNextRequest + response: BaseNextResponse + parsedUrl: ParsedNextUrl + parsed: UrlWithParsedQuery + onWarning?: (warning: Error) => void + }): Promise { + middlewareBetaWarning() + const normalizedPathname = removePathTrailingSlash( + params.parsedUrl.pathname + ) + + // For middleware to "fetch" we must always provide an absolute URL + const url = getRequestMeta(params.request, '__NEXT_INIT_URL')! + if (!url.startsWith('http')) { + throw new Error( + 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' + ) + } + + 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 allHeaders = new Headers() + let result: FetchEventResult | null = null + const method = (params.request.method || 'GET').toUpperCase() + let originalBody = + method !== 'GET' && method !== 'HEAD' + ? clonableBodyForRequest(params.request.body) + : undefined + + for (const middleware of this.getMiddleware()) { + if (middleware.match(normalizedPathname)) { + if (!(await this.hasMiddleware(middleware.page, middleware.ssr))) { + console.warn(`The Edge Function for ${middleware.page} was not found`) + continue + } + + await this.ensureMiddleware(middleware.page, middleware.ssr) + const middlewareInfo = this.getMiddlewareInfo(middleware.page) + + result = await run({ + name: middlewareInfo.name, + paths: middlewareInfo.paths, + env: middlewareInfo.env, + wasm: middlewareInfo.wasm, + request: { + headers: params.request.headers, + method, + nextConfig: { + basePath: this.nextConfig.basePath, + i18n: this.nextConfig.i18n, + trailingSlash: this.nextConfig.trailingSlash, + }, + url: url, + page: page, + body: originalBody?.cloneBodyStream(), + }, + useCache: !this.nextConfig.experimental.runtime, + onWarning: (warning: Error) => { + if (params.onWarning) { + warning.message += ` "./${middlewareInfo.name}"` + params.onWarning(warning) + } + }, + }) + + for (let [key, value] of result.response.headers) { + if (key !== 'x-middleware-next') { + allHeaders.append(key, value) + } + } + + if (!this.renderOpts.dev) { + result.waitUntil.catch((error) => { + console.error(`Uncaught: middleware waitUntil errored`, error) + }) + } + + if (!result.response.headers.has('x-middleware-next')) { + break + } + } + } + + if (!result) { + this.render404(params.request, params.response, params.parsed) + } else { + for (let [key, value] of allHeaders) { + result.response.headers.set(key, value) + } + } + + await originalBody?.finalize() + return result + } + protected generateCatchAllMiddlewareRoute(): Route | undefined { if (this.minimalMode) return undefined @@ -1042,7 +1216,8 @@ export default class NextNodeServer extends BaseServer { type: 'route', name: 'middleware catchall', fn: async (req, res, _params, parsed) => { - if (!this.middleware?.length) { + const middleware = this.getMiddleware() + if (!middleware.length) { return { finished: false } } @@ -1058,7 +1233,7 @@ export default class NextNodeServer extends BaseServer { }) const normalizedPathname = removePathTrailingSlash(parsedUrl.pathname) - if (!this.middleware?.some((m) => m.match(normalizedPathname))) { + if (!middleware.some((m) => m.match(normalizedPathname))) { return { finished: false } } @@ -1210,133 +1385,6 @@ export default class NextNodeServer extends BaseServer { } } - protected getMiddleware() { - const middleware = this.middlewareManifest?.middleware || {} - return ( - this.middlewareManifest?.sortedMiddleware.map((page) => ({ - match: getRouteMatcher( - getMiddlewareRegex(page, MIDDLEWARE_ROUTE.test(middleware[page].name)) - ), - page, - })) || [] - ) - } - - private middlewareBetaWarning = execOnce(() => { - Log.warn( - `using beta Middleware (not covered by semver) - https://nextjs.org/docs/messages/beta-middleware` - ) - }) - - protected async runMiddleware(params: { - request: BaseNextRequest - response: BaseNextResponse - parsedUrl: ParsedNextUrl - parsed: UrlWithParsedQuery - onWarning?: (warning: Error) => void - }): Promise { - this.middlewareBetaWarning() - const normalizedPathname = removePathTrailingSlash( - params.parsedUrl.pathname - ) - - // For middleware to "fetch" we must always provide an absolute URL - const url = getRequestMeta(params.request, '__NEXT_INIT_URL')! - if (!url.startsWith('http')) { - throw new Error( - 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' - ) - } - - 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 allHeaders = new Headers() - let result: FetchEventResult | null = null - const method = (params.request.method || 'GET').toUpperCase() - let originalBody = - method !== 'GET' && method !== 'HEAD' - ? clonableBodyForRequest(params.request.body) - : undefined - - for (const middleware of this.middleware || []) { - if (middleware.match(normalizedPathname)) { - if (!(await this.hasMiddleware(middleware.page, middleware.ssr))) { - console.warn(`The Edge Function for ${middleware.page} was not found`) - continue - } - - await this.ensureMiddleware(middleware.page, middleware.ssr) - const middlewareInfo = this.getMiddlewareInfo(middleware.page) - - result = await run({ - name: middlewareInfo.name, - paths: middlewareInfo.paths, - env: middlewareInfo.env, - wasm: middlewareInfo.wasm, - request: { - headers: params.request.headers, - method, - nextConfig: { - basePath: this.nextConfig.basePath, - i18n: this.nextConfig.i18n, - trailingSlash: this.nextConfig.trailingSlash, - }, - url: url, - page: page, - body: originalBody?.cloneBodyStream(), - }, - useCache: !this.nextConfig.experimental.runtime, - onWarning: (warning: Error) => { - if (params.onWarning) { - warning.message += ` "./${middlewareInfo.name}"` - params.onWarning(warning) - } - }, - }) - - for (let [key, value] of result.response.headers) { - if (key !== 'x-middleware-next') { - allHeaders.append(key, value) - } - } - - if (!this.renderOpts.dev) { - result.waitUntil.catch((error) => { - console.error(`Uncaught: middleware waitUntil errored`, error) - }) - } - - if (!result.response.headers.has('x-middleware-next')) { - break - } - } - } - - if (!result) { - this.render404(params.request, params.response, params.parsed) - } else { - for (let [key, value] of allHeaders) { - result.response.headers.set(key, value) - } - } - - await originalBody?.finalize() - - return result - } - private _cachedPreviewManifest: PrerenderManifest | undefined protected getPrerenderManifest(): PrerenderManifest { if (this._cachedPreviewManifest) { diff --git a/packages/next/server/next.ts b/packages/next/server/next.ts index 8469bf1b8b..9e045631f8 100644 --- a/packages/next/server/next.ts +++ b/packages/next/server/next.ts @@ -125,12 +125,11 @@ export class NextServer { } private async loadConfig() { - const phase = this.options.dev - ? PHASE_DEVELOPMENT_SERVER - : PHASE_PRODUCTION_SERVER - const dir = resolve(this.options.dir || '.') - const conf = await loadConfig(phase, dir, this.options.conf) - return conf + return loadConfig( + this.options.dev ? PHASE_DEVELOPMENT_SERVER : PHASE_PRODUCTION_SERVER, + resolve(this.options.dir || '.'), + this.options.conf + ) } private async getServer() { diff --git a/packages/next/server/require.ts b/packages/next/server/require.ts index c5bebb5aca..756059105d 100644 --- a/packages/next/server/require.ts +++ b/packages/next/server/require.ts @@ -2,7 +2,6 @@ import { promises } from 'fs' import { join } from 'path' import { FONT_MANIFEST, - MIDDLEWARE_MANIFEST, PAGES_MANIFEST, SERVER_DIRECTORY, SERVERLESS_DIRECTORY, @@ -12,8 +11,6 @@ import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' -import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' -import type { WasmBinding } from '../build/webpack/loaders/get-module-build-info' export function pageNotFoundError(page: string): Error { const err: any = new Error(`Cannot find module for page: ${page}`) @@ -111,48 +108,3 @@ export function requireFontManifest(distDir: string, serverless: boolean) { const fontManifest = require(join(serverBuildPath, FONT_MANIFEST)) return fontManifest } - -export function getMiddlewareInfo(params: { - dev?: boolean - distDir: string - page: string - serverless: boolean -}): { - name: string - paths: string[] - env: string[] - wasm: WasmBinding[] -} { - const serverBuildPath = join( - params.distDir, - params.serverless && !params.dev ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY - ) - - const middlewareManifest: MiddlewareManifest = require(join( - serverBuildPath, - MIDDLEWARE_MANIFEST - )) - - let page: string - - try { - page = denormalizePagePath(normalizePagePath(params.page)) - } catch (err) { - throw pageNotFoundError(params.page) - } - - let pageInfo = middlewareManifest.middleware[page] - if (!pageInfo) { - throw pageNotFoundError(page) - } - - return { - name: pageInfo.name, - paths: pageInfo.files.map((file) => join(params.distDir, file)), - env: pageInfo.env ?? [], - wasm: (pageInfo.wasm ?? []).map((binding) => ({ - ...binding, - filePath: join(params.distDir, binding.filePath), - })), - } -} diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 1546c97a2b..19a2045b95 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -1,5 +1,9 @@ import type { ParsedUrlQuery } from 'querystring' import type { BaseNextRequest, BaseNextResponse } from './base-http' +import type { + RouteMatch, + Params, +} from '../shared/lib/router/utils/route-matcher' import { getNextInternalQuery, NextUrlWithParsedQuery } from './request-meta' import { getPathMatch } from '../shared/lib/router/utils/path-match' @@ -9,10 +13,6 @@ import { RouteHas } from '../lib/load-custom-routes' import { matchHas } from '../shared/lib/router/utils/prepare-destination' import { getRequestMeta } from './request-meta' -export type Params = { [param: string]: any } - -export type RouteMatch = (pathname: string | null | undefined) => false | Params - type RouteResult = { finished: boolean pathname?: string diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 4747f91ca1..151db8e4cb 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -2,7 +2,7 @@ import type { WebNextRequest, WebNextResponse } from './base-http/web' import type { RenderOpts } from './render' import type RenderResult from './render-result' import type { NextParsedUrlQuery } from './request-meta' -import type { Params } from './router' +import type { Params } from '../shared/lib/router/utils/route-matcher' import type { PayloadOptions } from './send-payload' import type { LoadComponentsReturnType } from './load-components' import type { Options } from './base-server' @@ -71,9 +71,6 @@ export default class NextWebServer extends BaseServer { protected getHasStaticDir() { return false } - protected async hasMiddleware() { - return false - } protected generateImageRoutes() { return [] } @@ -86,18 +83,12 @@ export default class NextWebServer extends BaseServer { protected generatePublicRoutes() { return [] } - protected getMiddleware() { - return [] - } protected generateCatchAllMiddlewareRoute() { return undefined } protected getFontManifest() { return undefined } - protected getMiddlewareManifest() { - return undefined - } protected getPagesManifest() { return { [this.serverOptions.webServerConfig.page]: '', diff --git a/packages/next/shared/lib/page-path/absolute-path-to-page.ts b/packages/next/shared/lib/page-path/absolute-path-to-page.ts index 9e9b7a2b55..2f5e4d8272 100644 --- a/packages/next/shared/lib/page-path/absolute-path-to-page.ts +++ b/packages/next/shared/lib/page-path/absolute-path-to-page.ts @@ -9,19 +9,24 @@ import { removePagePathTail } from './remove-page-path-tail' * relative to the pages folder. It doesn't consider index tail. Example: * - `/Users/rick/my-project/pages/foo/bar/baz.js` -> `/foo/bar/baz` * - * @param pagesDir Absolute path to the pages folder. * @param filepath Absolute path to the page. - * @param extensions Extensions allowed for the page. + * @param opts.pagesDir Absolute path to the pages folder. + * @param opts.extensions Extensions allowed for the page. + * @param opts.keepIndex When true the trailing `index` kept in the path. */ export function absolutePathToPage( - pagesDir: string, pagePath: string, - extensions: string[], - stripIndex = true + options: { + extensions: string[] + keepIndex?: boolean + pagesDir: string + } ) { return removePagePathTail( - normalizePathSep(ensureLeadingSlash(relative(pagesDir, pagePath))), - extensions, - stripIndex + normalizePathSep(ensureLeadingSlash(relative(options.pagesDir, pagePath))), + { + extensions: options.extensions, + keepIndex: options.keepIndex, + } ) } diff --git a/packages/next/shared/lib/page-path/remove-page-path-tail.ts b/packages/next/shared/lib/page-path/remove-page-path-tail.ts index d945b52d9e..1e1757b98d 100644 --- a/packages/next/shared/lib/page-path/remove-page-path-tail.ts +++ b/packages/next/shared/lib/page-path/remove-page-path-tail.ts @@ -8,19 +8,22 @@ import { normalizePathSep } from './normalize-path-sep' * - `/foo/bar/baz.js` -> `/foo/bar/baz` * * @param pagePath A page to a page file (absolute or relative) - * @param extensions Extensions allowed for the page. + * @param options.extensions Extensions allowed for the page. + * @param options.keepIndex When true the trailing `index` is _not_ removed. */ export function removePagePathTail( pagePath: string, - extensions: string[], - stripIndex?: boolean + options: { + extensions: string[] + keepIndex?: boolean + } ) { pagePath = normalizePathSep(pagePath).replace( - new RegExp(`\\.+(?:${extensions.join('|')})$`), + new RegExp(`\\.+(?:${options.extensions.join('|')})$`), '' ) - if (stripIndex) { + if (options.keepIndex !== true) { pagePath = pagePath.replace(/\/index$/, '') || '/' } diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index c14ed91717..5454e7f401 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -36,8 +36,7 @@ import { parseRelativeUrl } from './utils/parse-relative-url' import { searchParamsToUrlQuery } from './utils/querystring' import resolveRewrites from './utils/resolve-rewrites' import { getRouteMatcher } from './utils/route-matcher' -import { getRouteRegex } from './utils/route-regex' -import { getMiddlewareRegex } from './utils/get-middleware-regex' +import { getRouteRegex, getMiddlewareRegex } from './utils/route-regex' import { formatWithValidation } from './utils/format-url' declare global { @@ -1920,7 +1919,11 @@ export default class Router implements BaseRouter { const fns = await this.pageLoader.getMiddlewareList() const requiresPreflight = fns.some(([middleware, isSSR]) => { - return getRouteMatcher(getMiddlewareRegex(middleware, !isSSR))(cleanedAs) + return getRouteMatcher( + getMiddlewareRegex(middleware, { + catchAll: !isSSR, + }) + )(cleanedAs) }) if (!requiresPreflight) { diff --git a/packages/next/shared/lib/router/utils/get-middleware-regex.ts b/packages/next/shared/lib/router/utils/get-middleware-regex.ts deleted file mode 100644 index 446a059936..0000000000 --- a/packages/next/shared/lib/router/utils/get-middleware-regex.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getParametrizedRoute, RouteRegex } from './route-regex' - -export function getMiddlewareRegex( - normalizedRoute: string, - catchAll: boolean = true -): RouteRegex { - const result = getParametrizedRoute(normalizedRoute) - - let catchAllRegex = catchAll ? '(?!_next).*' : '' - let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' - - if ('routeKeys' in result) { - if (result.parameterizedRoute === '/') { - return { - groups: {}, - namedRegex: `^/${catchAllRegex}$`, - re: new RegExp(`^/${catchAllRegex}$`), - routeKeys: {}, - } - } - - return { - groups: result.groups, - namedRegex: `^${result.namedParameterizedRoute}${catchAllGroupedRegex}$`, - re: new RegExp(`^${result.parameterizedRoute}${catchAllGroupedRegex}$`), - routeKeys: result.routeKeys, - } - } - - if (result.parameterizedRoute === '/') { - return { - groups: {}, - re: new RegExp(`^/${catchAllRegex}$`), - } - } - - return { - groups: {}, - re: new RegExp(`^${result.parameterizedRoute}${catchAllGroupedRegex}$`), - } -} diff --git a/packages/next/shared/lib/router/utils/index.ts b/packages/next/shared/lib/router/utils/index.ts index aa4bce0912..549e234d3f 100644 --- a/packages/next/shared/lib/router/utils/index.ts +++ b/packages/next/shared/lib/router/utils/index.ts @@ -1,5 +1,2 @@ -export { getMiddlewareRegex } from './get-middleware-regex' -export { getRouteMatcher } from './route-matcher' -export { getRouteRegex } from './route-regex' export { getSortedRoutes } from './sorted-routes' export { isDynamicRoute } from './is-dynamic' diff --git a/packages/next/shared/lib/router/utils/prepare-destination.ts b/packages/next/shared/lib/router/utils/prepare-destination.ts index 52a2bbe689..a34067e1af 100644 --- a/packages/next/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/shared/lib/router/utils/prepare-destination.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from 'http' import type { Key } from 'next/dist/compiled/path-to-regexp' import type { NextParsedUrlQuery } from '../../../../server/request-meta' -import type { Params } from '../../../../server/router' +import type { Params } from './route-matcher' import type { RouteHas } from '../../../../lib/load-custom-routes' import type { BaseNextRequest } from '../../../../server/base-http' diff --git a/packages/next/shared/lib/router/utils/remove-trailing-slash.ts b/packages/next/shared/lib/router/utils/remove-trailing-slash.ts new file mode 100644 index 0000000000..03abf4242e --- /dev/null +++ b/packages/next/shared/lib/router/utils/remove-trailing-slash.ts @@ -0,0 +1,10 @@ +/** + * Removes the trailing slash for a given route or page path. Preserves the + * root page. Examples: + * - `/foo/bar/` -> `/foo/bar` + * - `/foo/bar` -> `/foo/bar` + * - `/` -> `/` + */ +export function removeTrailingSlash(route: string) { + return route.replace(/\/$/, '') || '/' +} diff --git a/packages/next/shared/lib/router/utils/route-matcher.ts b/packages/next/shared/lib/router/utils/route-matcher.ts index 22a6c85bda..bdc074c955 100644 --- a/packages/next/shared/lib/router/utils/route-matcher.ts +++ b/packages/next/shared/lib/router/utils/route-matcher.ts @@ -1,8 +1,15 @@ +import type { RouteRegex } from './route-regex' import { DecodeError } from '../../utils' -import { getRouteRegex } from './route-regex' -export function getRouteMatcher(routeRegex: ReturnType) { - const { re, groups } = routeRegex +export interface RouteMatch { + (pathname: string | null | undefined): false | Params +} + +export interface Params { + [param: string]: any +} + +export function getRouteMatcher({ re, groups }: RouteRegex): RouteMatch { return (pathname: string | null | undefined) => { const routeMatch = re.exec(pathname!) if (!routeMatch) { diff --git a/packages/next/shared/lib/router/utils/route-regex.ts b/packages/next/shared/lib/router/utils/route-regex.ts index 0787b62343..9fa42ca299 100644 --- a/packages/next/shared/lib/router/utils/route-regex.ts +++ b/packages/next/shared/lib/router/utils/route-regex.ts @@ -1,65 +1,70 @@ import { escapeStringRegexp } from '../../escape-regexp' +import { removeTrailingSlash } from './remove-trailing-slash' -interface Group { +export interface Group { pos: number repeat: boolean optional: boolean } -function parseParameter(param: string) { - const optional = param.startsWith('[') && param.endsWith(']') - if (optional) { - param = param.slice(1, -1) - } - const repeat = param.startsWith('...') - if (repeat) { - param = param.slice(3) - } - return { key: param, repeat, optional } +export interface RouteRegex { + groups: { [groupName: string]: Group } + re: RegExp } -export function getParametrizedRoute(route: string) { - const segments = (route.replace(/\/$/, '') || '/').slice(1).split('/') +/** + * From a normalized route this function generates a regular expression and + * a corresponding groups object inteded to be used to store matching groups + * from the regular expression. + */ +export function getRouteRegex(normalizedRoute: string): RouteRegex { + const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute) + return { + re: new RegExp(`^${parameterizedRoute}(?:/)?$`), + groups: groups, + } +} +/** + * This function extends `getRouteRegex` generating also a named regexp where + * each group is named along with a routeKeys object that indexes the assigned + * named group with its corresponding key. + */ +export function getNamedRouteRegex(normalizedRoute: string) { + const result = getNamedParametrizedRoute(normalizedRoute) + return { + ...getRouteRegex(normalizedRoute), + namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, + routeKeys: result.routeKeys, + } +} + +function getParametrizedRoute(route: string) { + const segments = removeTrailingSlash(route).slice(1).split('/') const groups: { [groupName: string]: Group } = {} let groupIndex = 1 - const parameterizedRoute = segments - .map((segment) => { - if (segment.startsWith('[') && segment.endsWith(']')) { - const { key, optional, repeat } = parseParameter(segment.slice(1, -1)) - groups[key] = { pos: groupIndex++, repeat, optional } - return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)' - } else { - return `/${escapeStringRegexp(segment)}` - } - }) - .join('') - - // dead code eliminate for browser since it's only needed - // while generating routes-manifest - if (typeof window === 'undefined') { - let routeKeyCharCode = 97 - let routeKeyCharLength = 1 - - // builds a minimal routeKey using only a-z and minimal number of characters - const getSafeRouteKey = () => { - let routeKey = '' - - for (let i = 0; i < routeKeyCharLength; i++) { - routeKey += String.fromCharCode(routeKeyCharCode) - routeKeyCharCode++ - - if (routeKeyCharCode > 122) { - routeKeyCharLength++ - routeKeyCharCode = 97 + return { + parameterizedRoute: segments + .map((segment) => { + if (segment.startsWith('[') && segment.endsWith(']')) { + const { key, optional, repeat } = parseParameter(segment.slice(1, -1)) + groups[key] = { pos: groupIndex++, repeat, optional } + return repeat ? (optional ? '(?:/(.+?))?' : '/(.+?)') : '/([^/]+?)' + } else { + return `/${escapeStringRegexp(segment)}` } - } - return routeKey - } + }) + .join(''), + groups, + } +} - const routeKeys: { [named: string]: string } = {} - - let namedParameterizedRoute = segments +function getNamedParametrizedRoute(route: string) { + const segments = removeTrailingSlash(route).slice(1).split('/') + const getSafeRouteKey = buildGetSafeRouteKey() + const routeKeys: { [named: string]: string } = {} + return { + namedParameterizedRoute: segments .map((segment) => { if (segment.startsWith('[') && segment.endsWith(']')) { const { key, optional, repeat } = parseParameter(segment.slice(1, -1)) @@ -91,42 +96,104 @@ export function getParametrizedRoute(route: string) { return `/${escapeStringRegexp(segment)}` } }) - .join('') + .join(''), + routeKeys, + } +} +/** + * Parses a given parameter from a route to a data structure that can be used + * to generate the parametrized route. Examples: + * - `[...slug]` -> `{ name: 'slug', repeat: true, optional: true }` + * - `[foo]` -> `{ name: 'foo', repeat: false, optional: true }` + * - `bar` -> `{ name: 'bar', repeat: false, optional: false }` + */ +function parseParameter(param: string) { + const optional = param.startsWith('[') && param.endsWith(']') + if (optional) { + param = param.slice(1, -1) + } + const repeat = param.startsWith('...') + if (repeat) { + param = param.slice(3) + } + return { key: param, repeat, optional } +} + +/** + * Builds a function to generate a minimal routeKey using only a-z and minimal + * number of characters. + */ +function buildGetSafeRouteKey() { + let routeKeyCharCode = 97 + let routeKeyCharLength = 1 + + return () => { + let routeKey = '' + for (let i = 0; i < routeKeyCharLength; i++) { + routeKey += String.fromCharCode(routeKeyCharCode) + routeKeyCharCode++ + + if (routeKeyCharCode > 122) { + routeKeyCharLength++ + routeKeyCharCode = 97 + } + } + return routeKey + } +} + +/** + * From a middleware normalized route this function generates a regular + * expression for it. Temporarly we are using this to generate Edge Function + * routes too. In such cases the route should not include a trailing catch-all. + * For these cases the option `catchAll` should be set to false. + */ +export function getMiddlewareRegex( + normalizedRoute: string, + options?: { + catchAll?: boolean + } +): RouteRegex { + const { parameterizedRoute, groups } = getParametrizedRoute(normalizedRoute) + const { catchAll = true } = options ?? {} + if (parameterizedRoute === '/') { + let catchAllRegex = catchAll ? '(?!_next).*' : '' return { - parameterizedRoute, - namedParameterizedRoute, - groups, - routeKeys, + groups: {}, + re: new RegExp(`^/${catchAllRegex}$`), } } + let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' return { - parameterizedRoute, - groups, + groups: groups, + re: new RegExp(`^${parameterizedRoute}${catchAllGroupedRegex}$`), } } -export interface RouteRegex { - groups: { [groupName: string]: Group } - namedRegex?: string - re: RegExp - routeKeys?: { [named: string]: string } -} - -export function getRouteRegex(normalizedRoute: string): RouteRegex { - const result = getParametrizedRoute(normalizedRoute) - if ('routeKeys' in result) { +/** + * A server version for getMiddlewareRegex that generates a named regexp. + * This is intended to be using for build time only. + */ +export function getNamedMiddlewareRegex( + normalizedRoute: string, + options: { + catchAll?: boolean + } +) { + const { parameterizedRoute } = getParametrizedRoute(normalizedRoute) + const { catchAll = true } = options + if (parameterizedRoute === '/') { + let catchAllRegex = catchAll ? '(?!_next).*' : '' return { - re: new RegExp(`^${result.parameterizedRoute}(?:/)?$`), - groups: result.groups, - routeKeys: result.routeKeys, - namedRegex: `^${result.namedParameterizedRoute}(?:/)?$`, + namedRegex: `^/${catchAllRegex}$`, } } + const { namedParameterizedRoute } = getNamedParametrizedRoute(normalizedRoute) + let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' return { - re: new RegExp(`^${result.parameterizedRoute}(?:/)?$`), - groups: result.groups, + namedRegex: `^${namedParameterizedRoute}${catchAllGroupedRegex}$`, } } diff --git a/test/e2e/middleware-can-use-wasm-files/index.test.ts b/test/e2e/middleware-can-use-wasm-files/index.test.ts index 808bff3444..5441f4d913 100644 --- a/test/e2e/middleware-can-use-wasm-files/index.test.ts +++ b/test/e2e/middleware-can-use-wasm-files/index.test.ts @@ -17,8 +17,11 @@ function baseNextConfig(): Parameters[0] { return exports.add_one(a); } `, - 'pages/_middleware.js': ` - import { increment } from '../src/add.js' + 'pages/index.js': ` + export default function () { return
Hello, world!
} + `, + 'middleware.js': ` + import { increment } from './src/add.js' export default async function middleware(request) { const input = Number(request.nextUrl.searchParams.get('input')) || 1; const value = await increment(input); diff --git a/test/integration/middleware/with-base-path/pages/_middleware.js b/test/integration/middleware-base-path/middleware.js similarity index 100% rename from test/integration/middleware/with-base-path/pages/_middleware.js rename to test/integration/middleware-base-path/middleware.js diff --git a/test/integration/middleware/with-base-path/next.config.js b/test/integration/middleware-base-path/next.config.js similarity index 100% rename from test/integration/middleware/with-base-path/next.config.js rename to test/integration/middleware-base-path/next.config.js diff --git a/test/integration/middleware/with-base-path/pages/about.js b/test/integration/middleware-base-path/pages/about.js similarity index 100% rename from test/integration/middleware/with-base-path/pages/about.js rename to test/integration/middleware-base-path/pages/about.js diff --git a/test/integration/middleware/with-base-path/pages/index.js b/test/integration/middleware-base-path/pages/index.js similarity index 100% rename from test/integration/middleware/with-base-path/pages/index.js rename to test/integration/middleware-base-path/pages/index.js diff --git a/test/integration/middleware/with-base-path/test/index.test.js b/test/integration/middleware-base-path/test/index.test.js similarity index 100% rename from test/integration/middleware/with-base-path/test/index.test.js rename to test/integration/middleware-base-path/test/index.test.js diff --git a/test/integration/middleware/with-eval/lib/utils.js b/test/integration/middleware-dynamic-code/lib/utils.js similarity index 100% rename from test/integration/middleware/with-eval/lib/utils.js rename to test/integration/middleware-dynamic-code/lib/utils.js diff --git a/test/integration/middleware/with-eval/pages/_middleware.js b/test/integration/middleware-dynamic-code/middleware.js similarity index 88% rename from test/integration/middleware/with-eval/pages/_middleware.js rename to test/integration/middleware-dynamic-code/middleware.js index 0ea96d0bb8..60a8860854 100644 --- a/test/integration/middleware/with-eval/pages/_middleware.js +++ b/test/integration/middleware-dynamic-code/middleware.js @@ -1,4 +1,4 @@ -import { notUsingEval, usingEval } from '../lib/utils' +import { notUsingEval, usingEval } from './lib/utils' export async function middleware(request) { if (request.nextUrl.pathname === '/using-eval') { diff --git a/test/integration/middleware/with-eval/pages/index.js b/test/integration/middleware-dynamic-code/pages/index.js similarity index 100% rename from test/integration/middleware/with-eval/pages/index.js rename to test/integration/middleware-dynamic-code/pages/index.js diff --git a/test/integration/middleware/with-eval/test/index.test.js b/test/integration/middleware-dynamic-code/test/index.test.js similarity index 95% rename from test/integration/middleware/with-eval/test/index.test.js rename to test/integration/middleware-dynamic-code/test/index.test.js index ce2239fd02..a51409d84e 100644 --- a/test/integration/middleware/with-eval/test/index.test.js +++ b/test/integration/middleware-dynamic-code/test/index.test.js @@ -45,7 +45,7 @@ describe('Middleware usage of dynamic code evaluation', () => { expect(json.value).toEqual(100) expect(output).toContain(DYNAMIC_CODE_ERROR) expect(output).toContain('DynamicCodeEvaluationWarning') - expect(output).toContain('pages/_middleware') + expect(output).toContain('./middleware') // TODO check why that has a backslash on windows expect(output).toMatch(/lib[\\/]utils\.js/) expect(output).toContain('usingEval') @@ -81,7 +81,7 @@ describe('Middleware usage of dynamic code evaluation', () => { it('should have middleware warning during build', () => { expect(buildResult.stderr).toContain(`Failed to compile`) expect(buildResult.stderr).toContain(`Used by usingEval`) - expect(buildResult.stderr).toContain(`./pages/_middleware.js`) + expect(buildResult.stderr).toContain(`./middleware.js`) expect(buildResult.stderr).toContain(DYNAMIC_CODE_ERROR) }) }) diff --git a/test/integration/middleware/core/pages/interface/_middleware.js b/test/integration/middleware-general/middleware.js similarity index 63% rename from test/integration/middleware/core/pages/interface/_middleware.js rename to test/integration/middleware-general/middleware.js index 5f70ff396e..40dd617a6e 100644 --- a/test/integration/middleware/core/pages/interface/_middleware.js +++ b/test/integration/middleware-general/middleware.js @@ -1,37 +1,11 @@ /* global globalThis */ - -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' +import magicValue from 'shared-package' export async function middleware(request) { const url = request.nextUrl - if (url.pathname.endsWith('/globalthis')) { - return new NextResponse(JSON.stringify(Object.keys(globalThis)), { - headers: { - 'content-type': 'application/json; charset=utf-8', - }, - }) - } - - if (url.pathname.endsWith('/fetchURL')) { - const response = {} - try { - await fetch(new URL('http://localhost')) - } catch (err) { - response.error = { - name: err.name, - message: err.message, - } - } finally { - return new NextResponse(JSON.stringify(response), { - headers: { - 'content-type': 'application/json; charset=utf-8', - }, - }) - } - } - - if (url.pathname.includes('/fetchUserAgentDefault')) { + if (url.pathname.startsWith('/fetch-user-agent-default')) { try { const apiRoute = new URL(url) apiRoute.pathname = '/api/headers' @@ -52,7 +26,7 @@ export async function middleware(request) { } } - if (url.pathname.includes('/fetchUserAgentCustom')) { + if (url.pathname.startsWith('/fetch-user-agent-crypto')) { try { const apiRoute = new URL(url) apiRoute.pathname = '/api/headers' @@ -77,6 +51,25 @@ export async function middleware(request) { } } + if (url.pathname === '/global') { + // The next line is required to allow to find the env variable + // eslint-disable-next-line no-unused-expressions + process.env.MIDDLEWARE_TEST + return NextResponse.json({ + process: { + env: process.env, + }, + }) + } + + if (url.pathname.endsWith('/globalthis')) { + return new NextResponse(JSON.stringify(Object.keys(globalThis)), { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } + if (url.pathname.endsWith('/webcrypto')) { const response = {} try { @@ -99,11 +92,25 @@ export async function middleware(request) { } } - if (url.pathname.endsWith('/root-subrequest')) { - return fetch(url) + if (url.pathname.endsWith('/fetch-url')) { + const response = {} + try { + await fetch(new URL('http://localhost')) + } catch (err) { + response.error = { + name: err.name, + message: err.message, + } + } finally { + return new NextResponse(JSON.stringify(response), { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }) + } } - if (url.pathname.endsWith('/abort-controller')) { + if (url.pathname === '/abort-controller') { const controller = new AbortController() const signal = controller.signal @@ -126,11 +133,52 @@ export async function middleware(request) { } } - if (url.pathname.endsWith('/dynamic-replace')) { - url.pathname = '/_interface/dynamic-path' - return NextResponse.rewrite(url) + if (url.pathname.endsWith('/root-subrequest')) { + const res = await fetch(url) + res.headers.set('x-dynamic-path', 'true') + return res } + if (url.pathname === '/about') { + if (magicValue !== 42) throw new Error('shared-package problem') + return NextResponse.rewrite(new URL('/about/a', request.url)) + } + + if (url.pathname.startsWith('/url')) { + try { + if (request.nextUrl.pathname === '/url/relative-url') { + return NextResponse.json({ message: String(new URL('/relative')) }) + } + + if (request.nextUrl.pathname === '/url/relative-request') { + return fetch(new Request('/urls-b')) + } + + if (request.nextUrl.pathname === '/url/relative-redirect') { + return Response.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-redirect') { + return NextResponse.redirect('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-rewrite') { + return NextResponse.rewrite('/urls-b') + } + + if (request.nextUrl.pathname === '/url/relative-next-request') { + return fetch(new NextRequest('/urls-b')) + } + } catch (error) { + return NextResponse.json({ + error: { + message: error.message, + }, + }) + } + } + + // Map metadata by default return new Response(null, { headers: { 'req-url-basepath': request.nextUrl.basePath, diff --git a/test/integration/middleware/core/next.config.js b/test/integration/middleware-general/next.config.js similarity index 100% rename from test/integration/middleware/core/next.config.js rename to test/integration/middleware-general/next.config.js diff --git a/test/integration/middleware/hmr/node_modules/shared-package/index.js b/test/integration/middleware-general/node_modules/shared-package/index.js similarity index 100% rename from test/integration/middleware/hmr/node_modules/shared-package/index.js rename to test/integration/middleware-general/node_modules/shared-package/index.js diff --git a/test/integration/middleware/hmr/node_modules/shared-package/package.json b/test/integration/middleware-general/node_modules/shared-package/package.json similarity index 100% rename from test/integration/middleware/hmr/node_modules/shared-package/package.json rename to test/integration/middleware-general/node_modules/shared-package/package.json diff --git a/test/integration/middleware/core/pages/interface/[id]/index.js b/test/integration/middleware-general/pages/[id].js similarity index 100% rename from test/integration/middleware/core/pages/interface/[id]/index.js rename to test/integration/middleware-general/pages/[id].js diff --git a/test/integration/middleware/hmr/pages/about/a.js b/test/integration/middleware-general/pages/about/a.js similarity index 100% rename from test/integration/middleware/hmr/pages/about/a.js rename to test/integration/middleware-general/pages/about/a.js diff --git a/test/integration/middleware/hmr/pages/about/b.js b/test/integration/middleware-general/pages/about/b.js similarity index 100% rename from test/integration/middleware/hmr/pages/about/b.js rename to test/integration/middleware-general/pages/about/b.js diff --git a/test/integration/middleware/core/pages/api/headers.js b/test/integration/middleware-general/pages/api/headers.js similarity index 100% rename from test/integration/middleware/core/pages/api/headers.js rename to test/integration/middleware-general/pages/api/headers.js diff --git a/test/integration/middleware-general/test/index.test.js b/test/integration/middleware-general/test/index.test.js new file mode 100644 index 0000000000..73d52d1ae5 --- /dev/null +++ b/test/integration/middleware-general/test/index.test.js @@ -0,0 +1,298 @@ +/* eslint-env jest */ + +import { join } from 'path' +import fs from 'fs-extra' +import webdriver from 'next-webdriver' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, + waitFor, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const middlewareWarning = 'using beta Middleware (not covered by semver)' +const urlsError = 'Please use only absolute URLs' +const context = { + appDir: join(__dirname, '../'), + buildLogs: { output: '', stdout: '', stderr: '' }, + logs: { output: '', stdout: '', stderr: '' }, +} + +describe('Middleware Runtime', () => { + describe('dev mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort, { + env: { + MIDDLEWARE_TEST: 'asdf', + NEXT_RUNTIME: 'edge', + }, + onStdout(msg) { + context.logs.output += msg + context.logs.stdout += msg + }, + onStderr(msg) { + context.logs.output += msg + context.logs.stderr += msg + }, + }) + }) + + tests(context) + + // This test has to be after something has been executed with middleware + it('should have showed warning for middleware usage', () => { + expect(context.logs.output).toContain(middlewareWarning) + }) + + it('refreshes the page when middleware changes ', async () => { + const browser = await webdriver(context.appPort, `/about`) + await browser.eval('window.didrefresh = "hello"') + const text = await browser.elementByCss('h1').text() + expect(text).toEqual('AboutA') + + const middlewarePath = join(context.appDir, '/middleware.js') + const originalContent = fs.readFileSync(middlewarePath, 'utf-8') + const editedContent = originalContent.replace('/about/a', '/about/b') + + try { + fs.writeFileSync(middlewarePath, editedContent) + await waitFor(1000) + const textb = await browser.elementByCss('h1').text() + expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') + expect(textb).toEqual('AboutB') + } finally { + fs.writeFileSync(middlewarePath, originalContent) + await browser.close() + } + }) + }) + + describe('production mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + const build = await nextBuild(context.appDir, undefined, { + stderr: true, + stdout: true, + }) + + context.buildLogs = { + output: build.stdout + build.stderr, + stderr: build.stderr, + stdout: build.stdout, + } + + context.appPort = await findPort() + context.app = await nextStart(context.appDir, context.appPort, { + env: { + MIDDLEWARE_TEST: 'asdf', + NEXT_RUNTIME: 'edge', + }, + onStdout(msg) { + context.logs.output += msg + context.logs.stdout += msg + }, + onStderr(msg) { + context.logs.output += msg + context.logs.stderr += msg + }, + }) + }) + + it('should have middleware warning during build', () => { + expect(context.buildLogs.output).toContain(middlewareWarning) + }) + + it('should have middleware warning during start', () => { + expect(context.logs.output).toContain(middlewareWarning) + }) + + it('should have correct files in manifest', async () => { + const manifest = await fs.readJSON( + join(context.appDir, '.next/server/middleware-manifest.json') + ) + for (const key of Object.keys(manifest.middleware)) { + const middleware = manifest.middleware[key] + expect(middleware.files).toContainEqual( + expect.stringContaining('server/edge-runtime-webpack') + ) + expect(middleware.files).not.toContainEqual( + expect.stringContaining('static/chunks/') + ) + } + }) + + tests(context) + }) +}) + +function tests(context, locale = '') { + it('should set fetch user agent correctly', async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/fetch-user-agent-default` + ) + expect((await res.json()).headers['user-agent']).toBe('Next.js Middleware') + + const res2 = await fetchViaHTTP( + context.appPort, + `${locale}/fetch-user-agent-crypto` + ) + expect((await res2.json()).headers['user-agent']).toBe('custom-agent') + }) + + it('should contain process polyfill', async () => { + const res = await fetchViaHTTP(context.appPort, `/global`) + const json = await res.json() + expect(json).toEqual({ + process: { + env: { + MIDDLEWARE_TEST: 'asdf', + NEXT_RUNTIME: 'edge', + }, + }, + }) + }) + + it(`should contain \`globalThis\``, async () => { + const res = await fetchViaHTTP(context.appPort, '/globalthis') + const globals = await res.json() + expect(globals.length > 0).toBe(true) + }) + + it(`should contain crypto APIs`, async () => { + const res = await fetchViaHTTP(context.appPort, '/webcrypto') + const response = await res.json() + expect('error' in response).toBe(false) + }) + + it(`should accept a URL instance for fetch`, async () => { + const res = await fetchViaHTTP(context.appPort, '/fetch-url') + const response = await res.json() + expect('error' in response).toBe(true) + expect(response.error.name).not.toBe('TypeError') + }) + + it(`should allow to abort a fetch request`, async () => { + const res = await fetchViaHTTP(context.appPort, '/abort-controller') + const response = await res.json() + expect('error' in response).toBe(true) + expect(response.error.name).toBe('AbortError') + expect(response.error.message).toBe('The user aborted a request.') + }) + + it(`should validate & parse request url from any route`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/static`) + expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-pathname')).toBe('/static') + expect(res.headers.get('req-url-params')).not.toBe('{}') + expect(res.headers.get('req-url-query')).not.toBe('bar') + if (locale !== '') { + expect(res.headers.get('req-url-locale')).toBe(locale.slice(1)) + } + }) + + it(`should validate & parse request url from a dynamic route with params`, async () => { + const res = await fetchViaHTTP(context.appPort, `/fr/1`) + expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-pathname')).toBe('/1') + expect(res.headers.get('req-url-params')).toBe('{"id":"1"}') + expect(res.headers.get('req-url-page')).toBe('/[id]') + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + + it(`should validate & parse request url from a dynamic route with params and no query`, async () => { + const res = await fetchViaHTTP(context.appPort, `/fr/abc123`) + expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-pathname')).toBe('/abc123') + expect(res.headers.get('req-url-params')).toBe('{"id":"abc123"}') + expect(res.headers.get('req-url-page')).toBe('/[id]') + expect(res.headers.get('req-url-query')).not.toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('fr') + }) + + it(`should validate & parse request url from a dynamic route with params and query`, async () => { + const res = await fetchViaHTTP(context.appPort, `/abc123?foo=bar`) + expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-pathname')).toBe('/abc123') + expect(res.headers.get('req-url-params')).toBe('{"id":"abc123"}') + expect(res.headers.get('req-url-page')).toBe('/[id]') + expect(res.headers.get('req-url-query')).toBe('bar') + expect(res.headers.get('req-url-locale')).toBe('en') + }) + + it(`should render correctly rewriting with a root subrequest`, async () => { + const browser = await webdriver(context.appPort, '/root-subrequest') + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('Dynamic route') + }) + + it(`should allow subrequests without infinite loops`, async () => { + const res = await fetchViaHTTP(context.appPort, `/root-subrequest`) + expect(res.headers.get('x-dynamic-path')).toBe('true') + }) + + it('should throw when using URL with a relative URL', async () => { + const res = await fetchViaHTTP(context.appPort, `/url/relative-url`) + const json = await res.json() + expect(json.error.message).toContain('Invalid URL') + }) + + it('should throw when using Request with a relative URL', async () => { + const res = await fetchViaHTTP(context.appPort, `/url/relative-request`) + const json = await res.json() + expect(json.error.message).toContain('Invalid URL') + }) + + it('should throw when using NextRequest with a relative URL', async () => { + const res = await fetchViaHTTP( + context.appPort, + `/url/relative-next-request` + ) + const json = await res.json() + expect(json.error.message).toContain('Invalid URL') + }) + + it('should warn when using Response.redirect with a relative URL', async () => { + const response = await fetchViaHTTP( + context.appPort, + `/url/relative-redirect` + ) + expect(await response.json()).toEqual({ + error: { + message: expect.stringContaining(urlsError), + }, + }) + }) + + it('should warn when using NextResponse.redirect with a relative URL', async () => { + const response = await fetchViaHTTP( + context.appPort, + `/url/relative-next-redirect` + ) + expect(await response.json()).toEqual({ + error: { + message: expect.stringContaining(urlsError), + }, + }) + }) + + it('should throw when using NextResponse.rewrite with a relative URL', async () => { + const response = await fetchViaHTTP( + context.appPort, + `/url/relative-next-rewrite` + ) + expect(await response.json()).toEqual({ + error: { + message: expect.stringContaining(urlsError), + }, + }) + }) +} diff --git a/test/integration/middleware-module-errors/middleware.js b/test/integration/middleware-module-errors/middleware.js new file mode 100644 index 0000000000..ead516c976 --- /dev/null +++ b/test/integration/middleware-module-errors/middleware.js @@ -0,0 +1 @@ +export default () => {} diff --git a/test/integration/middleware-module-errors/pages/about/_middleware.js b/test/integration/middleware-module-errors/pages/about/_middleware.js new file mode 100644 index 0000000000..499e7603b0 --- /dev/null +++ b/test/integration/middleware-module-errors/pages/about/_middleware.js @@ -0,0 +1 @@ +export function middleware() {} diff --git a/test/integration/middleware-module-errors/pages/about/index.js b/test/integration/middleware-module-errors/pages/about/index.js new file mode 100644 index 0000000000..9924acf037 --- /dev/null +++ b/test/integration/middleware-module-errors/pages/about/index.js @@ -0,0 +1,7 @@ +export default function About() { + return ( +
+

About Page

+
+ ) +} diff --git a/test/integration/middleware-module-errors/pages/index.js b/test/integration/middleware-module-errors/pages/index.js new file mode 100644 index 0000000000..c5cc676685 --- /dev/null +++ b/test/integration/middleware-module-errors/pages/index.js @@ -0,0 +1,3 @@ +export default function Page() { + return
ok
+} diff --git a/test/integration/middleware-module-errors/test/index.test.js b/test/integration/middleware-module-errors/test/index.test.js new file mode 100644 index 0000000000..af092f39c3 --- /dev/null +++ b/test/integration/middleware-module-errors/test/index.test.js @@ -0,0 +1,230 @@ +/* eslint-env jest */ + +import stripAnsi from 'next/dist/compiled/strip-ansi' +import { getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage } from 'next/dist/build/utils' +import { join } from 'path' +import { + fetchViaHTTP, + File, + findPort, + killApp, + launchApp, + nextBuild, + waitFor, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const WEBPACK_BREAKING_CHANGE = 'BREAKING CHANGE:' +const context = { + appDir: join(__dirname, '../'), + buildLogs: { output: '', stdout: '', stderr: '' }, + logs: { output: '', stdout: '', stderr: '' }, + middleware: new File(join(__dirname, '../middleware.js')), + page: new File(join(__dirname, '../pages/index.js')), +} + +describe('Middleware importing Node.js modules', () => { + function getModuleNotFound(name) { + return `Module not found: Can't resolve '${name}'` + } + + function escapeLF(s) { + return s.replace(/\n/g, '\\n') + } + + afterEach(() => { + context.middleware.restore() + context.page.restore() + if (context.app) { + killApp(context.app) + } + }) + + describe('dev mode', () => { + // restart the app for every test since the latest error is not shown sometimes + // See https://github.com/vercel/next.js/issues/36575 + beforeEach(async () => { + context.logs = { output: '', stdout: '', stderr: '' } + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort, { + env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, + onStdout(msg) { + context.logs.output += msg + context.logs.stdout += msg + }, + onStderr(msg) { + context.logs.output += msg + context.logs.stderr += msg + }, + }) + }) + + it('shows the right error when importing `path` on middleware', async () => { + context.middleware.write(` + import { NextResponse } from 'next/server' + import { basename } from 'path' + + export async function middleware(request) { + console.log(basename('/foo/bar/baz/asdf/quux.html')) + return NextResponse.next() + } + `) + const res = await fetchViaHTTP(context.appPort, '/') + const text = await res.text() + await waitFor(500) + const msg = getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('path') + expect(res.status).toBe(500) + expect(context.logs.output).toContain(getModuleNotFound('path')) + expect(context.logs.output).toContain(msg) + expect(text).toContain(escapeLF(msg)) + expect(stripAnsi(context.logs.output)).toContain( + "import { basename } from 'path'" + ) + expect(context.logs.output).not.toContain(WEBPACK_BREAKING_CHANGE) + }) + + it('shows the right error when importing `child_process` on middleware', async () => { + context.middleware.write(` + import { NextResponse } from 'next/server' + import { spawn } from 'child_process' + + export async function middleware(request) { + console.log(spawn('ls', ['-lh', '/usr'])) + return NextResponse.next() + } + `) + const res = await fetchViaHTTP(context.appPort, '/') + const text = await res.text() + await waitFor(500) + const msg = + getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') + expect(res.status).toBe(500) + expect(context.logs.output).toContain(getModuleNotFound('child_process')) + expect(context.logs.output).toContain(msg) + expect(text).toContain(escapeLF(msg)) + expect(stripAnsi(context.logs.output)).toContain( + "import { spawn } from 'child_process'" + ) + expect(context.logs.output).not.toContain(WEBPACK_BREAKING_CHANGE) + }) + + it('shows the right error when importing a non-node-builtin module on middleware', async () => { + context.middleware.write(` + import { NextResponse } from 'next/server' + import NotExist from 'not-exist' + + export async function middleware(request) { + new NotExist() + return NextResponse.next() + } + `) + const res = await fetchViaHTTP(context.appPort, '/') + expect(res.status).toBe(500) + + const text = await res.text() + await waitFor(500) + const msg = + getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('not-exist') + expect(context.logs.output).toContain(getModuleNotFound('not-exist')) + expect(context.logs.output).not.toContain(msg) + expect(text).not.toContain(escapeLF(msg)) + }) + + it('shows the right error when importing `child_process` on a page', async () => { + context.page.write(` + import { spawn } from 'child_process' + export default function Page() { + spawn('ls', ['-lh', '/usr']) + return
ok
+ } + `) + + await fetchViaHTTP(context.appPort, '/') + + // Need to request twice + // See: https://github.com/vercel/next.js/issues/36387 + const res = await fetchViaHTTP(context.appPort, '/') + expect(res.status).toBe(500) + + const text = await res.text() + await waitFor(500) + const msg = + getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') + expect(context.logs.output).toContain(getModuleNotFound('child_process')) + expect(context.logs.output).not.toContain(msg) + expect(text).not.toContain(escapeLF(msg)) + }) + + it('warns about nested middleware being not allowed', async () => { + const file = new File(join(__dirname, '../pages/about/_middleware.js')) + file.write(`export function middleware() {}`) + try { + const res = await fetchViaHTTP(context.appPort, '/about') + expect(context.logs.stderr).toContain( + 'nested Middleware is not allowed (found pages/about/_middleware) - https://nextjs.org/docs/messages/nested-middleware' + ) + expect(res.status).toBe(200) + } finally { + file.delete() + } + }) + }) + + describe('production mode', () => { + it('fails with the right middleware error during build', async () => { + context.middleware.write(` + import { NextResponse } from 'next/server' + import { spawn } from 'child_process' + + export async function middleware(request) { + console.log(spawn('ls', ['-lh', '/usr'])) + return NextResponse.next() + } + `) + const buildResult = await nextBuild(context.appDir, undefined, { + stderr: true, + stdout: true, + }) + + expect(buildResult.stderr).toContain(getModuleNotFound('child_process')) + expect(buildResult.stderr).toContain( + getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') + ) + expect(buildResult.stderr).not.toContain(WEBPACK_BREAKING_CHANGE) + }) + + it('fails with the right page error during build', async () => { + context.page.write(` + import { spawn } from 'child_process' + export default function Page() { + spawn('ls', ['-lh', '/usr']) + return
ok
+ } + `) + + const buildResult = await nextBuild(context.appDir, undefined, { + stderr: true, + stdout: true, + }) + + expect(buildResult.stderr).toContain(getModuleNotFound('child_process')) + expect(buildResult.stderr).not.toContain( + getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') + ) + }) + + it('fails when there is a not allowed middleware', async () => { + const file = new File(join(__dirname, '../pages/about/_middleware.js')) + file.write(`export function middleware() {}`) + const buildResult = await nextBuild(context.appDir, undefined, { + stderr: true, + stdout: true, + }) + + expect(buildResult.stderr).toContain( + 'Error: nested Middleware is not allowed (found pages/about/_middleware) - https://nextjs.org/docs/messages/nested-middleware' + ) + }) + }) +}) diff --git a/test/integration/middleware-preflight/middleware.js b/test/integration/middleware-preflight/middleware.js new file mode 100644 index 0000000000..84649595c9 --- /dev/null +++ b/test/integration/middleware-preflight/middleware.js @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' + +export function middleware(req) { + const url = req.nextUrl.clone() + if (url.pathname.startsWith('/i18n')) { + url.searchParams.set('locale', url.locale) + return NextResponse.rewrite(url) + } + + if ( + url.pathname === '/error-throw' && + req.headers.has('x-middleware-preflight') + ) { + throw new Error('test error') + } +} diff --git a/test/integration/middleware/with-i18n-preflight/next.config.js b/test/integration/middleware-preflight/next.config.js similarity index 73% rename from test/integration/middleware/with-i18n-preflight/next.config.js rename to test/integration/middleware-preflight/next.config.js index 51c5585977..1bb5ce3d23 100644 --- a/test/integration/middleware/with-i18n-preflight/next.config.js +++ b/test/integration/middleware-preflight/next.config.js @@ -1,6 +1,6 @@ module.exports = { i18n: { locales: ['ja', 'en', 'fr'], - defaultLocale: 'ja', + defaultLocale: 'en', }, } diff --git a/test/integration/middleware/core/pages/errors/throw-on-preflight.js b/test/integration/middleware-preflight/pages/error-throw.js similarity index 100% rename from test/integration/middleware/core/pages/errors/throw-on-preflight.js rename to test/integration/middleware-preflight/pages/error-throw.js diff --git a/test/integration/middleware/core/pages/errors/index.js b/test/integration/middleware-preflight/pages/error.js similarity index 72% rename from test/integration/middleware/core/pages/errors/index.js rename to test/integration/middleware-preflight/pages/error.js index 4c740edb43..3a303fe627 100644 --- a/test/integration/middleware/core/pages/errors/index.js +++ b/test/integration/middleware-preflight/pages/error.js @@ -3,7 +3,7 @@ import Link from 'next/link' export default function Errors() { return ( diff --git a/test/integration/middleware/with-i18n-preflight/pages/index.js b/test/integration/middleware-preflight/pages/i18n.js similarity index 76% rename from test/integration/middleware/with-i18n-preflight/pages/index.js rename to test/integration/middleware-preflight/pages/i18n.js index 60ada1770a..baaa991ff0 100644 --- a/test/integration/middleware/with-i18n-preflight/pages/index.js +++ b/test/integration/middleware-preflight/pages/i18n.js @@ -6,27 +6,27 @@ export default function Home({ locale }) {
{locale}
  • - + Go to en
  • - + Go to en2
  • - + Go to ja
  • - + Go to ja2
  • - + Go to fr
  • diff --git a/test/integration/middleware/with-i18n-preflight/test/index.test.js b/test/integration/middleware-preflight/test/index.test.js similarity index 79% rename from test/integration/middleware/with-i18n-preflight/test/index.test.js rename to test/integration/middleware-preflight/test/index.test.js index ceee0a3947..f2a4f04d39 100644 --- a/test/integration/middleware/with-i18n-preflight/test/index.test.js +++ b/test/integration/middleware-preflight/test/index.test.js @@ -50,9 +50,7 @@ function runTests() { itif(!USE_SELENIUM)( `should send preflight for specified locale`, async () => { - const browser = await webdriver(context.appPort, '/', { - locale: 'en-US,en', - }) + const browser = await webdriver(context.appPort, '/i18n') await browser.waitForElementByCss('.en') await browser.elementByCss('#link-ja').click() await browser.waitForElementByCss('.ja') @@ -66,4 +64,12 @@ function runTests() { await browser.waitForElementByCss('.en') } ) + + it(`hard-navigates when preflight request failed`, async () => { + const browser = await webdriver(context.appPort, `/error`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#throw-on-preflight').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBeUndefined() + }) } diff --git a/test/integration/middleware-redirects/middleware.js b/test/integration/middleware-redirects/middleware.js new file mode 100644 index 0000000000..f8a2f35ee8 --- /dev/null +++ b/test/integration/middleware-redirects/middleware.js @@ -0,0 +1,67 @@ +export async function middleware(request) { + const url = request.nextUrl + + if (url.pathname === '/old-home') { + url.pathname = '/new-home' + return Response.redirect(url) + } + + if (url.searchParams.get('foo') === 'bar') { + url.pathname = '/new-home' + url.searchParams.delete('foo') + return Response.redirect(url) + } + + // Chained redirects + if (url.pathname === '/redirect-me-alot') { + url.pathname = '/redirect-me-alot-2' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-2') { + url.pathname = '/redirect-me-alot-3' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-3') { + url.pathname = '/redirect-me-alot-4' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-4') { + url.pathname = '/redirect-me-alot-5' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-5') { + url.pathname = '/redirect-me-alot-6' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-6') { + url.pathname = '/redirect-me-alot-7' + return Response.redirect(url) + } + + if (url.pathname === '/redirect-me-alot-7') { + url.pathname = '/new-home' + return Response.redirect(url) + } + + // Infinite loop + if (url.pathname === '/infinite-loop') { + url.pathname = '/infinite-loop-1' + return Response.redirect(url) + } + + if (url.pathname === '/infinite-loop-1') { + url.pathname = '/infinite-loop' + return Response.redirect(url) + } + + if (url.pathname === '/to') { + url.pathname = url.searchParams.get('pathname') + url.searchParams.delete('pathname') + return Response.redirect(url) + } +} diff --git a/test/integration/middleware-redirects/next.config.js b/test/integration/middleware-redirects/next.config.js new file mode 100644 index 0000000000..f548199a3b --- /dev/null +++ b/test/integration/middleware-redirects/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, +} diff --git a/test/integration/middleware/core/pages/api/ok.js b/test/integration/middleware-redirects/pages/api/ok.js similarity index 100% rename from test/integration/middleware/core/pages/api/ok.js rename to test/integration/middleware-redirects/pages/api/ok.js diff --git a/test/integration/middleware/core/pages/redirects/index.js b/test/integration/middleware-redirects/pages/index.js similarity index 61% rename from test/integration/middleware/core/pages/redirects/index.js rename to test/integration/middleware-redirects/pages/index.js index f1a9a1e193..d4b4bbb80a 100644 --- a/test/integration/middleware/core/pages/redirects/index.js +++ b/test/integration/middleware-redirects/pages/index.js @@ -4,32 +4,32 @@ export default function Home() { return (

    Home Page

    - + Redirect me to a new version of a page
    - + Redirect me with URL params intact
    - + Redirect me to Google (with no body response)
    - + Redirect me to Google (with no stream response)
    - + Redirect me alot (chained requests)
    - + Redirect me alot (infinite loop)
    - - >Redirect me to api with locale + + Redirect me to api with locale
    diff --git a/test/integration/middleware/core/pages/redirects/new-home.js b/test/integration/middleware-redirects/pages/new-home.js similarity index 100% rename from test/integration/middleware/core/pages/redirects/new-home.js rename to test/integration/middleware-redirects/pages/new-home.js diff --git a/test/integration/middleware-redirects/test/index.test.js b/test/integration/middleware-redirects/test/index.test.js new file mode 100644 index 0000000000..5cfc083464 --- /dev/null +++ b/test/integration/middleware-redirects/test/index.test.js @@ -0,0 +1,115 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const context = { + appDir: join(__dirname, '../'), + logs: { output: '', stdout: '', stderr: '' }, +} + +describe('Middleware Redirect', () => { + describe('dev mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort) + }) + + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) + + describe('production mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + await nextBuild(context.appDir) + context.appPort = await findPort() + context.app = await nextStart(context.appDir, context.appPort) + }) + + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) +}) + +function testsWithLocale(context, locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should redirect`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/old-home`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(context.appPort, `${locale}/old-home`) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should redirect cleanly with the original url param`, async () => { + const browser = await webdriver( + context.appPort, + `${locale}/blank-page?foo=bar` + ) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(`${locale}/new-home`) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect multiple times`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/redirect-me-alot` + ) + const browser = await webdriver( + context.appPort, + `${locale}/redirect-me-alot` + ) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('Welcome to a new page') + }) + + it(`${label}should redirect (infinite-loop)`, async () => { + await expect( + fetchViaHTTP(context.appPort, `${locale}/infinite-loop`) + ).rejects.toThrow() + }) + + it(`${label}should redirect to api route with locale`, async () => { + const browser = await webdriver(context.appPort, `${locale}`) + await browser.elementByCss('#link-to-api-with-locale').click() + await browser.waitForCondition('window.location.pathname === "/api/ok"') + const body = await browser.elementByCss('body').text() + expect(body).toBe('ok') + }) +} diff --git a/test/integration/middleware/core/lib/utils.js b/test/integration/middleware-responses/lib/utils.js similarity index 100% rename from test/integration/middleware/core/lib/utils.js rename to test/integration/middleware-responses/lib/utils.js diff --git a/test/integration/middleware/core/pages/responses/_middleware.js b/test/integration/middleware-responses/middleware.js similarity index 86% rename from test/integration/middleware/core/pages/responses/_middleware.js rename to test/integration/middleware-responses/middleware.js index ea3782adb2..2e91aec519 100644 --- a/test/integration/middleware/core/pages/responses/_middleware.js +++ b/test/integration/middleware-responses/middleware.js @@ -1,7 +1,7 @@ +import { NextResponse } from 'next/server' import { createElement } from 'react' import { renderToString } from 'react-dom/server.browser' -import { NextResponse } from 'next/server' -import { getText } from '../../lib/utils' +import { getText } from './lib/utils' export async function middleware(request, ev) { // eslint-disable-next-line no-undef @@ -27,12 +27,12 @@ export async function middleware(request, ev) { } // Sends a header - if (url.pathname === '/responses/header') { + if (url.pathname === '/header') { next.headers.set('x-first-header', 'valid') return next } - if (url.pathname === '/responses/two-cookies') { + if (url.pathname === '/two-cookies') { const headers = new Headers() headers.append('set-cookie', 'foo=chocochip') headers.append('set-cookie', 'bar=chocochip') @@ -42,7 +42,7 @@ export async function middleware(request, ev) { } // Streams a basic response - if (url.pathname === '/responses/stream-a-response') { + if (url.pathname === '/stream-a-response') { ev.waitUntil( (async () => { writer.write(encoder.encode('this is a streamed ')) @@ -55,14 +55,14 @@ export async function middleware(request, ev) { return new Response(readable) } - if (url.pathname === '/responses/bad-status') { + if (url.pathname === '/bad-status') { return new Response('Auth required', { headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, status: 401, }) } - if (url.pathname === '/responses/stream-long') { + if (url.pathname === '/stream-long') { ev.waitUntil( (async () => { writer.write(encoder.encode('this is a streamed '.repeat(10))) @@ -79,12 +79,12 @@ export async function middleware(request, ev) { } // Sends response - if (url.pathname === '/responses/send-response') { + if (url.pathname === '/send-response') { return new Response(JSON.stringify({ message: 'hi!' })) } // Render React component - if (url.pathname === '/responses/react') { + if (url.pathname === '/react') { return new Response( renderToString( createElement( @@ -97,7 +97,7 @@ export async function middleware(request, ev) { } // Stream React component - if (url.pathname === '/responses/react-stream') { + if (url.pathname === '/react-stream') { ev.waitUntil( (async () => { writer.write( diff --git a/test/integration/middleware-responses/next.config.js b/test/integration/middleware-responses/next.config.js new file mode 100644 index 0000000000..f548199a3b --- /dev/null +++ b/test/integration/middleware-responses/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + i18n: { + locales: ['en', 'fr', 'nl'], + defaultLocale: 'en', + }, +} diff --git a/test/integration/middleware/core/pages/responses/index.js b/test/integration/middleware-responses/pages/index.js similarity index 66% rename from test/integration/middleware/core/pages/responses/index.js rename to test/integration/middleware-responses/pages/index.js index 416ee0b556..caae111a7c 100644 --- a/test/integration/middleware/core/pages/responses/index.js +++ b/test/integration/middleware-responses/pages/index.js @@ -4,58 +4,58 @@ export default function Home({ message }) { return (

    Hello {message}

    - + Stream a response
    - + Stream a long response - + Test streaming after response ends
    - + Attempt to add a header after stream ends
    - + Redirect to Google and attempt to stream after
    - + Respond with a header
    - + Respond with 2 headers (nested middleware effect)
    - + Respond with body, end, set a header
    - + Respond with body, end, send another body
    - + Respond with body
    - + Redirect and then send a body
    - + Send React component as a body
    - + Stream React component
    - + 404
    diff --git a/test/integration/middleware-responses/test/index.test.js b/test/integration/middleware-responses/test/index.test.js new file mode 100644 index 0000000000..e5dcaa21fa --- /dev/null +++ b/test/integration/middleware-responses/test/index.test.js @@ -0,0 +1,125 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import { + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) +const context = { appDir: join(__dirname, '../') } + +describe('Middleware Responses', () => { + describe('dev mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort) + }) + + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) + + describe('production mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + await nextBuild(context.appDir) + context.appPort = await findPort() + context.app = await nextStart(context.appDir, context.appPort) + }) + + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) +}) + +function testsWithLocale(context, locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}responds with multiple cookies`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/two-cookies`) + expect(res.headers.raw()['set-cookie']).toEqual([ + 'foo=chocochip', + 'bar=chocochip', + ]) + }) + + it(`${label}should stream a response`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/stream-a-response` + ) + const html = await res.text() + expect(html).toBe('this is a streamed response with some text') + }) + + it(`${label}should respond with a body`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/send-response`) + const html = await res.text() + expect(html).toBe('{"message":"hi!"}') + }) + + it(`${label}should respond with a 401 status code`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/bad-status`) + const html = await res.text() + expect(res.status).toBe(401) + expect(html).toBe('Auth required') + }) + + it(`${label}should render a React component`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/react?name=jack`) + const html = await res.text() + expect(html).toBe('

    SSR with React! Hello, jack

    ') + }) + + it(`${label}should stream a React component`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/react-stream`) + const html = await res.text() + expect(html).toBe('

    I am a stream

    I am another stream

    ') + }) + + it(`${label}should stream a long response`, async () => { + const res = await fetchViaHTTP(context.appPort, '/stream-long') + const html = await res.text() + expect(html).toBe( + 'this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds ' + ) + }) + + it(`${label}should render the right content via SSR`, async () => { + const res = await fetchViaHTTP(context.appPort, '/') + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('Hello World') + }) + + it(`${label}should respond with one header`, async () => { + const res = await fetchViaHTTP(context.appPort, `${locale}/header`) + expect(res.headers.get('x-first-header')).toBe('valid') + }) + + it(`${label}should respond with two headers`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/header?nested-header=true` + ) + expect(res.headers.get('x-first-header')).toBe('valid') + expect(res.headers.get('x-nested-header')).toBe('valid') + }) + + it(`${label}should respond appending headers headers`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/?nested-header=true&append-me=true&cookie-me=true` + ) + expect(res.headers.get('x-nested-header')).toBe('valid') + expect(res.headers.get('x-append-me')).toBe('top') + expect(res.headers.raw()['set-cookie']).toEqual(['bar=chocochip']) + }) +} diff --git a/test/integration/middleware/core/pages/rewrites/_middleware.js b/test/integration/middleware-rewrites/middleware.js similarity index 52% rename from test/integration/middleware/core/pages/rewrites/_middleware.js rename to test/integration/middleware-rewrites/middleware.js index 925c4dec9d..87d1200494 100644 --- a/test/integration/middleware/core/pages/rewrites/_middleware.js +++ b/test/integration/middleware-rewrites/middleware.js @@ -1,63 +1,62 @@ import { NextResponse } from 'next/server' +const PUBLIC_FILE = /\.(.*)$/ + /** * @param {import('next/server').NextRequest} request */ export async function middleware(request) { const url = request.nextUrl - if ( - url.pathname.startsWith('/rewrites/about') && - url.searchParams.has('override') - ) { + if (url.pathname.startsWith('/about') && url.searchParams.has('override')) { const isExternal = url.searchParams.get('override') === 'external' return NextResponse.rewrite( isExternal ? 'https://example.vercel.sh' - : new URL('/rewrites/a', request.url) + : new URL('/ab-test/a', request.url) ) } - if (url.pathname.startsWith('/rewrites/to-blog')) { + if (url.pathname.startsWith('/to-blog')) { const slug = url.pathname.split('/').pop() - url.pathname = `/rewrites/fallback-true-blog/${slug}` + url.pathname = `/fallback-true-blog/${slug}` return NextResponse.rewrite(url) } - if (url.pathname === '/rewrites/rewrite-to-ab-test') { + if (url.pathname === '/rewrite-to-ab-test') { let bucket = request.cookies.get('bucket') if (!bucket) { bucket = Math.random() >= 0.5 ? 'a' : 'b' - url.pathname = `/rewrites/${bucket}` + url.pathname = `/ab-test/${bucket}` const response = NextResponse.rewrite(url) response.cookies.set('bucket', bucket, { maxAge: 10 }) return response } - url.pathname = `/rewrites/${bucket}` + url.pathname = `/${bucket}` return NextResponse.rewrite(url) } - if (url.pathname === '/rewrites/rewrite-me-to-about') { - url.pathname = '/rewrites/about' + if (url.pathname === '/rewrite-me-to-about') { + url.pathname = '/about' return NextResponse.rewrite(url) } - if (url.pathname === '/rewrites/rewrite-me-with-a-colon') { - url.pathname = '/rewrites/with:colon' + if (url.pathname === '/rewrite-me-with-a-colon') { + url.pathname = '/with:colon' return NextResponse.rewrite(url) } - if (url.pathname === '/rewrites/colon:here') { - url.pathname = '/rewrites/no-colon-here' + if (url.pathname === '/colon:here') { + url.pathname = '/no-colon-here' return NextResponse.rewrite(url) } - if (url.pathname === '/rewrites/rewrite-me-to-vercel') { + if (url.pathname === '/rewrite-me-to-vercel') { return NextResponse.rewrite('https://example.vercel.sh') } - if (url.pathname === '/rewrites/clear-query-params') { + if (url.pathname === '/clear-query-params') { const allowedKeys = ['allowed'] for (const key of [...url.searchParams.keys()]) { if (!allowedKeys.includes(key)) { @@ -68,16 +67,34 @@ export async function middleware(request) { } if ( - url.pathname === '/rewrites/rewrite-me-without-hard-navigation' || + url.pathname === '/rewrite-me-without-hard-navigation' || url.searchParams.get('path') === 'rewrite-me-without-hard-navigation' ) { url.searchParams.set('middleware', 'foo') url.pathname = request.cookies.has('about-bypass') - ? '/rewrites/about-bypass' - : '/rewrites/about' + ? '/about-bypass' + : '/about' const response = NextResponse.rewrite(url) response.headers.set('x-middleware-cache', 'no-cache') return response } + + if (url.pathname.endsWith('/dynamic-replace')) { + url.pathname = '/dynamic-fallback/catch-all' + return NextResponse.rewrite(url) + } + + if (url.pathname.startsWith('/country')) { + const locale = url.searchParams.get('my-locale') + if (locale) { + url.locale = locale + } + + const country = url.searchParams.get('country') || 'us' + if (!PUBLIC_FILE.test(url.pathname) && !url.pathname.includes('/api/')) { + url.pathname = `/country/${country}` + return NextResponse.rewrite(url) + } + } } diff --git a/test/integration/middleware/with-i18n/next.config.js b/test/integration/middleware-rewrites/next.config.js similarity index 100% rename from test/integration/middleware/with-i18n/next.config.js rename to test/integration/middleware-rewrites/next.config.js diff --git a/test/integration/middleware/core/pages/rewrites/[param].js b/test/integration/middleware-rewrites/pages/[param].js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/[param].js rename to test/integration/middleware-rewrites/pages/[param].js diff --git a/test/integration/middleware/core/pages/rewrites/a.js b/test/integration/middleware-rewrites/pages/ab-test/a.js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/a.js rename to test/integration/middleware-rewrites/pages/ab-test/a.js diff --git a/test/integration/middleware/core/pages/rewrites/b.js b/test/integration/middleware-rewrites/pages/ab-test/b.js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/b.js rename to test/integration/middleware-rewrites/pages/ab-test/b.js diff --git a/test/integration/middleware/core/pages/rewrites/about-bypass.js b/test/integration/middleware-rewrites/pages/about-bypass.js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/about-bypass.js rename to test/integration/middleware-rewrites/pages/about-bypass.js diff --git a/test/integration/middleware/core/pages/rewrites/about.js b/test/integration/middleware-rewrites/pages/about.js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/about.js rename to test/integration/middleware-rewrites/pages/about.js diff --git a/test/integration/middleware/core/pages/rewrites/clear-query-params.js b/test/integration/middleware-rewrites/pages/clear-query-params.js similarity index 100% rename from test/integration/middleware/core/pages/rewrites/clear-query-params.js rename to test/integration/middleware-rewrites/pages/clear-query-params.js diff --git a/test/integration/middleware/with-i18n/pages/test/[country].js b/test/integration/middleware-rewrites/pages/country/[country].js similarity index 100% rename from test/integration/middleware/with-i18n/pages/test/[country].js rename to test/integration/middleware-rewrites/pages/country/[country].js diff --git a/test/integration/middleware/core/pages/_interface/[...parts].js b/test/integration/middleware-rewrites/pages/dynamic-fallback/[...parts].js similarity index 100% rename from test/integration/middleware/core/pages/_interface/[...parts].js rename to test/integration/middleware-rewrites/pages/dynamic-fallback/[...parts].js diff --git a/test/integration/middleware/core/pages/rewrites/fallback-true-blog/[slug].js b/test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js similarity index 88% rename from test/integration/middleware/core/pages/rewrites/fallback-true-blog/[slug].js rename to test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js index bee8e293fa..acd458886a 100644 --- a/test/integration/middleware/core/pages/rewrites/fallback-true-blog/[slug].js +++ b/test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js @@ -10,7 +10,7 @@ export default function Page(props) { export function getStaticPaths() { return { - paths: ['/rewrites/fallback-true-blog/first'], + paths: ['/fallback-true-blog/first'], fallback: true, } } diff --git a/test/integration/middleware/core/pages/rewrites/index.js b/test/integration/middleware-rewrites/pages/index.js similarity index 69% rename from test/integration/middleware/core/pages/rewrites/index.js rename to test/integration/middleware-rewrites/pages/index.js index 4fbadef290..9d75d6ad15 100644 --- a/test/integration/middleware/core/pages/rewrites/index.js +++ b/test/integration/middleware-rewrites/pages/index.js @@ -7,31 +7,31 @@ export default function Home() {

    Home Page

    - + A/B test homepage
    - + Rewrite me to about
    - + Rewrite me to Vercel
    - + Redirect me to Vercel (but with double reroutes)
    - + Rewrite me without a hard navigation
    - + Rewrite me to external site
    - + Rewrite me to internal path
    @@ -41,7 +41,7 @@ export default function Home() { onClick={(e) => { e.preventDefault() router.push( - '/rewrites?path=rewrite-me-without-hard-navigation&message=refreshed', + '/?path=rewrite-me-without-hard-navigation&message=refreshed', undefined, { shallow: true } ) diff --git a/test/integration/middleware-rewrites/test/index.test.js b/test/integration/middleware-rewrites/test/index.test.js new file mode 100644 index 0000000000..6a0b4e2f9d --- /dev/null +++ b/test/integration/middleware-rewrites/test/index.test.js @@ -0,0 +1,400 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { + check, + fetchViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const context = { + appDir: join(__dirname, '../'), + logs: { output: '', stdout: '', stderr: '' }, +} + +describe('Middleware Rewrite', () => { + describe('dev mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + context.appPort = await findPort() + context.app = await launchApp(context.appDir, context.appPort, { + onStdout(msg) { + context.logs.output += msg + context.logs.stdout += msg + }, + onStderr(msg) { + context.logs.output += msg + context.logs.stderr += msg + }, + }) + }) + + tests(context) + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) + + describe('production mode', () => { + afterAll(() => killApp(context.app)) + beforeAll(async () => { + await nextBuild(context.appDir, undefined) + context.appPort = await findPort() + context.app = await nextStart(context.appDir, context.appPort, { + onStdout(msg) { + context.logs.output += msg + context.logs.stdout += msg + }, + onStderr(msg) { + context.logs.output += msg + context.logs.stderr += msg + }, + }) + }) + + tests(context) + testsWithLocale(context) + testsWithLocale(context, '/fr') + }) +}) + +function tests(context, locale = '') { + it('should override with rewrite internally correctly', async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/about`, + { override: 'internal' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(context.appPort, `${locale}`) + await browser.elementByCss('#override-with-internal-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Welcome Page A/ + ) + expect(await browser.eval('window.location.pathname')).toBe( + `${locale || ''}/about` + ) + expect(await browser.eval('window.location.search')).toBe( + '?override=internal' + ) + }) + + it('should override with rewrite externally correctly', async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/about`, + { override: 'external' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + const browser = await webdriver(context.appPort, `${locale}`) + await browser.elementByCss('#override-with-external-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Example Domain/ + ) + await check( + () => browser.eval('window.location.pathname'), + `${locale || ''}/about` + ) + await check( + () => browser.eval('window.location.search'), + '?override=external' + ) + }) + + it('should rewrite to fallback: true page successfully', async () => { + const randomSlug = `another-${Date.now()}` + const res2 = await fetchViaHTTP( + context.appPort, + `${locale}/to-blog/${randomSlug}` + ) + expect(res2.status).toBe(200) + expect(await res2.text()).toContain('Loading...') + + const randomSlug2 = `another-${Date.now()}` + const browser = await webdriver( + context.appPort, + `${locale}/to-blog/${randomSlug2}` + ) + + await check(async () => { + const props = JSON.parse(await browser.elementByCss('#props').text()) + return props.params.slug === randomSlug2 + ? 'success' + : JSON.stringify(props) + }, 'success') + }) + + it(`warns about a query param deleted`, async () => { + await fetchViaHTTP(context.appPort, `${locale}/clear-query-params`, { + a: '1', + allowed: 'kept', + }) + expect(context.logs.output).toContain( + 'Query params are no longer automatically merged for rewrites in middleware' + ) + }) + + it('should allow to opt-out preflight caching', async () => { + const browser = await webdriver(context.appPort, '/') + await browser.addCookie({ name: 'about-bypass', value: '1' }) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + await browser.deleteCookies() + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Bypassed Page') + }) + + it(`should allow to rewrite keeping the locale in pathname`, async () => { + const res = await fetchViaHTTP(context.appPort, '/fr/country', { + country: 'spain', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('fr') + expect($('#country').text()).toBe('spain') + }) + + it(`should allow to rewrite to a different locale`, async () => { + const res = await fetchViaHTTP(context.appPort, '/country', { + 'my-locale': 'es', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('es') + expect($('#country').text()).toBe('us') + }) +} + +function testsWithLocale(context, locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should add a cookie and rewrite to a/b test`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/rewrite-to-ab-test` + ) + const html = await res.text() + const $ = cheerio.load(html) + // Set-Cookie header with Expires should not be split into two + expect(res.headers.raw()['set-cookie']).toHaveLength(1) + const bucket = getCookieFromResponse(res, 'bucket') + const expectedText = bucket === 'a' ? 'Welcome Page A' : 'Welcome Page B' + const browser = await webdriver( + context.appPort, + `${locale}/rewrite-to-ab-test` + ) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/rewrite-to-ab-test` + ) + } finally { + await browser.close() + } + // -1 is returned if bucket was not found in func getCookieFromResponse + expect(bucket).not.toBe(-1) + expect($('.title').text()).toBe(expectedText) + }) + + it(`${label}should clear query parameters`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/clear-query-params`, + { + a: '1', + b: '2', + foo: 'bar', + allowed: 'kept', + } + ) + const html = await res.text() + const $ = cheerio.load(html) + expect(JSON.parse($('#my-query-params').text())).toEqual({ + allowed: 'kept', + }) + }) + + it(`${label}should rewrite to about page`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/rewrite-me-to-about` + ) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver( + context.appPort, + `${locale}/rewrite-me-to-about` + ) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/rewrite-me-to-about` + ) + } finally { + await browser.close() + } + expect($('.title').text()).toBe('About Page') + }) + + it(`${label}support colons in path`, async () => { + const path = `${locale}/not:param` + const res = await fetchViaHTTP(context.appPort, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('not:param') + const browser = await webdriver(context.appPort, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon`, async () => { + const path = `${locale}/rewrite-me-with-a-colon` + const res = await fetchViaHTTP(context.appPort, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + const browser = await webdriver(context.appPort, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon`, async () => { + const path = `${locale}/colon:here` + const res = await fetchViaHTTP(context.appPort, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + const browser = await webdriver(context.appPort, path) + try { + expect(await browser.eval(`window.location.pathname`)).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite from path with colon and retain query parameter`, async () => { + const path = `${locale}/colon:here?qp=arg` + const res = await fetchViaHTTP(context.appPort, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(context.appPort, path) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}can rewrite to path with colon and retain query parameter`, async () => { + const path = `${locale}/rewrite-me-with-a-colon?qp=arg` + const res = await fetchViaHTTP(context.appPort, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + expect($('#qp').text()).toBe('arg') + const browser = await webdriver(context.appPort, path) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(path) + } finally { + await browser.close() + } + }) + + it(`${label}should rewrite when not using localhost`, async () => { + const res = await fetchViaHTTP( + `http://localtest.me:${context.appPort}`, + `${locale}/rewrite-me-without-hard-navigation` + ) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) + + it(`${label}should rewrite to Vercel`, async () => { + const res = await fetchViaHTTP( + context.appPort, + `${locale}/rewrite-me-to-vercel` + ) + const html = await res.text() + // const browser = await webdriver(context.appPort, '/rewrite-me-to-vercel') + // TODO: running this to chech the window.location.pathname hangs for some reason; + expect(html).toContain('Example Domain') + }) + + it(`${label}should rewrite without hard navigation`, async () => { + const browser = await webdriver(context.appPort, '/') + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#link-with-rewritten-url').click() + await browser.waitForElementByCss('.refreshed') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + const element = await browser.elementByCss('.middleware') + expect(await element.text()).toEqual('foo') + }) + + it(`${label}should not call middleware with shallow push`, async () => { + const browser = await webdriver(context.appPort, '') + await browser.elementByCss('#link-to-shallow-push').click() + await browser.waitForCondition( + 'new URL(window.location.href).searchParams.get("path") === "rewrite-me-without-hard-navigation"' + ) + await expect(async () => { + await browser.waitForElementByCss('.refreshed', 500) + }).rejects.toThrow() + }) + + it(`${label}should correctly rewriting to a different dynamic path`, async () => { + const browser = await webdriver(context.appPort, '/dynamic-replace') + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('Parts page') + const logs = await browser.log() + expect( + logs.every((log) => log.source === 'log' || log.source === 'info') + ).toEqual(true) + }) +} + +function getCookieFromResponse(res, cookieName) { + // node-fetch bundles the cookies as string in the Response + const cookieArray = res.headers.raw()['set-cookie'] + for (const cookie of cookieArray) { + let individualCookieParams = cookie.split(';') + let individualCookie = individualCookieParams[0].split('=') + if (individualCookie[0] === cookieName) { + return individualCookie[1] + } + } + return -1 +} diff --git a/test/integration/middleware/core/pages/errors/_middleware.js b/test/integration/middleware/core/pages/errors/_middleware.js deleted file mode 100644 index 39f8795f49..0000000000 --- a/test/integration/middleware/core/pages/errors/_middleware.js +++ /dev/null @@ -1,14 +0,0 @@ -import { NextResponse } from 'next/server' - -export async function middleware(request) { - const url = request.nextUrl - - if ( - url.pathname === '/errors/throw-on-preflight' && - request.headers.has('x-middleware-preflight') - ) { - throw new Error('test error') - } - - return NextResponse.next() -} diff --git a/test/integration/middleware/core/pages/global/_middleware.js b/test/integration/middleware/core/pages/global/_middleware.js deleted file mode 100644 index 964148bc1e..0000000000 --- a/test/integration/middleware/core/pages/global/_middleware.js +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server' - -export async function middleware(request, ev) { - console.log(process.env.MIDDLEWARE_TEST) - - return NextResponse.json({ - process: { - env: process.env, - }, - }) -} diff --git a/test/integration/middleware/core/pages/interface/[id]/_middleware.js b/test/integration/middleware/core/pages/interface/[id]/_middleware.js deleted file mode 100644 index 679ab02834..0000000000 --- a/test/integration/middleware/core/pages/interface/[id]/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' - -export function middleware() { - const response = NextResponse.next() - response.headers.set('x-dynamic-path', 'true') - return response -} diff --git a/test/integration/middleware/core/pages/redirects/_middleware.js b/test/integration/middleware/core/pages/redirects/_middleware.js deleted file mode 100644 index 2e54cf550f..0000000000 --- a/test/integration/middleware/core/pages/redirects/_middleware.js +++ /dev/null @@ -1,67 +0,0 @@ -export async function middleware(request) { - const url = request.nextUrl - - if (url.searchParams.get('foo') === 'bar') { - url.pathname = '/redirects/new-home' - url.searchParams.delete('foo') - return Response.redirect(url) - } - - if (url.pathname === '/redirects/old-home') { - url.pathname = '/redirects/new-home' - return Response.redirect(url) - } - - // Chained redirects - if (url.pathname === '/redirects/redirect-me-alot') { - url.pathname = '/redirects/redirect-me-alot-2' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-2') { - url.pathname = '/redirects/redirect-me-alot-3' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-3') { - url.pathname = '/redirects/redirect-me-alot-4' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-4') { - url.pathname = '/redirects/redirect-me-alot-5' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-5') { - url.pathname = '/redirects/redirect-me-alot-6' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-6') { - url.pathname = '/redirects/redirect-me-alot-7' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/redirect-me-alot-7') { - url.pathname = '/redirects/new-home' - return Response.redirect(url) - } - - // Infinite loop - if (url.pathname === '/redirects/infinite-loop') { - url.pathname = '/redirects/infinite-loop-1' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/infinite-loop-1') { - url.pathname = '/redirects/infinite-loop' - return Response.redirect(url) - } - - if (url.pathname === '/redirects/to') { - url.pathname = url.searchParams.get('pathname') - url.searchParams.delete('pathname') - return Response.redirect(url) - } -} diff --git a/test/integration/middleware/core/pages/redirects/header.js b/test/integration/middleware/core/pages/redirects/header.js deleted file mode 100644 index 1f3d53eca6..0000000000 --- a/test/integration/middleware/core/pages/redirects/header.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Account() { - return

    Welcome to a header page

    -} diff --git a/test/integration/middleware/core/pages/redirects/old-home.js b/test/integration/middleware/core/pages/redirects/old-home.js deleted file mode 100644 index 7b2079db1b..0000000000 --- a/test/integration/middleware/core/pages/redirects/old-home.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function Account() { - return

    Welcome to a old page

    -} diff --git a/test/integration/middleware/core/pages/responses/deep/_middleware.js b/test/integration/middleware/core/pages/responses/deep/_middleware.js deleted file mode 100644 index 37d9dbe052..0000000000 --- a/test/integration/middleware/core/pages/responses/deep/_middleware.js +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from 'next/server' - -export async function middleware(request, _event) { - if (request.nextUrl.searchParams.get('deep-intercept') === 'true') { - return new NextResponse('intercepted!') - } - const next = NextResponse.next() - next.headers.set('x-deep-header', 'valid') - next.headers.append('x-append-me', 'deep') - next.headers.append('set-cookie', 'foo=oatmeal') - return next -} diff --git a/test/integration/middleware/core/pages/responses/deep/index.js b/test/integration/middleware/core/pages/responses/deep/index.js deleted file mode 100644 index 5d2341782b..0000000000 --- a/test/integration/middleware/core/pages/responses/deep/index.js +++ /dev/null @@ -1,10 +0,0 @@ -function Deep() { - return ( -
    -

    Deep

    -

    This is a deep page with deep middleware, check the headers

    -
    - ) -} - -export default Deep diff --git a/test/integration/middleware/core/pages/urls/_middleware.js b/test/integration/middleware/core/pages/urls/_middleware.js deleted file mode 100644 index 43efc371d2..0000000000 --- a/test/integration/middleware/core/pages/urls/_middleware.js +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse, NextRequest } from 'next/server' - -export function middleware(request) { - try { - if (request.nextUrl.pathname === '/urls/relative-url') { - return NextResponse.json({ message: String(new URL('/relative')) }) - } - - if (request.nextUrl.pathname === '/urls/relative-request') { - return fetch(new Request('/urls/urls-b')) - } - - if (request.nextUrl.pathname === '/urls/relative-redirect') { - return Response.redirect('/urls/urls-b') - } - - if (request.nextUrl.pathname === '/urls/relative-next-redirect') { - return NextResponse.redirect('/urls/urls-b') - } - - if (request.nextUrl.pathname === '/urls/relative-next-rewrite') { - return NextResponse.rewrite('/urls/urls-b') - } - - if (request.nextUrl.pathname === '/urls/relative-next-request') { - return fetch(new NextRequest('/urls/urls-b')) - } - } catch (error) { - return NextResponse.json({ - error: { - message: error.message, - }, - }) - } -} diff --git a/test/integration/middleware/core/pages/urls/index.js b/test/integration/middleware/core/pages/urls/index.js deleted file mode 100644 index a1a42f2f39..0000000000 --- a/test/integration/middleware/core/pages/urls/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function URLsA() { - return

    URLs A

    -} diff --git a/test/integration/middleware/core/pages/urls/urls-b.js b/test/integration/middleware/core/pages/urls/urls-b.js deleted file mode 100644 index 19326a7b8d..0000000000 --- a/test/integration/middleware/core/pages/urls/urls-b.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function URLsB() { - return

    URLs B

    -} diff --git a/test/integration/middleware/core/test/index.test.js b/test/integration/middleware/core/test/index.test.js deleted file mode 100644 index 80e86207e6..0000000000 --- a/test/integration/middleware/core/test/index.test.js +++ /dev/null @@ -1,846 +0,0 @@ -/* eslint-env jest */ - -import fs from 'fs-extra' -import { join } from 'path' -import cheerio from 'cheerio' -import webdriver from 'next-webdriver' -import { - check, - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - nextStart, -} from 'next-test-utils' - -jest.setTimeout(1000 * 60 * 2) -const context = {} -context.appDir = join(__dirname, '../') - -const middlewareWarning = 'using beta Middleware (not covered by semver)' -const urlsError = 'Please use only absolute URLs' - -describe('Middleware base tests', () => { - describe('dev mode', () => { - const log = { output: '' } - - beforeAll(async () => { - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - onStdout(msg) { - log.output += msg - }, - onStderr(msg) { - log.output += msg - }, - }) - }) - afterAll(() => killApp(context.app)) - rewriteTests(log) - rewriteTests(log, '/fr') - redirectTests() - redirectTests('/fr') - responseTests() - responseTests('/fr') - interfaceTests() - interfaceTests('/fr') - urlTests(log) - urlTests(log, '/fr') - errorTests() - errorTests('/fr') - - it('should have showed warning for middleware usage', () => { - expect(log.output).toContain(middlewareWarning) - }) - }) - describe('production mode', () => { - let serverOutput = { output: '' } - let buildOutput - - beforeAll(async () => { - const res = await nextBuild(context.appDir, undefined, { - stderr: true, - stdout: true, - }) - buildOutput = res.stdout + res.stderr - - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort, { - onStdout(msg) { - serverOutput.output += msg - }, - onStderr(msg) { - serverOutput.output += msg - }, - }) - }) - afterAll(() => killApp(context.app)) - rewriteTests(serverOutput) - rewriteTests(serverOutput, '/fr') - redirectTests() - redirectTests('/fr') - responseTests() - responseTests('/fr') - interfaceTests() - interfaceTests('/fr') - urlTests(serverOutput) - urlTests(serverOutput, '/fr') - errorTests() - errorTests('/fr') - - it('should have middleware warning during build', () => { - expect(buildOutput).toContain(middlewareWarning) - }) - - it('should have middleware warning during start', () => { - expect(serverOutput.output).toContain(middlewareWarning) - }) - - it('should have correct files in manifest', async () => { - const manifest = await fs.readJSON( - join(context.appDir, '.next/server/middleware-manifest.json') - ) - for (const key of Object.keys(manifest.middleware)) { - const middleware = manifest.middleware[key] - expect(middleware.files).toContainEqual( - expect.stringContaining('server/edge-runtime-webpack') - ) - expect(middleware.files).not.toContainEqual( - expect.stringContaining('static/chunks/') - ) - } - }) - }) - - describe('global', () => { - beforeAll(async () => { - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - env: { - MIDDLEWARE_TEST: 'asdf', - NEXT_RUNTIME: 'edge', - }, - }) - }) - - it('should contains process polyfill', async () => { - const res = await fetchViaHTTP(context.appPort, `/global`) - const json = await res.json() - expect(json).toEqual({ - process: { - env: { - MIDDLEWARE_TEST: 'asdf', - NEXT_RUNTIME: 'edge', - }, - }, - }) - }) - }) -}) - -function urlTests(_log, locale = '') { - it('should set fetch user agent correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/interface/fetchUserAgentDefault` - ) - expect((await res.json()).headers['user-agent']).toBe('Next.js Middleware') - - const res2 = await fetchViaHTTP( - context.appPort, - `${locale}/interface/fetchUserAgentCustom` - ) - expect((await res2.json()).headers['user-agent']).toBe('custom-agent') - }) - - it('rewrites by default to a target location', async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/urls`) - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('URLs A') - }) - - it('throws when using URL with a relative URL', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-url` - ) - const json = await res.json() - expect(json.error.message).toContain('Invalid URL') - }) - - it('throws when using Request with a relative URL', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-request` - ) - const json = await res.json() - expect(json.error.message).toContain('Invalid URL') - }) - - it('throws when using NextRequest with a relative URL', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-next-request` - ) - const json = await res.json() - expect(json.error.message).toContain('Invalid URL') - }) - - it('warns when using Response.redirect with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-redirect` - ) - expect(await response.json()).toEqual({ - error: { - message: expect.stringContaining(urlsError), - }, - }) - }) - - it('warns when using NextResponse.redirect with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-next-redirect` - ) - expect(await response.json()).toEqual({ - error: { - message: expect.stringContaining(urlsError), - }, - }) - }) - - it('throws when using NextResponse.rewrite with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `${locale}/urls/relative-next-rewrite` - ) - expect(await response.json()).toEqual({ - error: { - message: expect.stringContaining(urlsError), - }, - }) - }) -} - -function rewriteTests(log, locale = '') { - it('should override with rewrite internally correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/about`, - { override: 'internal' }, - { redirect: 'manual' } - ) - - expect(res.status).toBe(200) - expect(await res.text()).toContain('Welcome Page A') - - const browser = await webdriver(context.appPort, `${locale}/rewrites`) - await browser.elementByCss('#override-with-internal-rewrite').click() - await check( - () => browser.eval('document.documentElement.innerHTML'), - /Welcome Page A/ - ) - expect(await browser.eval('window.location.pathname')).toBe( - `${locale || ''}/rewrites/about` - ) - expect(await browser.eval('window.location.search')).toBe( - '?override=internal' - ) - }) - - it('should override with rewrite externally correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/about`, - { override: 'external' }, - { redirect: 'manual' } - ) - - expect(res.status).toBe(200) - expect(await res.text()).toContain('Example Domain') - - const browser = await webdriver(context.appPort, `${locale}/rewrites`) - await browser.elementByCss('#override-with-external-rewrite').click() - await check( - () => browser.eval('document.documentElement.innerHTML'), - /Example Domain/ - ) - await check( - () => browser.eval('window.location.pathname'), - `${locale || ''}/rewrites/about` - ) - await check( - () => browser.eval('window.location.search'), - '?override=external' - ) - }) - - it('should rewrite to fallback: true page successfully', async () => { - const randomSlug = `another-${Date.now()}` - const res2 = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/to-blog/${randomSlug}` - ) - expect(res2.status).toBe(200) - expect(await res2.text()).toContain('Loading...') - - const randomSlug2 = `another-${Date.now()}` - const browser = await webdriver( - context.appPort, - `${locale}/rewrites/to-blog/${randomSlug2}` - ) - - await check(async () => { - const props = JSON.parse(await browser.elementByCss('#props').text()) - return props.params.slug === randomSlug2 - ? 'success' - : JSON.stringify(props) - }, 'success') - }) - - it(`${locale} should add a cookie and rewrite to a/b test`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/rewrite-to-ab-test` - ) - const html = await res.text() - const $ = cheerio.load(html) - // Set-Cookie header with Expires should not be split into two - expect(res.headers.raw()['set-cookie']).toHaveLength(1) - const bucket = getCookieFromResponse(res, 'bucket') - const expectedText = bucket === 'a' ? 'Welcome Page A' : 'Welcome Page B' - const browser = await webdriver( - context.appPort, - `${locale}/rewrites/rewrite-to-ab-test` - ) - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - `${locale}/rewrites/rewrite-to-ab-test` - ) - } finally { - await browser.close() - } - // -1 is returned if bucket was not found in func getCookieFromResponse - expect(bucket).not.toBe(-1) - expect($('.title').text()).toBe(expectedText) - }) - - it(`${locale} should clear query parameters`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/clear-query-params`, - { - a: '1', - b: '2', - foo: 'bar', - allowed: 'kept', - } - ) - const html = await res.text() - const $ = cheerio.load(html) - expect(JSON.parse($('#my-query-params').text())).toEqual({ - allowed: 'kept', - }) - }) - - it(`warns about a query param deleted`, async () => { - await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/clear-query-params`, - { a: '1', allowed: 'kept' } - ) - expect(log.output).toContain( - 'Query params are no longer automatically merged for rewrites in middleware' - ) - }) - - it(`${locale} should rewrite to about page`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/rewrite-me-to-about` - ) - const html = await res.text() - const $ = cheerio.load(html) - const browser = await webdriver( - context.appPort, - `${locale}/rewrites/rewrite-me-to-about` - ) - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - `${locale}/rewrites/rewrite-me-to-about` - ) - } finally { - await browser.close() - } - expect($('.title').text()).toBe('About Page') - }) - - it(`${locale} support colons in path`, async () => { - const path = `${locale}/rewrites/not:param` - const res = await fetchViaHTTP(context.appPort, path) - const html = await res.text() - const $ = cheerio.load(html) - expect($('#props').text()).toBe('not:param') - const browser = await webdriver(context.appPort, path) - try { - expect(await browser.eval(`window.location.pathname`)).toBe(path) - } finally { - await browser.close() - } - }) - - it(`${locale} can rewrite to path with colon`, async () => { - const path = `${locale}/rewrites/rewrite-me-with-a-colon` - const res = await fetchViaHTTP(context.appPort, path) - const html = await res.text() - const $ = cheerio.load(html) - expect($('#props').text()).toBe('with:colon') - const browser = await webdriver(context.appPort, path) - try { - expect(await browser.eval(`window.location.pathname`)).toBe(path) - } finally { - await browser.close() - } - }) - - it(`${locale} can rewrite from path with colon`, async () => { - const path = `${locale}/rewrites/colon:here` - const res = await fetchViaHTTP(context.appPort, path) - const html = await res.text() - const $ = cheerio.load(html) - expect($('#props').text()).toBe('no-colon-here') - const browser = await webdriver(context.appPort, path) - try { - expect(await browser.eval(`window.location.pathname`)).toBe(path) - } finally { - await browser.close() - } - }) - - it(`${locale} can rewrite from path with colon and retain query parameter`, async () => { - const path = `${locale}/rewrites/colon:here?qp=arg` - const res = await fetchViaHTTP(context.appPort, path) - const html = await res.text() - const $ = cheerio.load(html) - expect($('#props').text()).toBe('no-colon-here') - expect($('#qp').text()).toBe('arg') - const browser = await webdriver(context.appPort, path) - try { - expect( - await browser.eval( - `window.location.href.replace(window.location.origin, '')` - ) - ).toBe(path) - } finally { - await browser.close() - } - }) - - it(`${locale} can rewrite to path with colon and retain query parameter`, async () => { - const path = `${locale}/rewrites/rewrite-me-with-a-colon?qp=arg` - const res = await fetchViaHTTP(context.appPort, path) - const html = await res.text() - const $ = cheerio.load(html) - expect($('#props').text()).toBe('with:colon') - expect($('#qp').text()).toBe('arg') - const browser = await webdriver(context.appPort, path) - try { - expect( - await browser.eval( - `window.location.href.replace(window.location.origin, '')` - ) - ).toBe(path) - } finally { - await browser.close() - } - }) - - it(`${locale} should rewrite when not using localhost`, async () => { - const res = await fetchViaHTTP( - `http://localtest.me:${context.appPort}`, - `${locale}/rewrites/rewrite-me-without-hard-navigation` - ) - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('About Page') - }) - - it(`${locale} should rewrite to Vercel`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/rewrites/rewrite-me-to-vercel` - ) - const html = await res.text() - // const browser = await webdriver(context.appPort, '/rewrite-me-to-vercel') - // TODO: running this to chech the window.location.pathname hangs for some reason; - expect(html).toContain('Example Domain') - }) - - it(`${locale} should rewrite without hard navigation`, async () => { - const browser = await webdriver(context.appPort, '/rewrites/') - await browser.eval('window.__SAME_PAGE = true') - await browser.elementByCss('#link-with-rewritten-url').click() - await browser.waitForElementByCss('.refreshed') - expect(await browser.eval('window.__SAME_PAGE')).toBe(true) - const element = await browser.elementByCss('.middleware') - expect(await element.text()).toEqual('foo') - }) - - it('should allow to opt-out preflight caching', async () => { - const browser = await webdriver(context.appPort, '/rewrites/') - await browser.addCookie({ name: 'about-bypass', value: '1' }) - await browser.eval('window.__SAME_PAGE = true') - await browser.elementByCss('#link-with-rewritten-url').click() - await browser.waitForElementByCss('.refreshed') - await browser.deleteCookies() - expect(await browser.eval('window.__SAME_PAGE')).toBe(true) - const element = await browser.elementByCss('.title') - expect(await element.text()).toEqual('About Bypassed Page') - }) - - it(`${locale} should not call middleware with shallow push`, async () => { - const browser = await webdriver(context.appPort, '/rewrites') - await browser.elementByCss('#link-to-shallow-push').click() - await browser.waitForCondition( - 'new URL(window.location.href).searchParams.get("path") === "rewrite-me-without-hard-navigation"' - ) - await expect(async () => { - await browser.waitForElementByCss('.refreshed', 500) - }).rejects.toThrow() - }) -} - -function redirectTests(locale = '') { - it(`${locale} should redirect`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/redirects/old-home` - ) - const html = await res.text() - const $ = cheerio.load(html) - const browser = await webdriver( - context.appPort, - `${locale}/redirects/old-home` - ) - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - `${locale}/redirects/new-home` - ) - } finally { - await browser.close() - } - expect($('.title').text()).toBe('Welcome to a new page') - }) - - it(`${locale} should redirect cleanly with the original url param`, async () => { - const browser = await webdriver( - context.appPort, - `${locale}/redirects/blank-page?foo=bar` - ) - try { - expect( - await browser.eval( - `window.location.href.replace(window.location.origin, '')` - ) - ).toBe(`${locale}/redirects/new-home`) - } finally { - await browser.close() - } - }) - - it(`${locale} should redirect multiple times`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/redirects/redirect-me-alot` - ) - const browser = await webdriver( - context.appPort, - `${locale}/redirects/redirect-me-alot` - ) - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - `${locale}/redirects/new-home` - ) - } finally { - await browser.close() - } - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('Welcome to a new page') - }) - - it(`${locale} should redirect (infinite-loop)`, async () => { - await expect( - fetchViaHTTP(context.appPort, `${locale}/redirects/infinite-loop`) - ).rejects.toThrow() - }) - - it(`${locale} should redirect to api route with locale`, async () => { - const browser = await webdriver(context.appPort, `${locale}/redirects`) - await browser.elementByCss('#link-to-api-with-locale').click() - await browser.waitForCondition('window.location.pathname === "/api/ok"') - const body = await browser.elementByCss('body').text() - expect(body).toBe('ok') - }) -} - -function responseTests(locale = '') { - it(`${locale} responds with multiple cookies`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/two-cookies` - ) - - expect(res.headers.raw()['set-cookie']).toEqual([ - 'foo=chocochip', - 'bar=chocochip', - ]) - }) - - it(`${locale} should stream a response`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/stream-a-response` - ) - const html = await res.text() - expect(html).toBe('this is a streamed response with some text') - }) - - it(`${locale} should respond with a body`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/send-response` - ) - const html = await res.text() - expect(html).toBe('{"message":"hi!"}') - }) - - it(`${locale} should respond with a 401 status code`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/bad-status` - ) - const html = await res.text() - expect(res.status).toBe(401) - expect(html).toBe('Auth required') - }) - - it(`${locale} should render a React component`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/react?name=jack` - ) - const html = await res.text() - expect(html).toBe('

    SSR with React! Hello, jack

    ') - }) - - it(`${locale} should stream a React component`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/react-stream` - ) - const html = await res.text() - expect(html).toBe('

    I am a stream

    I am another stream

    ') - }) - - it(`${locale} should stream a long response`, async () => { - const res = await fetchViaHTTP(context.appPort, '/responses/stream-long') - const html = await res.text() - expect(html).toBe( - 'this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed this is a streamed after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 2 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds after 4 seconds ' - ) - }) - - it(`${locale} should render the right content via SSR`, async () => { - const res = await fetchViaHTTP(context.appPort, '/responses/') - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('Hello World') - }) - - it(`${locale} should respond with 2 nested headers`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/header?nested-header=true` - ) - expect(res.headers.get('x-first-header')).toBe('valid') - expect(res.headers.get('x-nested-header')).toBe('valid') - }) - - it(`${locale} should respond with a header`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/header` - ) - expect(res.headers.get('x-first-header')).toBe('valid') - }) - - it(`${locale} should respond with top level headers and append deep headers`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/deep?nested-header=true&append-me=true&cookie-me=true` - ) - expect(res.headers.get('x-nested-header')).toBe('valid') - expect(res.headers.get('x-deep-header')).toBe('valid') - expect(res.headers.get('x-append-me')).toBe('top, deep') - expect(res.headers.raw()['set-cookie']).toEqual([ - 'bar=chocochip', - 'foo=oatmeal', - ]) - }) - - it(`${locale} should be intercepted by deep middleware`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/responses/deep?deep-intercept=true` - ) - expect(await res.text()).toBe('intercepted!') - }) -} - -function interfaceTests(locale = '') { - it(`${locale} \`globalThis\` is accessible`, async () => { - const res = await fetchViaHTTP(context.appPort, '/interface/globalthis') - const globals = await res.json() - expect(globals.length > 0).toBe(true) - }) - - it(`${locale} collection constructors are shared`, async () => { - const res = await fetchViaHTTP(context.appPort, '/interface/webcrypto') - const response = await res.json() - expect('error' in response).toBe(false) - }) - - it(`${locale} fetch accepts a URL instance`, async () => { - const res = await fetchViaHTTP(context.appPort, '/interface/fetchURL') - const response = await res.json() - expect('error' in response).toBe(true) - expect(response.error.name).not.toBe('TypeError') - }) - - it(`${locale} abort a fetch request`, async () => { - const res = await fetchViaHTTP( - context.appPort, - '/interface/abort-controller' - ) - const response = await res.json() - - expect('error' in response).toBe(true) - expect(response.error.name).toBe('AbortError') - expect(response.error.message).toBe('The user aborted a request.') - }) - - it(`${locale} should validate request url parameters from a static route`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/interface/static` - ) - //expect(res.headers.get('req-url-basepath')).toBe('') - expect(res.headers.get('req-url-pathname')).toBe('/interface/static') - expect(res.headers.get('req-url-params')).not.toBe('{}') - expect(res.headers.get('req-url-query')).not.toBe('bar') - if (locale !== '') { - expect(res.headers.get('req-url-locale')).toBe(locale.slice(1)) - } - }) - - it(`${locale} should validate request url parameters from a dynamic route with param 1`, async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/interface/1`) - //expect(res.headers.get('req-url-basepath')).toBe('') - expect(res.headers.get('req-url-pathname')).toBe('/interface/1') - expect(res.headers.get('req-url-params')).toBe('{"id":"1"}') - expect(res.headers.get('req-url-page')).toBe('/interface/[id]') - expect(res.headers.get('req-url-query')).not.toBe('bar') - - if (locale !== '') { - expect(res.headers.get('req-url-locale')).toBe(locale.slice(1)) - } - }) - - it(`${locale} should validate request url parameters from a dynamic route with param abc123`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/interface/abc123` - ) - //expect(res.headers.get('req-url-basepath')).toBe('') - expect(res.headers.get('req-url-pathname')).toBe('/interface/abc123') - expect(res.headers.get('req-url-params')).toBe('{"id":"abc123"}') - expect(res.headers.get('req-url-page')).toBe('/interface/[id]') - expect(res.headers.get('req-url-query')).not.toBe('bar') - - if (locale !== '') { - expect(res.headers.get('req-url-locale')).toBe(locale.slice(1)) - } - }) - - it(`${locale} should validate request url parameters from a dynamic route with param abc123 and query foo = bar`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/interface/abc123?foo=bar` - ) - //expect(res.headers.get('req-url-basepath')).toBe('') - expect(res.headers.get('req-url-pathname')).toBe('/interface/abc123') - expect(res.headers.get('req-url-params')).toBe('{"id":"abc123"}') - expect(res.headers.get('req-url-page')).toBe('/interface/[id]') - expect(res.headers.get('req-url-query')).toBe('bar') - if (locale !== '') { - expect(res.headers.get('req-url-locale')).toBe(locale.slice(1)) - } - }) - - it(`${locale} renders correctly rewriting with a root subrequest`, async () => { - const browser = await webdriver( - context.appPort, - '/interface/root-subrequest' - ) - const element = await browser.elementByCss('.title') - expect(await element.text()).toEqual('Dynamic route') - }) - - it(`${locale} allows subrequests without infinite loops`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `/interface/root-subrequest` - ) - expect(res.headers.get('x-dynamic-path')).toBe('true') - }) - - it(`${locale} renders correctly rewriting to a different dynamic path`, async () => { - const browser = await webdriver( - context.appPort, - '/interface/dynamic-replace' - ) - const element = await browser.elementByCss('.title') - expect(await element.text()).toEqual('Parts page') - const logs = await browser.log() - expect( - logs.every((log) => log.source === 'log' || log.source === 'info') - ).toEqual(true) - }) -} - -function errorTests(locale = '') { - it(`${locale} should hard-navigate when preflight request failed`, async () => { - const browser = await webdriver(context.appPort, `${locale}/errors`) - await browser.eval('window.__SAME_PAGE = true') - await browser.elementByCss('#throw-on-preflight').click() - await browser.waitForElementByCss('.refreshed') - expect(await browser.eval('window.__SAME_PAGE')).toBeUndefined() - }) -} - -function getCookieFromResponse(res, cookieName) { - // node-fetch bundles the cookies as string in the Response - const cookieArray = res.headers.raw()['set-cookie'] - for (const cookie of cookieArray) { - let individualCookieParams = cookie.split(';') - let individualCookie = individualCookieParams[0].split('=') - if (individualCookie[0] === cookieName) { - return individualCookie[1] - } - } - return -1 -} diff --git a/test/integration/middleware/hmr/pages/_middleware.js b/test/integration/middleware/hmr/pages/_middleware.js deleted file mode 100644 index 931fcfa54b..0000000000 --- a/test/integration/middleware/hmr/pages/_middleware.js +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from 'next/server' - -export function middleware() { - return NextResponse.next(null, { - headers: { - 'x-test-header': 'just a header', - }, - }) -} diff --git a/test/integration/middleware/hmr/pages/about/_middleware.js b/test/integration/middleware/hmr/pages/about/_middleware.js deleted file mode 100644 index 3146e1d202..0000000000 --- a/test/integration/middleware/hmr/pages/about/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' -import magicValue from 'shared-package' - -export function middleware(request) { - if (magicValue !== 42) throw new Error('shared-package problem') - return NextResponse.rewrite(new URL('/about/a', request.url)) -} diff --git a/test/integration/middleware/hmr/pages/index.js b/test/integration/middleware/hmr/pages/index.js deleted file mode 100644 index fa99cf9c2e..0000000000 --- a/test/integration/middleware/hmr/pages/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import magicValue from 'shared-package' -if (magicValue !== 42) throw new Error('shared-package problem') - -export default function Home() { - return ( -
    -

    Home

    -
    - ) -} diff --git a/test/integration/middleware/hmr/test/index.test.js b/test/integration/middleware/hmr/test/index.test.js deleted file mode 100644 index 77f11fb713..0000000000 --- a/test/integration/middleware/hmr/test/index.test.js +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import fs from 'fs' -import webdriver from 'next-webdriver' -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - waitFor, -} from 'next-test-utils' - -jest.setTimeout(1000 * 60 * 2) -const context = {} -context.appDir = join(__dirname, '../') - -describe('HMR with middleware', () => { - let output = '' - - beforeAll(async () => { - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - onStdout(msg) { - output += msg - }, - onStderr(msg) { - output += msg - }, - }) - }) - afterAll(() => killApp(context.app)) - - it('works for pages when middleware is compiled', async () => { - const browser = await webdriver(context.appPort, `/`) - - try { - await browser.eval('window.itdidnotrefresh = "hello"') - await fetchViaHTTP(context.appPort, `/about`) - await waitFor(1000) - - expect(output.includes('about/_middleware')).toEqual(true) - expect(await browser.eval('window.itdidnotrefresh')).toBe('hello') - } finally { - await browser.close() - } - }) - - it('refreshes the page when middleware changes ', async () => { - const browser = await webdriver(context.appPort, `/about`) - await browser.eval('window.didrefresh = "hello"') - const text = await browser.elementByCss('h1').text() - expect(text).toEqual('AboutA') - - const middlewarePath = join(context.appDir, '/pages/about/_middleware.js') - const originalContent = fs.readFileSync(middlewarePath, 'utf-8') - const editedContent = originalContent.replace('/about/a', '/about/b') - - try { - fs.writeFileSync(middlewarePath, editedContent) - await waitFor(1000) - const textb = await browser.elementByCss('h1').text() - expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello') - expect(textb).toEqual('AboutB') - } finally { - fs.writeFileSync(middlewarePath, originalContent) - await browser.close() - } - }) -}) diff --git a/test/integration/middleware/with-builtin-module/pages/using-child-process/_middleware.js b/test/integration/middleware/with-builtin-module/pages/using-child-process/_middleware.js deleted file mode 100644 index e4501469fd..0000000000 --- a/test/integration/middleware/with-builtin-module/pages/using-child-process/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' -import { spawn } from 'child_process' - -export async function middleware(request) { - console.log(spawn('ls', ['-lh', '/usr'])) - return NextResponse.next() -} diff --git a/test/integration/middleware/with-builtin-module/pages/using-path/_middleware.js b/test/integration/middleware/with-builtin-module/pages/using-path/_middleware.js deleted file mode 100644 index c10aadb272..0000000000 --- a/test/integration/middleware/with-builtin-module/pages/using-path/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' -import { basename } from 'path' - -export async function middleware(request) { - console.log(basename('/foo/bar/baz/asdf/quux.html')) - return NextResponse.next() -} diff --git a/test/integration/middleware/with-builtin-module/test/index.test.js b/test/integration/middleware/with-builtin-module/test/index.test.js deleted file mode 100644 index 8dc72b64ff..0000000000 --- a/test/integration/middleware/with-builtin-module/test/index.test.js +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-env jest */ - -import stripAnsi from 'next/dist/compiled/strip-ansi' -import { getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage } from 'next/dist/build/utils' -import { join } from 'path' -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - waitFor, -} from 'next-test-utils' - -const context = {} -const WEBPACK_BREAKING_CHANGE = 'BREAKING CHANGE:' - -jest.setTimeout(1000 * 60 * 2) -context.appDir = join(__dirname, '../') - -describe('Middleware importing Node.js built-in module', () => { - function getModuleNotFound(name) { - return `Module not found: Can't resolve '${name}'` - } - - function escapeLF(s) { - return s.replace(/\n/g, '\\n') - } - - describe('dev mode', () => { - let output = '' - - // restart the app for every test since the latest error is not shown sometimes - // See https://github.com/vercel/next.js/issues/36575 - beforeEach(async () => { - output = '' - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, - onStdout(msg) { - output += msg - }, - onStderr(msg) { - output += msg - }, - }) - }) - - afterEach(() => killApp(context.app)) - - it('shows error when importing path module', async () => { - const res = await fetchViaHTTP(context.appPort, '/using-path') - const text = await res.text() - await waitFor(500) - const msg = getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('path') - expect(res.status).toBe(500) - expect(output).toContain(getModuleNotFound('path')) - expect(output).toContain(msg) - expect(text).toContain(escapeLF(msg)) - expect(stripAnsi(output)).toContain("import { basename } from 'path'") - expect(output).not.toContain(WEBPACK_BREAKING_CHANGE) - }) - - it('shows error when importing child_process module', async () => { - const res = await fetchViaHTTP(context.appPort, '/using-child-process') - const text = await res.text() - await waitFor(500) - const msg = - getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') - expect(res.status).toBe(500) - expect(output).toContain(getModuleNotFound('child_process')) - expect(output).toContain(msg) - expect(text).toContain(escapeLF(msg)) - expect(stripAnsi(output)).toContain( - "import { spawn } from 'child_process'" - ) - expect(output).not.toContain(WEBPACK_BREAKING_CHANGE) - }) - }) - - describe('production mode', () => { - let buildResult - - beforeAll(async () => { - buildResult = await nextBuild(context.appDir, undefined, { - stderr: true, - stdout: true, - }) - }) - - it('should have middleware error during build', () => { - expect(buildResult.stderr).toContain(getModuleNotFound('child_process')) - expect(buildResult.stderr).toContain(getModuleNotFound('path')) - expect(buildResult.stderr).toContain( - getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') - ) - expect(buildResult.stderr).not.toContain(WEBPACK_BREAKING_CHANGE) - }) - }) -}) diff --git a/test/integration/middleware/with-i18n-preflight/pages/_middleware.js b/test/integration/middleware/with-i18n-preflight/pages/_middleware.js deleted file mode 100644 index 3c3baeee67..0000000000 --- a/test/integration/middleware/with-i18n-preflight/pages/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' - -export function middleware(req) { - const url = req.nextUrl.clone() - url.searchParams.set('locale', url.locale) - return NextResponse.rewrite(url) -} diff --git a/test/integration/middleware/with-i18n/pages/_middleware.js b/test/integration/middleware/with-i18n/pages/_middleware.js deleted file mode 100644 index 407a751aa5..0000000000 --- a/test/integration/middleware/with-i18n/pages/_middleware.js +++ /dev/null @@ -1,20 +0,0 @@ -import { NextResponse } from 'next/server' - -const PUBLIC_FILE = /\.(.*)$/ - -export function middleware(req) { - const locale = req.nextUrl.searchParams.get('my-locale') - const country = req.nextUrl.searchParams.get('country') || 'us' - - if (locale) { - req.nextUrl.locale = locale - } - - if ( - !PUBLIC_FILE.test(req.nextUrl.pathname) && - !req.nextUrl.pathname.includes('/api/') - ) { - req.nextUrl.pathname = `/test/${country}` - return NextResponse.rewrite(req.nextUrl) - } -} diff --git a/test/integration/middleware/with-i18n/test/index.test.js b/test/integration/middleware/with-i18n/test/index.test.js deleted file mode 100644 index 658c2f1475..0000000000 --- a/test/integration/middleware/with-i18n/test/index.test.js +++ /dev/null @@ -1,64 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import cheerio from 'cheerio' -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - nextStart, -} from 'next-test-utils' - -const context = {} -context.appDir = join(__dirname, '../') - -jest.setTimeout(1000 * 60 * 2) - -describe('Middleware i18n tests', () => { - describe('dev mode', () => { - beforeAll(async () => { - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort) - }) - - afterAll(() => killApp(context.app)) - runTests() - }) - - describe('production mode', () => { - beforeAll(async () => { - await nextBuild(context.appDir, undefined, { - stderr: true, - stdout: true, - }) - - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort) - }) - - afterAll(() => killApp(context.app)) - runTests() - }) -}) - -function runTests() { - it(`reads the locale from the pathname`, async () => { - const res = await fetchViaHTTP(context.appPort, '/fr', { country: 'spain' }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#locale').text()).toBe('fr') - expect($('#country').text()).toBe('spain') - }) - - it(`rewrites from a locale correctly`, async () => { - const res = await fetchViaHTTP(context.appPort, '/', { 'my-locale': 'es' }) - - const html = await res.text() - const $ = cheerio.load(html) - expect($('#locale').text()).toBe('es') - expect($('#country').text()).toBe('us') - }) -} diff --git a/test/integration/middleware/without-builtin-module/pages/using-child-process-on-page/index.js b/test/integration/middleware/without-builtin-module/pages/using-child-process-on-page/index.js deleted file mode 100644 index 7c9f7f8b91..0000000000 --- a/test/integration/middleware/without-builtin-module/pages/using-child-process-on-page/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import { spawn } from 'child_process' - -export default function Page() { - spawn('ls', ['-lh', '/usr']) - return
    ok
    -} diff --git a/test/integration/middleware/without-builtin-module/pages/using-not-exist/_middleware.js b/test/integration/middleware/without-builtin-module/pages/using-not-exist/_middleware.js deleted file mode 100644 index b1699a1acb..0000000000 --- a/test/integration/middleware/without-builtin-module/pages/using-not-exist/_middleware.js +++ /dev/null @@ -1,7 +0,0 @@ -import { NextResponse } from 'next/server' -import NotExist from 'not-exist' - -export async function middleware(request) { - new NotExist() - return NextResponse.next() -} diff --git a/test/integration/middleware/without-builtin-module/test/index.test.js b/test/integration/middleware/without-builtin-module/test/index.test.js deleted file mode 100644 index a08d902157..0000000000 --- a/test/integration/middleware/without-builtin-module/test/index.test.js +++ /dev/null @@ -1,113 +0,0 @@ -/* eslint-env jest */ - -import { getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage } from 'next/dist/build/utils' -import { join } from 'path' -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - waitFor, - File, -} from 'next-test-utils' - -const context = {} - -jest.setTimeout(1000 * 60 * 2) -context.appDir = join(__dirname, '../') - -const middleware = new File( - join(context.appDir, 'pages', 'using-not-exist', '_middleware.js') -) - -describe('Middleware importing Node.js built-in module', () => { - function getModuleNotFound(name) { - return `Module not found: Can't resolve '${name}'` - } - - function escapeLF(s) { - return s.replace(/\n/g, '\\n') - } - - describe('dev mode', () => { - let output = '' - - // restart the app for every test since the latest error is not shown sometimes - beforeEach(async () => { - output = '' - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - env: { __NEXT_TEST_WITH_DEVTOOL: 1 }, - onStdout(msg) { - output += msg - }, - onStderr(msg) { - output += msg - }, - }) - }) - - afterEach(() => killApp(context.app)) - - it('does not show the not-supported error when importing non-node-builtin module', async () => { - const res = await fetchViaHTTP(context.appPort, '/using-not-exist') - expect(res.status).toBe(500) - - const text = await res.text() - await waitFor(500) - const msg = - getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('not-exist') - expect(output).toContain(getModuleNotFound('not-exist')) - expect(output).not.toContain(msg) - expect(text).not.toContain(escapeLF(msg)) - }) - - it('does not show the not-supported error when importing child_process module on a page', async () => { - await fetchViaHTTP(context.appPort, '/using-child-process-on-page') - - // Need to request twice - // See: https://github.com/vercel/next.js/issues/36387 - const res = await fetchViaHTTP( - context.appPort, - '/using-child-process-on-page' - ) - - expect(res.status).toBe(500) - - const text = await res.text() - await waitFor(500) - const msg = - getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') - expect(output).toContain(getModuleNotFound('child_process')) - expect(output).not.toContain(msg) - expect(text).not.toContain(escapeLF(msg)) - }) - }) - - describe('production mode', () => { - let buildResult - - beforeAll(async () => { - // Make sure to only keep the child_process error in prod build. - middleware.replace(`import NotExist from 'not-exist'`, '') - middleware.replace(`new NotExist()`, '') - - buildResult = await nextBuild(context.appDir, undefined, { - stderr: true, - stdout: true, - }) - }) - - afterAll(() => { - middleware.restore() - }) - - it('should not have middleware error during build', () => { - expect(buildResult.stderr).toContain(getModuleNotFound('child_process')) - expect(buildResult.stderr).not.toContain( - getNodeBuiltinModuleNotSupportedInEdgeRuntimeMessage('child_process') - ) - }) - }) -}) diff --git a/test/integration/telemetry/test/index.test.js b/test/integration/telemetry/test/index.test.js index d70761f985..c3d912fed3 100644 --- a/test/integration/telemetry/test/index.test.js +++ b/test/integration/telemetry/test/index.test.js @@ -789,18 +789,10 @@ describe('Telemetry CLI', () => { expect(nextScriptWorkers).toContain(`"invocationCount": 1`) }) - it('emits telemetry for usage of _middleware', async () => { + it('emits telemetry for usage of middleware', async () => { await fs.writeFile( - path.join(appDir, 'pages/ssg/_middleware.js'), - `export function middleware (evt) { - evt.respondWith(new Response(null)) - }` - ) - await fs.writeFile( - path.join(appDir, 'pages/_middleware.js'), - `export function middleware (evt) { - evt.respondWith(new Response(null)) - }` + path.join(appDir, 'middleware.js'), + `export function middleware () { }` ) const { stderr } = await nextBuild(appDir, [], { @@ -808,11 +800,10 @@ describe('Telemetry CLI', () => { env: { NEXT_TELEMETRY_DEBUG: 1 }, }) - await fs.remove(path.join(appDir, 'pages/ssg/_middleware.js')) - await fs.remove(path.join(appDir, 'pages/_middleware.js')) + await fs.remove(path.join(appDir, 'middleware.js')) const regex = /NEXT_BUILD_OPTIMIZED[\s\S]+?{([\s\S]+?)}/ const optimizedEvt = regex.exec(stderr).pop() - expect(optimizedEvt).toContain(`"middlewareCount": 2`) + expect(optimizedEvt).toContain(`"middlewareCount": 1`) }) }) diff --git a/test/production/dependencies-can-use-env-vars-in-middlewares/index.test.ts b/test/production/dependencies-can-use-env-vars-in-middlewares/index.test.ts index cd6b3b926e..b3645de051 100644 --- a/test/production/dependencies-can-use-env-vars-in-middlewares/index.test.ts +++ b/test/production/dependencies-can-use-env-vars-in-middlewares/index.test.ts @@ -25,8 +25,11 @@ describe('dependencies can use env vars in middlewares', () => { module.exports = () => process.env.MY_CUSTOM_PACKAGE_ENV_VAR; `, - // The actual middleware code - 'pages/api/_middleware.js': ` + 'pages/index.js': ` + export default function () { return
    Hello, world!
    } + `, + + 'middleware.js': ` import customPackage from 'my-custom-package'; export default function middleware(_req) { return new Response(JSON.stringify({ @@ -59,7 +62,7 @@ describe('dependencies can use env vars in middlewares', () => { '.next/server/middleware-manifest.json' ) const manifest = await readJson(manifestPath) - const envVars = manifest?.middleware?.['/api']?.env + const envVars = manifest?.middleware?.['/']?.env expect(envVars).toHaveLength(2) expect(envVars).toContain('ENV_VAR_USED_IN_MIDDLEWARE') diff --git a/test/production/generate-middleware-source-maps/index.test.ts b/test/production/generate-middleware-source-maps/index.test.ts index c4c3cf5f87..6bf2be3f8e 100644 --- a/test/production/generate-middleware-source-maps/index.test.ts +++ b/test/production/generate-middleware-source-maps/index.test.ts @@ -14,7 +14,10 @@ describe('experimental.middlewareSourceMaps: true', () => { }, }, files: { - 'pages/_middleware.js': ` + 'pages/index.js': ` + export default function () { return
    Hello, world!
    } + `, + 'middleware.js': ` export default function middleware() { return new Response("Hello, world!"); } @@ -28,7 +31,7 @@ describe('experimental.middlewareSourceMaps: true', () => { it('generates a source map', async () => { const middlewarePath = path.resolve( next.testDir, - '.next/server/pages/_middleware.js' + '.next/server/middleware.js' ) expect(await fs.pathExists(middlewarePath)).toEqual(true) expect(await fs.pathExists(`${middlewarePath}.map`)).toEqual(true) @@ -41,7 +44,10 @@ describe('experimental.middlewareSourceMaps: false', () => { beforeAll(async () => { next = await createNext({ files: { - 'pages/_middleware.js': ` + 'pages/index.js': ` + export default function () { return
    Hello, world!
    } + `, + 'middleware.js': ` export default function middleware() { return new Response("Hello, world!"); } @@ -55,7 +61,7 @@ describe('experimental.middlewareSourceMaps: false', () => { it('does not generate a source map', async () => { const middlewarePath = path.resolve( next.testDir, - '.next/server/pages/_middleware.js' + '.next/server/middleware.js' ) expect(await fs.pathExists(middlewarePath)).toEqual(true) expect(await fs.pathExists(`${middlewarePath}.map`)).toEqual(false) diff --git a/test/production/middleware-environment-variables-in-node-server-reflect-the-usage-inference/index.test.ts b/test/production/middleware-environment-variables-in-node-server-reflect-the-usage-inference/index.test.ts index 7aa6ddb757..a364d7bca8 100644 --- a/test/production/middleware-environment-variables-in-node-server-reflect-the-usage-inference/index.test.ts +++ b/test/production/middleware-environment-variables-in-node-server-reflect-the-usage-inference/index.test.ts @@ -14,7 +14,10 @@ describe('middleware environment variables in node server reflect the usage infe beforeAll(async () => { next = await createNext({ files: { - 'pages/_middleware.js': ` + 'pages/index.js': ` + export default function () { return
    Hello, world!
    } + `, + 'middleware.js': ` export default function middleware() { return new Response(JSON.stringify({ canBeInferred: process.env.CAN_BE_INFERRED, diff --git a/test/production/middleware-typescript/app/middleware.ts b/test/production/middleware-typescript/app/middleware.ts new file mode 100644 index 0000000000..4b84726dd5 --- /dev/null +++ b/test/production/middleware-typescript/app/middleware.ts @@ -0,0 +1,16 @@ +import { NextMiddleware, NextResponse } from 'next/server' + +export const middleware: NextMiddleware = function (request) { + if (request.nextUrl.pathname === '/static') { + return new NextResponse('hello from middleware', { + headers: { + 'req-url-basepath': request.nextUrl.basePath, + 'req-url-pathname': request.nextUrl.pathname, + 'req-url-params': JSON.stringify(request.page.params), + 'req-url-page': request.page.name || '', + 'req-url-query': request.nextUrl.searchParams.get('foo') || '', + 'req-url-locale': request.nextUrl.locale, + }, + }) + } +} diff --git a/test/integration/middleware/core/pages/interface/static.js b/test/production/middleware-typescript/app/pages/index.tsx similarity index 100% rename from test/integration/middleware/core/pages/interface/static.js rename to test/production/middleware-typescript/app/pages/index.tsx diff --git a/test/production/middleware-typescript/app/pages/interface/[id]/index.tsx b/test/production/middleware-typescript/app/pages/interface/[id]/index.tsx deleted file mode 100644 index 11f4614a67..0000000000 --- a/test/production/middleware-typescript/app/pages/interface/[id]/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Index() { - return

    Dynamic route

    -} diff --git a/test/production/middleware-typescript/app/pages/interface/_middleware.ts b/test/production/middleware-typescript/app/pages/interface/_middleware.ts deleted file mode 100644 index 04685749e3..0000000000 --- a/test/production/middleware-typescript/app/pages/interface/_middleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextMiddleware } from 'next/server' - -export const middleware: NextMiddleware = function (request) { - console.log(request.ua?.browser) - console.log(request.ua?.isBot) - console.log(request.ua?.ua) - - return new Response('hello from middleware', { - headers: { - 'req-url-basepath': request.nextUrl.basePath, - 'req-url-pathname': request.nextUrl.pathname, - 'req-url-params': JSON.stringify(request.page.params), - 'req-url-page': request.page.name || '', - 'req-url-query': request.nextUrl.searchParams.get('foo') || '', - 'req-url-locale': request.nextUrl.locale, - }, - }) -} diff --git a/test/production/middleware-typescript/app/pages/interface/static.tsx b/test/production/middleware-typescript/app/pages/interface/static.tsx deleted file mode 100644 index d683fec42a..0000000000 --- a/test/production/middleware-typescript/app/pages/interface/static.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Index() { - return

    Static route

    -} diff --git a/test/production/middleware-typescript/app/pages/redirects/_middleware.ts b/test/production/middleware-typescript/app/pages/redirects/_middleware.ts deleted file mode 100644 index c93a58e625..0000000000 --- a/test/production/middleware-typescript/app/pages/redirects/_middleware.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextMiddleware, NextResponse } from 'next/server' - -export const middleware: NextMiddleware = async function (request) { - const url = request.nextUrl - - if (url.searchParams.get('foo') === 'bar') { - url.pathname = '/redirects/new-home' - url.searchParams.delete('foo') - return Response.redirect(url) - } - - if (url.pathname === '/redirects/old-home') { - url.pathname = '/redirects/new-home' - return NextResponse.redirect(url) - } -} diff --git a/test/production/middleware-typescript/app/pages/redirects/index.tsx b/test/production/middleware-typescript/app/pages/redirects/index.tsx deleted file mode 100644 index bed6f8511c..0000000000 --- a/test/production/middleware-typescript/app/pages/redirects/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Home() { - return ( -
    -

    Home Page

    -
    - ) -} diff --git a/test/production/middleware-typescript/app/pages/redirects/new-home.tsx b/test/production/middleware-typescript/app/pages/redirects/new-home.tsx deleted file mode 100644 index e54c7f5a90..0000000000 --- a/test/production/middleware-typescript/app/pages/redirects/new-home.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function NewHome() { - return

    New Home

    -} diff --git a/test/production/middleware-typescript/app/pages/redirects/old-home.tsx b/test/production/middleware-typescript/app/pages/redirects/old-home.tsx deleted file mode 100644 index e3b7b239c1..0000000000 --- a/test/production/middleware-typescript/app/pages/redirects/old-home.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function OldHome() { - return

    Old Home

    -} diff --git a/test/production/middleware-typescript/app/pages/responses/_middleware.ts b/test/production/middleware-typescript/app/pages/responses/_middleware.ts deleted file mode 100644 index 9868c16887..0000000000 --- a/test/production/middleware-typescript/app/pages/responses/_middleware.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextMiddleware, NextResponse } from 'next/server' - -export const middleware: NextMiddleware = async function (request, ev) { - // eslint-disable-next-line no-undef - const { readable, writable } = new TransformStream() - const url = request.nextUrl - const writer = writable.getWriter() - const encoder = new TextEncoder() - const next = NextResponse.next() - - // Header based on query param - if (url.searchParams.get('set-header') === 'true') { - next.headers.set('x-set-header', 'valid') - } - - // Streams a basic response - if (url.pathname === '/responses/stream-a-response') { - ev.waitUntil( - (async () => { - writer.write(encoder.encode('this is a streamed ')) - writer.write(encoder.encode('response')) - writer.close() - })() - ) - - return new Response(readable) - } - - if (url.pathname === '/responses/bad-status') { - return new Response('Auth required', { - headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"' }, - status: 401, - }) - } - - // Sends response - if (url.pathname === '/responses/send-response') { - return new NextResponse(JSON.stringify({ message: 'hi!' })) - } - - return next -} diff --git a/test/production/middleware-typescript/app/pages/responses/index.tsx b/test/production/middleware-typescript/app/pages/responses/index.tsx deleted file mode 100644 index 2a1306c09e..0000000000 --- a/test/production/middleware-typescript/app/pages/responses/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Home() { - return ( -
    -

    Hello Middleware

    -
    - ) -} diff --git a/test/production/middleware-typescript/app/pages/rewrites/_middleware.ts b/test/production/middleware-typescript/app/pages/rewrites/_middleware.ts deleted file mode 100644 index 402ce45667..0000000000 --- a/test/production/middleware-typescript/app/pages/rewrites/_middleware.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextMiddleware, NextResponse } from 'next/server' - -export const middleware: NextMiddleware = async function (request) { - const url = request.nextUrl - - if (url.pathname === '/') { - let bucket = request.cookies.get('bucket') - if (!bucket) { - bucket = Math.random() >= 0.5 ? 'a' : 'b' - const response = NextResponse.rewrite(`/rewrites/${bucket}`) - response.cookies.set('bucket', bucket, { maxAge: 10 }) - return response - } - - return NextResponse.rewrite(`/rewrites/${bucket}`) - } - - return null -} diff --git a/test/production/middleware-typescript/app/pages/rewrites/a.tsx b/test/production/middleware-typescript/app/pages/rewrites/a.tsx deleted file mode 100644 index 4ea5eaf19e..0000000000 --- a/test/production/middleware-typescript/app/pages/rewrites/a.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return

    Welcome Page A

    -} diff --git a/test/production/middleware-typescript/app/pages/rewrites/b.tsx b/test/production/middleware-typescript/app/pages/rewrites/b.tsx deleted file mode 100644 index 32505e9eae..0000000000 --- a/test/production/middleware-typescript/app/pages/rewrites/b.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Home() { - return

    Welcome Page B

    -} diff --git a/test/production/middleware-typescript/app/tsconfig.json b/test/production/middleware-typescript/app/tsconfig.json index e327470b18..db3318134f 100644 --- a/test/production/middleware-typescript/app/tsconfig.json +++ b/test/production/middleware-typescript/app/tsconfig.json @@ -16,5 +16,5 @@ "isolatedModules": true }, "exclude": ["node_modules"], - "include": ["next-env.d.ts", "pages"] + "include": ["next-env.d.ts", "pages", "middleware.ts"] } diff --git a/test/production/middleware-typescript/test/index.test.ts b/test/production/middleware-typescript/test/index.test.ts index dd04154749..b14ab2a372 100644 --- a/test/production/middleware-typescript/test/index.test.ts +++ b/test/production/middleware-typescript/test/index.test.ts @@ -14,6 +14,7 @@ describe('should set-up next', () => { next = await createNext({ files: { pages: new FileRef(join(appDir, 'pages')), + 'middleware.ts': new FileRef(join(appDir, 'middleware.ts')), 'tsconfig.json': new FileRef(join(appDir, 'tsconfig.json')), 'next.config.js': new FileRef(join(appDir, 'next.config.js')), }, @@ -28,7 +29,7 @@ describe('should set-up next', () => { afterAll(() => next.destroy()) it('should have built and started', async () => { - const html = await renderViaHTTP(next.url, '/interface/static') + const html = await renderViaHTTP(next.url, '/static') expect(html).toContain('hello from middleware') }) }) diff --git a/test/production/middleware-with-dynamic-code/index.test.ts b/test/production/middleware-with-dynamic-code/index.test.ts index 65338b9e38..80faf56cd6 100644 --- a/test/production/middleware-with-dynamic-code/index.test.ts +++ b/test/production/middleware-with-dynamic-code/index.test.ts @@ -8,8 +8,11 @@ describe('Middleware with Dynamic code invokations', () => { next = await createNext({ files: { 'lib/utils.js': '', - 'pages/_middleware.js': ` - import '../lib/utils' + 'pages/index.js': ` + export default function () { return
    Hello, world!
    } + `, + 'middleware.js': ` + import './lib/utils' export default function middleware() { return new Response() } @@ -54,7 +57,7 @@ describe('Middleware with Dynamic code invokations', () => { await expect(next.start()).rejects.toThrow() expect(next.cliOutput).toContain(` ./node_modules/ts-invariant/lib/invariant.esm.js -Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware pages/_middleware`) +Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware middleware`) }) it('detects dynamic code nested in has', async () => { @@ -68,10 +71,10 @@ Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware await expect(next.start()).rejects.toThrow() expect(next.cliOutput).toContain(` ./node_modules/function-bind/implementation.js -Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware pages/_middleware`) +Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware middleware`) expect(next.cliOutput).toContain(` ./node_modules/has/src/index.js -Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware pages/_middleware`) +Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware middleware`) }) it('detects dynamic code nested in qs', async () => { @@ -85,7 +88,7 @@ Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware await expect(next.start()).rejects.toThrow() expect(next.cliOutput).toContain(` ./node_modules/get-intrinsic/index.js -Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware pages/_middleware`) +Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware middleware`) }) it('does not detects dynamic code nested in @aws-sdk/client-s3 (legit Function.bind)', async () => { @@ -101,7 +104,7 @@ Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware `./node_modules/@aws-sdk/smithy-client/dist-es/lazy-json.js` ) expect(next.cliOutput).not.toContain( - `Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware pages/_middleware` + `Dynamic Code Evaluation (e. g. 'eval', 'new Function') not allowed in Middleware middleware` ) }) }) diff --git a/test/production/reading-request-body-in-middleware/index.test.ts b/test/production/reading-request-body-in-middleware/index.test.ts index 9e64502d86..2a3f79d73c 100644 --- a/test/production/reading-request-body-in-middleware/index.test.ts +++ b/test/production/reading-request-body-in-middleware/index.test.ts @@ -8,7 +8,7 @@ describe('reading request body in middleware', () => { beforeAll(async () => { next = await createNext({ files: { - 'pages/_middleware.js': ` + 'middleware.js': ` const { NextResponse } = require('next/server'); export default async function middleware(request) { @@ -40,28 +40,6 @@ describe('reading request body in middleware', () => { } `, - 'pages/nested/_middleware.js': ` - const { NextResponse } = require('next/server'); - - export default async function middleware(request) { - if (!request.body) { - return new Response('No body', { status: 400 }); - } - - const json = await request.json(); - - return new Response(JSON.stringify({ - root: false, - ...json, - }), { - status: 200, - headers: { - 'content-type': 'application/json', - }, - }) - } - `, - 'pages/api/hi.js': ` export default function hi(req, res) { res.json({ @@ -100,27 +78,6 @@ describe('reading request body in middleware', () => { }) }) - it('reads the same body on both middlewares', async () => { - const response = await fetchViaHTTP( - next.url, - '/nested/hello', - { - next: '1', - }, - { - method: 'POST', - body: JSON.stringify({ - foo: 'bar', - }), - } - ) - expect(response.status).toEqual(200) - expect(await response.json()).toEqual({ - foo: 'bar', - root: false, - }) - }) - it('passes the body to the api endpoint', async () => { const response = await fetchViaHTTP( next.url, diff --git a/test/production/required-server-files.test.ts b/test/production/required-server-files.test.ts index 445e6034c7..a4d19c3352 100644 --- a/test/production/required-server-files.test.ts +++ b/test/production/required-server-files.test.ts @@ -30,6 +30,9 @@ describe('should set-up next', () => { files: { pages: new FileRef(join(__dirname, 'required-server-files/pages')), lib: new FileRef(join(__dirname, 'required-server-files/lib')), + 'middleware.js': new FileRef( + join(__dirname, 'required-server-files/middleware.js') + ), 'data.txt': new FileRef( join(__dirname, 'required-server-files/data.txt') ), @@ -159,15 +162,7 @@ describe('should set-up next', () => { ).toBe(true) expect( await fs.pathExists( - join( - next.testDir, - 'standalone/.next/server/pages/middleware/_middleware.js' - ) - ) - ).toBe(true) - expect( - await fs.pathExists( - join(next.testDir, 'standalone/.next/server/pages/_middleware.js') + join(next.testDir, 'standalone/.next/server/middleware.js') ) ).toBe(true) }) diff --git a/test/production/required-server-files/pages/_middleware.js b/test/production/required-server-files/middleware.js similarity index 100% rename from test/production/required-server-files/pages/_middleware.js rename to test/production/required-server-files/middleware.js diff --git a/test/production/required-server-files/pages/middleware/_middleware.js b/test/production/required-server-files/pages/middleware/_middleware.js deleted file mode 100644 index c07ee4d1f4..0000000000 --- a/test/production/required-server-files/pages/middleware/_middleware.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function middleware(req) { - return new Response('hello from middleware') -} diff --git a/test/unit/eslint-plugin-next/no-server-import-in-page.test.ts b/test/unit/eslint-plugin-next/no-server-import-in-page.test.ts index da1dbad31d..3ec3ba8d63 100644 --- a/test/unit/eslint-plugin-next/no-server-import-in-page.test.ts +++ b/test/unit/eslint-plugin-next/no-server-import-in-page.test.ts @@ -22,7 +22,7 @@ ruleTester.run('no-server-import-in-page', rule, { return new Response('Hello, world!') } `, - filename: 'pages/_middleware.js', + filename: 'middleware.js', }, { code: `import { NextFetchEvent, NextRequest } from "next/server" @@ -31,7 +31,7 @@ ruleTester.run('no-server-import-in-page', rule, { return new Response('Hello, world!') } `, - filename: `pages${path.sep}_middleware.js`, + filename: `${path.sep}middleware.js`, }, { code: `import NextDocument from "next/document" @@ -40,7 +40,7 @@ ruleTester.run('no-server-import-in-page', rule, { return new Response('Hello, world!') } `, - filename: `pages${path.posix.sep}_middleware.tsx`, + filename: `${path.posix.sep}middleware.tsx`, }, { code: `import { NextFetchEvent, NextRequest } from "next/server" @@ -49,43 +49,7 @@ ruleTester.run('no-server-import-in-page', rule, { return new Response('Hello, world!') } `, - filename: 'pages/_middleware.page.tsx', - }, - { - code: `import { NextFetchEvent, NextRequest } from "next/server" - - export function middleware(req, ev) { - return new Response('Hello, world!') - } - `, - filename: 'pages/_middleware/index.js', - }, - { - code: `import { NextFetchEvent, NextRequest } from "next/server" - - export function middleware(req, ev) { - return new Response('Hello, world!') - } - `, - filename: 'pages/_middleware/index.tsx', - }, - { - code: `import { NextFetchEvent, NextRequest } from "next/server" - - export function middleware(req, ev) { - return new Response('Hello, world!') - } - `, - filename: 'pagesapp/src/pages/_middleware.js', - }, - { - code: `import { NextFetchEvent, NextRequest } from "next/server" - - export function middleware(req, ev) { - return new Response('Hello, world!') - } - `, - filename: 'src/pages/subFolder/_middleware.js', + filename: 'middleware.page.tsx', }, ], invalid: [ @@ -98,7 +62,7 @@ ruleTester.run('no-server-import-in-page', rule, { errors: [ { message: - 'next/server should not be imported outside of pages/_middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', + 'next/server should not be imported outside of middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', type: 'ImportDeclaration', }, ], @@ -112,7 +76,7 @@ ruleTester.run('no-server-import-in-page', rule, { errors: [ { message: - 'next/server should not be imported outside of pages/_middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', + 'next/server should not be imported outside of middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', type: 'ImportDeclaration', }, ], @@ -126,7 +90,7 @@ ruleTester.run('no-server-import-in-page', rule, { errors: [ { message: - 'next/server should not be imported outside of pages/_middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', + 'next/server should not be imported outside of middleware.js. See: https://nextjs.org/docs/messages/no-server-import-in-page', type: 'ImportDeclaration', }, ],