Add has route field (#22341)

This adds support for a `has` field to `rewrites`, `redirects`, and `headers` to allow matching against `header`, `cookie`, and `query` values. Documentation and additional tests for the feature is also added in this PR. 

Closes: https://github.com/vercel/next.js/issues/22345
This commit is contained in:
JJ Kasper 2021-03-24 11:50:16 -05:00 committed by GitHub
parent 3141b6ff70
commit 75c721c583
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1188 additions and 18 deletions

View file

@ -149,6 +149,100 @@ module.exports = {
}
```
## Header, Cookie, and Query Matching
To only apply a header when either header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the header to be applied.
`has` items have the following fields:
- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`.
- `key`: `String` - the key from the selected type to match against.
- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?<paramName>.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`.
```js
module.exports = {
async headers() {
return [
// if the header `x-add-header` is present,
// the `x-another-header` header will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-add-header',
},
],
headers: [
{
key: 'x-another-header',
value: 'hello',
},
],
},
// if the source, query, and cookie are matched,
// the `x-authorized` header will be applied
{
source: '/specific/:path*',
has: [
{
type: 'query',
key: 'page',
value: 'home',
},
{
type: 'cookie',
key: 'authorized',
value: 'true',
},
],
headers: [
{
key: 'x-authorized',
value: ':authorized',
},
],
},
// if the header `x-authorized` is present and
// contains a matching value, the `x-another-header` will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-authorized',
value: '(?<authorized>yes|true)',
},
],
headers: [
{
key: 'x-another-header',
value: ':authorized',
},
],
},
// if the host is `example.com`,
// this header will be applied
{
source: '/:path*',
has: [
{
type: 'host',
value: 'example.com',
},
],
headers: [
{
key: 'x-another-header',
value: ':authorized',
},
],
},
]
},
}
```
### Headers with basePath support
When leveraging [`basePath` support](/docs/api-reference/next.config.js/basepath.md) with headers each `source` is automatically prefixed with the `basePath` unless you add `basePath: false` to the header:

View file

@ -93,6 +93,83 @@ module.exports = {
}
```
## Header, Cookie, and Query Matching
To only match a redirect when header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the redirect to be applied.
`has` items have the following fields:
- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`.
- `key`: `String` - the key from the selected type to match against.
- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?<paramName>.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`.
```js
module.exports = {
async redirects() {
return [
// if the header `x-redirect-me` is present,
// this redirect will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-redirect-me',
},
],
permanent: false,
destination: '/another-page',
},
// if the source, query, and cookie are matched,
// this redirect will be applied
{
source: '/specific/:path*',
has: [
{
type: 'query',
key: 'page',
value: 'home',
},
{
type: 'cookie',
key: 'authorized',
value: 'true',
},
],
permanent: false,
destination: '/:path*/:page',
},
// if the header `x-authorized` is present and
// contains a matching value, this redirect will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-authorized',
value: '(?<authorized>yes|true)',
},
],
permanent: false,
destination: '/home?authorized=:authorized',
},
// if the host is `example.com`,
// this redirect will be applied
{
source: '/:path*',
has: [
{
type: 'host',
value: 'example.com',
},
],
destination: '/another-page',
},
]
},
}
```
### Redirects with basePath support
When leveraging [`basePath` support](/docs/api-reference/next.config.js/basepath.md) with redirects each `source` and `destination` is automatically prefixed with the `basePath` unless you add `basePath: false` to the redirect:

View file

