rsnext/packages/next/lib/load-custom-routes.ts
JJ Kasper aab6b37e50
Omit un-necessary stack trace from invalid routes (#24240)
This removes the extra stack trace from throwing an error instead of logging and then exiting since the stack trace doesn't provide any additional information that is helpful for debugging in this case. 

<details>

<summary>Before screenshot</summary>

![image](https://user-images.githubusercontent.com/22380829/115301794-4f3b1280-a127-11eb-8a0d-0797efb8fc9f.png)


</details>

<details>

<summary>After screenshot</summary>

<img width="962" alt="Screen Shot 2021-04-19 at 3 53 45 PM" src="https://user-images.githubusercontent.com/22380829/115301901-6ed23b00-a127-11eb-83f9-e3f4cf0ed8fe.png">


</details>
2021-04-20 02:03:46 +00:00

671 lines
18 KiB
TypeScript

import { parse as parseUrl } from 'url'
import { NextConfig } from '../next-server/server/config'
import * as pathToRegexp from 'next/dist/compiled/path-to-regexp'
import escapeStringRegexp from 'next/dist/compiled/escape-string-regexp'
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'
// @ts-ignore
import Lexer from 'next/dist/compiled/regexr-lexer/lexer'
// @ts-ignore
import lexerProfiles from 'next/dist/compiled/regexr-lexer/profiles'
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?: number
permanent?: boolean
}
export const allowedStatusCodes = new Set([301, 302, 303, 307, 308])
const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host'])
export function getRedirectStatus(route: {
statusCode?: number
permanent?: boolean
}): number {
return (
route.statusCode ||
(route.permanent ? PERMANENT_REDIRECT_STATUS : TEMPORARY_REDIRECT_STATUS)
)
}
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 {
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
}
type ParseAttemptResult = {
error?: boolean
tokens?: pathToRegexp.Token[]
}
function tryParsePath(route: string, handleUrl?: boolean): ParseAttemptResult {
const result: ParseAttemptResult = {}
let routePath = route
try {
if (handleUrl) {
const parsedDestination = parseUrl(route, true)
routePath = `${parsedDestination.pathname!}${
parsedDestination.hash || ''
}`
}
// Make sure we can parse the source properly
result.tokens = pathToRegexp.parse(routePath)
pathToRegexp.tokensToRegexp(result.tokens)
} catch (err) {
// If there is an error show our error link but still show original error or a formatted one if we can
const errMatches = err.message.match(/at (\d{0,})/)
if (errMatches) {
const position = parseInt(errMatches[1], 10)
console.error(
`\nError parsing \`${route}\` ` +
`https://nextjs.org/docs/messages/invalid-route-source\n` +
`Reason: ${err.message}\n\n` +
` ${routePath}\n` +
` ${new Array(position).fill(' ').join('')}^\n`
)
} else {
console.error(
`\nError parsing ${route} https://nextjs.org/docs/messages/invalid-route-source`,
err
)
}
result.error = true
}
return result
}
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
): 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', 'basePath', 'locale', 'has'])
if (type === 'rewrite') {
allowedKeys.add('destination')
}
if (type === 'redirect') {
allowedKeys.add('statusCode')
allowedKeys.add('permanent')
allowedKeys.add('destination')
}
if (type === 'header') {
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\` 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 (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) {
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') {
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 {
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: pathToRegexp.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 } = tryParsePath(route.source)
if (error) {
invalidParts.push('`source` parse failed')
}
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) {
const matcher = new RegExp(`^${hasItem.value}$`)
const lexer = new Lexer()
lexer.profile = lexerProfiles.js
lexer.parse(`/${matcher.source}/`)
Object.keys(lexer.namedGroups).forEach((groupKey) => {
hasSegments.add(groupKey)
})
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,
error: destinationParseFailed,
} = tryParsePath((route as Rewrite).destination, true)
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) {
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}`
if (r.destination) {
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()
checkCustomRoutes(redirects, 'redirect')
return processRoutes(redirects, config, 'redirect')
}
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
}
checkCustomRoutes(beforeFiles, 'rewrite')
checkCustomRoutes(afterFiles, 'rewrite')
checkCustomRoutes(fallback, 'rewrite')
return {
beforeFiles: processRoutes(beforeFiles, config, 'rewrite'),
afterFiles: processRoutes(afterFiles, config, 'rewrite'),
fallback: processRoutes(fallback, config, 'rewrite'),
}
}
async function loadHeaders(config: NextConfig) {
if (typeof config.headers !== 'function') {
return []
}
let headers = await config.headers()
checkCustomRoutes(headers, 'header')
return processRoutes(headers, config, 'header')
}
export default async function loadCustomRoutes(
config: NextConfig
): Promise<CustomRoutes> {
const [headers, rewrites, redirects] = await Promise.all([
loadHeaders(config),
loadRewrites(config),
loadRedirects(config),
])
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,
}
}