提交 ad7bb4e3 编写于 作者: J JJ Kasper 提交者: Tim Neutkens

Add headers support to custom-routes (#9879)

* Add headers support to custom-routes

* Update manifest version test

* Add headers field for dynamic routes test

* Update test
上级 7bf6aca7
......@@ -9,7 +9,12 @@ import path from 'path'
import { pathToRegexp } from 'path-to-regexp'
import { promisify } from 'util'
import formatWebpackMessages from '../client/dev/error-overlay/format-webpack-messages'
import checkCustomRoutes from '../lib/check-custom-routes'
import checkCustomRoutes, {
RouteType,
Redirect,
Rewrite,
Header,
} from '../lib/check-custom-routes'
import { PUBLIC_DIR_MIDDLEWARE_CONFLICT } from '../lib/constants'
import { findPagesDir } from '../lib/find-pages-dir'
import { recursiveDelete } from '../lib/recursive-delete'
......@@ -99,8 +104,9 @@ export default async function build(dir: string, conf = null): Promise<void> {
const { target } = config
const buildId = await generateBuildId(config.generateBuildId, nanoid)
const distDir = path.join(dir, config.distDir)
const rewrites = []
const redirects = []
const rewrites: Rewrite[] = []
const redirects: Redirect[] = []
const headers: Header[] = []
if (typeof config.experimental.redirects === 'function') {
redirects.push(...(await config.experimental.redirects()))
......@@ -110,6 +116,10 @@ export default async function build(dir: string, conf = null): Promise<void> {
rewrites.push(...(await config.experimental.rewrites()))
checkCustomRoutes(rewrites, 'rewrite')
}
if (typeof config.experimental.headers === 'function') {
headers.push(...(await config.experimental.headers()))
checkCustomRoutes(headers, 'header')
}
if (ciEnvironment.isCI) {
const cacheDir = path.join(distDir, 'cache')
......@@ -223,7 +233,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
source: string
statusCode?: number
},
isRedirect = false
type: RouteType
) => {
const keys: any[] = []
const routeRegex = pathToRegexp(r.source, keys, {
......@@ -234,7 +244,7 @@ export default async function build(dir: string, conf = null): Promise<void> {
return {
...r,
...(isRedirect
...(type === 'redirect'
? {
statusCode: r.statusCode || DEFAULT_REDIRECT_STATUS,
}
......@@ -250,8 +260,9 @@ export default async function build(dir: string, conf = null): Promise<void> {
JSON.stringify({
version: 1,
basePath: config.experimental.basePath,
redirects: redirects.map(r => buildCustomRoute(r, true)),
rewrites: rewrites.map(r => buildCustomRoute(r)),
redirects: redirects.map(r => buildCustomRoute(r, 'redirect')),
rewrites: rewrites.map(r => buildCustomRoute(r, 'rewrite')),
headers: headers.map(r => buildCustomRoute(r, 'header')),
dynamicRoutes: getSortedRoutes(dynamicRoutes).map(page => ({
page,
regex: getRouteRegex(page).re.source,
......
......@@ -9,27 +9,80 @@ export type Redirect = Rewrite & {
statusCode?: number
}
export type Header = {
source: string
headers: Array<{ key: string; value: string }>
}
const allowedStatusCodes = new Set([301, 302, 303, 307, 308])
function checkRedirect(route: Redirect) {
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`)
}
return {
invalidParts,
hadInvalidStatus,
}
}
function checkHeader(route: Header) {
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
}
export type RouteType = 'rewrite' | 'redirect' | 'header'
export default function checkCustomRoutes(
routes: Array<Rewrite | Redirect>,
type: 'redirect' | 'rewrite'
routes: Redirect[] | Header[] | Rewrite[],
type: RouteType
): void {
let numInvalidRoutes = 0
let hadInvalidStatus = false
const isRedirect = type === 'redirect'
const allowedKeys = new Set([
'source',
'destination',
...(isRedirect ? ['statusCode'] : []),
])
let allowedKeys: Set<string>
if (type === 'rewrite' || isRedirect) {
allowedKeys = new Set([
'source',
'destination',
...(isRedirect ? ['statusCode'] : []),
])
} else {
allowedKeys = new Set(['source', 'headers'])
}
for (const route of routes) {
const keys = Object.keys(route)
const invalidKeys = keys.filter(key => !allowedKeys.has(key))
const invalidParts = []
const invalidParts: string[] = []
// TODO: investigate allowing RegExp directly
if (!route.source) {
invalidParts.push('`source` is missing')
} else if (typeof route.source !== 'string') {
......@@ -38,35 +91,38 @@ export default function checkCustomRoutes(
invalidParts.push('`source` does not start with /')
}
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.startsWith('/')) {
invalidParts.push('`destination` 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.startsWith('/')) {
invalidParts.push('`destination` does not start with /')
}
}
if (isRedirect) {
const redirRoute = route as Redirect
if (
redirRoute.statusCode &&
!allowedStatusCodes.has(redirRoute.statusCode)
) {
hadInvalidStatus = true
invalidParts.push(`\`statusCode\` is not undefined or valid statusCode`)
}
if (type === 'redirect') {
const result = checkRedirect(route as Redirect)
hadInvalidStatus = result.hadInvalidStatus
invalidParts.push(...result.invalidParts)
}
try {
// Make sure we can parse the source properly
regexpMatch(route.source)
} catch (err) {
// If there is an error show our err.sh but still show original error
console.error(
`\nError parsing ${route.source} https://err.sh/zeit/next.js/invalid-route-source`,
err
)
if (typeof route.source === 'string') {
// only show parse error if we didn't already show error
// for not being a string
try {
// Make sure we can parse the source properly
regexpMatch(route.source)
} catch (err) {
// If there is an error show our err.sh but still show original error
console.error(
`\nError parsing ${route.source} https://err.sh/zeit/next.js/invalid-route-source`,
err
)
}
}
const hasInvalidKeys = invalidKeys.length > 0
......
......@@ -45,7 +45,12 @@ import { sendHTML } from './send-html'
import { serveStatic } from './serve-static'
import { getSprCache, initializeSprCache, setSprCache } from './spr-cache'
import { isBlockedPage } from './utils'
import { Redirect, Rewrite } from '../../lib/check-custom-routes'
import {
Redirect,
Rewrite,
RouteType,
Header,
} from '../../lib/check-custom-routes'
const getCustomRouteMatcher = pathMatch(true)
......@@ -105,6 +110,7 @@ export default class Server {
protected customRoutes?: {
rewrites: Rewrite[]
redirects: Redirect[]
headers: Header[]
}
public constructor({
......@@ -402,17 +408,36 @@ export default class Server {
const routes: Route[] = []
if (this.customRoutes) {
const { redirects, rewrites } = this.customRoutes
const { redirects, rewrites, headers } = this.customRoutes
const getCustomRoute = (
r: { source: string; destination: string; statusCode?: number },
type: 'redirect' | 'rewrite'
r: Rewrite | Redirect | Header,
type: RouteType
) => ({
...r,
type,
matcher: getCustomRouteMatcher(r.source),
})
// Headers come very first
routes.push(
...headers.map(r => {
const route = getCustomRoute(r, 'header')
return {
check: true,
match: route.matcher,
type: route.type,
name: `${route.type} ${route.source} header route`,
fn: async (_req, res, _params, _parsedUrl) => {
for (const header of (route as Header).headers) {
res.setHeader(header.key, header.value)
}
return { finished: false }
},
} as Route
})
)
const customRoutes = [
...redirects.map(r => getCustomRoute(r, 'redirect')),
...rewrites.map(r => getCustomRoute(r, 'rewrite')),
......@@ -424,7 +449,7 @@ export default class Server {
check: true,
match: route.matcher,
type: route.type,
statusCode: route.statusCode,
statusCode: (route as Redirect).statusCode,
name: `${route.type} ${route.source} route`,
fn: async (_req, res, params, _parsedUrl) => {
const parsedDestination = parseUrl(route.destination, true)
......@@ -471,7 +496,8 @@ export default class Server {
search: undefined,
})
)
res.statusCode = route.statusCode || DEFAULT_REDIRECT_STATUS
res.statusCode =
(route as Redirect).statusCode || DEFAULT_REDIRECT_STATUS
res.end()
return {
finished: true,
......
......@@ -315,8 +315,9 @@ export default class DevServer extends Server {
const result = {
redirects: [],
rewrites: [],
headers: [],
}
const { redirects, rewrites } = this.nextConfig.experimental
const { redirects, rewrites, headers } = this.nextConfig.experimental
if (typeof redirects === 'function') {
result.redirects = await redirects()
......@@ -326,6 +327,11 @@ export default class DevServer extends Server {
result.rewrites = await rewrites()
checkCustomRoutes(result.rewrites, 'rewrite')
}
if (typeof headers === 'function') {
result.headers = await headers()
checkCustomRoutes(result.headers, 'header')
}
this.customRoutes = result
}
......
......@@ -122,5 +122,36 @@ module.exports = {
},
]
},
async headers() {
return [
{
source: '/add-header',
headers: [
{
key: 'x-custom-header',
value: 'hello world',
},
{
key: 'x-another-header',
value: 'hello again',
},
],
},
{
source: '/my-headers/(.*)',
headers: [
{
key: 'x-first-header',
value: 'first',
},
{
key: 'x-second-header',
value: 'second',
},
],
},
]
},
},
}
......@@ -217,6 +217,18 @@ const runTests = (isDev = false) => {
expect(location).toBe('https://google.com/')
})
it('should apply headers for exact match', async () => {
const res = await fetchViaHTTP(appPort, '/add-header')
expect(res.headers.get('x-custom-header')).toBe('hello world')
expect(res.headers.get('x-another-header')).toBe('hello again')
})
it('should apply headers for multi match', async () => {
const res = await fetchViaHTTP(appPort, '/my-headers/first')
expect(res.headers.get('x-first-header')).toBe('first')
expect(res.headers.get('x-second-header')).toBe('second')
})
if (!isDev) {
it('should output routes-manifest successfully', async () => {
const manifest = await fs.readJSON(
......@@ -325,6 +337,38 @@ const runTests = (isDev = false) => {
statusCode: 307,
},
],
headers: [
{
headers: [
{
key: 'x-custom-header',
value: 'hello world',
},
{
key: 'x-another-header',
value: 'hello again',
},
],
regex: normalizeRegEx('^\\/add-header$'),
regexKeys: [],
source: '/add-header',
},
{
headers: [
{
key: 'x-first-header',
value: 'first',
},
{
key: 'x-second-header',
value: 'second',
},
],
regex: normalizeRegEx('^\\/my-headers(?:\\/(.*))$'),
regexKeys: [0],
source: '/my-headers/(.*)',
},
],
rewrites: [
{
destination: '/another/one',
......
......@@ -392,6 +392,7 @@ function runTests(dev) {
expect(manifest).toEqual({
version: 1,
basePath: '',
headers: [],
rewrites: [],
redirects: [],
dynamicRoutes: [
......
......@@ -134,6 +134,91 @@ const invalidRewriteAssertions = (stderr = '') => {
expect(stderr).toContain('Invalid rewrites found')
}
const invalidHeaders = [
{
// missing source
headers: [
{
'x-first': 'first',
},
],
},
{
// invalid headers value
source: '/hello',
headers: {
'x-first': 'first',
},
},
{
source: '/again',
headers: [
{
// missing key
value: 'idk',
},
],
},
{
source: '/again',
headers: [
{
// missing value
key: 'idk',
},
],
},
{
// non-allowed destination
source: '/again',
destination: '/another',
headers: [
{
key: 'x-first',
value: 'idk',
},
],
},
{
// valid one
source: '/valid-header',
headers: [
{
key: 'x-first',
value: 'first',
},
{
key: 'x-another',
value: 'again',
},
],
},
]
const invalidHeaderAssertions = (stderr = '') => {
expect(stderr).toContain(
'`source` is missing, `key` in header item must be string for route {"headers":[{"x-first":"first"}]}'
)
expect(stderr).toContain(
'`headers` field must be an array for route {"source":"/hello","headers":{"x-first":"first"}}'
)
expect(stderr).toContain(
'`key` in header item must be string for route {"source":"/again","headers":[{"value":"idk"}]}'
)
expect(stderr).toContain(
'`value` in header item must be string for route {"source":"/again","headers":[{"key":"idk"}]}'
)
expect(stderr).toContain(
'invalid field: destination for route {"source":"/again","destination":"/another","headers":[{"key":"x-first","value":"idk"}]}'
)
expect(stderr).not.toContain('/valid-header')
}
describe('Errors on invalid custom routes', () => {
afterAll(() => fs.remove(nextConfigPath))
......@@ -149,6 +234,12 @@ describe('Errors on invalid custom routes', () => {
invalidRewriteAssertions(stderr)
})
it('should error during next build for invalid headers', async () => {
await writeConfig(invalidHeaders, 'headers')
const { stderr } = await nextBuild(appDir, undefined, { stderr: true })
invalidHeaderAssertions(stderr)
})
it('should error during next dev for invalid redirects', async () => {
await writeConfig(invalidRedirects, 'redirects')
let stderr = ''
......@@ -170,4 +261,15 @@ describe('Errors on invalid custom routes', () => {
})
invalidRewriteAssertions(stderr)
})
it('should error during next dev for invalid headers', async () => {
await writeConfig(invalidHeaders, 'headers')
let stderr = ''
await launchApp(appDir, await findPort(), {
onStderr: msg => {
stderr += msg
},
})
invalidHeaderAssertions(stderr)
})
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册