@ -140,6 +140,80 @@ module.exports = {
}
```
## Header, Cookie, and Query Matching
To only match a rewrite when header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the rewrite to be applied.
`has` items have the following fields:
- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`.
- `key`: `String` - the key from the selected type to match against.
- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?<paramName>.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`.
```js
module.exports = {
async rewrites() {
return [
// if the header `x-rewrite-me` is present,
// this rewrite will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-rewrite-me',
},
],
destination: '/another-page',
},
// if the source, query, and cookie are matched,
// this rewrite will be applied
{
source: '/specific/:path*',
has: [
{
type: 'query',
key: 'page',
value: 'home',
},
{
type: 'cookie',
key: 'authorized',
value: 'true',
},
],
destination: '/:path*/:page',
},
// if the header `x-authorized` is present and
// contains a matching value, this rewrite will be applied
{
source: '/:path*',
has: [
{
type: 'header',
key: 'x-authorized',
value: '(?<authorized>yes|true)',
},
],
destination: '/home?authorized=:authorized',
},
// if the host is `example.com`,
// this rewrite will be applied
{
source: '/:path*',
has: [
{
type: 'host',
value: 'example.com',
},
],
destination: '/another-page',
},
]
},
}
```
## Rewriting to an external URL
<details>

View file

@ -1377,6 +1377,9 @@ export default async function build(
rewritesCount: rewrites.length,
headersCount: headers.length,
redirectsCount: redirects.length - 1, // reduce one for trailing slash
headersWithHasCount: headers.filter((r: any) => !!r.has).length,
rewritesWithHasCount: rewrites.filter((r: any) => !!r.has).length,
redirectsWithHasCount: redirects.filter((r: any) => !!r.has).length,
})
)

View file

@ -24,7 +24,7 @@ export function getApiHandler(ctx: ServerlessHandlerCtx) {
// We need to trust the dynamic route params from the proxy
// to ensure we are using the correct values
const trustQuery = req.headers[vercelHeader]
const parsedUrl = handleRewrites(parseUrl(req.url!, true))
const parsedUrl = handleRewrites(req, parseUrl(req.url!, true))
if (parsedUrl.query.nextInternalLocale) {
delete parsedUrl.query.nextInternalLocale

View file

@ -140,7 +140,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) {
}
const origQuery = Object.assign({}, parsedUrl.query)
parsedUrl = handleRewrites(parsedUrl)
parsedUrl = handleRewrites(req, parsedUrl)
handleBasePath(req, parsedUrl)
// remove ?amp=1 from request URL if rendering for export

View file

@ -6,7 +6,9 @@ import { normalizeLocalePath } from '../../../../next-server/lib/i18n/normalize-
import pathMatch from '../../../../next-server/lib/router/utils/path-match'
import { getRouteRegex } from '../../../../next-server/lib/router/utils/route-regex'
import { getRouteMatcher } from '../../../../next-server/lib/router/utils/route-matcher'
import prepareDestination from '../../../../next-server/lib/router/utils/prepare-destination'
import prepareDestination, {
matchHas,
} from '../../../../next-server/lib/router/utils/prepare-destination'
import { __ApiPreviewProps } from '../../../../next-server/server/api-utils'
import { BuildManifest } from '../../../../next-server/server/get-page-files'
import {
@ -85,10 +87,20 @@ export function getUtils({
defaultRouteMatches = dynamicRouteMatcher(page) as ParsedUrlQuery
}
function handleRewrites(parsedUrl: UrlWithParsedQuery) {
function handleRewrites(req: IncomingMessage, parsedUrl: UrlWithParsedQuery) {
for (const rewrite of rewrites) {
const matcher = getCustomRouteMatcher(rewrite.source)
const params = matcher(parsedUrl.pathname)
let params = matcher(parsedUrl.pathname)
if (rewrite.has && params) {
const hasParams = matchHas(req, rewrite.has, parsedUrl.query)
if (hasParams) {
Object.assign(params, hasParams)
} else {
params = false
}
}
if (params) {
const { parsedDestination } = prepareDestination(

View file

@ -6,12 +6,27 @@ import {
PERMANENT_REDIRECT_STATUS,
TEMPORARY_REDIRECT_STATUS,
} from '../next-server/lib/constants'
import { execOnce } from '../next-server/lib/utils'
import * as Log from '../build/output/log'
export type RouteHas =
| {
type: 'header' | 'query' | 'cookie'
key: string
value?: string
}
| {
type: 'host'
key?: undefined
value: string
}
export type Rewrite = {
source: string
destination: string
basePath?: false
locale?: false
has?: RouteHas[]
}
export type Header = {
@ -19,6 +34,7 @@ export type Header = {
basePath?: false
locale?: false
headers: Array<{ key: string; value: string }>
has?: RouteHas[]
}
// internal type used for validation (not user facing)
@ -28,6 +44,7 @@ export type Redirect = Rewrite & {
}
export const allowedStatusCodes = new Set([301, 302, 303, 307, 308])
const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host'])
export function getRedirectStatus(route: {
statusCode?: number
@ -137,6 +154,12 @@ function tryParsePath(route: string, handleUrl?: boolean): ParseAttemptResult {
export type RouteType = 'rewrite' | 'redirect' | 'header'
const experimentalHasWarn = execOnce(() => {
Log.warn(
`'has' route field support is still experimental and not covered by semver, use at your own risk.`
)
})
function checkCustomRoutes(
routes: Redirect[] | Header[] | Rewrite[],
type: RouteType
@ -150,6 +173,7 @@ function checkCustomRoutes(
let numInvalidRoutes = 0
let hadInvalidStatus = false
let hadInvalidHas = false
const isRedirect = type === 'redirect'
let allowedKeys: Set<string>
@ -161,9 +185,10 @@ function checkCustomRoutes(
'basePath',
'locale',
...(isRedirect ? ['statusCode', 'permanent'] : []),
'has',
])
} else {
allowedKeys = new Set(['source', 'headers', 'basePath', 'locale'])
allowedKeys = new Set(['source', 'headers', 'basePath', 'locale', 'has'])
}
for (const route of routes) {
@ -208,6 +233,51 @@ function checkCustomRoutes(
invalidParts.push('`locale` must be undefined or false')
}
if (typeof route.has !== 'undefined' && !Array.isArray(route.has)) {
invalidParts.push('`has` must be undefined or valid has object')
hadInvalidHas = true
} else if (route.has) {
experimentalHasWarn()
const invalidHasItems = []
for (const hasItem of route.has) {
let invalidHasParts = []
if (!allowedHasTypes.has(hasItem.type)) {
invalidHasParts.push(`invalid type "${hasItem.type}"`)
}
if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') {
invalidHasParts.push(`invalid key "${hasItem.key}"`)
}
if (
typeof hasItem.value !== 'undefined' &&
typeof hasItem.value !== 'string'
) {
invalidHasParts.push(`invalid value "${hasItem.value}"`)
}
if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') {
invalidHasParts.push(`value is required for "host" type`)
}
if (invalidHasParts.length > 0) {
invalidHasItems.push(
`${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}`
)
}
}
if (invalidHasItems.length > 0) {
hadInvalidHas = true
const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}`
console.error(
`Invalid \`has\` ${itemStr}:\n` + invalidHasItems.join('\n')
)
console.error()
invalidParts.push(`invalid \`has\` ${itemStr} found`)
}
}
if (!route.source) {
invalidParts.push('`source` is missing')
} else if (typeof route.source !== 'string') {
@ -327,6 +397,7 @@ function checkCustomRoutes(
: ''
} for route ${JSON.stringify(route)}`
)
console.error()
numInvalidRoutes++
}
}
@ -339,6 +410,19 @@ function checkCustomRoutes(
)}`
)
}
if (hadInvalidHas) {
console.error(
`\nValid \`has\` object shape is ${JSON.stringify(
{
type: [...allowedHasTypes].join(', '),
key: 'the key to check for',
value: 'undefined or a value string to match against',
},
null,
2
)}`
)
}
console.error()
throw new Error(`Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`)

