2020-03-29 19:17:06 +02:00
|
|
|
import * as pathToRegexp from 'next/dist/compiled/path-to-regexp'
|
2020-04-21 14:21:41 +02:00
|
|
|
import { parse as parseUrl } from 'url'
|
2020-01-14 19:28:48 +01:00
|
|
|
import {
|
|
|
|
PERMANENT_REDIRECT_STATUS,
|
|
|
|
TEMPORARY_REDIRECT_STATUS,
|
|
|
|
} from '../next-server/lib/constants'
|
2019-12-19 17:48:34 +01:00
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
export type Rewrite = {
|
|
|
|
source: string
|
|
|
|
destination: string
|
|
|
|
}
|
|
|
|
|
|
|
|
export type Redirect = Rewrite & {
|
|
|
|
statusCode?: number
|
2020-01-14 19:28:48 +01:00
|
|
|
permanent?: boolean
|
2019-12-10 15:54:56 +01:00
|
|
|
}
|
|
|
|
|
2020-01-01 13:47:58 +01:00
|
|
|
export type Header = {
|
|
|
|
source: string
|
|
|
|
headers: Array<{ key: string; value: string }>
|
|
|
|
}
|
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
const allowedStatusCodes = new Set([301, 302, 303, 307, 308])
|
|
|
|
|
2020-05-19 10:59:03 +02:00
|
|
|
export function getRedirectStatus(route: Redirect): number {
|
2020-01-14 19:28:48 +01:00
|
|
|
return (
|
|
|
|
route.statusCode ||
|
|
|
|
(route.permanent ? PERMANENT_REDIRECT_STATUS : TEMPORARY_REDIRECT_STATUS)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2020-05-19 10:59:03 +02:00
|
|
|
function checkRedirect(
|
|
|
|
route: Redirect
|
|
|
|
): { invalidParts: string[]; hadInvalidStatus: boolean } {
|
2020-01-01 13:47:58 +01:00
|
|
|
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`)
|
|
|
|
}
|
2020-01-14 19:28:48 +01:00
|
|
|
if (typeof route.permanent !== 'boolean' && !route.statusCode) {
|
|
|
|
invalidParts.push(`\`permanent\` is not set to \`true\` or \`false\``)
|
|
|
|
}
|
|
|
|
|
2020-01-01 13:47:58 +01:00
|
|
|
return {
|
|
|
|
invalidParts,
|
|
|
|
hadInvalidStatus,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-19 10:59:03 +02:00
|
|
|
function checkHeader(route: Header): string[] {
|
2020-01-01 13:47:58 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-04-21 14:21:41 +02:00
|
|
|
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)
|
2020-05-18 21:24:37 +02:00
|
|
|
routePath = `${parsedDestination.pathname!}${
|
|
|
|
parsedDestination.hash || ''
|
|
|
|
}`
|
2020-04-21 14:21:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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 err.sh 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}\` ` +
|
2020-05-27 23:51:11 +02:00
|
|
|
`https://err.sh/vercel/next.js/invalid-route-source\n` +
|
2020-04-21 14:21:41 +02:00
|
|
|
`Reason: ${err.message}\n\n` +
|
|
|
|
` ${routePath}\n` +
|
|
|
|
` ${new Array(position).fill(' ').join('')}^\n`
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
console.error(
|
2020-05-27 23:51:11 +02:00
|
|
|
`\nError parsing ${route} https://err.sh/vercel/next.js/invalid-route-source`,
|
2020-04-21 14:21:41 +02:00
|
|
|
err
|
|
|
|
)
|
|
|
|
}
|
|
|
|
result.error = true
|
|
|
|
}
|
|
|
|
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2020-01-01 13:47:58 +01:00
|
|
|
export type RouteType = 'rewrite' | 'redirect' | 'header'
|
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
export default function checkCustomRoutes(
|
2020-01-01 13:47:58 +01:00
|
|
|
routes: Redirect[] | Header[] | Rewrite[],
|
|
|
|
type: RouteType
|
2019-12-10 15:54:56 +01:00
|
|
|
): void {
|
2020-02-24 23:01:02 +01:00
|
|
|
if (!Array.isArray(routes)) {
|
|
|
|
throw new Error(
|
|
|
|
`${type}s must return an array, received ${typeof routes}.\n` +
|
|
|
|
`See here for more info: https://err.sh/next.js/routes-must-be-array`
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
let numInvalidRoutes = 0
|
|
|
|
let hadInvalidStatus = false
|
2020-01-01 13:47:58 +01:00
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
const isRedirect = type === 'redirect'
|
2020-01-01 13:47:58 +01:00
|
|
|
let allowedKeys: Set<string>
|
|
|
|
|
|
|
|
if (type === 'rewrite' || isRedirect) {
|
|
|
|
allowedKeys = new Set([
|
|
|
|
'source',
|
|
|
|
'destination',
|
2020-01-14 19:28:48 +01:00
|
|
|
...(isRedirect ? ['statusCode', 'permanent'] : []),
|
2020-01-01 13:47:58 +01:00
|
|
|
])
|
|
|
|
} else {
|
|
|
|
allowedKeys = new Set(['source', 'headers'])
|
|
|
|
}
|
2019-12-10 15:54:56 +01:00
|
|
|
|
|
|
|
for (const route of routes) {
|
2020-01-23 18:08:25 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
const keys = Object.keys(route)
|
2020-05-18 21:24:37 +02:00
|
|
|
const invalidKeys = keys.filter((key) => !allowedKeys.has(key))
|
2020-01-01 13:47:58 +01:00
|
|
|
const invalidParts: string[] = []
|
2019-12-10 15:54:56 +01:00
|
|
|
|
|
|
|
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 /')
|
|
|
|
}
|
|
|
|
|
2020-01-01 13:47:58 +01:00
|
|
|
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')
|
2020-02-04 20:08:03 +01:00
|
|
|
} else if (
|
|
|
|
type === 'rewrite' &&
|
|
|
|
!_route.destination.match(/^(\/|https:\/\/|http:\/\/)/)
|
|
|
|
) {
|
|
|
|
invalidParts.push(
|
|
|
|
'`destination` does not start with `/`, `http://`, or `https://`'
|
|
|
|
)
|
2020-01-01 13:47:58 +01:00
|
|
|
}
|
2019-12-10 15:54:56 +01:00
|
|
|
}
|
|
|
|
|
2020-01-01 13:47:58 +01:00
|
|
|
if (type === 'redirect') {
|
|
|
|
const result = checkRedirect(route as Redirect)
|
2020-01-14 19:28:48 +01:00
|
|
|
hadInvalidStatus = hadInvalidStatus || result.hadInvalidStatus
|
2020-01-01 13:47:58 +01:00
|
|
|
invalidParts.push(...result.invalidParts)
|
2019-12-10 15:54:56 +01:00
|
|
|
}
|
|
|
|
|
2020-03-10 21:09:35 +01:00
|
|
|
let sourceTokens: pathToRegexp.Token[] | undefined
|
|
|
|
|
2020-01-22 11:16:13 +01:00
|
|
|
if (typeof route.source === 'string' && route.source.startsWith('/')) {
|
2020-01-01 13:47:58 +01:00
|
|
|
// only show parse error if we didn't already show error
|
|
|
|
// for not being a string
|
2020-04-21 14:21:41 +02:00
|
|
|
const { tokens, error } = tryParsePath(route.source)
|
|
|
|
|
|
|
|
if (error) {
|
2020-01-22 11:16:13 +01:00
|
|
|
invalidParts.push('`source` parse failed')
|
2020-01-01 13:47:58 +01:00
|
|
|
}
|
2020-04-21 14:21:41 +02:00
|
|
|
sourceTokens = tokens
|
2019-12-19 17:48:34 +01:00
|
|
|
}
|
|
|
|
|
2020-03-10 21:09:35 +01:00
|
|
|
// 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') {
|
2020-04-21 14:21:41 +02:00
|
|
|
const unnamedIndex = new RegExp(`:${token.name}(?!\\d)`)
|
|
|
|
if ((route as Rewrite).destination.match(unnamedIndex)) {
|
|
|
|
unnamedInDest.add(`:${token.name}`)
|
2020-03-10 21:09:35 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (unnamedInDest.size > 0) {
|
|
|
|
invalidParts.push(
|
|
|
|
`\`destination\` has unnamed params ${[...unnamedInDest].join(
|
|
|
|
', '
|
|
|
|
)}`
|
|
|
|
)
|
2020-04-21 14:21:41 +02:00
|
|
|
} 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
|
2020-05-18 21:24:37 +02:00
|
|
|
.map((item) => typeof item === 'object' && item.name)
|
2020-04-21 14:21:41 +02:00
|
|
|
.filter(Boolean)
|
|
|
|
)
|
|
|
|
const invalidDestSegments = new Set()
|
|
|
|
|
|
|
|
for (const token of destTokens!) {
|
|
|
|
if (
|
|
|
|
typeof token === 'object' &&
|
|
|
|
!sourceSegments.has(token.name)
|
|
|
|
) {
|
|
|
|
invalidDestSegments.add(token.name)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (invalidDestSegments.size) {
|
|
|
|
invalidParts.push(
|
|
|
|
`\`destination\` has segments not in \`source\` (${[
|
|
|
|
...invalidDestSegments,
|
|
|
|
].join(', ')})`
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2020-03-10 21:09:35 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-10 15:54:56 +01:00
|
|
|
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)}`
|
|
|
|
)
|
|
|
|
numInvalidRoutes++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (numInvalidRoutes > 0) {
|
|
|
|
if (hadInvalidStatus) {
|
|
|
|
console.error(
|
|
|
|
`\nValid redirect statusCode values are ${[...allowedStatusCodes].join(
|
|
|
|
', '
|
|
|
|
)}`
|
|
|
|
)
|
|
|
|
}
|
|
|
|
console.error()
|
|
|
|
|
|
|
|
throw new Error(`Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`)
|
|
|
|
}
|
|
|
|
}
|