diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 5f83eb7921204ccf024a2a2ea2e1a566c971ca9b..f7a8c791fd2de9ee6588fb8394d7604e56a89878 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -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 { 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 { 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 { 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 { 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 { 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, diff --git a/packages/next/lib/check-custom-routes.ts b/packages/next/lib/check-custom-routes.ts index 3331a6818ebb6edfd4382dca69f294cd7d9358d2..07f2f3a77c9cae18a76211cff37dc693bea6b289 100644 --- a/packages/next/lib/check-custom-routes.ts +++ b/packages/next/lib/check-custom-routes.ts @@ -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, - 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 + + 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 diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 2b4de2e4d722b59d9ceb2d728321f44be194fae3..03c42f9e5f28a55ef916e1f628069212cfbcc92a 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -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, diff --git a/packages/next/server/next-dev-server.ts b/packages/next/server/next-dev-server.ts index d88bc28240670f90098fcb399d2cc12f56d637f1..6039f714d7039e1b44d0e68408782b9c2c28a905 100644 --- a/packages/next/server/next-dev-server.ts +++ b/packages/next/server/next-dev-server.ts @@ -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 } diff --git a/test/integration/custom-routes/next.config.js b/test/integration/custom-routes/next.config.js index 1d4a491c59671a355d924c6e601b2e68bd7655bf..decf161877d2b9401ef8c083c20fb525b8f0dd73 100644 --- a/test/integration/custom-routes/next.config.js +++ b/test/integration/custom-routes/next.config.js @@ -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', + }, + ], + }, + ] + }, }, } diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 32f5a7ae90dd45bbeb1e1bc401913adaa369ae99..269eded557397b719fd29da45a437f16c25986c2 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -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', diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index fc0563adf967d8621630e51d9471812ff3c57e43..59dbc21b5bc25571723a3725d93af25d0ed5a166 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -392,6 +392,7 @@ function runTests(dev) { expect(manifest).toEqual({ version: 1, basePath: '', + headers: [], rewrites: [], redirects: [], dynamicRoutes: [ diff --git a/test/integration/invalid-custom-routes/test/index.test.js b/test/integration/invalid-custom-routes/test/index.test.js index a8cdbd6f8e752ab9e0e0d8a8659dfbe6470e9bd1..6bd1b3d2ed77c5390d6d7719bad91b7357161ac6 100644 --- a/test/integration/invalid-custom-routes/test/index.test.js +++ b/test/integration/invalid-custom-routes/test/index.test.js @@ -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) + }) })