8d4840b15a
This applies the experimental configs for testing and also fixes `set-cookie` headers from middleware/edge functions being merged unexpectedly. x-ref: [slack thread](https://vercel.slack.com/archives/CGU8HUTUH/p1664313529422279) Fixes: https://github.com/vercel/next.js/issues/40820 ## Bug - [x] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md`
681 lines
19 KiB
TypeScript
681 lines
19 KiB
TypeScript
import type { NextConfig } from '../server/config'
|
|
import type { Token } from 'next/dist/compiled/path-to-regexp'
|
|
|
|
import chalk from './chalk'
|
|
import { escapeStringRegexp } from '../shared/lib/escape-regexp'
|
|
import { tryToParsePath } from './try-to-parse-path'
|
|
import { allowedStatusCodes } from './redirect-status'
|
|
|
|
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 = {
|
|
source: string
|
|
basePath?: false
|
|
locale?: false
|
|
headers: Array<{ key: string; value: string }>
|
|
has?: RouteHas[]
|
|
}
|
|
|
|
// internal type used for validation (not user facing)
|
|
export type Redirect = {
|
|
source: string
|
|
destination: string
|
|
basePath?: false
|
|
locale?: false
|
|
has?: RouteHas[]
|
|
} & (
|
|
| {
|
|
statusCode?: never
|
|
permanent: boolean
|
|
}
|
|
| {
|
|
statusCode: number
|
|
permanent?: never
|
|
}
|
|
)
|
|
|
|
export type Middleware = {
|
|
source: string
|
|
locale?: false
|
|
has?: RouteHas[]
|
|
}
|
|
|
|
const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host'])
|
|
const namedGroupsRegex = /\(\?<([a-zA-Z][a-zA-Z0-9]*)>/g
|
|
|
|
export function normalizeRouteRegex(regex: string) {
|
|
// clean up un-necessary escaping from regex.source which turns / into \\/
|
|
return regex.replace(/\\\//g, '/')
|
|
}
|
|
|
|
function checkRedirect(route: Redirect): {
|
|
invalidParts: string[]
|
|
hadInvalidStatus: boolean
|
|
} {
|
|
const invalidParts: string[] = []
|
|
let hadInvalidStatus: boolean = false
|
|
|
|
if (route.statusCode && !allowedStatusCodes.has(route.statusCode)) {
|
|
hadInvalidStatus = true
|
|
invalidParts.push(`\`statusCode\` is not undefined or valid statusCode`)
|
|
}
|
|
if (typeof route.permanent !== 'boolean' && !route.statusCode) {
|
|
invalidParts.push(`\`permanent\` is not set to \`true\` or \`false\``)
|
|
}
|
|
|
|
return {
|
|
invalidParts,
|
|
hadInvalidStatus,
|
|
}
|
|
}
|
|
|
|
function checkHeader(route: Header): string[] {
|
|
const invalidParts: string[] = []
|
|
|
|
if (!Array.isArray(route.headers)) {
|
|
invalidParts.push('`headers` field must be an array')
|
|
} else if (route.headers.length === 0) {
|
|
invalidParts.push('`headers` field cannot be empty')
|
|
} else {
|
|
for (const header of route.headers) {
|
|
if (!header || typeof header !== 'object') {
|
|
invalidParts.push(
|
|
"`headers` items must be object with { key: '', value: '' }"
|
|
)
|
|
break
|
|
}
|
|
if (typeof header.key !== 'string') {
|
|
invalidParts.push('`key` in header item must be string')
|
|
break
|
|
}
|
|
if (typeof header.value !== 'string') {
|
|
invalidParts.push('`value` in header item must be string')
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return invalidParts
|
|
}
|
|
|
|
export type RouteType = 'rewrite' | 'redirect' | 'header'
|
|
|
|
export function checkCustomRoutes(
|
|
routes: Redirect[] | Header[] | Rewrite[] | Middleware[],
|
|
type: RouteType | 'middleware'
|
|
): void {
|
|
if (!Array.isArray(routes)) {
|
|
console.error(
|
|
`Error: ${type}s must return an array, received ${typeof routes}.\n` +
|
|
`See here for more info: https://nextjs.org/docs/messages/routes-must-be-array`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
let numInvalidRoutes = 0
|
|
let hadInvalidStatus = false
|
|
let hadInvalidHas = false
|
|
|
|
const allowedKeys = new Set<string>(['source', 'locale', 'has'])
|
|
|
|
if (type === 'rewrite') {
|
|
allowedKeys.add('basePath')
|
|
allowedKeys.add('destination')
|
|
}
|
|
if (type === 'redirect') {
|
|
allowedKeys.add('basePath')
|
|
allowedKeys.add('statusCode')
|
|
allowedKeys.add('permanent')
|
|
allowedKeys.add('destination')
|
|
}
|
|
if (type === 'header') {
|
|
allowedKeys.add('basePath')
|
|
allowedKeys.add('headers')
|
|
}
|
|
|
|
for (const route of routes) {
|
|
if (!route || typeof route !== 'object') {
|
|
console.error(
|
|
`The route ${JSON.stringify(
|
|
route
|
|
)} is not a valid object with \`source\`${
|
|
type !== 'middleware'
|
|
? ` and \`${type === 'header' ? 'headers' : 'destination'}\``
|
|
: ''
|
|
}`
|
|
)
|
|
numInvalidRoutes++
|
|
continue
|
|
}
|
|
|
|
if (
|
|
type === 'rewrite' &&
|
|
(route as Rewrite).basePath === false &&
|
|
!(
|
|
(route as Rewrite).destination.startsWith('http://') ||
|
|
(route as Rewrite).destination.startsWith('https://')
|
|
)
|
|
) {
|
|
console.error(
|
|
`The route ${
|
|
(route as Rewrite).source
|
|
} rewrites urls outside of the basePath. Please use a destination that starts with \`http://\` or \`https://\` https://nextjs.org/docs/messages/invalid-external-rewrite`
|
|
)
|
|
numInvalidRoutes++
|
|
continue
|
|
}
|
|
|
|
const keys = Object.keys(route)
|
|
const invalidKeys = keys.filter((key) => !allowedKeys.has(key))
|
|
const invalidParts: string[] = []
|
|
|
|
if (
|
|
'basePath' in route &&
|
|
typeof route.basePath !== 'undefined' &&
|
|
route.basePath !== false
|
|
) {
|
|
invalidParts.push('`basePath` must be undefined or false')
|
|
}
|
|
|
|
if (typeof route.locale !== 'undefined' && route.locale !== false) {
|
|
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) {
|
|
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') {
|
|
invalidParts.push('`source` is not a string')
|
|
} else if (!route.source.startsWith('/')) {
|
|
invalidParts.push('`source` does not start with /')
|
|
}
|
|
|
|
if (type === 'header') {
|
|
invalidParts.push(...checkHeader(route as Header))
|
|
} else if (type !== 'middleware') {
|
|
let _route = route as Rewrite | Redirect
|
|
if (!_route.destination) {
|
|
invalidParts.push('`destination` is missing')
|
|
} else if (typeof _route.destination !== 'string') {
|
|
invalidParts.push('`destination` is not a string')
|
|
} else if (
|
|
type === 'rewrite' &&
|
|
!_route.destination.match(/^(\/|https:\/\/|http:\/\/)/)
|
|
) {
|
|
invalidParts.push(
|
|
'`destination` does not start with `/`, `http://`, or `https://`'
|
|
)
|
|
}
|
|
}
|
|
|
|
if (type === 'redirect') {
|
|
const result = checkRedirect(route as Redirect)
|
|
hadInvalidStatus = hadInvalidStatus || result.hadInvalidStatus
|
|
invalidParts.push(...result.invalidParts)
|
|
}
|
|
|
|
let sourceTokens: Token[] | undefined
|
|
|
|
if (typeof route.source === 'string' && route.source.startsWith('/')) {
|
|
// only show parse error if we didn't already show error
|
|
// for not being a string
|
|
const { tokens, error, regexStr } = tryToParsePath(route.source)
|
|
|
|
if (error) {
|
|
invalidParts.push('`source` parse failed')
|
|
}
|
|
|
|
if (regexStr && regexStr.length > 4096) {
|
|
invalidParts.push('`source` exceeds max built length of 4096')
|
|
}
|
|
|
|
sourceTokens = tokens
|
|
}
|
|
const hasSegments = new Set<string>()
|
|
|
|
if (route.has) {
|
|
for (const hasItem of route.has) {
|
|
if (!hasItem.value && hasItem.key) {
|
|
hasSegments.add(hasItem.key)
|
|
}
|
|
|
|
if (hasItem.value) {
|
|
for (const match of hasItem.value.matchAll(namedGroupsRegex)) {
|
|
if (match[1]) {
|
|
hasSegments.add(match[1])
|
|
}
|
|
}
|
|
|
|
if (hasItem.type === 'host') {
|
|
hasSegments.add('host')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// make sure no unnamed patterns are attempted to be used in the
|
|
// destination as this can cause confusion and is not allowed
|
|
if (typeof (route as Rewrite).destination === 'string') {
|
|
if (
|
|
(route as Rewrite).destination.startsWith('/') &&
|
|
Array.isArray(sourceTokens)
|
|
) {
|
|
const unnamedInDest = new Set()
|
|
|
|
for (const token of sourceTokens) {
|
|
if (typeof token === 'object' && typeof token.name === 'number') {
|
|
const unnamedIndex = new RegExp(`:${token.name}(?!\\d)`)
|
|
if ((route as Rewrite).destination.match(unnamedIndex)) {
|
|
unnamedInDest.add(`:${token.name}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (unnamedInDest.size > 0) {
|
|
invalidParts.push(
|
|
`\`destination\` has unnamed params ${[...unnamedInDest].join(
|
|
', '
|
|
)}`
|
|
)
|
|
} else {
|
|
const {
|
|
tokens: destTokens,
|
|
regexStr: destRegexStr,
|
|
error: destinationParseFailed,
|
|
} = tryToParsePath((route as Rewrite).destination, {
|
|
handleUrl: true,
|
|
})
|
|
|
|
if (destRegexStr && destRegexStr.length > 4096) {
|
|
invalidParts.push('`destination` exceeds max built length of 4096')
|
|
}
|
|
|
|
if (destinationParseFailed) {
|
|
invalidParts.push('`destination` parse failed')
|
|
} else {
|
|
const sourceSegments = new Set(
|
|
sourceTokens
|
|
.map((item) => typeof item === 'object' && item.name)
|
|
.filter(Boolean)
|
|
)
|
|
const invalidDestSegments = new Set()
|
|
|
|
for (const token of destTokens!) {
|
|
if (
|
|
typeof token === 'object' &&
|
|
!sourceSegments.has(token.name) &&
|
|
!hasSegments.has(token.name as string)
|
|
) {
|
|
invalidDestSegments.add(token.name)
|
|
}
|
|
}
|
|
|
|
if (invalidDestSegments.size) {
|
|
invalidParts.push(
|
|
`\`destination\` has segments not in \`source\` or \`has\` (${[
|
|
...invalidDestSegments,
|
|
].join(', ')})`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasInvalidKeys = invalidKeys.length > 0
|
|
const hasInvalidParts = invalidParts.length > 0
|
|
|
|
if (hasInvalidKeys || hasInvalidParts) {
|
|
console.error(
|
|
`${invalidParts.join(', ')}${
|
|
invalidKeys.length
|
|
? (hasInvalidParts ? ',' : '') +
|
|
` invalid field${invalidKeys.length === 1 ? '' : 's'}: ` +
|
|
invalidKeys.join(',')
|
|
: ''
|
|
} for route ${JSON.stringify(route)}`
|
|
)
|
|
console.error()
|
|
numInvalidRoutes++
|
|
}
|
|
}
|
|
|
|
if (numInvalidRoutes > 0) {
|
|
if (hadInvalidStatus) {
|
|
console.error(
|
|
`\nValid redirect statusCode values are ${[...allowedStatusCodes].join(
|
|
', '
|
|
)}`
|
|
)
|
|
}
|
|
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()
|
|
console.error(
|
|
`Error: Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
export interface CustomRoutes {
|
|
headers: Header[]
|
|
rewrites: {
|
|
fallback: Rewrite[]
|
|
afterFiles: Rewrite[]
|
|
beforeFiles: Rewrite[]
|
|
}
|
|
redirects: Redirect[]
|
|
}
|
|
|
|
function processRoutes<T>(
|
|
routes: T,
|
|
config: NextConfig,
|
|
type: 'redirect' | 'rewrite' | 'header'
|
|
): T {
|
|
const _routes = routes as any as Array<{
|
|
source: string
|
|
locale?: false
|
|
basePath?: false
|
|
destination?: string
|
|
}>
|
|
const newRoutes: typeof _routes = []
|
|
const defaultLocales: Array<{
|
|
locale: string
|
|
base: string
|
|
}> = []
|
|
|
|
if (config.i18n && type === 'redirect') {
|
|
for (const item of config.i18n?.domains || []) {
|
|
defaultLocales.push({
|
|
locale: item.defaultLocale,
|
|
base: `http${item.http ? '' : 's'}://${item.domain}`,
|
|
})
|
|
}
|
|
|
|
defaultLocales.push({
|
|
locale: config.i18n.defaultLocale,
|
|
base: '',
|
|
})
|
|
}
|
|
|
|
for (const r of _routes) {
|
|
const srcBasePath =
|
|
config.basePath && r.basePath !== false ? config.basePath : ''
|
|
const isExternal = !r.destination?.startsWith('/')
|
|
const destBasePath = srcBasePath && !isExternal ? srcBasePath : ''
|
|
|
|
if (config.i18n && r.locale !== false) {
|
|
if (!isExternal) {
|
|
defaultLocales.forEach((item) => {
|
|
let destination
|
|
|
|
if (r.destination) {
|
|
destination = item.base
|
|
? `${item.base}${destBasePath}${r.destination}`
|
|
: `${destBasePath}${r.destination}`
|
|
}
|
|
|
|
newRoutes.push({
|
|
...r,
|
|
destination,
|
|
source: `${srcBasePath}/${item.locale}${r.source}`,
|
|
})
|
|
})
|
|
}
|
|
|
|
r.source = `/:nextInternalLocale(${config.i18n.locales
|
|
.map((locale: string) => escapeStringRegexp(locale))
|
|
.join('|')})${
|
|
r.source === '/' && !config.trailingSlash ? '' : r.source
|
|
}`
|
|
|
|
if (r.destination && r.destination?.startsWith('/')) {
|
|
r.destination = `/:nextInternalLocale${
|
|
r.destination === '/' && !config.trailingSlash ? '' : r.destination
|
|
}`
|
|
}
|
|
}
|
|
r.source = `${srcBasePath}${
|
|
r.source === '/' && srcBasePath ? '' : r.source
|
|
}`
|
|
|
|
if (r.destination) {
|
|
r.destination = `${destBasePath}${
|
|
r.destination === '/' && destBasePath ? '' : r.destination
|
|
}`
|
|
}
|
|
newRoutes.push(r)
|
|
}
|
|
return newRoutes as any as T
|
|
}
|
|
|
|
async function loadRedirects(config: NextConfig) {
|
|
if (typeof config.redirects !== 'function') {
|
|
return []
|
|
}
|
|
let redirects = await config.redirects()
|
|
// check before we process the routes and after to ensure
|
|
// they are still valid
|
|
checkCustomRoutes(redirects, 'redirect')
|
|
|
|
redirects = processRoutes(redirects, config, 'redirect')
|
|
checkCustomRoutes(redirects, 'redirect')
|
|
return redirects
|
|
}
|
|
|
|
async function loadRewrites(config: NextConfig) {
|
|
if (typeof config.rewrites !== 'function') {
|
|
return {
|
|
beforeFiles: [],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}
|
|
}
|
|
const _rewrites = await config.rewrites()
|
|
let beforeFiles: Rewrite[] = []
|
|
let afterFiles: Rewrite[] = []
|
|
let fallback: Rewrite[] = []
|
|
|
|
if (
|
|
!Array.isArray(_rewrites) &&
|
|
typeof _rewrites === 'object' &&
|
|
Object.keys(_rewrites).every(
|
|
(key) =>
|
|
key === 'beforeFiles' || key === 'afterFiles' || key === 'fallback'
|
|
)
|
|
) {
|
|
beforeFiles = _rewrites.beforeFiles || []
|
|
afterFiles = _rewrites.afterFiles || []
|
|
fallback = _rewrites.fallback || []
|
|
} else {
|
|
afterFiles = _rewrites as any
|
|
}
|
|
// check before we process the routes and after to ensure
|
|
// they are still valid
|
|
checkCustomRoutes(beforeFiles, 'rewrite')
|
|
checkCustomRoutes(afterFiles, 'rewrite')
|
|
checkCustomRoutes(fallback, 'rewrite')
|
|
|
|
beforeFiles = processRoutes(beforeFiles, config, 'rewrite')
|
|
afterFiles = processRoutes(afterFiles, config, 'rewrite')
|
|
fallback = processRoutes(fallback, config, 'rewrite')
|
|
|
|
checkCustomRoutes(beforeFiles, 'rewrite')
|
|
checkCustomRoutes(afterFiles, 'rewrite')
|
|
checkCustomRoutes(fallback, 'rewrite')
|
|
|
|
return {
|
|
beforeFiles,
|
|
afterFiles,
|
|
fallback,
|
|
}
|
|
}
|
|
|
|
async function loadHeaders(config: NextConfig) {
|
|
if (typeof config.headers !== 'function') {
|
|
return []
|
|
}
|
|
let headers = await config.headers()
|
|
// check before we process the routes and after to ensure
|
|
// they are still valid
|
|
checkCustomRoutes(headers, 'header')
|
|
|
|
headers = processRoutes(headers, config, 'header')
|
|
checkCustomRoutes(headers, 'header')
|
|
return headers
|
|
}
|
|
|
|
export default async function loadCustomRoutes(
|
|
config: NextConfig
|
|
): Promise<CustomRoutes> {
|
|
const [headers, rewrites, redirects] = await Promise.all([
|
|
loadHeaders(config),
|
|
loadRewrites(config),
|
|
loadRedirects(config),
|
|
])
|
|
|
|
const totalRewrites =
|
|
rewrites.beforeFiles.length +
|
|
rewrites.afterFiles.length +
|
|
rewrites.fallback.length
|
|
|
|
const totalRoutes = headers.length + redirects.length + totalRewrites
|
|
|
|
if (totalRoutes > 1000) {
|
|
console.warn(
|
|
chalk.bold.yellow(`Warning: `) +
|
|
`total number of custom routes exceeds 1000, this can reduce performance. Route counts:\n` +
|
|
`headers: ${headers.length}\n` +
|
|
`rewrites: ${totalRewrites}\n` +
|
|
`redirects: ${redirects.length}\n` +
|
|
`See more info: https://nextjs.org/docs/messages/max-custom-routes-reached`
|
|
)
|
|
}
|
|
|
|
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: '/:path+/',
|
|
destination: '/:path+',
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
headers,
|
|
rewrites,
|
|
redirects,
|
|
}
|
|
}
|