diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index 42de1eff4679f..e30f4d279c15b 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -117,6 +117,7 @@ export default class PageLoader { asPath: string href: string locale?: string | false + skipInterpolation?: boolean }): string { const { asPath, href, locale } = params const { pathname: hrefPathname, query, search } = parseRelativeUrl(href) @@ -138,7 +139,9 @@ export default class PageLoader { } return getHrefForSlug( - isDynamicRoute(route) + params.skipInterpolation + ? asPathname + : isDynamicRoute(route) ? interpolateAs(hrefPathname, asPathname, query).result : route ) diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index 98f039af7e8f4..fd6989fb99569 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -439,71 +439,60 @@ export default abstract class Server { if ( this.minimalMode && - req.headers['x-matched-path'] && typeof req.headers['x-matched-path'] === 'string' ) { - const reqUrlIsDataUrl = req.url?.includes('/_next/data') - const parsedMatchedPath = parseUrl(req.headers['x-matched-path'] || '') - const matchedPathIsDataUrl = - parsedMatchedPath.pathname?.includes('/_next/data') - const isDataUrl = reqUrlIsDataUrl || matchedPathIsDataUrl - - let parsedPath = parseUrl( - isDataUrl ? req.url! : (req.headers['x-matched-path'] as string), - true - ) - let matchedPathname = parsedPath.pathname! - - let matchedPathnameNoExt = isDataUrl - ? matchedPathname.replace(/\.json$/, '') - : matchedPathname - - let srcPathname = isDataUrl - ? this.stripNextDataPath( - parsedMatchedPath.pathname?.replace(/\.json$/, '') || - matchedPathnameNoExt - ) || '/' - : matchedPathnameNoExt - - if (this.nextConfig.i18n) { - const localePathResult = normalizeLocalePath( - matchedPathname || '/', - this.nextConfig.i18n.locales - ) - - if (localePathResult.detectedLocale) { - parsedUrl.query.__nextLocale = localePathResult.detectedLocale + try { + // x-matched-path is the source of truth, it tells what page + // should be rendered because we don't process rewrites in minimalMode + let matchedPath = new URL( + req.headers['x-matched-path'], + 'http://localhost' + ).pathname + + let urlPathname = new URL(req.url, 'http://localhost').pathname + + // For ISR the URL is normalized to the prerenderPath so if + // it's a data request the URL path will be the data URL, + // basePath is already stripped by this point + if (urlPathname.startsWith(`/_next/data/`)) { + parsedUrl.query.__nextDataReq = '1' } - } + matchedPath = this.stripNextDataPath(matchedPath, false) - if (isDataUrl) { - matchedPathname = denormalizePagePath(matchedPathname) - matchedPathnameNoExt = denormalizePagePath(matchedPathnameNoExt) - srcPathname = denormalizePagePath(srcPathname) - } + if (this.nextConfig.i18n) { + const localeResult = normalizeLocalePath( + matchedPath, + this.nextConfig.i18n.locales + ) + matchedPath = localeResult.pathname - if ( - !isDynamicRoute(srcPathname) && - !(await this.hasPage(srcPathname)) - ) { - for (const dynamicRoute of this.dynamicRoutes || []) { - if (dynamicRoute.match(srcPathname)) { - srcPathname = dynamicRoute.page - break + if (localeResult.detectedLocale) { + parsedUrl.query.__nextLocale = localeResult.detectedLocale } } - } + matchedPath = denormalizePagePath(matchedPath) + let srcPathname = matchedPath - const pageIsDynamic = isDynamicRoute(srcPathname) - const utils = getUtils({ - pageIsDynamic, - page: srcPathname, - i18n: this.nextConfig.i18n, - basePath: this.nextConfig.basePath, - rewrites: this.customRoutes.rewrites, - }) + if ( + !isDynamicRoute(srcPathname) && + !(await this.hasPage(srcPathname)) + ) { + for (const dynamicRoute of this.dynamicRoutes || []) { + if (dynamicRoute.match(srcPathname)) { + srcPathname = dynamicRoute.page + break + } + } + } - try { + const pageIsDynamic = isDynamicRoute(srcPathname) + const utils = getUtils({ + pageIsDynamic, + page: srcPathname, + i18n: this.nextConfig.i18n, + basePath: this.nextConfig.basePath, + rewrites: this.customRoutes.rewrites, + }) // ensure parsedUrl.pathname includes URL before processing // rewrites or they won't match correctly if (defaultLocale && !pathnameInfo.locale) { @@ -523,7 +512,6 @@ export default abstract class Server { if (pageIsDynamic) { let params: ParsedUrlQuery | false = {} - Object.assign(parsedUrl.query, parsedPath.query) const paramsResult = utils.normalizeDynamicRouteParams( parsedUrl.query ) @@ -542,7 +530,7 @@ export default abstract class Server { parsedUrl.query.__nextLocale = opts.locale } } else { - params = utils.dynamicRouteMatcher!(matchedPathnameNoExt) || {} + params = utils.dynamicRouteMatcher!(matchedPath) || {} } if (params) { @@ -550,19 +538,9 @@ export default abstract class Server { params = utils.normalizeDynamicRouteParams(params).params } - matchedPathname = utils.interpolateDynamicPath( - matchedPathname, - params - ) + matchedPath = utils.interpolateDynamicPath(srcPathname, params) req.url = utils.interpolateDynamicPath(req.url!, params) } - - if (reqUrlIsDataUrl && matchedPathIsDataUrl) { - req.url = formatUrl({ - ...parsedPath, - pathname: matchedPathname, - }) - } Object.assign(parsedUrl.query, params) } @@ -572,6 +550,10 @@ export default abstract class Server { ...Object.keys(utils.defaultRouteRegex?.groups || {}), ]) } + parsedUrl.pathname = `${this.nextConfig.basePath || ''}${ + matchedPath === '/' && this.nextConfig.basePath ? '' : matchedPath + }` + url.pathname = parsedUrl.pathname } catch (err) { if (err instanceof DecodeError || err instanceof NormalizeError) { res.statusCode = 400 @@ -579,13 +561,6 @@ export default abstract class Server { } throw err } - - parsedUrl.pathname = `${this.nextConfig.basePath || ''}${ - matchedPathname === '/' && this.nextConfig.basePath - ? '' - : matchedPathname - }` - url.pathname = parsedUrl.pathname } addRequestMeta(req, '__nextHadTrailingSlash', pathnameInfo.trailingSlash) @@ -773,18 +748,10 @@ export default abstract class Server { } } - const parsedUrl = parseUrl(pathname, true) - - await this.render( - req, - res, - pathname, - { ..._parsedUrl.query, _nextDataReq: '1' }, - parsedUrl, - true - ) return { - finished: true, + pathname, + query: { ..._parsedUrl.query, __nextDataReq: '1' }, + finished: false, } }, }, @@ -1136,7 +1103,7 @@ export default abstract class Server { if ( !internalRender && !this.minimalMode && - !query._nextDataReq && + !query.__nextDataReq && (req.url?.match(/^\/_next\//) || (this.hasStaticDir && req.url!.match(/^\/static\//))) ) { @@ -1208,9 +1175,36 @@ export default abstract class Server { // Toggle whether or not this is a Data request const isDataReq = - !!query._nextDataReq && (isSSG || hasServerProps || isServerComponent) + !!query.__nextDataReq && (isSSG || hasServerProps || isServerComponent) - delete query._nextDataReq + // normalize req.url for SSG paths as it is not exposed + // to getStaticProps and the asPath should not expose /_next/data + if ( + isSSG && + this.minimalMode && + req.headers['x-matched-path'] && + req.url.startsWith('/_next/data') + ) { + req.url = this.stripNextDataPath(req.url) + } + + if (!!query.__nextDataReq) { + res.setHeader( + 'x-nextjs-matched-path', + `${query.__nextLocale ? `/${query.__nextLocale}` : ''}${pathname}` + ) + // return empty JSON when not an SSG/SSP page and not an error + if ( + !(isSSG || hasServerProps) && + (!res.statusCode || res.statusCode === 200 || res.statusCode === 404) + ) { + res.setHeader('content-type', 'application/json') + res.body('{}') + res.send() + return null + } + } + delete query.__nextDataReq // Don't delete query.__flight__ yet, it still needs to be used in renderToHTML later const isFlightRequest = Boolean( @@ -1710,7 +1704,7 @@ export default abstract class Server { } } - private stripNextDataPath(path: string) { + private stripNextDataPath(path: string, stripLocale = true) { if (path.includes(this.buildId)) { const splitPath = path.substring( path.indexOf(this.buildId) + this.buildId.length @@ -1719,7 +1713,7 @@ export default abstract class Server { path = denormalizePagePath(splitPath.replace(/\.json$/, '')) } - if (this.nextConfig.i18n) { + if (this.nextConfig.i18n && stripLocale) { const { locales } = this.nextConfig.i18n return normalizeLocalePath(path, locales).pathname } diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 2ba1c6b10d282..88565446d4cbf 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -415,6 +415,9 @@ export default class DevServer extends Server { })) this.router.setDynamicRoutes(this.dynamicRoutes) + this.router.setCatchallMiddleware( + this.generateCatchAllMiddlewareRoute(true) + ) if (!resolved) { resolve() diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index a01efa2cb7e54..c1458a2dbdc87 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -707,7 +707,7 @@ export default class NextNodeServer extends BaseServer { ...(components.getStaticProps ? ({ amp: query.amp, - _nextDataReq: query._nextDataReq, + __nextDataReq: query.__nextDataReq, __nextLocale: query.__nextLocale, __nextDefaultLocale: query.__nextDefaultLocale, __flight__: query.__flight__, @@ -1116,7 +1116,12 @@ export default class NextNodeServer extends BaseServer { const normalizedPathname = removeTrailingSlash(params.parsedUrl.pathname) // For middleware to "fetch" we must always provide an absolute URL - const url = getRequestMeta(params.request, '__NEXT_INIT_URL')! + const query = urlQueryToSearchParams(params.parsed.query).toString() + const locale = params.parsed.query.__nextLocale + const url = `http://${this.hostname}:${this.port}${ + locale ? `/${locale}` : '' + }${params.parsed.pathname}${query ? `?${query}` : ''}` + if (!url.startsWith('http')) { throw new Error( 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' @@ -1211,9 +1216,15 @@ export default class NextNodeServer extends BaseServer { return result } - protected generateCatchAllMiddlewareRoute(): Route | undefined { + protected generateCatchAllMiddlewareRoute( + devReady?: boolean + ): Route | undefined { if (this.minimalMode) return undefined + if ((!this.renderOpts.dev || devReady) && !this.getMiddleware().length) { + return undefined + } + return { match: getPathMatch('/:path*'), type: 'route', diff --git a/packages/next/server/request-meta.ts b/packages/next/server/request-meta.ts index f906e5f5f1f05..a799a464413ef 100644 --- a/packages/next/server/request-meta.ts +++ b/packages/next/server/request-meta.ts @@ -59,7 +59,7 @@ type NextQueryMetadata = { __nextLocale?: string __nextSsgPath?: string _nextBubbleNoFallback?: '1' - _nextDataReq?: '1' + __nextDataReq?: '1' } export type NextParsedUrlQuery = ParsedUrlQuery & @@ -80,7 +80,7 @@ export function getNextInternalQuery( '__nextLocale', '__nextSsgPath', '_nextBubbleNoFallback', - '_nextDataReq', + '__nextDataReq', ] const nextInternalQuery: NextQueryMetadata = {} diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index 01cf8a7cfbc63..16bc54bd56c05 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -111,6 +111,9 @@ export default class Router { setDynamicRoutes(routes: DynamicRoutes = []) { this.dynamicRoutes = routes } + setCatchallMiddleware(route?: Route) { + this.catchAllMiddleware = route + } addFsRoute(fsRoute: Route) { this.fsRoutes.unshift(fsRoute) @@ -208,12 +211,15 @@ export default class Router { */ const allRoutes = [ + ...(this.catchAllMiddleware + ? this.fsRoutes.filter((r) => r.name === '_next/data catchall') + : []), ...this.headers, ...this.redirects, - ...this.rewrites.beforeFiles, ...(this.useFileSystemPublicRoutes && this.catchAllMiddleware ? [this.catchAllMiddleware] : []), + ...this.rewrites.beforeFiles, ...this.fsRoutes, // We only check the catch-all route if public page routes hasn't been // disabled diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index 45380a72cbf64..8b93126e2338a 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -23,6 +23,15 @@ export async function adapter(params: { const buildId = requestUrl.buildId requestUrl.buildId = '' + const isDataReq = params.request.headers['x-nextjs-data'] + + // clean-up any internal query params + for (const key of [...requestUrl.searchParams.keys()]) { + if (key.startsWith('__next')) { + requestUrl.searchParams.delete(key) + } + } + const request = new NextRequestHint({ page: params.page, input: String(requestUrl), @@ -41,7 +50,7 @@ export async function adapter(params: { * need to know about this property neither use it. We add it for testing * purposes. */ - if (buildId) { + if (isDataReq) { Object.defineProperty(request, '__isData', { enumerable: false, value: true, @@ -75,7 +84,7 @@ export async function adapter(params: { * with an internal header so the client knows which component to load * from the data request. */ - if (buildId) { + if (isDataReq) { response.headers.set( 'x-nextjs-matched-path', relativizeURL(String(rewriteUrl), String(requestUrl)) @@ -112,7 +121,7 @@ export async function adapter(params: { * it may end up with CORS error. Instead we map to an internal header so * the client knows the destination. */ - if (buildId) { + if (isDataReq) { response.headers.delete('Location') response.headers.set( 'x-nextjs-redirect', diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 2264635cf525f..5a1cd6a7e5114 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -437,7 +437,9 @@ function fetchRetry( // https://github.com/github/fetch#caveats credentials: 'same-origin', method: options.method || 'GET', - headers: options.headers ?? {}, + headers: Object.assign({}, options.headers, { + 'x-nextjs-data': '1', + }), }).then((response) => { return !response.ok && attempts > 1 && response.status >= 500 ? fetchRetry(url, attempts - 1, options) @@ -492,7 +494,10 @@ function fetchNextData({ * mapped location. * TODO: Change the status code in the handler. */ - if (hasMiddleware && [301, 302, 308].includes(response.status)) { + if ( + hasMiddleware && + [301, 302, 307, 308].includes(response.status) + ) { return { dataHref, response, text, json: {} } } @@ -539,7 +544,11 @@ function fetchNextData({ } }) .then((data) => { - if (!persistCache || process.env.NODE_ENV !== 'production') { + if ( + !persistCache || + process.env.NODE_ENV !== 'production' || + data.response.headers.get('x-middleware-cache') === 'no-cache' + ) { delete inflightCache[cacheKey] } return data @@ -588,10 +597,8 @@ export default class Router implements BaseRouter { * Map of all components loaded in `Router` */ components: { [pathname: string]: PrivateRouteInfo } - // Static Data Cache + // Server Data Cache sdc: NextDataCache = {} - // In-flight Server Data Requests, for deduping - sdr: NextDataCache = {} sub: Subscription clc: ComponentLoadCancel @@ -740,14 +747,29 @@ export default class Router implements BaseRouter { // in order for `e.state` to work on the `onpopstate` event // we have to register the initial route upon initialization const options: TransitionOptions = { locale } - ;(options as any)._shouldResolveHref = as !== pathname - - this.changeState( - 'replaceState', - formatWithValidation({ pathname: addBasePath(pathname), query }), - getURL(), - options - ) + const asPath = getURL() + + matchesMiddleware({ + router: this, + locale, + asPath, + }).then((matches) => { + // if middleware matches we leave resolving to the change function + // as the server needs to resolve for correct priority + ;(options as any)._shouldResolveHref = as !== pathname + + this.changeState( + 'replaceState', + matches + ? asPath + : formatWithValidation({ + pathname: addBasePath(pathname), + query, + }), + asPath, + options + ) + }) } window.addEventListener('popstate', this.onPopState) @@ -1115,7 +1137,15 @@ export default class Router implements BaseRouter { ? removeTrailingSlash(removeBasePath(pathname)) : pathname - if (shouldResolveHref && pathname !== '/_error') { + // we don't attempt resolve asPath when we need to execute + // middleware as the resolving will occur server-side + const isMiddlewareMatch = await matchesMiddleware({ + asPath: as, + locale: nextState.locale, + router: this, + }) + + if (!isMiddlewareMatch && shouldResolveHref && pathname !== '/_error') { ;(options as any)._shouldResolveHref = true if (process.env.__NEXT_HAS_REWRITES && as.startsWith('/')) { @@ -1168,60 +1198,59 @@ export default class Router implements BaseRouter { const route = removeTrailingSlash(pathname) - if (isDynamicRoute(route)) { - const matchInfo = await matchHrefAndAsPath({ - href: { pathname, query }, - asPath: resolvedAs, - getData: () => - withMiddlewareEffects({ - fetchData: () => - fetchNextData({ - dataHref: this.pageLoader.getDataHref({ - asPath: resolvedAs, - href: as, - locale: nextState.locale, - }), - hasMiddleware: true, - isServerRender: this.isSsr, - parseJSON: true, - inflightCache: {}, - persistCache: false, - isPrefetch: false, - }), - asPath: resolvedAs, - locale: nextState.locale, - router: this, - }), - }) + if (!isMiddlewareMatch && isDynamicRoute(route)) { + const parsedAs = parseRelativeUrl(resolvedAs) + const asPathname = parsedAs.pathname - if (matchInfo.error) { - if (matchInfo.missingParams.length > 0) { - const missingParams = matchInfo.missingParams.join(', ') + const routeRegex = getRouteRegex(route) + const routeMatch = getRouteMatcher(routeRegex)(asPathname) + const shouldInterpolate = route === asPathname + const interpolatedAs = shouldInterpolate + ? interpolateAs(route, asPathname, query) + : ({} as { result: undefined; params: undefined }) + + if (!routeMatch || (shouldInterpolate && !interpolatedAs.result)) { + const missingParams = Object.keys(routeRegex.groups).filter( + (param) => !query[param] + ) + + if (missingParams.length > 0) { if (process.env.NODE_ENV !== 'production') { console.warn( `${ - matchInfo.error === 'interpolate' + shouldInterpolate ? `Interpolating href` : `Mismatching \`as\` and \`href\`` - } failed to manually provide the params: ${missingParams} in the \`href\`'s \`query\`` + } failed to manually provide ` + + `the params: ${missingParams.join( + ', ' + )} in the \`href\`'s \`query\`` ) } throw new Error( - (matchInfo.error === 'interpolate' - ? `The provided \`href\` (${url}) value is missing query values (${missingParams}) to be interpolated properly. ` - : `The provided \`as\` value (${matchInfo.asPathname}) is incompatible with the \`href\` value (${route}). `) + + (shouldInterpolate + ? `The provided \`href\` (${url}) value is missing query values (${missingParams.join( + ', ' + )}) to be interpolated properly. ` + : `The provided \`as\` value (${asPathname}) is incompatible with the \`href\` value (${route}). `) + `Read more: https://nextjs.org/docs/messages/${ - matchInfo.error === 'interpolate' + shouldInterpolate ? 'href-interpolation-failed' : 'incompatible-href-as' }` ) } - } else if (matchInfo.as) { - as = matchInfo.as + } else if (shouldInterpolate) { + as = formatWithValidation( + Object.assign({}, parsedAs, { + pathname: interpolatedAs.result, + query: omit(query, interpolatedAs.params!), + }) + ) } else { - Object.assign(query, matchInfo.routeMatch) + // Merge params into `query`, overwriting any specified in search + Object.assign(query, routeMatch) } } @@ -1549,14 +1578,15 @@ export default class Router implements BaseRouter { fetchNextData({ dataHref: this.pageLoader.getDataHref({ href: formatWithValidation({ pathname, query }), + skipInterpolation: true, asPath: resolvedAs, locale, }), hasMiddleware: true, isServerRender: this.isSsr, parseJSON: true, - inflightCache: cachedRouteInfo?.__N_SSG ? this.sdc : this.sdr, - persistCache: !!cachedRouteInfo?.__N_SSG && !isPreview, + inflightCache: this.sdc, + persistCache: !isPreview, isPrefetch: false, }), asPath: resolvedAs, @@ -1603,6 +1633,13 @@ export default class Router implements BaseRouter { }) )) + // TODO: we only bust the data cache for SSP routes + // although middleware can skip cache per request with + // x-middleware-cache: no-cache + if (routeInfo.__N_SSP && data?.dataHref) { + delete this.sdc[data?.dataHref] + } + if (process.env.NODE_ENV !== 'production') { const { isValidElementType } = require('next/dist/compiled/react-is') if (!isValidElementType(routeInfo.Component)) { @@ -1636,7 +1673,7 @@ export default class Router implements BaseRouter { }), isServerRender: this.isSsr, parseJSON: true, - inflightCache: routeInfo.__N_SSG ? this.sdc : this.sdr, + inflightCache: this.sdc, persistCache: !!routeInfo.__N_SSG && !isPreview, isPrefetch: false, })) @@ -1861,13 +1898,14 @@ export default class Router implements BaseRouter { fetchNextData({ dataHref: this.pageLoader.getDataHref({ href: formatWithValidation({ pathname, query }), + skipInterpolation: true, asPath: resolvedAs, locale, }), hasMiddleware: true, isServerRender: this.isSsr, parseJSON: true, - inflightCache: this.sdr, + inflightCache: this.sdc, persistCache: false, isPrefetch: false, }), @@ -2042,73 +2080,67 @@ export default class Router implements BaseRouter { } } -function matchesMiddleware(params: { - fns: [location: string, isSSR: boolean][] - asPath: string - locale?: string -}) { - const { pathname: asPathname } = parsePath(params.asPath) - const cleanedAs = removeLocale( - hasBasePath(asPathname) ? removeBasePath(asPathname) : asPathname, - params.locale - ) - - return params.fns.some(([middleware, isSSR]) => { - return getRouteMatcher( - getMiddlewareRegex(middleware, { - catchAll: !isSSR, - }) - )(cleanedAs) - }) -} - interface MiddlewareEffectParams { - fetchData: () => Promise + fetchData?: () => Promise locale?: string asPath: string router: Router } -function withMiddlewareEffects( +function matchesMiddleware( options: MiddlewareEffectParams -) { +): Promise { return Promise.resolve(options.router.pageLoader.getMiddlewareList()).then( (fns) => { - const matches = matchesMiddleware({ - asPath: options.asPath, - locale: options.locale, - fns: fns, - }) + const { pathname: asPathname } = parsePath(options.asPath) + const cleanedAs = removeLocale( + hasBasePath(asPathname) ? removeBasePath(asPathname) : asPathname, + options.locale + ) - if (matches) { - return options - .fetchData() - .then((data) => - getMiddlewareData(data.dataHref, data.response, options).then( - (effect) => ({ - dataHref: data.dataHref, - json: data.json, - response: data.response, - text: data.text, - effect, - }) - ) - ) - .catch(() => { - /** - * TODO: Revisit this in the future. - * For now we will not consider middleware data errors to be fatal. - * maybe we should revisit in the future. - */ - return null + return fns.some(([middleware, isSSR]) => { + return getRouteMatcher( + getMiddlewareRegex(middleware, { + catchAll: !isSSR, }) - } - - return null + )(cleanedAs) + }) } ) } +function withMiddlewareEffects( + options: MiddlewareEffectParams +) { + return matchesMiddleware(options).then((matches) => { + if (matches && options.fetchData) { + return options + .fetchData() + .then((data) => + getMiddlewareData(data.dataHref, data.response, options).then( + (effect) => ({ + dataHref: data.dataHref, + json: data.json, + response: data.response, + text: data.text, + effect, + }) + ) + ) + .catch((_err) => { + /** + * TODO: Revisit this in the future. + * For now we will not consider middleware data errors to be fatal. + * maybe we should revisit in the future. + */ + return null + }) + } + + return null + }) +} + function getMiddlewareData( source: string, response: Response, @@ -2121,6 +2153,7 @@ function getMiddlewareData( } const rewriteTarget = response.headers.get('x-nextjs-matched-path') + if (rewriteTarget) { if (rewriteTarget.startsWith('/')) { const parsedRewriteTarget = parseRelativeUrl(rewriteTarget) @@ -2156,6 +2189,7 @@ function getMiddlewareData( } const redirectTarget = response.headers.get('x-nextjs-redirect') + if (redirectTarget) { if (redirectTarget.startsWith('/')) { const src = parsePath(redirectTarget) @@ -2180,71 +2214,3 @@ function getMiddlewareData( return Promise.resolve({ type: 'next' as const }) } - -function matchHrefAndAsPath(params: { - asPath: string - href: { pathname: string; query: ParsedUrlQuery } - getData: () => ReturnType -}) { - const result = matchHrefAndAsPathData(params) - if (result.error === 'mismatch') { - return params.getData().then((data) => { - if (data?.effect?.type === 'rewrite') { - return Object.assign( - { effect: data.effect }, - matchHrefAndAsPathData({ - asPath: data.effect.parsedAs.pathname, - href: { - pathname: data.effect.resolvedHref, - query: { ...params.href.query, ...data.effect.parsedAs.query }, - }, - }) - ) - } - - return result - }) - } - - return Promise.resolve(result) -} - -function matchHrefAndAsPathData(params: { - href: { pathname: string; query: ParsedUrlQuery } - asPath: string -}) { - const { asPath, href } = params - const { pathname, query } = href - const route = removeTrailingSlash(pathname) - const regex = getRouteRegex(route) - const parsedAs = parseRelativeUrl(asPath) - const routeMatch = getRouteMatcher(regex)(parsedAs.pathname) - if (!routeMatch) { - return { - error: 'mismatch' as const, - asPathname: parsedAs.pathname, - missingParams: Object.keys(regex.groups).filter((key) => !query[key]), - } - } - - if (route === parsedAs.pathname) { - const interpolated = interpolateAs(route, parsedAs.pathname, query) - if (!interpolated?.result) { - return { - error: 'interpolate' as const, - missingParams: Object.keys(regex.groups).filter((key) => !query[key]), - } - } - - return { - as: formatWithValidation( - Object.assign({}, parsedAs, { - pathname: interpolated.result, - query: omit(query, interpolated.params!), - }) - ), - } - } - - return { routeMatch } -} diff --git a/packages/next/shared/lib/router/utils/prepare-destination.ts b/packages/next/shared/lib/router/utils/prepare-destination.ts index a34067e1af8d0..72c48fd3c7780 100644 --- a/packages/next/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/shared/lib/router/utils/prepare-destination.ts @@ -121,6 +121,7 @@ export function prepareDestination(args: { const query = Object.assign({}, args.query) delete query.__nextLocale delete query.__nextDefaultLocale + delete query.__nextDataReq let escapedDestination = args.destination diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f7cb38ef6d3c..d8b010d52a9cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16177,7 +16177,7 @@ packages: dev: true /object-assign/4.1.1: - resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} /object-copy/0.1.0: @@ -19866,7 +19866,7 @@ packages: source-map: 0.6.1 /source-map-url/0.4.0: - resolution: {integrity: sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=} + resolution: {integrity: sha512-liJwHPI9x9d9w5WSIjM58MqGmmb7XzNqwdUA3kSBQ4lmDngexlKwawGzK3J1mKXi6+sysoMDlpVyZh9sv5vRfw==} deprecated: See https://github.com/lydell/source-map-url#deprecated requiresBuild: true @@ -19878,7 +19878,7 @@ packages: dev: true /source-map/0.5.7: - resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=} + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} engines: {node: '>=0.10.0'} /source-map/0.6.1: diff --git a/test/integration/middleware-base-path/middleware.js b/test/e2e/middleware-base-path/app/middleware.js similarity index 100% rename from test/integration/middleware-base-path/middleware.js rename to test/e2e/middleware-base-path/app/middleware.js diff --git a/test/integration/middleware-base-path/next.config.js b/test/e2e/middleware-base-path/app/next.config.js similarity index 100% rename from test/integration/middleware-base-path/next.config.js rename to test/e2e/middleware-base-path/app/next.config.js diff --git a/test/integration/middleware-base-path/pages/about.js b/test/e2e/middleware-base-path/app/pages/about.js similarity index 100% rename from test/integration/middleware-base-path/pages/about.js rename to test/e2e/middleware-base-path/app/pages/about.js diff --git a/test/integration/middleware-base-path/pages/index.js b/test/e2e/middleware-base-path/app/pages/index.js similarity index 100% rename from test/integration/middleware-base-path/pages/index.js rename to test/e2e/middleware-base-path/app/pages/index.js diff --git a/test/e2e/middleware-base-path/test/index.test.ts b/test/e2e/middleware-base-path/test/index.test.ts new file mode 100644 index 0000000000000..2b8ebcb8f8783 --- /dev/null +++ b/test/e2e/middleware-base-path/test/index.test.ts @@ -0,0 +1,38 @@ +/* eslint-env jest */ +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +describe('Middleware base tests', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + afterAll(() => next.destroy()) + + it('should execute from absolute paths', async () => { + const browser = await webdriver(next.url, '/redirect-with-basepath') + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + '/root/redirect-with-basepath' + ) + } finally { + await browser.close() + } + + const res = await fetchViaHTTP(next.url, '/root/redirect-with-basepath') + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + }) +}) diff --git a/test/integration/middleware-general/middleware.js b/test/e2e/middleware-general/app/middleware.js similarity index 92% rename from test/integration/middleware-general/middleware.js rename to test/e2e/middleware-general/app/middleware.js index 081aa474576df..dac5bb370990f 100644 --- a/test/integration/middleware-general/middleware.js +++ b/test/e2e/middleware-general/app/middleware.js @@ -36,11 +36,16 @@ const params = (url) => { export async function middleware(request) { const url = request.nextUrl + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + if (url.pathname.startsWith('/fetch-user-agent-default')) { try { const apiRoute = new URL(url) apiRoute.pathname = '/api/headers' - const res = await fetch(apiRoute) + const res = await fetch(withLocalIp(apiRoute)) return serializeData(await res.text()) } catch (err) { return serializeError(err) @@ -51,7 +56,7 @@ export async function middleware(request) { try { const apiRoute = new URL(url) apiRoute.pathname = '/api/headers' - const res = await fetch(apiRoute, { + const res = await fetch(withLocalIp(apiRoute), { headers: { 'user-agent': 'custom-agent', }, @@ -74,9 +79,9 @@ export async function middleware(request) { console.log('missing ANOTHER_MIDDLEWARE_TEST') } - const { 'STRING-ENV-VAR': stringEnvVar } = process['env'] + const { STRING_ENV_VAR: stringEnvVar } = process['env'] if (!stringEnvVar) { - console.log('missing STRING-ENV-VAR') + console.log('missing STRING_ENV_VAR') } return serializeData(JSON.stringify({ process: { env: process.env } })) @@ -211,3 +216,7 @@ function serializeData(data) { function serializeError(error) { return new NextResponse(null, { headers: { error: error.message } }) } + +function withLocalIp(url) { + return String(url).replace('localhost', '127.0.0.1') +} diff --git a/test/integration/middleware-general/next.config.js b/test/e2e/middleware-general/app/next.config.js similarity index 65% rename from test/integration/middleware-general/next.config.js rename to test/e2e/middleware-general/app/next.config.js index b652e0d98d7df..ea7f71ed65d6f 100644 --- a/test/integration/middleware-general/next.config.js +++ b/test/e2e/middleware-general/app/next.config.js @@ -3,6 +3,15 @@ module.exports = { locales: ['en', 'fr', 'nl'], defaultLocale: 'en', }, + redirects() { + return [ + { + source: '/redirect-1', + destination: '/somewhere-else', + permanent: false, + }, + ] + }, rewrites() { return [ { diff --git a/test/integration/middleware-general/node_modules/shared-package/index.js b/test/e2e/middleware-general/app/node_modules/shared-package/index.js similarity index 100% rename from test/integration/middleware-general/node_modules/shared-package/index.js rename to test/e2e/middleware-general/app/node_modules/shared-package/index.js diff --git a/test/integration/middleware-general/node_modules/shared-package/package.json b/test/e2e/middleware-general/app/node_modules/shared-package/package.json similarity index 100% rename from test/integration/middleware-general/node_modules/shared-package/package.json rename to test/e2e/middleware-general/app/node_modules/shared-package/package.json diff --git a/test/integration/middleware-general/pages/[id].js b/test/e2e/middleware-general/app/pages/[id].js similarity index 100% rename from test/integration/middleware-general/pages/[id].js rename to test/e2e/middleware-general/app/pages/[id].js diff --git a/test/integration/middleware-general/pages/about/a.js b/test/e2e/middleware-general/app/pages/about/a.js similarity index 100% rename from test/integration/middleware-general/pages/about/a.js rename to test/e2e/middleware-general/app/pages/about/a.js diff --git a/test/integration/middleware-general/pages/about/b.js b/test/e2e/middleware-general/app/pages/about/b.js similarity index 100% rename from test/integration/middleware-general/pages/about/b.js rename to test/e2e/middleware-general/app/pages/about/b.js diff --git a/test/integration/middleware-general/pages/api/headers.js b/test/e2e/middleware-general/app/pages/api/headers.js similarity index 100% rename from test/integration/middleware-general/pages/api/headers.js rename to test/e2e/middleware-general/app/pages/api/headers.js diff --git a/test/integration/middleware-general/pages/error-throw.js b/test/e2e/middleware-general/app/pages/error-throw.js similarity index 100% rename from test/integration/middleware-general/pages/error-throw.js rename to test/e2e/middleware-general/app/pages/error-throw.js diff --git a/test/integration/middleware-general/pages/error.js b/test/e2e/middleware-general/app/pages/error.js similarity index 100% rename from test/integration/middleware-general/pages/error.js rename to test/e2e/middleware-general/app/pages/error.js diff --git a/test/integration/middleware-general/pages/ssr-page-2.js b/test/e2e/middleware-general/app/pages/ssr-page-2.js similarity index 100% rename from test/integration/middleware-general/pages/ssr-page-2.js rename to test/e2e/middleware-general/app/pages/ssr-page-2.js diff --git a/test/integration/middleware-general/pages/ssr-page.js b/test/e2e/middleware-general/app/pages/ssr-page.js similarity index 100% rename from test/integration/middleware-general/pages/ssr-page.js rename to test/e2e/middleware-general/app/pages/ssr-page.js diff --git a/test/integration/middleware-general/test/index.test.js b/test/e2e/middleware-general/test/index.test.ts similarity index 55% rename from test/integration/middleware-general/test/index.test.js rename to test/e2e/middleware-general/test/index.test.ts index 307b56834a621..95f8c86f2a9c4 100644 --- a/test/integration/middleware-general/test/index.test.js +++ b/test/e2e/middleware-general/test/index.test.ts @@ -1,68 +1,66 @@ /* eslint-env jest */ -import { join } from 'path' import fs from 'fs-extra' +import { join } from 'path' import webdriver from 'next-webdriver' -import { - check, - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - nextStart, - waitFor, -} from 'next-test-utils' - -jest.setTimeout(1000 * 60 * 2) +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP, renderViaHTTP, waitFor } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' +import escapeStringRegexp from 'escape-string-regexp' 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.dev = true - context.appPort = await findPort() - context.buildId = 'development' - context.app = await launchApp(context.appDir, context.appPort, { - env: { - ANOTHER_MIDDLEWARE_TEST: 'asdf2', - 'STRING-ENV-VAR': 'asdf3', - 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 + let next: NextInstance + let locale = '' + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + pages: new FileRef(join(__dirname, '../app/pages')), + 'shared-package': new FileRef( + join(__dirname, '../app/node_modules/shared-package') + ), + }, + packageJson: { + scripts: { + setup: `cp -r ./shared-package ./node_modules`, + build: 'yarn setup && next build', + dev: 'yarn setup && next dev', + start: 'next start', }, - }) + }, + startCommand: (global as any).isNextDev ? 'yarn dev' : 'yarn start', + buildCommand: 'yarn build', + env: { + ANOTHER_MIDDLEWARE_TEST: 'asdf2', + STRING_ENV_VAR: 'asdf3', + MIDDLEWARE_TEST: 'asdf', + NEXT_RUNTIME: 'edge', + }, }) + }) - 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) + if ((global as any).isNextDev) { + it('should have showed warning for middleware usage', async () => { + await renderViaHTTP(next.url, '/') + await check( + () => next.cliOutput, + new RegExp(escapeStringRegexp(middlewareWarning)) + ) }) it('refreshes the page when middleware changes ', async () => { - const browser = await webdriver(context.appPort, `/about`) + const browser = await webdriver(next.url, `/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 middlewarePath = join(next.testDir, '/middleware.js') const originalContent = fs.readFileSync(middlewarePath, 'utf-8') const editedContent = originalContent.replace('/about/a', '/about/b') @@ -77,54 +75,16 @@ describe('Middleware Runtime', () => { await browser.close() } }) - }) - - describe('production mode', () => { - afterAll(() => killApp(context.app)) - beforeAll(async () => { - const build = await nextBuild(context.appDir, undefined, { - stderr: true, - stdout: true, - }) - - context.buildId = await fs.readFile( - join(context.appDir, '.next/BUILD_ID'), - 'utf8' - ) - - context.buildLogs = { - output: build.stdout + build.stderr, - stderr: build.stderr, - stdout: build.stdout, - } - context.dev = false - - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort, { - env: { - ANOTHER_MIDDLEWARE_TEST: 'asdf2', - 'STRING-ENV-VAR': 'asdf3', - 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 - }, - }) - }) + } + if ((global as any).isNextStart) { it('should have valid middleware field in manifest', async () => { const manifest = await fs.readJSON( - join(context.appDir, '.next/server/middleware-manifest.json') + join(next.testDir, '.next/server/middleware-manifest.json') ) expect(manifest.middleware).toEqual({ '/': { - env: ['MIDDLEWARE_TEST', 'ANOTHER_MIDDLEWARE_TEST', 'STRING-ENV-VAR'], + env: ['MIDDLEWARE_TEST', 'ANOTHER_MIDDLEWARE_TEST', 'STRING_ENV_VAR'], files: ['server/edge-runtime-webpack.js', 'server/middleware.js'], name: 'middleware', page: '/', @@ -135,16 +95,16 @@ describe('Middleware Runtime', () => { }) it('should have middleware warning during build', () => { - expect(context.buildLogs.output).toContain(middlewareWarning) + expect(next.cliOutput).toContain(middlewareWarning) }) it('should have middleware warning during start', () => { - expect(context.logs.output).toContain(middlewareWarning) + expect(next.cliOutput).toContain(middlewareWarning) }) it('should have correct files in manifest', async () => { const manifest = await fs.readJSON( - join(context.appDir, '.next/server/middleware-manifest.json') + join(next.testDir, '.next/server/middleware-manifest.json') ) for (const key of Object.keys(manifest.middleware)) { const middleware = manifest.middleware[key] @@ -156,19 +116,38 @@ describe('Middleware Runtime', () => { ) } }) + } + + // TODO: re-enable after fixing server-side resolving priority + it('should redirect the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/redirect-1`, + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/somewhere-else' + ) - tests(context) + const browser = await webdriver(next.url, `${locale}/`) + await browser.eval(`next.router.push('/redirect-1')`) + await check(async () => { + const pathname = await browser.eval('location.pathname') + return pathname === '/somewhere-else' ? 'success' : pathname + }, 'success') }) -}) -function tests(context, locale = '') { // TODO: re-enable after fixing server-side resolving priority - it.skip('should rewrite the same for direct visit and client-transition', async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/rewrite-1`) + it('should rewrite the same for direct visit and client-transition', async () => { + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-1`) expect(res.status).toBe(200) expect(await res.text()).toContain('Hello World') - const browser = await webdriver(context.appPort, `${locale}/`) + const browser = await webdriver(next.url, `${locale}/`) await browser.eval(`next.router.push('/rewrite-1')`) await check(async () => { const content = await browser.eval('document.documentElement.innerHTML') @@ -177,11 +156,11 @@ function tests(context, locale = '') { }) it('should rewrite correctly for non-SSG/SSP page', async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/rewrite-2`) + const res = await fetchViaHTTP(next.url, `${locale}/rewrite-2`) expect(res.status).toBe(200) expect(await res.text()).toContain('AboutA') - const browser = await webdriver(context.appPort, `${locale}/`) + const browser = await webdriver(next.url, `${locale}/`) await browser.eval(`next.router.push('/rewrite-2')`) await check(async () => { const content = await browser.eval('document.documentElement.innerHTML') @@ -190,63 +169,76 @@ function tests(context, locale = '') { }) it('should respond with 400 on decode failure', async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/%2`) + const res = await fetchViaHTTP(next.url, `${locale}/%2`) expect(res.status).toBe(400) - if (!context.dev) { + if ((global as any).isNextStart) { expect(await res.text()).toContain('Bad Request') } }) - it('should set fetch user agent correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/fetch-user-agent-default` - ) - expect(readMiddlewareJSON(res).headers['user-agent']).toBe( - 'Next.js Middleware' - ) + if (!(global as any).isNextDeploy) { + // user agent differs on Vercel + it('should set fetch user agent correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `${locale}/fetch-user-agent-default` + ) - const res2 = await fetchViaHTTP( - context.appPort, - `${locale}/fetch-user-agent-crypto` - ) - expect(readMiddlewareJSON(res2).headers['user-agent']).toBe('custom-agent') - }) + expect(readMiddlewareJSON(res).headers['user-agent']).toBe( + 'Next.js Middleware' + ) + + const res2 = await fetchViaHTTP( + next.url, + `${locale}/fetch-user-agent-crypto` + ) + expect(readMiddlewareJSON(res2).headers['user-agent']).toBe( + 'custom-agent' + ) + }) + } it('should contain process polyfill', async () => { - const res = await fetchViaHTTP(context.appPort, `/global`) + const res = await fetchViaHTTP(next.url, `/global`) expect(readMiddlewareJSON(res)).toEqual({ process: { env: { ANOTHER_MIDDLEWARE_TEST: 'asdf2', - 'STRING-ENV-VAR': 'asdf3', + STRING_ENV_VAR: 'asdf3', MIDDLEWARE_TEST: 'asdf', - NEXT_RUNTIME: 'edge', + ...((global as any).isNextDeploy + ? {} + : { + NEXT_RUNTIME: 'edge', + }), }, }, }) }) it(`should contain \`globalThis\``, async () => { - const res = await fetchViaHTTP(context.appPort, '/globalthis') + const res = await fetchViaHTTP(next.url, '/globalthis') expect(readMiddlewareJSON(res).length > 0).toBe(true) }) it(`should contain crypto APIs`, async () => { - const res = await fetchViaHTTP(context.appPort, '/webcrypto') + const res = await fetchViaHTTP(next.url, '/webcrypto') expect('error' in readMiddlewareJSON(res)).toBe(false) }) - it(`should accept a URL instance for fetch`, async () => { - const response = await fetchViaHTTP(context.appPort, '/fetch-url') - const { error } = readMiddlewareJSON(response) - expect(error).toBeTruthy() - expect(error.message).not.toContain("Failed to construct 'URL'") - }) + if (!(global as any).isNextDeploy) { + it(`should accept a URL instance for fetch`, async () => { + const response = await fetchViaHTTP(next.url, '/fetch-url') + // TODO: why is an error expected here if it should work? + const { error } = readMiddlewareJSON(response) + expect(error).toBeTruthy() + expect(error.message).not.toContain("Failed to construct 'URL'") + }) + } it(`should allow to abort a fetch request`, async () => { - const response = await fetchViaHTTP(context.appPort, '/abort-controller') + const response = await fetchViaHTTP(next.url, '/abort-controller') const payload = readMiddlewareJSON(response) expect('error' in payload).toBe(true) expect(payload.error.name).toBe('AbortError') @@ -254,9 +246,9 @@ function tests(context, locale = '') { }) it(`should validate & parse request url from any route`, async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/static`) + const res = await fetchViaHTTP(next.url, `${locale}/static`) - expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-basepath')).toBeFalsy() expect(res.headers.get('req-url-pathname')).toBe('/static') const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) @@ -270,9 +262,9 @@ function tests(context, locale = '') { }) it(`should validate & parse request url from a dynamic route with params`, async () => { - const res = await fetchViaHTTP(context.appPort, `/fr/1`) + const res = await fetchViaHTTP(next.url, `/fr/1`) - expect(res.headers.get('req-url-basepath')).toBe('') + expect(res.headers.get('req-url-basepath')).toBeFalsy() expect(res.headers.get('req-url-pathname')).toBe('/1') const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) @@ -284,8 +276,8 @@ function tests(context, locale = '') { }) 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('') + const res = await fetchViaHTTP(next.url, `/fr/abc123`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) expect(pathname).toBe('/:locale/:id') @@ -296,8 +288,8 @@ function tests(context, locale = '') { }) 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('') + const res = await fetchViaHTTP(next.url, `/abc123?foo=bar`) + expect(res.headers.get('req-url-basepath')).toBeFalsy() const { pathname, params } = JSON.parse(res.headers.get('req-url-params')) @@ -309,57 +301,45 @@ function tests(context, locale = '') { }) it('should throw when using URL with a relative URL', async () => { - const res = await fetchViaHTTP(context.appPort, `/url/relative-url`) + const res = await fetchViaHTTP(next.url, `/url/relative-url`) expect(readMiddlewareError(res)).toContain('Invalid URL') }) - it('should throw when using Request with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `/url/relative-request` - ) - expect(readMiddlewareError(response)).toContain(urlsError) - }) - it('should throw when using NextRequest with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `/url/relative-next-request` - ) + const response = await fetchViaHTTP(next.url, `/url/relative-next-request`) expect(readMiddlewareError(response)).toContain(urlsError) }) - it('should warn when using Response.redirect with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `/url/relative-redirect` - ) - expect(readMiddlewareError(response)).toContain(urlsError) - }) + if (!(global as any).isNextDeploy) { + // these errors differ on Vercel + it('should throw when using Request with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-request`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + + it('should warn when using Response.redirect with a relative URL', async () => { + const response = await fetchViaHTTP(next.url, `/url/relative-redirect`) + expect(readMiddlewareError(response)).toContain(urlsError) + }) + } it('should warn when using NextResponse.redirect with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `/url/relative-next-redirect` - ) + const response = await fetchViaHTTP(next.url, `/url/relative-next-redirect`) expect(readMiddlewareError(response)).toContain(urlsError) }) it('should throw when using NextResponse.rewrite with a relative URL', async () => { - const response = await fetchViaHTTP( - context.appPort, - `/url/relative-next-rewrite` - ) + const response = await fetchViaHTTP(next.url, `/url/relative-next-rewrite`) expect(readMiddlewareError(response)).toContain(urlsError) }) it('should trigger middleware for data requests', async () => { - const browser = await webdriver(context.appPort, `/ssr-page`) + const browser = await webdriver(next.url, `/ssr-page`) const text = await browser.elementByCss('h1').text() expect(text).toEqual('Bye Cruel World') const res = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/en/ssr-page.json` + next.url, + `/_next/data/${next.buildId}/en/ssr-page.json` ) const json = await res.json() expect(json.pageProps.message).toEqual('Bye Cruel World') @@ -367,44 +347,44 @@ function tests(context, locale = '') { it('should normalize data requests into page requests', async () => { const res = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/en/send-url.json` + next.url, + `/_next/data/${next.buildId}/en/send-url.json` ) expect(res.headers.get('req-url-path')).toEqual('/send-url') }) it('should keep non data requests in their original shape', async () => { const res = await fetchViaHTTP( - context.appPort, - `/_next/static/${context.buildId}/_devMiddlewareManifest.json?foo=1` + next.url, + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` ) expect(res.headers.get('req-url-path')).toEqual( - `/_next/static/${context.buildId}/_devMiddlewareManifest.json?foo=1` + `/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1` ) }) it('should add a rewrite header on data requests for rewrites', async () => { - const res = await fetchViaHTTP(context.appPort, `/ssr-page`) + const res = await fetchViaHTTP(next.url, `/ssr-page`) const dataRes = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/en/ssr-page.json` + next.url, + `/_next/data/${next.buildId}/en/ssr-page.json` ) const json = await dataRes.json() expect(json.pageProps.message).toEqual('Bye Cruel World') expect(res.headers.get('x-nextjs-matched-path')).toBeNull() expect(dataRes.headers.get('x-nextjs-matched-path')).toEqual( - `/_next/data/${context.buildId}/en/ssr-page-2.json` + `/en/ssr-page-2` ) }) it(`hard-navigates when the data request failed`, async () => { - const browser = await webdriver(context.appPort, `/error`) + const browser = await webdriver(next.url, `/error`) await browser.eval('window.__SAME_PAGE = true') await browser.elementByCss('#throw-on-data').click() await browser.waitForElementByCss('.refreshed') expect(await browser.eval('window.__SAME_PAGE')).toBeUndefined() }) -} +}) function readMiddlewareJSON(response) { return JSON.parse(response.headers.get('data')) diff --git a/test/integration/middleware-redirects/middleware.js b/test/e2e/middleware-redirects/app/middleware.js similarity index 90% rename from test/integration/middleware-redirects/middleware.js rename to test/e2e/middleware-redirects/app/middleware.js index f0b152517e7fe..a86848a42a454 100644 --- a/test/integration/middleware-redirects/middleware.js +++ b/test/e2e/middleware-redirects/app/middleware.js @@ -1,6 +1,13 @@ +import { NextResponse } from 'next/server' + export async function middleware(request) { const url = request.nextUrl + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + if (url.pathname === '/old-home') { if (url.searchParams.get('override') === 'external') { return Response.redirect('https://example.com') diff --git a/test/integration/middleware-redirects/next.config.js b/test/e2e/middleware-redirects/app/next.config.js similarity index 100% rename from test/integration/middleware-redirects/next.config.js rename to test/e2e/middleware-redirects/app/next.config.js diff --git a/test/integration/middleware-redirects/pages/api/ok.js b/test/e2e/middleware-redirects/app/pages/api/ok.js similarity index 100% rename from test/integration/middleware-redirects/pages/api/ok.js rename to test/e2e/middleware-redirects/app/pages/api/ok.js diff --git a/test/integration/middleware-redirects/pages/index.js b/test/e2e/middleware-redirects/app/pages/index.js similarity index 100% rename from test/integration/middleware-redirects/pages/index.js rename to test/e2e/middleware-redirects/app/pages/index.js diff --git a/test/integration/middleware-redirects/pages/new-home.js b/test/e2e/middleware-redirects/app/pages/new-home.js similarity index 100% rename from test/integration/middleware-redirects/pages/new-home.js rename to test/e2e/middleware-redirects/app/pages/new-home.js diff --git a/test/e2e/middleware-redirects/test/index.test.ts b/test/e2e/middleware-redirects/test/index.test.ts new file mode 100644 index 0000000000000..d4c22e20763f6 --- /dev/null +++ b/test/e2e/middleware-redirects/test/index.test.ts @@ -0,0 +1,153 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { check, fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Redirect', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + + tests() + testsWithLocale() + testsWithLocale('/fr') + + function tests() { + it('does not include the locale in redirects by default', async () => { + const res = await fetchViaHTTP(next.url, `/old-home`, undefined, { + redirect: 'manual', + }) + expect(res.headers.get('location')?.endsWith('/default/about')).toEqual( + false + ) + }) + + it(`should redirect to data urls with data requests and internal redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + + expect( + res.headers + .get('x-nextjs-redirect') + ?.endsWith(`/es/new-home?override=internal`) + ).toEqual(true) + expect(res.headers.get('location')).toEqual(null) + }) + + it(`should redirect to external urls with data requests and external redirects`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/old-home.json`, + { override: 'external' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + + expect(res.headers.get('x-nextjs-redirect')).toEqual( + 'https://example.com/' + ) + expect(res.headers.get('location')).toEqual(null) + + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#old-home-external').click() + await check(async () => { + expect(await browser.elementByCss('h1').text()).toEqual( + 'Example Domain' + ) + return 'yes' + }, 'yes') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should redirect`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/old-home`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${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 implement internal redirects`, async () => { + const browser = await webdriver(next.url, `${locale}`) + await browser.eval('window.__SAME_PAGE = true') + await browser.elementByCss('#old-home').click() + await browser.waitForElementByCss('#new-home-title') + expect(await browser.eval('window.__SAME_PAGE')).toBe(true) + try { + expect(await browser.eval(`window.location.pathname`)).toBe( + `${locale}/new-home` + ) + } finally { + await browser.close() + } + }) + + it(`${label}should redirect cleanly with the original url param`, async () => { + const browser = await webdriver(next.url, `${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(next.url, `${locale}/redirect-me-alot`) + const browser = await webdriver(next.url, `${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(next.url, `${locale}/infinite-loop`) + ).rejects.toThrow() + }) + + it(`${label}should redirect to api route with locale`, async () => { + const browser = await webdriver(next.url, `${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-responses/middleware.js b/test/e2e/middleware-responses/app/middleware.js similarity index 92% rename from test/integration/middleware-responses/middleware.js rename to test/e2e/middleware-responses/app/middleware.js index 2116abc64f1c2..abc81bbe91010 100644 --- a/test/integration/middleware-responses/middleware.js +++ b/test/e2e/middleware-responses/app/middleware.js @@ -12,6 +12,11 @@ export async function middleware(request, ev) { const encoder = new TextEncoder() const next = NextResponse.next() + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + // Header based on query param if (url.searchParams.get('nested-header') === 'true') { next.headers.set('x-nested-header', 'valid') diff --git a/test/integration/middleware-responses/next.config.js b/test/e2e/middleware-responses/app/next.config.js similarity index 100% rename from test/integration/middleware-responses/next.config.js rename to test/e2e/middleware-responses/app/next.config.js diff --git a/test/integration/middleware-responses/pages/index.js b/test/e2e/middleware-responses/app/pages/index.js similarity index 100% rename from test/integration/middleware-responses/pages/index.js rename to test/e2e/middleware-responses/app/pages/index.js diff --git a/test/e2e/middleware-responses/test/index.test.ts b/test/e2e/middleware-responses/test/index.test.ts new file mode 100644 index 0000000000000..181b67ed2e118 --- /dev/null +++ b/test/e2e/middleware-responses/test/index.test.ts @@ -0,0 +1,99 @@ +/* eslint-env jest */ + +import { join } from 'path' +import { fetchViaHTTP } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Responses', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + }, + }) + }) + + testsWithLocale() + testsWithLocale('/fr') + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}responds with multiple cookies`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/two-cookies`) + expect(res.headers.raw()['set-cookie']).toEqual([ + 'foo=chocochip', + 'bar=chocochip', + ]) + }) + + it(`${label}should fail when returning a stream`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/stream-a-response`) + expect(res.status).toBe(500) + + if ((global as any).isNextDeploy) { + expect(await res.text()).toContain( + 'INTERNAL_EDGE_FUNCTION_INVOCATION_FAILED' + ) + } else { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should fail when returning a text body`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/send-response`) + expect(res.status).toBe(500) + + if ((global as any).isNextDeploy) { + expect(await res.text()).toContain( + 'INTERNAL_EDGE_FUNCTION_INVOCATION_FAILED' + ) + } else { + expect(await res.text()).toEqual('Internal Server Error') + expect(next.cliOutput).toContain( + `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` + ) + } + }) + + it(`${label}should respond with a 401 status code`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/bad-status`) + const html = await res.text() + expect(res.status).toBe(401) + expect(html).toBe('') + }) + + it(`${label}should respond with one header`, async () => { + const res = await fetchViaHTTP(next.url, `${locale}/header`) + expect(res.headers.get('x-first-header')).toBe('valid') + }) + + it(`${label}should respond with two headers`, async () => { + const res = await fetchViaHTTP( + next.url, + `${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( + next.url, + `${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-rewrites/middleware.js b/test/e2e/middleware-rewrites/app/middleware.js similarity index 95% rename from test/integration/middleware-rewrites/middleware.js rename to test/e2e/middleware-rewrites/app/middleware.js index 07ad1195d6f42..b1a97d4e2e1da 100644 --- a/test/integration/middleware-rewrites/middleware.js +++ b/test/e2e/middleware-rewrites/app/middleware.js @@ -8,6 +8,11 @@ const PUBLIC_FILE = /\.(.*)$/ export async function middleware(request) { const url = request.nextUrl + // this is needed for tests to get the BUILD_ID + if (url.pathname.startsWith('/_next/static/__BUILD_ID')) { + return NextResponse.next() + } + if (url.pathname.startsWith('/about') && url.searchParams.has('override')) { const isExternal = url.searchParams.get('override') === 'external' return NextResponse.rewrite( diff --git a/test/integration/middleware-rewrites/next.config.js b/test/e2e/middleware-rewrites/app/next.config.js similarity index 100% rename from test/integration/middleware-rewrites/next.config.js rename to test/e2e/middleware-rewrites/app/next.config.js diff --git a/test/integration/middleware-rewrites/pages/[param].js b/test/e2e/middleware-rewrites/app/pages/[param].js similarity index 100% rename from test/integration/middleware-rewrites/pages/[param].js rename to test/e2e/middleware-rewrites/app/pages/[param].js diff --git a/test/integration/middleware-rewrites/pages/ab-test/a.js b/test/e2e/middleware-rewrites/app/pages/ab-test/a.js similarity index 100% rename from test/integration/middleware-rewrites/pages/ab-test/a.js rename to test/e2e/middleware-rewrites/app/pages/ab-test/a.js diff --git a/test/integration/middleware-rewrites/pages/ab-test/b.js b/test/e2e/middleware-rewrites/app/pages/ab-test/b.js similarity index 100% rename from test/integration/middleware-rewrites/pages/ab-test/b.js rename to test/e2e/middleware-rewrites/app/pages/ab-test/b.js diff --git a/test/integration/middleware-rewrites/pages/about-bypass.js b/test/e2e/middleware-rewrites/app/pages/about-bypass.js similarity index 100% rename from test/integration/middleware-rewrites/pages/about-bypass.js rename to test/e2e/middleware-rewrites/app/pages/about-bypass.js diff --git a/test/integration/middleware-rewrites/pages/about.js b/test/e2e/middleware-rewrites/app/pages/about.js similarity index 100% rename from test/integration/middleware-rewrites/pages/about.js rename to test/e2e/middleware-rewrites/app/pages/about.js diff --git a/test/integration/middleware-rewrites/pages/clear-query-params.js b/test/e2e/middleware-rewrites/app/pages/clear-query-params.js similarity index 100% rename from test/integration/middleware-rewrites/pages/clear-query-params.js rename to test/e2e/middleware-rewrites/app/pages/clear-query-params.js diff --git a/test/integration/middleware-rewrites/pages/country/[country].js b/test/e2e/middleware-rewrites/app/pages/country/[country].js similarity index 100% rename from test/integration/middleware-rewrites/pages/country/[country].js rename to test/e2e/middleware-rewrites/app/pages/country/[country].js diff --git a/test/integration/middleware-rewrites/pages/dynamic-fallback/[...parts].js b/test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js similarity index 100% rename from test/integration/middleware-rewrites/pages/dynamic-fallback/[...parts].js rename to test/e2e/middleware-rewrites/app/pages/dynamic-fallback/[...parts].js diff --git a/test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js b/test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js similarity index 63% rename from test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js rename to test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js index acd458886ad55..24a936ad2ea69 100644 --- a/test/integration/middleware-rewrites/pages/fallback-true-blog/[slug].js +++ b/test/e2e/middleware-rewrites/app/pages/fallback-true-blog/[slug].js @@ -10,7 +10,13 @@ export default function Page(props) { export function getStaticPaths() { return { - paths: ['/fallback-true-blog/first'], + paths: [ + '/fallback-true-blog/first', + '/fallback-true-blog/build-time-1', + '/fallback-true-blog/build-time-2', + '/fallback-true-blog/build-time-3', + '/fallback-true-blog/build-time-4', + ], fallback: true, } } diff --git a/test/integration/middleware-rewrites/pages/i18n.js b/test/e2e/middleware-rewrites/app/pages/i18n.js similarity index 100% rename from test/integration/middleware-rewrites/pages/i18n.js rename to test/e2e/middleware-rewrites/app/pages/i18n.js diff --git a/test/integration/middleware-rewrites/pages/index.js b/test/e2e/middleware-rewrites/app/pages/index.js similarity index 100% rename from test/integration/middleware-rewrites/pages/index.js rename to test/e2e/middleware-rewrites/app/pages/index.js diff --git a/test/e2e/middleware-rewrites/test/index.test.ts b/test/e2e/middleware-rewrites/test/index.test.ts new file mode 100644 index 0000000000000..e9a793064dd2c --- /dev/null +++ b/test/e2e/middleware-rewrites/test/index.test.ts @@ -0,0 +1,451 @@ +/* eslint-env jest */ + +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { createNext, FileRef } from 'e2e-utils' + +describe('Middleware Rewrite', () => { + let next: NextInstance + + afterAll(() => next.destroy()) + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../app/pages')), + 'next.config.js': new FileRef(join(__dirname, '../app/next.config.js')), + 'middleware.js': new FileRef(join(__dirname, '../app/middleware.js')), + }, + }) + }) + + tests() + testsWithLocale() + testsWithLocale('/fr') + function tests() { + // TODO: middleware effect headers aren't available here + it.skip('includes the locale in rewrites by default', async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`) + expect( + res.headers.get('x-middleware-rewrite')?.endsWith('/en/about') + ).toEqual(true) + }) + + it('should rewrite correctly when navigating via history', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#override-with-internal-rewrite').click() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + + await browser.refresh() + await browser.back() + await browser.waitForElementByCss('#override-with-internal-rewrite') + await browser.forward() + await check(() => { + return browser.eval('document.documentElement.innerHTML') + }, /Welcome Page A/) + }) + + it('should return HTML/data correctly for pre-rendered page', async () => { + for (const slug of [ + 'first', + 'build-time-1', + 'build-time-2', + 'build-time-3', + ]) { + const res = await fetchViaHTTP(next.url, `/fallback-true-blog/${slug}`) + expect(res.status).toBe(200) + + const $ = cheerio.load(await res.text()) + expect(JSON.parse($('#props').text())?.params).toEqual({ + slug, + }) + + const dataRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/en/fallback-true-blog/${slug}.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(dataRes.status).toBe(200) + expect((await dataRes.json())?.pageProps?.params).toEqual({ + slug, + }) + } + }) + + it('should override with rewrite internally correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/about`, + { override: 'internal' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Welcome Page A') + + const browser = await webdriver(next.url, ``) + 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(`/about`) + expect(await browser.eval('window.location.search')).toBe( + '?override=internal' + ) + }) + + it(`should rewrite to data urls for incoming data request internally rewritten`, async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/es/about.json`, + { override: 'internal' }, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + const json = await res.json() + expect(json.pageProps).toEqual({ abtest: true }) + }) + + it('should override with rewrite externally correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/about`, + { override: 'external' }, + { redirect: 'manual' } + ) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + const browser = await webdriver(next.url, ``) + await browser.elementByCss('#override-with-external-rewrite').click() + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Example Domain/ + ) + await check(() => browser.eval('window.location.pathname'), `/about`) + await check( + () => browser.eval('window.location.search'), + '?override=external' + ) + }) + + it(`should rewrite to the external url for incoming data request externally rewritten`, async () => { + const browser = await webdriver( + next.url, + `/_next/data/${next.buildId}/es/about.json?override=external`, + undefined + ) + await check( + () => browser.eval('document.documentElement.innerHTML'), + /Example Domain/ + ) + }) + + it('should rewrite to fallback: true page successfully', async () => { + const randomSlug = `another-${Date.now()}` + const res2 = await fetchViaHTTP(next.url, `/to-blog/${randomSlug}`) + expect(res2.status).toBe(200) + expect(await res2.text()).toContain('Loading...') + + const randomSlug2 = `another-${Date.now()}` + const browser = await webdriver(next.url, `/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') + }) + + if (!(global as any).isNextDeploy) { + // runtime logs aren't currently available for deploy test + it(`warns about a query param deleted`, async () => { + await fetchViaHTTP(next.url, `/clear-query-params`, { + a: '1', + allowed: 'kept', + }) + expect(next.cliOutput).toContain( + 'Query params are no longer automatically merged for rewrites in middleware' + ) + }) + } + + it('should allow to opt-out prefetch caching', async () => { + const browser = await webdriver(next.url, '/') + await browser.addCookie({ name: 'about-bypass', value: '1' }) + await browser.refresh() + 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(next.url, '/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(next.url, '/country', { + 'my-locale': 'es', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#locale').text()).toBe('es') + expect($('#country').text()).toBe('us') + }) + + it(`should behave consistently on recursive rewrites`, async () => { + const res = await fetchViaHTTP(next.url, `/rewrite-me-to-about`, { + override: 'internal', + }) + const html = await res.text() + const $ = cheerio.load(html) + expect($('.title').text()).toBe('About Page') + + const browser = await webdriver(next.url, `/`) + await browser.elementByCss('#rewrite-me-to-about').click() + await check( + () => browser.eval(`window.location.pathname`), + `/rewrite-me-to-about` + ) + const element = await browser.elementByCss('.title') + expect(await element.text()).toEqual('About Page') + }) + + it(`should allow to switch locales`, async () => { + const browser = await webdriver(next.url, '/i18n') + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-ja').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en').click() + await browser.waitForElementByCss('.en') + await browser.elementByCss('#link-fr').click() + await browser.waitForElementByCss('.fr') + await browser.elementByCss('#link-ja2').click() + await browser.waitForElementByCss('.ja') + await browser.elementByCss('#link-en2').click() + await browser.waitForElementByCss('.en') + }) + } + + function testsWithLocale(locale = '') { + const label = locale ? `${locale} ` : `` + + it(`${label}should add a cookie and rewrite to a/b test`, async () => { + const res = await fetchViaHTTP(next.url, `${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(next.url, `${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(next.url, `${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(next.url, `${locale}/rewrite-me-to-about`) + const html = await res.text() + const $ = cheerio.load(html) + const browser = await webdriver(next.url, `${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(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('not:param') + const browser = await webdriver(next.url, 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(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('with:colon') + const browser = await webdriver(next.url, 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(next.url, path) + const html = await res.text() + const $ = cheerio.load(html) + expect($('#props').text()).toBe('no-colon-here') + const browser = await webdriver(next.url, 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(next.url, 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(next.url, 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(next.url, 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(next.url, path) + try { + expect( + await browser.eval( + `window.location.href.replace(window.location.origin, '')` + ) + ).toBe(path) + } finally { + await browser.close() + } + }) + + if (!(global as any).isNextDeploy) { + it(`${label}should rewrite when not using localhost`, async () => { + const customUrl = new URL(next.url) + customUrl.hostname = 'localtest.me' + + const res = await fetchViaHTTP( + customUrl.toString(), + `${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(next.url, `${locale}/rewrite-me-to-vercel`) + const html = await res.text() + // const browser = await webdriver(next.url, '/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(next.url, '/') + 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(next.url, '') + 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(next.url, '/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/client-navigation/pages/nav/hash-changes-with-state.js b/test/integration/client-navigation/pages/nav/hash-changes-with-state.js index 67f774d44c525..e6e2bb5a26fbf 100644 --- a/test/integration/client-navigation/pages/nav/hash-changes-with-state.js +++ b/test/integration/client-navigation/pages/nav/hash-changes-with-state.js @@ -16,8 +16,8 @@ export default class SelfReload extends Component { '/nav/hash-changes-with-state', '/nav/hash-changes-with-state#hello' + Math.random(), { - historyCount: (window.history.state.options.historyCount || 0) + 1, - shallowHistoryCount: window.history.state.options.shallowHistoryCount, + historyCount: (window.history.state?.options?.historyCount || 0) + 1, + shallowHistoryCount: window.history.state?.options?.shallowHistoryCount, } ) } @@ -28,9 +28,9 @@ export default class SelfReload extends Component { '/nav/hash-changes-with-state#hello' + Math.random(), { shallow: true, - historyCount: window.history.state.options.historyCount, + historyCount: window.history.state?.options?.historyCount, shallowHistoryCount: - (window.history.state.options.shallowHistoryCount || 0) + 1, + (window.history.state?.options?.shallowHistoryCount || 0) + 1, } ) } @@ -45,7 +45,7 @@ export default class SelfReload extends Component {
HISTORY COUNT:{' '} {typeof window !== 'undefined' && - window.history.state.options.historyCount} + window.history.state?.options?.historyCount}
SHALLOW HISTORY COUNT:{' '} {typeof window !== 'undefined' && - window.history.state.options.shallowHistoryCount} + window.history.state?.options?.shallowHistoryCount} ) diff --git a/test/integration/middleware-base-path/test/index.test.js b/test/integration/middleware-base-path/test/index.test.js deleted file mode 100644 index 3c37ad2f3b949..0000000000000 --- a/test/integration/middleware-base-path/test/index.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-env jest */ - -jest.setTimeout(1000 * 60 * 2) - -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - nextStart, -} from 'next-test-utils' -import { join } from 'path' -import cheerio from 'cheerio' -import webdriver from 'next-webdriver' - -const context = {} -context.appDir = join(__dirname, '../') - -describe('Middleware base 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) - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort) - }) - afterAll(() => killApp(context.app)) - runTests() - }) -}) - -function runTests() { - it('should execute from absolute paths', async () => { - const browser = await webdriver(context.appPort, '/redirect-with-basepath') - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - '/root/redirect-with-basepath' - ) - } finally { - await browser.close() - } - - const res = await fetchViaHTTP( - context.appPort, - '/root/redirect-with-basepath' - ) - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('About Page') - }) -} diff --git a/test/integration/middleware-redirects/test/index.test.js b/test/integration/middleware-redirects/test/index.test.js deleted file mode 100644 index 28caf0ce2a940..0000000000000 --- a/test/integration/middleware-redirects/test/index.test.js +++ /dev/null @@ -1,187 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import fs from 'fs-extra' -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 Redirect', () => { - describe('dev mode', () => { - afterAll(() => killApp(context.app)) - beforeAll(async () => { - context.appPort = await findPort() - context.buildId = 'development' - context.app = await launchApp(context.appDir, context.appPort) - }) - - tests(context) - testsWithLocale(context) - testsWithLocale(context, '/fr') - }) - - describe('production mode', () => { - afterAll(() => killApp(context.app)) - beforeAll(async () => { - await nextBuild(context.appDir) - context.buildId = await fs.readFile( - join(context.appDir, '.next/BUILD_ID'), - 'utf8' - ) - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort) - }) - - tests(context) - testsWithLocale(context) - testsWithLocale(context, '/fr') - }) -}) - -function tests(context) { - it('does not include the locale in redirects by default', async () => { - const res = await fetchViaHTTP(context.appPort, `/old-home`, undefined, { - redirect: 'manual', - }) - expect(res.headers.get('location')?.endsWith('/default/about')).toEqual( - false - ) - }) - - it(`should redirect to data urls with data requests and internal redirects`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/es/old-home.json`, - { override: 'internal' }, - { redirect: 'manual' } - ) - - expect( - res.headers - .get('x-nextjs-redirect') - ?.endsWith( - `/_next/data/${context.buildId}/es/new-home.json?override=internal` - ) - ).toEqual(true) - expect(res.headers.get('location')).toEqual(null) - }) - - it(`should redirect to external urls with data requests and external redirects`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/es/old-home.json`, - { override: 'external' }, - { redirect: 'manual' } - ) - - expect(res.headers.get('x-nextjs-redirect')).toEqual('https://example.com/') - expect(res.headers.get('location')).toEqual(null) - - const browser = await webdriver(context.appPort, '/') - await browser.elementByCss('#old-home-external').click() - await check(async () => { - expect(await browser.elementByCss('h1').text()).toEqual('Example Domain') - return 'yes' - }, 'yes') - }) -} - -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 implement internal redirects`, async () => { - const browser = await webdriver(context.appPort, `${locale}`) - await browser.eval('window.__SAME_PAGE = true') - await browser.elementByCss('#old-home').click() - await browser.waitForElementByCss('#new-home-title') - expect(await browser.eval('window.__SAME_PAGE')).toBe(true) - try { - expect(await browser.eval(`window.location.pathname`)).toBe( - `${locale}/new-home` - ) - } finally { - await browser.close() - } - }) - - 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-responses/test/index.test.js b/test/integration/middleware-responses/test/index.test.js deleted file mode 100644 index 19509dad2cb74..0000000000000 --- a/test/integration/middleware-responses/test/index.test.js +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import { - fetchViaHTTP, - findPort, - killApp, - launchApp, - nextBuild, - nextStart, -} from 'next-test-utils' - -jest.setTimeout(1000 * 60 * 2) -const context = { appDir: join(__dirname, '../'), output: '' } - -describe('Middleware Responses', () => { - describe('dev mode', () => { - afterAll(() => killApp(context.app)) - beforeAll(async () => { - context.output = '' - context.appPort = await findPort() - context.app = await launchApp(context.appDir, context.appPort, { - onStdout(msg) { - context.output += msg - }, - onStderr(msg) { - context.output += msg - }, - }) - }) - - testsWithLocale(context) - testsWithLocale(context, '/fr') - }) - - describe('production mode', () => { - afterAll(() => killApp(context.app)) - beforeAll(async () => { - context.output = '' - await nextBuild(context.appDir) - context.appPort = await findPort() - context.app = await nextStart(context.appDir, context.appPort, { - onStdout(msg) { - context.output += msg - }, - onStderr(msg) { - context.output += msg - }, - }) - }) - - 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 fail when returning a stream`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `${locale}/stream-a-response` - ) - expect(res.status).toBe(500) - expect(await res.text()).toEqual('Internal Server Error') - expect(context.output).toContain( - `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` - ) - }) - - it(`${label}should fail when returning a text body`, async () => { - const res = await fetchViaHTTP(context.appPort, `${locale}/send-response`) - expect(res.status).toBe(500) - expect(await res.text()).toEqual('Internal Server Error') - expect(context.output).toContain( - `A middleware can not alter response's body. Learn more: https://nextjs.org/docs/messages/returning-response-body-in-middleware` - ) - }) - - 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('') - }) - - 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-rewrites/test/index.test.js b/test/integration/middleware-rewrites/test/index.test.js deleted file mode 100644 index 1fc9e022ee0aa..0000000000000 --- a/test/integration/middleware-rewrites/test/index.test.js +++ /dev/null @@ -1,460 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import fs from 'fs-extra' -import cheerio from 'cheerio' -import webdriver, { USE_SELENIUM } 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.buildId = 'development' - 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.buildId = await fs.readFile( - join(context.appDir, '.next/BUILD_ID'), - 'utf8' - ) - - 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) { - it('includes the locale in rewrites by default', async () => { - const res = await fetchViaHTTP(context.appPort, `/rewrite-me-to-about`) - expect( - res.headers.get('x-middleware-rewrite')?.endsWith('/en/about') - ).toEqual(true) - }) - - it('should override with rewrite internally correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `/about`, - { override: 'internal' }, - { redirect: 'manual' } - ) - - expect(res.status).toBe(200) - expect(await res.text()).toContain('Welcome Page A') - - const browser = await webdriver(context.appPort, ``) - 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(`/about`) - expect(await browser.eval('window.location.search')).toBe( - '?override=internal' - ) - }) - - it(`should rewrite to data urls for incoming data request internally rewritten`, async () => { - const res = await fetchViaHTTP( - context.appPort, - `/_next/data/${context.buildId}/es/about.json`, - { override: 'internal' }, - { redirect: 'manual' } - ) - const json = await res.json() - expect(json.pageProps).toEqual({ abtest: true }) - }) - - it('should override with rewrite externally correctly', async () => { - const res = await fetchViaHTTP( - context.appPort, - `/about`, - { override: 'external' }, - { redirect: 'manual' } - ) - - expect(res.status).toBe(200) - expect(await res.text()).toContain('Example Domain') - - const browser = await webdriver(context.appPort, ``) - await browser.elementByCss('#override-with-external-rewrite').click() - await check( - () => browser.eval('document.documentElement.innerHTML'), - /Example Domain/ - ) - await check(() => browser.eval('window.location.pathname'), `/about`) - await check( - () => browser.eval('window.location.search'), - '?override=external' - ) - }) - - it(`should rewrite to the external url for incoming data request externally rewritten`, async () => { - const browser = await webdriver( - context.appPort, - `/_next/data/${context.buildId}/es/about.json?override=external` - ) - await check( - () => browser.eval('document.documentElement.innerHTML'), - /Example Domain/ - ) - }) - - it('should rewrite to fallback: true page successfully', async () => { - const randomSlug = `another-${Date.now()}` - const res2 = await fetchViaHTTP(context.appPort, `/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, `/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, `/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') - }) - - it(`should behave consistently on recursive rewrites`, async () => { - const res = await fetchViaHTTP(context.appPort, `/rewrite-me-to-about`, { - override: 'internal', - }) - const html = await res.text() - const $ = cheerio.load(html) - expect($('.title').text()).toBe('About Page') - - const browser = await webdriver(context.appPort, `/`) - await browser.elementByCss('#rewrite-me-to-about').click() - await check( - () => browser.eval(`window.location.pathname`), - `/rewrite-me-to-about` - ) - const element = await browser.elementByCss('.title') - expect(await element.text()).toEqual('About Page') - }) - - if (!USE_SELENIUM) { - it(`should allow to switch locales`, async () => { - const browser = await webdriver(context.appPort, '/i18n') - await browser.waitForElementByCss('.en') - await browser.elementByCss('#link-ja').click() - await browser.waitForElementByCss('.ja') - await browser.elementByCss('#link-en').click() - await browser.waitForElementByCss('.en') - await browser.elementByCss('#link-fr').click() - await browser.waitForElementByCss('.fr') - await browser.elementByCss('#link-ja2').click() - await browser.waitForElementByCss('.ja') - await browser.elementByCss('#link-en2').click() - await browser.waitForElementByCss('.en') - }) - } -} - -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/required-server-files-ssr-404/test/index.test.js b/test/integration/required-server-files-ssr-404/test/index.test.js index f87fa1d6f4559..dd24cc93565f3 100644 --- a/test/integration/required-server-files-ssr-404/test/index.test.js +++ b/test/integration/required-server-files-ssr-404/test/index.test.js @@ -191,11 +191,16 @@ describe('Required Server Files', () => { }) it('should render dynamic SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', - }, - }) + const html = await renderViaHTTP( + appPort, + '/some-other-path?slug=first', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -203,11 +208,16 @@ describe('Required Server Files', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=second', - }, - }) + const html2 = await renderViaHTTP( + appPort, + '/some-other-path?slug=second', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -249,11 +259,11 @@ describe('Required Server Files', () => { it('should return data correctly with x-matched-path', async () => { const res = await fetchViaHTTP( appPort, - `/_next/data/${buildId}/dynamic/first.json`, + `/_next/data/${buildId}/dynamic/first.json?slug=first`, undefined, { headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', + 'x-matched-path': '/dynamic/[slug]', }, } ) diff --git a/test/lib/e2e-utils.ts b/test/lib/e2e-utils.ts index d5ce7f1839736..462d95f32e578 100644 --- a/test/lib/e2e-utils.ts +++ b/test/lib/e2e-utils.ts @@ -124,6 +124,7 @@ export async function createNext(opts: { packageJson?: PackageJson startCommand?: string packageLockPath?: string + env?: Record }): Promise { try { if (nextInstance) { diff --git a/test/lib/next-modes/base.ts b/test/lib/next-modes/base.ts index 560db0e5aef78..c29082f126a48 100644 --- a/test/lib/next-modes/base.ts +++ b/test/lib/next-modes/base.ts @@ -34,6 +34,7 @@ export class NextInstance { protected packageJson: PackageJson protected packageLockPath?: string protected basePath?: string + protected env?: Record constructor({ files, @@ -44,6 +45,7 @@ export class NextInstance { startCommand, packageJson = {}, packageLockPath, + env, }: { files: { [filename: string]: string | FileRef @@ -57,6 +59,7 @@ export class NextInstance { installCommand?: InstallCommand buildCommand?: string startCommand?: string + env?: Record }) { this.files = files this.dependencies = dependencies @@ -69,6 +72,7 @@ export class NextInstance { this.events = {} this.isDestroyed = false this.isStopping = false + this.env = env } protected async createTestDir({ @@ -111,13 +115,13 @@ export class NextInstance { require('next/package.json').version, }, scripts: { + ...pkgScripts, build: (pkgScripts['build'] || this.buildCommand || 'next build') + ' && yarn post-build', // since we can't get the build id as a build artifact, make it // available under the static files 'post-build': 'cp .next/BUILD_ID .next/static/__BUILD_ID', - ...pkgScripts, }, }, null, diff --git a/test/lib/next-modes/next-deploy.ts b/test/lib/next-modes/next-deploy.ts index 6b81ad0ed4d0c..2264014aacb63 100644 --- a/test/lib/next-modes/next-deploy.ts +++ b/test/lib/next-modes/next-deploy.ts @@ -65,6 +65,15 @@ export class NextDeployInstance extends NextInstance { } require('console').log(`Deploying project at ${this.testDir}`) + const additionalEnv = [] + + for (const key of Object.keys(this.env || {})) { + additionalEnv.push('--build-env') + additionalEnv.push(`${key}=${this.env[key]}`) + additionalEnv.push('--env') + additionalEnv.push(`${key}=${this.env[key]}`) + } + const deployRes = await execa( 'vercel', [ @@ -75,6 +84,7 @@ export class NextDeployInstance extends NextInstance { 'FORCE_RUNTIME_TAG=canary', '--build-env', 'NEXT_TELEMETRY_DISABLED=1', + ...additionalEnv, '--force', ...vercelFlags, ], diff --git a/test/lib/next-modes/next-dev.ts b/test/lib/next-modes/next-dev.ts index fcb424911d201..88057494e77d8 100644 --- a/test/lib/next-modes/next-dev.ts +++ b/test/lib/next-modes/next-dev.ts @@ -35,6 +35,7 @@ export class NextDevInstance extends NextInstance { shell: false, env: { ...process.env, + ...this.env, NODE_ENV: '' as any, __NEXT_TEST_MODE: '1', __NEXT_RAND_PORT: '1', diff --git a/test/lib/next-modes/next-start.ts b/test/lib/next-modes/next-start.ts index 9f76e29d7f19f..98f98c7614b4d 100644 --- a/test/lib/next-modes/next-start.ts +++ b/test/lib/next-modes/next-start.ts @@ -46,6 +46,7 @@ export class NextStartInstance extends NextInstance { shell: false, env: { ...process.env, + ...this.env, NODE_ENV: '' as any, __NEXT_TEST_MODE: '1', __NEXT_RAND_PORT: '1', diff --git a/test/production/required-server-files-i18n.test.ts b/test/production/required-server-files-i18n.test.ts index a1b5d0d6f8d71..a14001e62b6b6 100644 --- a/test/production/required-server-files-i18n.test.ts +++ b/test/production/required-server-files-i18n.test.ts @@ -294,11 +294,16 @@ describe('should set-up next', () => { }) it('should render dynamic SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', - }, - }) + const html = await renderViaHTTP( + appPort, + '/some-other-path?slug=first', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -306,11 +311,16 @@ describe('should set-up next', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=second', - }, - }) + const html2 = await renderViaHTTP( + appPort, + '/some-other-path?slug=second', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -366,11 +376,11 @@ describe('should set-up next', () => { it('should return data correctly with x-matched-path', async () => { const res = await fetchViaHTTP( appPort, - `/_next/data/${next.buildId}/en/dynamic/first.json`, + `/_next/data/${next.buildId}/en/dynamic/first.json?slug=first`, undefined, { headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', + 'x-matched-path': '/dynamic/[slug]', }, } ) diff --git a/test/production/required-server-files.test.ts b/test/production/required-server-files.test.ts index a4d19c3352de2..d03c4ac001e56 100644 --- a/test/production/required-server-files.test.ts +++ b/test/production/required-server-files.test.ts @@ -415,11 +415,16 @@ describe('should set-up next', () => { }) it('should render dynamic SSR page correctly with x-matched-path', async () => { - const html = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', - }, - }) + const html = await renderViaHTTP( + appPort, + '/some-other-path?slug=first', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $ = cheerio.load(html) const data = JSON.parse($('#props').text()) @@ -427,11 +432,16 @@ describe('should set-up next', () => { expect($('#slug').text()).toBe('first') expect(data.hello).toBe('world') - const html2 = await renderViaHTTP(appPort, '/some-other-path', undefined, { - headers: { - 'x-matched-path': '/dynamic/[slug]?slug=second', - }, - }) + const html2 = await renderViaHTTP( + appPort, + '/some-other-path?slug=second', + undefined, + { + headers: { + 'x-matched-path': '/dynamic/[slug]', + }, + } + ) const $2 = cheerio.load(html2) const data2 = JSON.parse($2('#props').text()) @@ -442,7 +452,7 @@ describe('should set-up next', () => { const html3 = await renderViaHTTP(appPort, '/some-other-path', undefined, { headers: { - 'x-matched-path': '/dynamic/[slug]?slug=%5Bslug%5D.json', + 'x-matched-path': '/dynamic/[slug]', 'x-now-route-matches': '1=second&slug=second', }, }) @@ -487,11 +497,11 @@ describe('should set-up next', () => { it('should return data correctly with x-matched-path', async () => { const res = await fetchViaHTTP( appPort, - `/_next/data/${next.buildId}/dynamic/first.json`, + `/_next/data/${next.buildId}/dynamic/first.json?slug=first`, undefined, { headers: { - 'x-matched-path': '/dynamic/[slug]?slug=first', + 'x-matched-path': `/dynamic/[slug]`, }, } ) @@ -581,6 +591,7 @@ describe('should set-up next', () => { const res = await fetchViaHTTP( appPort, `/_next/data/${next.buildId}/catch-all.json`, + undefined, { headers: {