From 1b8ab4e38b445c57dc2ea550dc70853b31051efb Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 8 Nov 2022 09:09:45 -0800 Subject: [PATCH] Apply middleware patches --- packages/next/build/webpack-config.ts | 8 + .../webpack/plugins/middleware-plugin.ts | 22 +- packages/next/lib/load-custom-routes.ts | 80 +++---- packages/next/server/base-server.ts | 27 +++ packages/next/server/config-schema.ts | 9 + packages/next/server/config-shared.ts | 3 + packages/next/server/next-server.ts | 27 ++- packages/next/server/request-meta.ts | 2 + packages/next/server/router.ts | 20 +- packages/next/server/web/adapter.ts | 28 ++- packages/next/server/web/next-url.ts | 2 +- .../app/middleware.js | 40 ++++ .../app/next.config.js | 28 +++ .../app/pages/another.js | 13 ++ .../app/pages/api/test-cookie-edge.js | 12 ++ .../app/pages/api/test-cookie.js | 5 + .../app/pages/blog/[slug].js | 13 ++ .../app/pages/index.js | 17 ++ .../index.test.ts | 197 ++++++++++++++++++ 19 files changed, 494 insertions(+), 59 deletions(-) create mode 100644 test/e2e/skip-trailing-slash-redirect/app/middleware.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/next.config.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/another.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie.js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/blog/[slug].js create mode 100644 test/e2e/skip-trailing-slash-redirect/app/pages/index.js create mode 100644 test/e2e/skip-trailing-slash-redirect/index.test.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 6aaa9ceeccae6..4d19cd2ba268c 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -155,6 +155,12 @@ export function getDefineEnv({ 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( config.experimental.optimisticClientCache ), + 'process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE': JSON.stringify( + config.experimental.skipMiddlewareUrlNormalize + ), + 'process.env.__NEXT_ALLOW_MIDDLEWARE_RESPONSE_BODY': JSON.stringify( + config.experimental.allowMiddlewareResponseBody + ), 'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(config.crossOrigin), 'process.browser': JSON.stringify(isClient), 'process.env.__NEXT_TEST_MODE': JSON.stringify( @@ -1779,6 +1785,8 @@ export default async function getBaseWebpackConfig( new MiddlewarePlugin({ dev, sriEnabled: !dev && !!config.experimental.sri?.algorithm, + allowMiddlewareResponseBody: + !!config.experimental.allowMiddlewareResponseBody, }), isClient && new BuildManifestPlugin({ diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 7a09595941a0e..2888bfe0c5e6f 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -335,12 +335,14 @@ function getCodeAnalyzer(params: { dev: boolean compiler: webpack.Compiler compilation: webpack.Compilation + allowMiddlewareResponseBody: boolean }) { return (parser: webpack.javascript.JavascriptParser) => { const { dev, compiler: { webpack: wp }, compilation, + allowMiddlewareResponseBody, } = params const { hooks } = parser @@ -557,8 +559,10 @@ Learn More: https://nextjs.org/docs/messages/node-module-in-edge-runtime`, .for(`${prefix}WebAssembly.instantiate`) .tap(NAME, handleWrapWasmInstantiateExpression) } - hooks.new.for('Response').tap(NAME, handleNewResponseExpression) - hooks.new.for('NextResponse').tap(NAME, handleNewResponseExpression) + if (!allowMiddlewareResponseBody) { + hooks.new.for('Response').tap(NAME, handleNewResponseExpression) + hooks.new.for('NextResponse').tap(NAME, handleNewResponseExpression) + } hooks.callMemberChain.for('process').tap(NAME, handleCallMemberChain) hooks.expressionMemberChain.for('process').tap(NAME, handleCallMemberChain) hooks.importCall.tap(NAME, handleImport) @@ -797,10 +801,19 @@ function getExtractMetadata(params: { export default class MiddlewarePlugin { private readonly dev: boolean private readonly sriEnabled: boolean - - constructor({ dev, sriEnabled }: { dev: boolean; sriEnabled: boolean }) { + private readonly allowMiddlewareResponseBody: boolean + constructor({ + dev, + sriEnabled, + allowMiddlewareResponseBody, + }: { + dev: boolean + sriEnabled: boolean + allowMiddlewareResponseBody: boolean + }) { this.dev = dev this.sriEnabled = sriEnabled + this.allowMiddlewareResponseBody = allowMiddlewareResponseBody } public apply(compiler: webpack.Compiler) { @@ -813,6 +826,7 @@ export default class MiddlewarePlugin { dev: this.dev, compiler, compilation, + allowMiddlewareResponseBody: this.allowMiddlewareResponseBody, }) hooks.parser.for('javascript/auto').tap(NAME, codeAnalyzer) hooks.parser.for('javascript/dynamic').tap(NAME, codeAnalyzer) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 0658b6fd1ee11..a6316ce25c36b 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -624,50 +624,52 @@ export default async function loadCustomRoutes( ) } - if (config.trailingSlash) { - redirects.unshift( - { - source: '/:file((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+)/', - destination: '/:file', - permanent: true, - locale: config.i18n ? false : undefined, - internal: true, - } as Redirect, - { - source: '/:notfile((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+)', - destination: '/:notfile/', - permanent: true, - locale: config.i18n ? false : undefined, - internal: true, - } as Redirect - ) - if (config.basePath) { - redirects.unshift({ - source: config.basePath, - destination: config.basePath + '/', - permanent: true, - basePath: false, - locale: config.i18n ? false : undefined, - internal: true, - } as Redirect) - } - } else { - redirects.unshift({ - source: '/:path+/', - destination: '/:path+', - permanent: true, - locale: config.i18n ? false : undefined, - internal: true, - } as Redirect) - if (config.basePath) { + if (!config.experimental?.skipTrailingSlashRedirect) { + if (config.trailingSlash) { + redirects.unshift( + { + source: '/:file((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+)/', + destination: '/:file', + permanent: true, + locale: config.i18n ? false : undefined, + internal: true, + } as Redirect, + { + source: '/:notfile((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+)', + destination: '/:notfile/', + permanent: true, + locale: config.i18n ? false : undefined, + internal: true, + } as Redirect + ) + if (config.basePath) { + redirects.unshift({ + source: config.basePath, + destination: config.basePath + '/', + permanent: true, + basePath: false, + locale: config.i18n ? false : undefined, + internal: true, + } as Redirect) + } + } else { redirects.unshift({ - source: config.basePath + '/', - destination: config.basePath, + source: '/:path+/', + destination: '/:path+', permanent: true, - basePath: false, locale: config.i18n ? false : undefined, internal: true, } as Redirect) + if (config.basePath) { + redirects.unshift({ + source: config.basePath + '/', + destination: config.basePath, + permanent: true, + basePath: false, + locale: config.i18n ? false : undefined, + internal: true, + } as Redirect) + } } } diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index cccb74732abed..1f09f53e69f08 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -434,6 +434,33 @@ export default abstract class Server { parsedUrl?: NextUrlWithParsedQuery ): Promise { try { + // ensure cookies set in middleware are merged and + // not overridden by API routes/getServerSideProps + const _res = (res as any).originalResponse || res + const origSetHeader = _res.setHeader.bind(_res) + + _res.setHeader = (name: string, val: string | string[]) => { + if (name.toLowerCase() === 'set-cookie') { + const middlewareValue = getRequestMeta(req, '_nextMiddlewareCookie') + + if ( + !middlewareValue || + !Array.isArray(val) || + !val.every((item, idx) => item === middlewareValue[idx]) + ) { + val = [ + ...(middlewareValue || []), + ...(typeof val === 'string' + ? [val] + : Array.isArray(val) + ? val + : []), + ] + } + } + return origSetHeader(name, val) + } + const urlParts = (req.url || '').split('?') const urlNoQuery = urlParts[0] diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index d22bb9d61c491..4298b3935a96f 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -222,6 +222,9 @@ const configSchema = { adjustFontFallbacks: { type: 'boolean', }, + allowMiddlewareResponseBody: { + type: 'boolean', + }, amp: { additionalProperties: false, properties: { @@ -348,6 +351,12 @@ const configSchema = { sharedPool: { type: 'boolean', }, + skipMiddlewareUrlNormalize: { + type: 'boolean', + }, + skipTrailingSlashRedirect: { + type: 'boolean', + }, sri: { properties: { algorithm: { diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 769fe0bb6fedc..88ed61237ea84 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -78,6 +78,9 @@ export interface NextJsWebpackConfig { } export interface ExperimentalConfig { + allowMiddlewareResponseBody?: boolean + skipMiddlewareUrlNormalize?: boolean + skipTrailingSlashRedirect?: boolean optimisticClientCache?: boolean legacyBrowsers?: boolean browsersListForSwc?: boolean diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 4ca035f980095..c70db3599b317 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -83,7 +83,7 @@ import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { loadComponents } from './load-components' import isError, { getProperError } from '../lib/is-error' import { FontManifest } from './font-utils' -import { toNodeHeaders } from './web/utils' +import { splitCookiesString, toNodeHeaders } from './web/utils' import { relativizeURL } from '../shared/lib/router/utils/relativize-url' import { prepareDestination } from '../shared/lib/router/utils/prepare-destination' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' @@ -1051,9 +1051,17 @@ export default class NextNodeServer extends BaseServer { name: '_next/data catchall', check: true, fn: async (req, res, params, _parsedUrl) => { + const isNextDataNormalizing = getRequestMeta( + req, + '_nextDataNormalizing' + ) + // Make sure to 404 for /_next/data/ itself and // we also want to 404 if the buildId isn't correct if (!params.path || params.path[0] !== this.buildId) { + if (isNextDataNormalizing) { + return { finished: false } + } await this.render404(req, res, _parsedUrl) return { finished: true, @@ -1797,6 +1805,14 @@ export default class NextNodeServer extends BaseServer { } else { for (let [key, value] of allHeaders) { result.response.headers.set(key, value) + + if (key.toLowerCase() === 'set-cookie') { + addRequestMeta( + params.request, + '_nextMiddlewareCookie', + splitCookiesString(value) + ) + } } } @@ -2105,8 +2121,13 @@ export default class NextNodeServer extends BaseServer { params.res.statusCode = result.response.status params.res.statusMessage = result.response.statusText - result.response.headers.forEach((value, key) => { - params.res.appendHeader(key, value) + result.response.headers.forEach((value: string, key) => { + // the append handling is special cased for `set-cookie` + if (key.toLowerCase() === 'set-cookie') { + params.res.setHeader(key, value) + } else { + params.res.appendHeader(key, value) + } }) if (result.response.body) { diff --git a/packages/next/server/request-meta.ts b/packages/next/server/request-meta.ts index 39985a4918483..fd86e86122cfa 100644 --- a/packages/next/server/request-meta.ts +++ b/packages/next/server/request-meta.ts @@ -22,6 +22,8 @@ export interface RequestMeta { _nextHadBasePath?: boolean _nextRewroteUrl?: string _protocol?: string + _nextMiddlewareCookie?: string[] + _nextDataNormalizing?: boolean } export function getRequestMeta( diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index ca2ce97b1a7dc..4ad13bd88142a 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -7,7 +7,11 @@ import type { } from '../shared/lib/router/utils/route-matcher' import type { RouteHas } from '../lib/load-custom-routes' -import { getNextInternalQuery, NextUrlWithParsedQuery } from './request-meta' +import { + addRequestMeta, + getNextInternalQuery, + NextUrlWithParsedQuery, +} from './request-meta' import { getPathMatch } from '../shared/lib/router/utils/path-match' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' @@ -189,7 +193,11 @@ export default class Router { ...(middlewareCatchAllRoute ? this.fsRoutes .filter((route) => route.name === '_next/data catchall') - .map((route) => ({ ...route, check: false })) + .map((route) => ({ + ...route, + name: '_next/data normalizing', + check: false, + })) : []), ...this.headers, ...this.redirects, @@ -433,6 +441,11 @@ export default class Router { } if (params) { + const isNextDataNormalizing = route.name === '_next/data normalizing' + + if (isNextDataNormalizing) { + addRequestMeta(req, '_nextDataNormalizing', true) + } parsedUrlUpdated.pathname = matchPathname const result = await route.fn( req, @@ -441,6 +454,9 @@ export default class Router { parsedUrlUpdated, upgradeHead ) + if (isNextDataNormalizing) { + addRequestMeta(req, '_nextDataNormalizing', false) + } if (result.finished) { return true } diff --git a/packages/next/server/web/adapter.ts b/packages/next/server/web/adapter.ts index 0a03f3f8936c5..e94dfce107f57 100644 --- a/packages/next/server/web/adapter.ts +++ b/packages/next/server/web/adapter.ts @@ -110,11 +110,13 @@ export async function adapter(params: { nextConfig: params.request.nextConfig, }) - if (rewriteUrl.host === request.nextUrl.host) { - rewriteUrl.buildId = buildId || rewriteUrl.buildId - rewriteUrl.flightSearchParameters = - flightSearchParameters || rewriteUrl.flightSearchParameters - response.headers.set('x-middleware-rewrite', String(rewriteUrl)) + if (!process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE) { + if (rewriteUrl.host === request.nextUrl.host) { + rewriteUrl.buildId = buildId || rewriteUrl.buildId + rewriteUrl.flightSearchParameters = + flightSearchParameters || rewriteUrl.flightSearchParameters + response.headers.set('x-middleware-rewrite', String(rewriteUrl)) + } } /** @@ -149,11 +151,13 @@ export async function adapter(params: { */ response = new Response(response.body, response) - if (redirectURL.host === request.nextUrl.host) { - redirectURL.buildId = buildId || redirectURL.buildId - redirectURL.flightSearchParameters = - flightSearchParameters || redirectURL.flightSearchParameters - response.headers.set('Location', String(redirectURL)) + if (!process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE) { + if (redirectURL.host === request.nextUrl.host) { + redirectURL.buildId = buildId || redirectURL.buildId + redirectURL.flightSearchParameters = + flightSearchParameters || redirectURL.flightSearchParameters + response.headers.set('Location', String(redirectURL)) + } } /** @@ -179,6 +183,10 @@ export async function adapter(params: { export function blockUnallowedResponse( promise: Promise ): Promise { + if (process.env.__NEXT_ALLOW_MIDDLEWARE_RESPONSE_BODY) { + return promise + } + return promise.then((result) => { if (result.response?.body) { console.error( diff --git a/packages/next/server/web/next-url.ts b/packages/next/server/web/next-url.ts index 5f05ec8e8ddb8..67e4b11aa5839 100644 --- a/packages/next/server/web/next-url.ts +++ b/packages/next/server/web/next-url.ts @@ -100,7 +100,7 @@ export class NextURL { private analyzeUrl() { const pathnameInfo = getNextPathnameInfo(this[Internal].url.pathname, { nextConfig: this[Internal].options.nextConfig, - parseData: true, + parseData: !process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE, }) this[Internal].domainLocale = detectDomainLocale( diff --git a/test/e2e/skip-trailing-slash-redirect/app/middleware.js b/test/e2e/skip-trailing-slash-redirect/app/middleware.js new file mode 100644 index 0000000000000..a92b36d5f3987 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/middleware.js @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server' + +export default function handler(req) { + if (req.nextUrl.pathname.startsWith('/_next/data/missing-id')) { + console.log(`missing-id rewrite: ${req.nextUrl.toString()}`) + return NextResponse.rewrite('https://example.vercel.sh') + } + + if (req.nextUrl.pathname === '/middleware-rewrite-with-slash') { + return NextResponse.rewrite(new URL('/another/', req.nextUrl)) + } + + if (req.nextUrl.pathname === '/middleware-rewrite-without-slash') { + return NextResponse.rewrite(new URL('/another', req.nextUrl)) + } + + if (req.nextUrl.pathname === '/middleware-redirect-external-with') { + return NextResponse.redirect('https://example.vercel.sh/somewhere/', 307) + } + + if (req.nextUrl.pathname === '/middleware-redirect-external-without') { + return NextResponse.redirect('https://example.vercel.sh/somewhere', 307) + } + + if (req.nextUrl.pathname.startsWith('/api/test-cookie')) { + const res = NextResponse.next() + res.cookies.set('from-middleware', 1) + return res + } + + if (req.nextUrl.pathname === '/middleware-response-body') { + return new Response('hello from middleware', { + headers: { + 'x-from-middleware': 'true', + }, + }) + } + + return NextResponse.next() +} diff --git a/test/e2e/skip-trailing-slash-redirect/app/next.config.js b/test/e2e/skip-trailing-slash-redirect/app/next.config.js new file mode 100644 index 0000000000000..f2e84613720e1 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/next.config.js @@ -0,0 +1,28 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + skipTrailingSlashRedirect: true, + skipMiddlewareUrlNormalize: true, + allowMiddlewareResponseBody: true, + newNextLinkBehavior: true, + }, + async redirects() { + return [ + { + source: '/redirect-me', + destination: '/another', + permanent: false, + }, + ] + }, + async rewrites() { + return [ + { + source: '/rewrite-me', + destination: '/another', + }, + ] + }, +} + +module.exports = nextConfig diff --git a/test/e2e/skip-trailing-slash-redirect/app/pages/another.js b/test/e2e/skip-trailing-slash-redirect/app/pages/another.js new file mode 100644 index 0000000000000..b6cb822ba15bb --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/pages/another.js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

another page

+ + to index + +
+ + ) +} diff --git a/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js b/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js new file mode 100644 index 0000000000000..6018a223708fd --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie-edge.js @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' + +export const config = { + runtime: 'experimental-edge', +} + +export default function handler(req) { + console.log('setting cookie in api route') + const res = NextResponse.json({ name: 'API' }) + res.cookies.set('hello', 'From API') + return res +} diff --git a/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie.js b/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie.js new file mode 100644 index 0000000000000..4aec0e3eec7b7 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/pages/api/test-cookie.js @@ -0,0 +1,5 @@ +export default function handler(req, res) { + console.log('setting cookie in api route') + res.setHeader('Set-Cookie', 'hello=From API') + res.status(200).json({ name: 'API' }) +} diff --git a/test/e2e/skip-trailing-slash-redirect/app/pages/blog/[slug].js b/test/e2e/skip-trailing-slash-redirect/app/pages/blog/[slug].js new file mode 100644 index 0000000000000..d981ff9ea3a82 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/pages/blog/[slug].js @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

blog page

+ + to index + +
+ + ) +} diff --git a/test/e2e/skip-trailing-slash-redirect/app/pages/index.js b/test/e2e/skip-trailing-slash-redirect/app/pages/index.js new file mode 100644 index 0000000000000..04fbd07fee5b1 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/app/pages/index.js @@ -0,0 +1,17 @@ +import Link from 'next/link' + +export default function Page(props) { + return ( + <> +

index page

+ + to another + +
+ + to /blog/first + +
+ + ) +} diff --git a/test/e2e/skip-trailing-slash-redirect/index.test.ts b/test/e2e/skip-trailing-slash-redirect/index.test.ts new file mode 100644 index 0000000000000..76b54feb80fd3 --- /dev/null +++ b/test/e2e/skip-trailing-slash-redirect/index.test.ts @@ -0,0 +1,197 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { check, fetchViaHTTP } from 'next-test-utils' +import { join } from 'path' +import webdriver from 'next-webdriver' + +describe('skip-trailing-slash-redirect', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should allow rewriting invalid buildId correctly', async () => { + const res = await fetchViaHTTP( + next.url, + '/_next/data/missing-id/hello.json', + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + expect(await res.text()).toContain('Example Domain') + + if (!(global as any).isNextDeploy) { + await check(() => next.cliOutput, /missing-id rewrite/) + expect(next.cliOutput).toContain('/_next/data/missing-id/hello.json') + } + }) + + it('should allow response body from middleware with flag', async () => { + const res = await fetchViaHTTP(next.url, '/middleware-response-body') + expect(res.status).toBe(200) + expect(res.headers.get('x-from-middleware')).toBe('true') + expect(await res.text()).toBe('hello from middleware') + }) + + it('should merge cookies from middleware and API routes correctly', async () => { + const res = await fetchViaHTTP(next.url, '/api/test-cookie', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('set-cookie')).toEqual( + 'from-middleware=1; Path=/, hello=From API' + ) + }) + + it('should merge cookies from middleware and edge API routes correctly', async () => { + const res = await fetchViaHTTP( + next.url, + '/api/test-cookie-edge', + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(200) + expect(res.headers.get('set-cookie')).toEqual( + 'from-middleware=1; Path=/, hello=From%20API; Path=/' + ) + }) + + if ((global as any).isNextStart) { + it('should not have trailing slash redirects in manifest', async () => { + const routesManifest = JSON.parse( + await next.readFile('.next/routes-manifest.json') + ) + + expect( + routesManifest.redirects.some((redirect) => { + return ( + redirect.statusCode === 308 && + (redirect.destination === '/:path+' || + redirect.destination === '/:path+/') + ) + }) + ).toBe(false) + }) + } + + it('should correct skip URL normalizing in middleware', async () => { + let res = await fetchViaHTTP( + next.url, + '/middleware-rewrite-with-slash', + undefined, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + expect(res.headers.get('x-nextjs-rewrite').endsWith('/another/')).toBe(true) + + res = await fetchViaHTTP( + next.url, + '/middleware-rewrite-without-slash', + undefined, + { redirect: 'manual', headers: { 'x-nextjs-data': '1' } } + ) + expect(res.headers.get('x-nextjs-rewrite').endsWith('/another')).toBe(true) + + res = await fetchViaHTTP( + next.url, + '/middleware-redirect-external-with', + undefined, + { redirect: 'manual' } + ) + expect(res.status).toBe(307) + expect(res.headers.get('Location')).toBe( + 'https://example.vercel.sh/somewhere/' + ) + + res = await fetchViaHTTP( + next.url, + '/middleware-redirect-external-without', + undefined, + { redirect: 'manual' } + ) + expect(res.status).toBe(307) + expect(res.headers.get('Location')).toBe( + 'https://example.vercel.sh/somewhere' + ) + }) + + it('should apply config redirect correctly', async () => { + const res = await fetchViaHTTP(next.url, '/redirect-me', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe( + '/another' + ) + }) + + it('should apply config rewrites correctly', async () => { + const res = await fetchViaHTTP(next.url, '/rewrite-me', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + + it('should not apply trailing slash redirect (with slash)', async () => { + const res = await fetchViaHTTP(next.url, '/another/', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + + it('should not apply trailing slash redirect (without slash)', async () => { + const res = await fetchViaHTTP(next.url, '/another', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('another page') + }) + + it('should respond to index correctly', async () => { + const res = await fetchViaHTTP(next.url, '/', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('index page') + }) + + it('should respond to dynamic route correctly', async () => { + const res = await fetchViaHTTP(next.url, '/blog/first', undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(await res.text()).toContain('blog page') + }) + + it('should navigate client side correctly', async () => { + const browser = await webdriver(next.url, '/') + + expect(await browser.eval('location.pathname')).toBe('/') + + await browser.elementByCss('#to-another').click() + await browser.waitForElementByCss('#another') + + expect(await browser.eval('location.pathname')).toBe('/another') + await browser.back() + await browser.waitForElementByCss('#index') + + expect(await browser.eval('location.pathname')).toBe('/') + + await browser.elementByCss('#to-blog-first').click() + await browser.waitForElementByCss('#blog') + + expect(await browser.eval('location.pathname')).toBe('/blog/first') + }) +})