View file

@ -1,10 +1,97 @@
import { IncomingMessage } from 'http'
import { ParsedUrlQuery } from 'querystring'
import { searchParamsToUrlQuery } from './querystring'
import { parseRelativeUrl } from './parse-relative-url'
import * as pathToRegexp from 'next/dist/compiled/path-to-regexp'
import { RouteHas } from '../../../../lib/load-custom-routes'
type Params = { [param: string]: any }
// ensure only a-zA-Z are used for param names for proper interpolating
// with path-to-regexp
const getSafeParamName = (paramName: string) => {
let newParamName = ''
for (let i = 0; i < paramName.length; i++) {
const charCode = paramName.charCodeAt(i)
if (
(charCode > 64 && charCode < 91) || // A-Z
(charCode > 96 && charCode < 123) // a-z
) {
newParamName += paramName[i]
}
}
return newParamName
}
export function matchHas(
req: IncomingMessage,
has: RouteHas[],
query: Params
): false | Params {
const params: Params = {}
const allMatch = has.every((hasItem) => {
let value: undefined | string
let key = hasItem.key
switch (hasItem.type) {
case 'header': {
key = key!.toLowerCase()
value = req.headers[key] as string
break
}
case 'cookie': {
value = (req as any).cookies[hasItem.key]
break
}
case 'query': {
value = query[key!]
break
}
case 'host': {
const { host } = req?.headers || {}
// remove port from host if present
const hostname = host?.split(':')[0].toLowerCase()
value = hostname
break
}
default: {
break
}
}
if (!hasItem.value && value) {
params[getSafeParamName(key!)] = value
return true
} else if (value) {
const matcher = new RegExp(`^${hasItem.value}$`)
const matches = value.match(matcher)
if (matches) {
if (matches.groups) {
Object.keys(matches.groups).forEach((groupKey) => {
const safeKey = getSafeParamName(groupKey)
if (safeKey && matches.groups![groupKey]) {
params[safeKey] = matches.groups![groupKey]
}
})
} else {
params[getSafeParamName(key || 'host')] = matches[0]
}
return true
}
}
return false
})
if (allMatch) {
return params
}
return false
}
export function compileNonPath(value: string, params: Params): string {
if (!value.includes(':')) {
return value

View file

@ -1,6 +1,6 @@
import { ParsedUrlQuery } from 'querystring'
import pathMatch from './path-match'
import prepareDestination from './prepare-destination'
import prepareDestination, { matchHas } from './prepare-destination'
import { Rewrite } from '../../../../lib/load-custom-routes'
import { removePathTrailingSlash } from '../../../../client/normalize-trailing-slash'
import { normalizeLocalePath } from '../../i18n/normalize-locale-path'
@ -32,7 +32,31 @@ export default function resolveRewrites(
if (!pages.includes(fsPathname)) {
for (const rewrite of rewrites) {
const matcher = customRouteMatcher(rewrite.source)
const params = matcher(parsedAs.pathname)
let params = matcher(parsedAs.pathname)
if (rewrite.has && params) {
const hasParams = matchHas(
{
headers: {
host: document.location.hostname,
},
cookies: Object.fromEntries(
document.cookie.split('; ').map((item) => {
const [key, ...value] = item.split('=')
return [key, value.join('=')]
})
),
} as any,
rewrite.has,
parsedAs.query
)
if (hasParams) {
Object.assign(params, hasParams)
} else {
params = false
}
}
if (params) {
if (!rewrite.destination) {

View file

@ -7,7 +7,6 @@ import { Stream } from 'stream'
import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils'
import { decryptWithSecret, encryptWithSecret } from './crypto-utils'
import { interopDefault } from './load-components'
import { Params } from './router'
import { sendEtagResponse } from './send-payload'
import generateETag from 'etag'
@ -517,7 +516,6 @@ export function sendError(
interface LazyProps {
req: NextApiRequest
params?: Params | boolean
}
/**
@ -527,7 +525,7 @@ interface LazyProps {
* @param getter function to get data
*/
export function setLazyProp<T>(
{ req, params }: LazyProps,
{ req }: LazyProps,
prop: string,
getter: () => T
): void {
@ -537,10 +535,7 @@ export function setLazyProp<T>(
Object.defineProperty(req, prop, {
...opts,
get: () => {
let value = getter()
if (params && typeof params !== 'boolean') {
value = { ...value, ...params }
}
const value = getter()
// we set the property on the object to avoid recalculating it
Object.defineProperty(req, prop, { ...optsReset, value })
return value

View file

@ -369,7 +369,7 @@ export default class Server {
rewrites: this.customRoutes.rewrites,
})
utils.handleRewrites(parsedUrl)
utils.handleRewrites(req, parsedUrl)
// interpolate dynamic params and normalize URL if needed
if (pageIsDynamic) {
@ -434,7 +434,7 @@ export default class Server {
: detectedLocale
const { host } = req?.headers || {}
// remove port from host and remove port if present
// remove port from host if present
const hostname = host?.split(':')[0].toLowerCase()
const detectedDomain = detectDomainLocale(i18n.domains, hostname)
@ -797,6 +797,7 @@ export default class Server {
const headerRoute = getCustomRoute(r, 'header')
return {
match: headerRoute.match,
has: headerRoute.has,
type: headerRoute.type,
name: `${headerRoute.type} ${headerRoute.source} header route`,
fn: async (_req, res, params, _parsedUrl) => {
@ -839,6 +840,7 @@ export default class Server {
internal: redirectRoute.internal,
type: redirectRoute.type,
match: redirectRoute.match,
has: redirectRoute.has,
statusCode: redirectRoute.statusCode,
name: `Redirect route ${redirectRoute.source}`,
fn: async (req, res, params, parsedUrl) => {

View file

@ -4,6 +4,8 @@ import { UrlWithParsedQuery } from 'url'
import pathMatch from '../lib/router/utils/path-match'
import { removePathTrailingSlash } from '../../client/normalize-trailing-slash'
import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path'
import { RouteHas } from '../../lib/load-custom-routes'
import { matchHas } from '../lib/router/utils/prepare-destination'
export const route = pathMatch()
@ -19,6 +21,7 @@ type RouteResult = {
export type Route = {
match: RouteMatch
has?: RouteHas[]
type: string
check?: boolean
statusCode?: number
@ -224,7 +227,17 @@ export default class Router {
}`
}
const newParams = testRoute.match(currentPathname)
let newParams = testRoute.match(currentPathname)
if (testRoute.has && newParams) {
const hasParams = matchHas(req, testRoute.has, parsedUrlUpdated.query)
if (hasParams) {
Object.assign(newParams, hasParams)
} else {
newParams = false
}
}
// Check if the match function matched
if (newParams) {

View file

@ -48,6 +48,9 @@ type EventBuildOptimized = {
headersCount: number
rewritesCount: number
redirectsCount: number
headersWithHasCount: number
rewritesWithHasCount: number
redirectsWithHasCount: number
}
export function eventBuildOptimize(

View file

@ -106,6 +106,48 @@ module.exports = {
source: '/catchall-query/:path*',
destination: '/with-params?another=:path*',
},
{
source: '/has-rewrite-1',
has: [
{
type: 'header',
key: 'x-my-header',
value: '(?<myHeader>.*)',
},
],
destination: '/with-params?myHeader=:myHeader',
},
{
source: '/has-rewrite-2',
has: [
{
type: 'query',
key: 'my-query',
},
],
destination: '/with-params?value=:myquery',
},
{
source: '/has-rewrite-3',
has: [
{
type: 'cookie',
key: 'loggedIn',
value: 'true',
},
],
destination: '/with-params?authorized=1',
},
{
source: '/has-rewrite-4',
has: [
{
type: 'host',
value: 'example.com',
},
],
destination: '/with-params?host=1',
},
]
},
async redirects() {
@ -217,6 +259,52 @@ module.exports = {
'https://authserver.example.com/set-password?returnUrl=https://www.example.com/login',
permanent: false,
},
{
source: '/has-redirect-1',
has: [
{
type: 'header',
key: 'x-my-header',
value: '(?<myHeader>.*)',
},
],
destination: '/another?myHeader=:myHeader',
permanent: false,
},
{
source: '/has-redirect-2',
has: [
{
type: 'query',
key: 'my-query',
},
],
destination: '/another?value=:myquery',
permanent: false,
},
{
source: '/has-redirect-3',
has: [
{
type: 'cookie',
key: 'loggedIn',
value: 'true',
},
],
destination: '/another?authorized=1',
permanent: false,
},
{
source: '/has-redirect-4',
has: [
{
type: 'host',
value: 'example.com',
},
],
destination: '/another?host=1',
permanent: false,
},
]
},
@ -360,6 +448,68 @@ module.exports = {
},
],
},
{
source: '/has-header-1',
has: [
{
type: 'header',
key: 'x-my-header',
value: '(?<myHeader>.*)',
},
],
headers: [
{
key: 'x-another',
value: 'header',
},
],
},
{
source: '/has-header-2',
has: [
{
type: 'query',
key: 'my-query',
},
],
headers: [
{
key: 'x-added',
value: 'value',
},
],
},
{
source: '/has-header-3',
has: [
{
type: 'cookie',
key: 'loggedIn',
value: 'true',
},
],
headers: [
{
key: 'x-is-user',
value: 'yuuuup',
},
],
},
{
source: '/has-header-4',
has: [
{
type: 'host',
value: 'example.com',
},
],
headers: [
{
key: 'x-is-host',
value: 'yuuuup',
},
],
},
]
},
}

View file

@ -616,6 +616,243 @@ const runTests = (isDev = false) => {
)
})
it('should match has header rewrite correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-rewrite-1', undefined, {
headers: {
'x-my-header': 'hello world!!',
},
})
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect(JSON.parse($('#query').text())).toEqual({
myHeader: 'hello world!!',
})
const res2 = await fetchViaHTTP(appPort, '/has-rewrite-1')
expect(res2.status).toBe(404)
})
it('should match has query rewrite correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-rewrite-2', {
'my-query': 'hellooo',
})
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect(JSON.parse($('#query').text())).toEqual({
'my-query': 'hellooo',
myquery: 'hellooo',
value: 'hellooo',
})
const res2 = await fetchViaHTTP(appPort, '/has-rewrite-2')
expect(res2.status).toBe(404)
})
it('should match has cookie rewrite correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-rewrite-3', undefined, {
headers: {
cookie: 'loggedIn=true',
},
})
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect(JSON.parse($('#query').text())).toEqual({
loggedIn: 'true',
authorized: '1',
})
const res2 = await fetchViaHTTP(appPort, '/has-rewrite-3')
expect(res2.status).toBe(404)
})
it('should match has host rewrite correctly', async () => {
const res1 = await fetchViaHTTP(appPort, '/has-rewrite-4')
expect(res1.status).toBe(404)
const res = await fetchViaHTTP(appPort, '/has-rewrite-4', undefined, {
headers: {
host: 'example.com',
},
})
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect(JSON.parse($('#query').text())).toEqual({
host: '1',
})
const res2 = await fetchViaHTTP(appPort, '/has-rewrite-3')
expect(res2.status).toBe(404)
})
it('should match has header redirect correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-redirect-1', undefined, {
headers: {
'x-my-header': 'hello world!!',
},
redirect: 'manual',
})
expect(res.status).toBe(307)
const parsed = url.parse(res.headers.get('location'), true)
expect(parsed.pathname).toBe('/another')
expect(parsed.query).toEqual({
myHeader: 'hello world!!',
})
const res2 = await fetchViaHTTP(appPort, '/has-redirect-1', undefined, {
redirect: 'manual',
})
expect(res2.status).toBe(404)
})
it('should match has query redirect correctly', async () => {
const res = await fetchViaHTTP(
appPort,
'/has-redirect-2',
{
'my-query': 'hellooo',
},
{
redirect: 'manual',
}
)
expect(res.status).toBe(307)
const parsed = url.parse(res.headers.get('location'), true)
expect(parsed.pathname).toBe('/another')
expect(parsed.query).toEqual({
value: 'hellooo',
'my-query': 'hellooo',
})
const res2 = await fetchViaHTTP(appPort, '/has-redirect-2', undefined, {
redirect: 'manual',
})
expect(res2.status).toBe(404)
})
it('should match has cookie redirect correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-redirect-3', undefined, {
headers: {
cookie: 'loggedIn=true',
},
redirect: 'manual',
})
expect(res.status).toBe(307)
const parsed = url.parse(res.headers.get('location'), true)
expect(parsed.pathname).toBe('/another')
expect(parsed.query).toEqual({
authorized: '1',
})
const res2 = await fetchViaHTTP(appPort, '/has-redirect-3', undefined, {
redirect: 'manual',
})
expect(res2.status).toBe(404)
})
it('should match has host redirect correctly', async () => {
const res1 = await fetchViaHTTP(appPort, '/has-redirect-4', undefined, {
redirect: 'manual',
})
expect(res1.status).toBe(404)
const res = await fetchViaHTTP(appPort, '/has-redirect-4', undefined, {
headers: {
host: 'example.com',
},
redirect: 'manual',
})
expect(res.status).toBe(307)
const parsed = url.parse(res.headers.get('location'), true)
expect(parsed.pathname).toBe('/another')
expect(parsed.query).toEqual({
host: '1',
})
})
it('should match has header for header correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-header-1', undefined, {
headers: {
'x-my-header': 'hello world!!',
},
redirect: 'manual',
})
expect(res.headers.get('x-another')).toBe('header')
const res2 = await fetchViaHTTP(appPort, '/has-header-1', undefined, {
redirect: 'manual',
})
expect(res2.headers.get('x-another')).toBe(null)
})
it('should match has query for header correctly', async () => {
const res = await fetchViaHTTP(
appPort,
'/has-header-2',
{
'my-query': 'hellooo',
},
{
redirect: 'manual',
}
)
expect(res.headers.get('x-added')).toBe('value')
const res2 = await fetchViaHTTP(appPort, '/has-header-2', undefined, {
redirect: 'manual',
})
expect(res2.headers.get('x-another')).toBe(null)
})
it('should match has cookie for header correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-header-3', undefined, {
headers: {
cookie: 'loggedIn=true',
},
redirect: 'manual',
})
expect(res.headers.get('x-is-user')).toBe('yuuuup')
const res2 = await fetchViaHTTP(appPort, '/has-header-3', undefined, {
redirect: 'manual',
})
expect(res2.headers.get('x-is-user')).toBe(null)
})
it('should match has host for header correctly', async () => {
const res = await fetchViaHTTP(appPort, '/has-header-4', undefined, {
headers: {
host: 'example.com',
},
redirect: 'manual',
})
expect(res.headers.get('x-is-host')).toBe('yuuuup')
const res2 = await fetchViaHTTP(appPort, '/has-header-4', undefined, {
redirect: 'manual',
})
expect(res2.headers.get('x-is-host')).toBe(null)
})
if (!isDev) {
it('should output routes-manifest successfully', async () => {
const manifest = await fs.readJSON(
@ -784,6 +1021,56 @@ const runTests = (isDev = false) => {
source: '/to-external-with-query-2',
statusCode: 307,
},
{
destination: '/another?myHeader=:myHeader',
has: [
{
key: 'x-my-header',
type: 'header',
value: '(?<myHeader>.*)',
},
],
regex: normalizeRegEx('^\\/has-redirect-1$'),
source: '/has-redirect-1',
statusCode: 307,
},
{
destination: '/another?value=:myquery',
has: [
{
key: 'my-query',
type: 'query',
},
],
regex: normalizeRegEx('^\\/has-redirect-2$'),
source: '/has-redirect-2',
statusCode: 307,
},
{
destination: '/another?authorized=1',
has: [
{
key: 'loggedIn',
type: 'cookie',
value: 'true',
},
],
regex: normalizeRegEx('^\\/has-redirect-3$'),
source: '/has-redirect-3',
statusCode: 307,
},
{
destination: '/another?host=1',
has: [
{
type: 'host',
value: 'example.com',
},
],
regex: normalizeRegEx('^\\/has-redirect-4$'),
source: '/has-redirect-4',
statusCode: 307,
},
],
headers: [
{
@ -941,6 +1228,72 @@ const runTests = (isDev = false) => {
),
source: '/catchall-header/:path*',
},
{
has: [
{
key: 'x-my-header',
type: 'header',
value: '(?<myHeader>.*)',
},
],
headers: [
{
key: 'x-another',
value: 'header',
},
],
regex: normalizeRegEx('^\\/has-header-1$'),
source: '/has-header-1',
},
{
has: [
{
key: 'my-query',
type: 'query',
},
],
headers: [
{
key: 'x-added',
value: 'value',
},
],
regex: normalizeRegEx('^\\/has-header-2$'),
source: '/has-header-2',
},
{
has: [
{
key: 'loggedIn',
type: 'cookie',
value: 'true',
},
],
headers: [
{
key: 'x-is-user',
value: 'yuuuup',
},
],
regex: normalizeRegEx('^\\/has-header-3$'),
source: '/has-header-3',
},
{
has: [
{
type: 'host',
value: 'example.com',
},
],
headers: [
{
key: 'x-is-host',
value: 'yuuuup',
},
],
regex: normalizeRegEx('^\\/has-header-4$'),
source: '/has-header-4',
},
],
rewrites: [
{
@ -1077,6 +1430,52 @@ const runTests = (isDev = false) => {
),
source: '/catchall-query/:path*',
},
{
destination: '/with-params?myHeader=:myHeader',
has: [
{
key: 'x-my-header',
type: 'header',
value: '(?<myHeader>.*)',
},
],
regex: normalizeRegEx('^\\/has-rewrite-1$'),
source: '/has-rewrite-1',
},
{
destination: '/with-params?value=:myquery',
has: [
{
key: 'my-query',
type: 'query',
},
],
regex: normalizeRegEx('^\\/has-rewrite-2$'),
source: '/has-rewrite-2',
},
{
destination: '/with-params?authorized=1',
has: [
{
key: 'loggedIn',
type: 'cookie',
value: 'true',
},
],
regex: normalizeRegEx('^\\/has-rewrite-3$'),
source: '/has-rewrite-3',
},
{
destination: '/with-params?host=1',
has: [
{
type: 'host',
value: 'example.com',
},
],
regex: '^\\/has-rewrite-4$',
source: '/has-rewrite-4',
},
],
dynamicRoutes: [
{
@ -1222,6 +1621,12 @@ describe('Custom routes', () => {
`rewrites, redirects, and headers are not applied when exporting your application detected`
)
})
it('should show warning for experimental has usage', async () => {
expect(stderr).toContain(
"'has' route field support is still experimental and not covered by semver, use at your own risk."
)
})
})
describe('export', () => {

View file

@ -66,6 +66,33 @@ const runTests = () => {
// invalid objects
null,
'string',
// invalid has items
{
source: '/hello',
destination: '/another',
has: [
{
type: 'cookiee',
key: 'loggedIn',
},
],
permanent: false,
},
{
source: '/hello',
destination: '/another',
permanent: false,
has: [
{
type: 'headerr',
},
{
type: 'queryr',
key: 'hello',
},
],
},
],
'redirects'
)
@ -115,6 +142,26 @@ const runTests = () => {
`The route "string" is not a valid object with \`source\` and \`destination\``
)
expect(stderr).toContain('Invalid `has` item:')
expect(stderr).toContain(
`invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}`
)
expect(stderr).toContain(
`invalid \`has\` item found for route {"source":"/hello","destination":"/another","has":[{"type":"cookiee","key":"loggedIn"}],"permanent":false}`
)
expect(stderr).toContain('Invalid `has` items:')
expect(stderr).toContain(
`invalid type "headerr", invalid key "undefined" for {"type":"headerr"}`
)
expect(stderr).toContain(
`invalid type "queryr" for {"type":"queryr","key":"hello"}`
)
expect(stderr).toContain(
`invalid \`has\` items found for route {"source":"/hello","destination":"/another","permanent":false,"has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}`
)
expect(stderr).toContain(`Valid \`has\` object shape is {`)
expect(stderr).toContain('Invalid redirects found')
})
@ -164,6 +211,31 @@ const runTests = () => {
// invalid objects
null,
'string',
// invalid has items
{
source: '/hello',
destination: '/another',
has: [
{
type: 'cookiee',
key: 'loggedIn',
},
],
},
{
source: '/hello',
destination: '/another',
has: [
{
type: 'headerr',
},
{
type: 'queryr',
key: 'hello',
},
],
},
],
'rewrites'
)
@ -216,6 +288,26 @@ const runTests = () => {
`The route /hello rewrites urls outside of the basePath. Please use a destination that starts with \`http://\` or \`https://\` https://err.sh/vercel/next.js/invalid-external-rewrite`
)
expect(stderr).toContain('Invalid `has` item:')
expect(stderr).toContain(
`invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}`
)
expect(stderr).toContain(
`invalid \`has\` item found for route {"source":"/hello","destination":"/another","has":[{"type":"cookiee","key":"loggedIn"}]}`
)
expect(stderr).toContain('Invalid `has` items:')
expect(stderr).toContain(
`invalid type "headerr", invalid key "undefined" for {"type":"headerr"}`
)
expect(stderr).toContain(
`invalid type "queryr" for {"type":"queryr","key":"hello"}`
)
expect(stderr).toContain(
`invalid \`has\` items found for route {"source":"/hello","destination":"/another","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}`
)
expect(stderr).toContain(`Valid \`has\` object shape is {`)
expect(stderr).toContain('Invalid rewrites found')
})
@ -283,6 +375,41 @@ const runTests = () => {
// invalid objects
null,
'string',
// invalid has items
{
source: '/hello',
has: [
{
type: 'cookiee',
key: 'loggedIn',
},
],
headers: [
{
key: 'x-hello',
value: 'world',
},
],
},
{
source: '/hello',
has: [
{
type: 'headerr',
},
{
type: 'queryr',
key: 'hello',
},
],
headers: [
{
key: 'x-hello',
value: 'world',
},
],
},
],
'headers'
)
@ -316,6 +443,26 @@ const runTests = () => {
`The route "string" is not a valid object with \`source\` and \`headers\``
)
expect(stderr).toContain('Invalid `has` item:')
expect(stderr).toContain(
`invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}`
)
expect(stderr).toContain(
`invalid \`has\` item found for route {"source":"/hello","has":[{"type":"cookiee","key":"loggedIn"}],"headers":[{"key":"x-hello","value":"world"}]}`
)
expect(stderr).toContain('Invalid `has` items:')
expect(stderr).toContain(
`invalid type "headerr", invalid key "undefined" for {"type":"headerr"}`
)
expect(stderr).toContain(
`invalid type "queryr" for {"type":"queryr","key":"hello"}`
)
expect(stderr).toContain(
`invalid \`has\` items found for route {"source":"/hello","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}],"headers":[{"key":"x-hello","value":"world"}]}`
)
expect(stderr).toContain(`Valid \`has\` object shape is {`)
expect(stderr).not.toContain('/valid-header')
})