Ensure trailing slash is handled correctly with middleware (#38282)

* Ensure trailing slash is handled correctly with middleware

* update source modifying

* undo extra change
This commit is contained in:
JJ Kasper 2022-07-04 09:31:07 -05:00 committed by GitHub
parent 672736c408
commit 9d22da476b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 774 additions and 22 deletions

View file

@ -74,12 +74,14 @@ export function getUtils({
basePath,
rewrites,
pageIsDynamic,
trailingSlash,
}: {
page: ServerlessHandlerCtx['page']
i18n?: ServerlessHandlerCtx['i18n']
basePath: ServerlessHandlerCtx['basePath']
rewrites: ServerlessHandlerCtx['rewrites']
pageIsDynamic: ServerlessHandlerCtx['pageIsDynamic']
trailingSlash?: boolean
}) {
let defaultRouteRegex: ReturnType<typeof getNamedRouteRegex> | undefined
let dynamicRouteMatcher: RouteMatch | undefined
@ -107,10 +109,13 @@ export function getUtils({
}
const checkRewrite = (rewrite: Rewrite): boolean => {
const matcher = getPathMatch(rewrite.source, {
removeUnnamedParams: true,
strict: true,
})
const matcher = getPathMatch(
rewrite.source + (trailingSlash ? '(/)?' : ''),
{
removeUnnamedParams: true,
strict: true,
}
)
let params = matcher(parsedUrl.pathname)
if (rewrite.has && params) {

View file

@ -489,7 +489,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
if (
!isDynamicRoute(srcPathname) &&
!(await this.hasPage(srcPathname))
!(await this.hasPage(removeTrailingSlash(srcPathname)))
) {
for (const dynamicRoute of this.dynamicRoutes || []) {
if (dynamicRoute.match(srcPathname)) {
@ -769,6 +769,20 @@ export default abstract class Server<ServerOptions extends Options = Options> {
let pathname = `/${params.path.join('/')}`
pathname = getRouteFromAssetPath(pathname, '.json')
// ensure trailing slash is normalized per config
if (this.router.catchAllMiddleware[0]) {
if (this.nextConfig.trailingSlash && !pathname.endsWith('/')) {
pathname += '/'
}
if (
!this.nextConfig.trailingSlash &&
pathname.length > 1 &&
pathname.endsWith('/')
) {
pathname = pathname.substring(0, pathname.length - 1)
}
}
if (this.nextConfig.i18n) {
const { host } = req?.headers || {}
// remove port from host and remove port if present

View file

@ -1191,13 +1191,15 @@ export default class Router implements BaseRouter {
// we don't attempt resolve asPath when we need to execute
// middleware as the resolving will occur server-side
const isMiddlewareMatch =
!options.shallow &&
(await matchesMiddleware({
asPath: as,
locale: nextState.locale,
router: this,
}))
const isMiddlewareMatch = await matchesMiddleware({
asPath: as,
locale: nextState.locale,
router: this,
})
if (options.shallow && isMiddlewareMatch) {
pathname = this.pathname
}
if (shouldResolveHref && pathname !== '/_error') {
;(options as any)._shouldResolveHref = true
@ -1672,16 +1674,12 @@ export default class Router implements BaseRouter {
* for shallow routing purposes.
*/
let route = requestedRoute
try {
const handleCancelled = getCancelledHandler({ route, router: this })
let existingInfo: PrivateRouteInfo | undefined = this.components[route]
if (
!hasMiddleware &&
routeProps.shallow &&
existingInfo &&
this.route === route
) {
if (routeProps.shallow && existingInfo && this.route === route) {
return existingInfo
}

View file

@ -34,10 +34,13 @@ export default function resolveRewrites(
let resolvedHref
const handleRewrite = (rewrite: Rewrite) => {
const matcher = getPathMatch(rewrite.source, {
removeUnnamedParams: true,
strict: true,
})
const matcher = getPathMatch(
rewrite.source + (process.env.__NEXT_TRAILING_SLASH ? '(/)?' : ''),
{
removeUnnamedParams: true,
strict: true,
}
)
let params = matcher(parsedAs.pathname)

View file

@ -0,0 +1,107 @@
/* global URLPattern */
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 (request.headers.get('x-prerender-revalidate')) {
return NextResponse.next({
headers: { 'x-middleware': 'hi' },
})
}
if (url.pathname === '/about/') {
return NextResponse.rewrite(new URL('/about/a', request.url))
}
if (url.pathname === '/ssr-page/') {
url.pathname = '/ssr-page-2'
return NextResponse.rewrite(url)
}
if (url.pathname === '/') {
url.pathname = '/ssg/first'
return NextResponse.rewrite(url)
}
if (url.pathname === '/to-ssg/') {
url.pathname = '/ssg/hello'
url.searchParams.set('from', 'middleware')
return NextResponse.rewrite(url)
}
if (url.pathname === '/sha/') {
url.pathname = '/shallow'
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-to-dynamic/') {
url.pathname = '/blog/from-middleware'
url.searchParams.set('some', 'middleware')
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-to-config-rewrite/') {
url.pathname = '/rewrite-3'
url.searchParams.set('some', 'middleware')
return NextResponse.rewrite(url)
}
if (url.pathname === '/redirect-to-somewhere/') {
url.pathname = '/somewhere'
return NextResponse.redirect(url, {
headers: {
'x-redirect-header': 'hi',
},
})
}
const original = new URL(request.url)
return NextResponse.next({
headers: {
'req-url-path': `${original.pathname}${original.search}`,
'req-url-basepath': request.nextUrl.basePath,
'req-url-pathname': request.nextUrl.pathname,
'req-url-query': request.nextUrl.searchParams.get('foo'),
'req-url-locale': request.nextUrl.locale,
'req-url-params':
url.pathname !== '/static' ? JSON.stringify(params(request.url)) : '{}',
},
})
}
const PATTERNS = [
[
new URLPattern({ pathname: '/:locale/:id' }),
({ pathname }) => ({
pathname: '/:locale/:id',
params: pathname.groups,
}),
],
[
new URLPattern({ pathname: '/:id' }),
({ pathname }) => ({
pathname: '/:id',
params: pathname.groups,
}),
],
]
const params = (url) => {
const input = url.split('?')[0]
let result = {}
for (const [pattern, handler] of PATTERNS) {
const patternResult = pattern.exec(input)
if (patternResult !== null && 'pathname' in patternResult) {
result = handler(patternResult)
break
}
}
return result
}

View file

@ -0,0 +1,32 @@
module.exports = {
trailingSlash: true,
redirects() {
return [
{
source: '/redirect-1',
destination: '/somewhere/else/',
permanent: false,
},
]
},
rewrites() {
return [
{
source: '/rewrite-1',
destination: '/ssr-page?from=config',
},
{
source: '/rewrite-2',
destination: '/about/a?from=next-config',
},
{
source: '/sha',
destination: '/shallow',
},
{
source: '/rewrite-3',
destination: '/blog/middleware-rewrite?hello=config',
},
]
},
}

View file

@ -0,0 +1,3 @@
export default function Index() {
return <p className="title">Dynamic route</p>
}

View file

@ -0,0 +1,8 @@
export default function App({ Component, pageProps }) {
if (!pageProps || typeof pageProps !== 'object') {
throw new Error(
`Invariant: received invalid pageProps in _app, received ${pageProps}`
)
}
return <Component {...pageProps} />
}

View file

@ -0,0 +1,7 @@
export default function AboutA() {
return (
<div>
<h1>AboutA</h1>
</div>
)
}

View file

@ -0,0 +1,7 @@
export default function AboutB() {
return (
<div>
<h1>AboutB</h1>
</div>
)
}

View file

@ -0,0 +1,3 @@
export default function handler(req, res) {
res.json({ url: req.url, headers: req.headers })
}

View file

@ -0,0 +1,23 @@
import { useRouter } from 'next/router'
export default function Page(props) {
const router = useRouter()
return (
<>
<p id="blog">/blog/[slug]</p>
<p id="query">{JSON.stringify(router.query)}</p>
<p id="pathname">{router.pathname}</p>
<p id="as-path">{router.asPath}</p>
<p id="props">{JSON.stringify(props)}</p>
</>
)
}
export function getServerSideProps({ params }) {
return {
props: {
now: Date.now(),
params,
},
}
}

View file

@ -0,0 +1,12 @@
export default function ThrowOnData({ message }) {
return (
<div>
<h1 className="title">Throw on data request</h1>
<p className={message}>{message}</p>
</div>
)
}
export const getServerSideProps = ({ query }) => ({
props: { message: query.message || '' },
})

View file

@ -0,0 +1,11 @@
import Link from 'next/link'
export default function Errors() {
return (
<div>
<Link href="/error-throw?message=refreshed">
<a id="throw-on-data">Throw on data</a>
</Link>
</div>
)
}

View file

@ -0,0 +1,43 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
export default function Shallow({ message }) {
const { pathname, query } = useRouter()
return (
<div>
<ul>
<li id="message-contents">{message}</li>
<li>
<Link href="/sha?hello=world" shallow>
<a id="shallow-link">Shallow link to ?hello=world</a>
</Link>
</li>
<li>
<Link href="/sha?hello=goodbye">
<a id="deep-link">Deep link to ?hello=goodbye</a>
</Link>
</li>
<li>
<h1 id="pathname">
Current path: <code>{pathname}</code>
</h1>
</li>
<li>
<h2 id="query" data-query-hello={query.hello}>
Current query: <code>{JSON.stringify(query)}</code>
</h2>
</li>
</ul>
</div>
)
}
let i = 0
export const getServerSideProps = () => {
return {
props: {
message: `Random: ${++i}${Math.random()}`,
},
}
}

View file

@ -0,0 +1,42 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useState } from 'react'
export default function Page(props) {
const router = useRouter()
const [asPath, setAsPath] = useState(
router.isReady ? router.asPath : router.href
)
useEffect(() => {
if (router.isReady) {
setAsPath(router.asPath)
}
}, [router.asPath, router.isReady])
return (
<>
<p id="ssg">/blog/[slug]</p>
<p id="query">{JSON.stringify(router.query)}</p>
<p id="pathname">{router.pathname}</p>
<p id="as-path">{asPath}</p>
<p id="props">{JSON.stringify(props)}</p>
</>
)
}
export function getStaticProps({ params }) {
return {
props: {
now: Date.now(),
params,
},
}
}
export function getStaticPaths() {
return {
paths: ['/ssg/first', '/ssg/hello'],
fallback: 'blocking',
}
}

View file

@ -0,0 +1,11 @@
export default function SSRPage(props) {
return <h1>{props.message}</h1>
}
export const getServerSideProps = (req) => {
return {
props: {
message: 'Bye Cruel World',
},
}
}

View file

@ -0,0 +1,11 @@
export default function SSRPage(props) {
return <h1>{props.message}</h1>
}
export const getServerSideProps = (req) => {
return {
props: {
message: 'Hello World',
},
}
}

View file

@ -0,0 +1,412 @@
/* eslint-env jest */
import fs from 'fs-extra'
import { join } from 'path'
import webdriver from 'next-webdriver'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { check, fetchViaHTTP, waitFor } from 'next-test-utils'
describe('Middleware Runtime trailing slash', () => {
let next: NextInstance
afterAll(async () => {
await 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')),
},
})
})
function runTests() {
if ((global as any).isNextDev) {
it('refreshes the page when middleware changes ', async () => {
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(next.testDir, '/middleware.js')
const originalContent = fs.readFileSync(middlewarePath, 'utf-8')
const editedContent = originalContent.replace('/about/a', '/about/b')
try {
fs.writeFileSync(middlewarePath, editedContent)
await waitFor(1000)
const textb = await browser.elementByCss('h1').text()
expect(await browser.eval('window.itdidnotrefresh')).not.toBe('hello')
expect(textb).toEqual('AboutB')
} finally {
fs.writeFileSync(middlewarePath, originalContent)
await browser.close()
}
})
}
if ((global as any).isNextStart) {
it('should have valid middleware field in manifest', async () => {
const manifest = await fs.readJSON(
join(next.testDir, '.next/server/middleware-manifest.json')
)
expect(manifest.middleware).toEqual({
'/': {
files: ['server/edge-runtime-webpack.js', 'server/middleware.js'],
name: 'middleware',
env: [],
page: '/',
regexp: '^/.*$',
wasm: [],
},
})
})
it('should have correct files in manifest', async () => {
const manifest = await fs.readJSON(
join(next.testDir, '.next/server/middleware-manifest.json')
)
for (const key of Object.keys(manifest.middleware)) {
const middleware = manifest.middleware[key]
expect(middleware.files).toContainEqual(
expect.stringContaining('server/edge-runtime-webpack')
)
expect(middleware.files).not.toContainEqual(
expect.stringContaining('static/chunks/')
)
}
})
it('should not run middleware for on-demand revalidate', async () => {
const bypassToken = (
await fs.readJSON(join(next.testDir, '.next/prerender-manifest.json'))
).preview.previewModeId
const res = await fetchViaHTTP(next.url, '/ssg/first/', undefined, {
headers: {
'x-prerender-revalidate': bypassToken,
},
})
expect(res.status).toBe(200)
expect(res.headers.get('x-middleware')).toBeFalsy()
expect(res.headers.get('x-nextjs-cache')).toBe('REVALIDATED')
})
}
it('should have init header for NextResponse.redirect', async () => {
const res = await fetchViaHTTP(
next.url,
'/redirect-to-somewhere/',
undefined,
{
redirect: 'manual',
}
)
expect(res.status).toBe(307)
expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe(
'/somewhere/'
)
expect(res.headers.get('x-redirect-header')).toBe('hi')
})
it('should have correct query values for rewrite to ssg page', async () => {
const browser = await webdriver(next.url, '/to-ssg/')
await browser.eval('window.beforeNav = 1')
await check(() => browser.elementByCss('body').text(), /\/to-ssg/)
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'hello',
from: 'middleware',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'hello',
})
expect(await browser.elementByCss('#pathname').text()).toBe('/ssg/[slug]')
expect(await browser.elementByCss('#as-path').text()).toBe('/to-ssg/')
})
it('should have correct dynamic route params on client-transition to dynamic route', async () => {
const browser = await webdriver(next.url, '/')
await browser.eval('window.beforeNav = 1')
await browser.eval('window.next.router.push("/blog/first")')
await browser.waitForElementByCss('#blog')
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'first',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'first',
})
expect(await browser.elementByCss('#pathname').text()).toBe(
'/blog/[slug]'
)
expect(await browser.elementByCss('#as-path').text()).toBe('/blog/first/')
await browser.eval('window.next.router.push("/blog/second")')
await check(() => browser.elementByCss('body').text(), /"slug":"second"/)
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'second',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'second',
})
expect(await browser.elementByCss('#pathname').text()).toBe(
'/blog/[slug]'
)
expect(await browser.elementByCss('#as-path').text()).toBe(
'/blog/second/'
)
})
it('should have correct dynamic route params for middleware rewrite to dynamic route', async () => {
const browser = await webdriver(next.url, '/')
await browser.eval('window.beforeNav = 1')
await browser.eval('window.next.router.push("/rewrite-to-dynamic")')
await browser.waitForElementByCss('#blog')
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'from-middleware',
some: 'middleware',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'from-middleware',
})
expect(await browser.elementByCss('#pathname').text()).toBe(
'/blog/[slug]'
)
expect(await browser.elementByCss('#as-path').text()).toBe(
'/rewrite-to-dynamic/'
)
})
it('should have correct route params for chained rewrite from middleware to config rewrite', async () => {
const browser = await webdriver(next.url, '/')
await browser.eval('window.beforeNav = 1')
await browser.eval(
'window.next.router.push("/rewrite-to-config-rewrite")'
)
await browser.waitForElementByCss('#blog')
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'middleware-rewrite',
hello: 'config',
some: 'middleware',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'middleware-rewrite',
})
expect(await browser.elementByCss('#pathname').text()).toBe(
'/blog/[slug]'
)
expect(await browser.elementByCss('#as-path').text()).toBe(
'/rewrite-to-config-rewrite/'
)
})
it('should have correct route params for rewrite from config dynamic route', async () => {
const browser = await webdriver(next.url, '/')
await browser.eval('window.beforeNav = 1')
await browser.eval('window.next.router.push("/rewrite-3")')
await browser.waitForElementByCss('#blog')
expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual({
slug: 'middleware-rewrite',
hello: 'config',
})
expect(
JSON.parse(await browser.elementByCss('#props').text()).params
).toEqual({
slug: 'middleware-rewrite',
})
expect(await browser.elementByCss('#pathname').text()).toBe(
'/blog/[slug]'
)
expect(await browser.elementByCss('#as-path').text()).toBe('/rewrite-3/')
})
it('should have correct route params for rewrite from config non-dynamic route', async () => {
const browser = await webdriver(next.url, '/')
await browser.eval('window.beforeNav = 1')
await browser.eval('window.next.router.push("/rewrite-1")')
await check(
() => browser.eval('document.documentElement.innerHTML'),
/Hello World/
)
expect(await browser.eval('window.next.router.query')).toEqual({
from: 'config',
})
})
it('should redirect the same for direct visit and client-transition', async () => {
const res = await fetchViaHTTP(next.url, `/redirect-1/`, undefined, {
redirect: 'manual',
})
expect(res.status).toBe(307)
expect(new URL(res.headers.get('location'), 'http://n').pathname).toBe(
'/somewhere/else/'
)
const browser = await webdriver(next.url, `/`)
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')
})
it('should rewrite the same for direct visit and client-transition', async () => {
const res = await fetchViaHTTP(next.url, `/rewrite-1/`)
expect(res.status).toBe(200)
expect(await res.text()).toContain('Hello World')
const browser = await webdriver(next.url, `/`)
await browser.eval('window.beforeNav = 1')
await browser.eval(`next.router.push('/rewrite-1')`)
await check(async () => {
const content = await browser.eval('document.documentElement.innerHTML')
return content.includes('Hello World') ? 'success' : content
}, 'success')
expect(await browser.eval('window.beforeNav')).toBe(1)
})
it('should rewrite correctly for non-SSG/SSP page', async () => {
const res = await fetchViaHTTP(next.url, `/rewrite-2/`)
expect(res.status).toBe(200)
expect(await res.text()).toContain('AboutA')
const browser = await webdriver(next.url, `/`)
await browser.eval(`next.router.push('/rewrite-2')`)
await check(async () => {
const content = await browser.eval('document.documentElement.innerHTML')
return content.includes('AboutA') ? 'success' : content
}, 'success')
})
it('should respond with 400 on decode failure', async () => {
const res = await fetchViaHTTP(next.url, `/%2/`)
expect(res.status).toBe(400)
if ((global as any).isNextStart) {
expect(await res.text()).toContain('Bad Request')
}
})
it(`should validate & parse request url from any route`, async () => {
const res = await fetchViaHTTP(next.url, `/static/`)
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'))
expect(pathname).toBe(undefined)
expect(params).toEqual(undefined)
expect(res.headers.get('req-url-query')).not.toBe('bar')
})
it('should trigger middleware for data requests', async () => {
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(
next.url,
`/_next/data/${next.buildId}/ssr-page.json`,
undefined,
{
headers: {
'x-nextjs-data': '1',
},
}
)
const json = await res.json()
expect(json.pageProps.message).toEqual('Bye Cruel World')
})
it('should normalize data requests into page requests', async () => {
const res = await fetchViaHTTP(
next.url,
`/_next/data/${next.buildId}/send-url.json`,
undefined,
{
headers: {
'x-nextjs-data': '1',
},
}
)
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(
next.url,
`/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1`
)
expect(res.headers.get('req-url-path')).toEqual(
`/_next/static/${next.buildId}/_devMiddlewareManifest.json?foo=1`
)
})
it('should add a rewrite header on data requests for rewrites', async () => {
const res = await fetchViaHTTP(next.url, `/ssr-page/`)
const dataRes = await fetchViaHTTP(
next.url,
`/_next/data/${next.buildId}/ssr-page.json`,
undefined,
{ headers: { 'x-nextjs-data': '1' } }
)
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(
`/ssr-page-2`
)
})
it('allows shallow linking with middleware', async () => {
const browser = await webdriver(next.url, '/sha/')
const getMessageContents = () =>
browser.elementById('message-contents').text()
const ssrMessage = await getMessageContents()
const requests: string[] = []
browser.on('request', (x) => {
requests.push(x.url())
})
browser.elementById('deep-link').click()
browser.waitForElementByCss('[data-query-hello="goodbye"]')
const deepLinkMessage = await getMessageContents()
expect(deepLinkMessage).not.toEqual(ssrMessage)
// Changing the route with a shallow link should not cause a server request
browser.elementById('shallow-link').click()
browser.waitForElementByCss('[data-query-hello="world"]')
expect(await getMessageContents()).toEqual(deepLinkMessage)
// Check that no server requests were made to ?hello=world,
// as it's a shallow request.
expect(requests.filter((req) => req.includes('_next/data'))).toEqual([
`${next.url}/_next/data/${next.buildId}/sha.json?hello=goodbye`,
])
})
}
runTests()
})