From 68c97ac69acba89bbce9a050d022089cd1c194ef Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 18 Feb 2021 22:10:13 -0600 Subject: [PATCH 01/11] Add has route field validating --- packages/next/lib/load-custom-routes.ts | 68 +++++++- test/integration/custom-routes/next.config.js | 114 ++++++++++++++ .../invalid-custom-routes/test/index.test.js | 147 ++++++++++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 61b388d1cc872..9ef5a3782b5fe 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -7,11 +7,18 @@ import { TEMPORARY_REDIRECT_STATUS, } from '../next-server/lib/constants' +export type RouteHas = { + type: 'header' | 'query' | 'cookie' + key: string + value?: string +} + export type Rewrite = { source: string destination: string basePath?: false locale?: false + has?: RouteHas[] } export type Header = { @@ -19,6 +26,7 @@ export type Header = { basePath?: false locale?: false headers: Array<{ key: string; value: string }> + has?: RouteHas[] } // internal type used for validation (not user facing) @@ -28,6 +36,7 @@ export type Redirect = Rewrite & { } export const allowedStatusCodes = new Set([301, 302, 303, 307, 308]) +const allowedHasTypes = new Set(['header', 'cookie', 'query']) export function getRedirectStatus(route: { statusCode?: number @@ -150,6 +159,7 @@ function checkCustomRoutes( let numInvalidRoutes = 0 let hadInvalidStatus = false + let hadInvalidHas = false const isRedirect = type === 'redirect' let allowedKeys: Set @@ -161,9 +171,10 @@ function checkCustomRoutes( 'basePath', 'locale', ...(isRedirect ? ['statusCode', 'permanent'] : []), + 'has', ]) } else { - allowedKeys = new Set(['source', 'headers', 'basePath', 'locale']) + allowedKeys = new Set(['source', 'headers', 'basePath', 'locale', 'has']) } for (const route of routes) { @@ -208,6 +219,47 @@ function checkCustomRoutes( 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') { + invalidHasParts.push(`invalid key "${hasItem.key}"`) + } + if ( + typeof hasItem.value !== 'undefined' && + typeof hasItem.value !== 'string' + ) { + invalidHasParts.push(`invalid value "${hasItem.value}"`) + } + + 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') { @@ -327,6 +379,7 @@ function checkCustomRoutes( : '' } for route ${JSON.stringify(route)}` ) + console.error() numInvalidRoutes++ } } @@ -339,6 +392,19 @@ function checkCustomRoutes( )}` ) } + 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() throw new Error(`Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found`) diff --git a/test/integration/custom-routes/next.config.js b/test/integration/custom-routes/next.config.js index 36376f8c3579d..661986582b25d 100644 --- a/test/integration/custom-routes/next.config.js +++ b/test/integration/custom-routes/next.config.js @@ -106,6 +106,38 @@ module.exports = { source: '/catchall-query/:path*', destination: '/with-params?another=:path*', }, + { + source: '/has-rewrite-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + destination: '/with-params?myHeader=:myHeader', + }, + { + source: '/has-rewrite-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + destination: '/with-params?value=:my-query', + }, + { + source: '/has-rewrite-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: 'true', + }, + ], + destination: '/with-params?authorized=1', + }, ] }, async redirects() { @@ -217,6 +249,41 @@ module.exports = { 'https://authserver.example.com/set-password?returnUrl=https://www.example.com/login', permanent: false, }, + { + source: '/has-redirect-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + destination: '/another?myHeader=:myHeader', + permanent: false, + }, + { + source: '/has-redirect-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + destination: '/another?value=:my-query', + permanent: false, + }, + { + source: '/has-redirect-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: 'true', + }, + ], + destination: '/another?authorized=1', + permanent: false, + }, ] }, @@ -360,6 +427,53 @@ module.exports = { }, ], }, + { + source: '/has-header-1', + has: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + headers: [ + { + key: 'x-another', + value: 'header', + }, + ], + }, + { + source: '/has-header-2', + has: [ + { + type: 'query', + key: 'my-query', + }, + ], + headers: [ + { + key: 'x-added', + value: 'value', + }, + ], + }, + { + source: '/has-header-3', + has: [ + { + type: 'cookie', + key: 'loggedIn', + value: 'true', + }, + ], + headers: [ + { + key: 'x-is-user', + value: 'yuuuup', + }, + ], + }, ] }, } diff --git a/test/integration/invalid-custom-routes/test/index.test.js b/test/integration/invalid-custom-routes/test/index.test.js index 1bf95e7994f70..3381223c2117f 100644 --- a/test/integration/invalid-custom-routes/test/index.test.js +++ b/test/integration/invalid-custom-routes/test/index.test.js @@ -66,6 +66,33 @@ const runTests = () => { // invalid objects null, 'string', + + // invalid has items + { + source: '/hello', + destination: '/another', + has: [ + { + type: 'cookiee', + key: 'loggedIn', + }, + ], + permanent: false, + }, + { + source: '/hello', + destination: '/another', + permanent: false, + has: [ + { + type: 'headerr', + }, + { + type: 'queryr', + key: 'hello', + }, + ], + }, ], 'redirects' ) @@ -115,6 +142,26 @@ const runTests = () => { `The route "string" is not a valid object with \`source\` and \`destination\`` ) + expect(stderr).toContain('Invalid `has` item:') + expect(stderr).toContain( + `invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}` + ) + expect(stderr).toContain( + `invalid \`has\` item found for route {"source":"/hello","destination":"/another","has":[{"type":"cookiee","key":"loggedIn"}],"permanent":false}` + ) + + expect(stderr).toContain('Invalid `has` items:') + expect(stderr).toContain( + `invalid type "headerr", invalid key "undefined" for {"type":"headerr"}` + ) + expect(stderr).toContain( + `invalid type "queryr" for {"type":"queryr","key":"hello"}` + ) + expect(stderr).toContain( + `invalid \`has\` items found for route {"source":"/hello","destination":"/another","permanent":false,"has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}` + ) + expect(stderr).toContain(`Valid \`has\` object shape is {`) + expect(stderr).toContain('Invalid redirects found') }) @@ -164,6 +211,31 @@ const runTests = () => { // invalid objects null, 'string', + + // invalid has items + { + source: '/hello', + destination: '/another', + has: [ + { + type: 'cookiee', + key: 'loggedIn', + }, + ], + }, + { + source: '/hello', + destination: '/another', + has: [ + { + type: 'headerr', + }, + { + type: 'queryr', + key: 'hello', + }, + ], + }, ], 'rewrites' ) @@ -216,6 +288,26 @@ const runTests = () => { `The route /hello rewrites urls outside of the basePath. Please use a destination that starts with \`http://\` or \`https://\` https://err.sh/vercel/next.js/invalid-external-rewrite.md` ) + expect(stderr).toContain('Invalid `has` item:') + expect(stderr).toContain( + `invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}` + ) + expect(stderr).toContain( + `invalid \`has\` item found for route {"source":"/hello","destination":"/another","has":[{"type":"cookiee","key":"loggedIn"}]}` + ) + + expect(stderr).toContain('Invalid `has` items:') + expect(stderr).toContain( + `invalid type "headerr", invalid key "undefined" for {"type":"headerr"}` + ) + expect(stderr).toContain( + `invalid type "queryr" for {"type":"queryr","key":"hello"}` + ) + expect(stderr).toContain( + `invalid \`has\` items found for route {"source":"/hello","destination":"/another","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}]}` + ) + expect(stderr).toContain(`Valid \`has\` object shape is {`) + expect(stderr).toContain('Invalid rewrites found') }) @@ -283,6 +375,41 @@ const runTests = () => { // invalid objects null, 'string', + + // invalid has items + { + source: '/hello', + has: [ + { + type: 'cookiee', + key: 'loggedIn', + }, + ], + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, + { + source: '/hello', + has: [ + { + type: 'headerr', + }, + { + type: 'queryr', + key: 'hello', + }, + ], + headers: [ + { + key: 'x-hello', + value: 'world', + }, + ], + }, ], 'headers' ) @@ -316,6 +443,26 @@ const runTests = () => { `The route "string" is not a valid object with \`source\` and \`headers\`` ) + expect(stderr).toContain('Invalid `has` item:') + expect(stderr).toContain( + `invalid type "cookiee" for {"type":"cookiee","key":"loggedIn"}` + ) + expect(stderr).toContain( + `invalid \`has\` item found for route {"source":"/hello","has":[{"type":"cookiee","key":"loggedIn"}],"headers":[{"key":"x-hello","value":"world"}]}` + ) + + expect(stderr).toContain('Invalid `has` items:') + expect(stderr).toContain( + `invalid type "headerr", invalid key "undefined" for {"type":"headerr"}` + ) + expect(stderr).toContain( + `invalid type "queryr" for {"type":"queryr","key":"hello"}` + ) + expect(stderr).toContain( + `invalid \`has\` items found for route {"source":"/hello","has":[{"type":"headerr"},{"type":"queryr","key":"hello"}],"headers":[{"key":"x-hello","value":"world"}]}` + ) + expect(stderr).toContain(`Valid \`has\` object shape is {`) + expect(stderr).not.toContain('/valid-header') }) From 0e8c5318a77b2005aaca63ec9731ef1f6c601c91 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 18 Feb 2021 22:18:07 -0600 Subject: [PATCH 02/11] Update routes-manifest test --- .../custom-routes/test/index.test.js | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index eba6e200df983..419f0dc5124cb 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -784,6 +784,44 @@ const runTests = (isDev = false) => { source: '/to-external-with-query-2', statusCode: 307, }, + { + destination: '/another?myHeader=:myHeader', + has: [ + { + key: 'x-my-header', + type: 'header', + value: '(?.*)', + }, + ], + regex: normalizeRegEx('^\\/has-redirect-1$'), + source: '/has-redirect-1', + statusCode: 307, + }, + { + destination: '/another?value=:my-query', + has: [ + { + key: 'my-query', + type: 'query', + }, + ], + regex: normalizeRegEx('^\\/has-redirect-2$'), + source: '/has-redirect-2', + statusCode: 307, + }, + { + destination: '/another?authorized=1', + has: [ + { + key: 'loggedIn', + type: 'cookie', + value: 'true', + }, + ], + regex: normalizeRegEx('^\\/has-redirect-3$'), + source: '/has-redirect-3', + statusCode: 307, + }, ], headers: [ { @@ -941,6 +979,56 @@ const runTests = (isDev = false) => { ), source: '/catchall-header/:path*', }, + { + has: [ + { + key: 'x-my-header', + type: 'header', + value: '(?.*)', + }, + ], + headers: [ + { + key: 'x-another', + value: 'header', + }, + ], + regex: normalizeRegEx('^\\/has-header-1$'), + source: '/has-header-1', + }, + { + has: [ + { + key: 'my-query', + type: 'query', + }, + ], + headers: [ + { + key: 'x-added', + value: 'value', + }, + ], + regex: normalizeRegEx('^\\/has-header-2$'), + source: '/has-header-2', + }, + { + has: [ + { + key: 'loggedIn', + type: 'cookie', + value: 'true', + }, + ], + headers: [ + { + key: 'x-is-user', + value: 'yuuuup', + }, + ], + regex: normalizeRegEx('^\\/has-header-3$'), + source: '/has-header-3', + }, ], rewrites: [ { @@ -1077,6 +1165,41 @@ const runTests = (isDev = false) => { ), source: '/catchall-query/:path*', }, + { + destination: '/with-params?myHeader=:myHeader', + has: [ + { + key: 'x-my-header', + type: 'header', + value: '(?.*)', + }, + ], + regex: normalizeRegEx('^\\/has-rewrite-1$'), + source: '/has-rewrite-1', + }, + { + destination: '/with-params?value=:my-query', + has: [ + { + key: 'my-query', + type: 'query', + }, + ], + regex: normalizeRegEx('^\\/has-rewrite-2$'), + source: '/has-rewrite-2', + }, + { + destination: '/with-params?authorized=1', + has: [ + { + key: 'loggedIn', + type: 'cookie', + value: 'true', + }, + ], + regex: normalizeRegEx('^\\/has-rewrite-3$'), + source: '/has-rewrite-3', + }, ], dynamicRoutes: [ { From 53e2fe52aaaa8ebabc90e8eedb61afdb91dd8bd1 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 19 Feb 2021 00:20:01 -0600 Subject: [PATCH 03/11] Add has field matching and tests --- .../next-serverless-loader/api-handler.ts | 2 +- .../next-serverless-loader/page-handler.ts | 2 +- .../loaders/next-serverless-loader/utils.ts | 18 +- .../lib/router/utils/prepare-destination.ts | 70 +++++++ packages/next/next-server/server/api-utils.ts | 9 +- .../next/next-server/server/next-server.ts | 4 +- packages/next/next-server/server/router.ts | 15 +- test/integration/custom-routes/next.config.js | 4 +- .../custom-routes/test/index.test.js | 182 +++++++++++++++++- 9 files changed, 288 insertions(+), 18 deletions(-) diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts index 0b5c9204625d6..f6939fd04af6a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/api-handler.ts @@ -24,7 +24,7 @@ export function getApiHandler(ctx: ServerlessHandlerCtx) { // We need to trust the dynamic route params from the proxy // to ensure we are using the correct values const trustQuery = req.headers[vercelHeader] - const parsedUrl = handleRewrites(parseUrl(req.url!, true)) + const parsedUrl = handleRewrites(req, parseUrl(req.url!, true)) if (parsedUrl.query.nextInternalLocale) { delete parsedUrl.query.nextInternalLocale diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts index 3bfccc2bd22ca..526d4b5d615d7 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/page-handler.ts @@ -140,7 +140,7 @@ export function getPageHandler(ctx: ServerlessHandlerCtx) { } const origQuery = Object.assign({}, parsedUrl.query) - parsedUrl = handleRewrites(parsedUrl) + parsedUrl = handleRewrites(req, parsedUrl) handleBasePath(req, parsedUrl) // remove ?amp=1 from request URL if rendering for export diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index ae03cb2fc7dc3..02fa94cf49a11 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -6,7 +6,9 @@ import { normalizeLocalePath } from '../../../../next-server/lib/i18n/normalize- import pathMatch from '../../../../next-server/lib/router/utils/path-match' import { getRouteRegex } from '../../../../next-server/lib/router/utils/route-regex' import { getRouteMatcher } from '../../../../next-server/lib/router/utils/route-matcher' -import prepareDestination from '../../../../next-server/lib/router/utils/prepare-destination' +import prepareDestination, { + matchHas, +} from '../../../../next-server/lib/router/utils/prepare-destination' import { __ApiPreviewProps } from '../../../../next-server/server/api-utils' import { BuildManifest } from '../../../../next-server/server/get-page-files' import { @@ -85,10 +87,20 @@ export function getUtils({ defaultRouteMatches = dynamicRouteMatcher(page) as ParsedUrlQuery } - function handleRewrites(parsedUrl: UrlWithParsedQuery) { + function handleRewrites(req: IncomingMessage, parsedUrl: UrlWithParsedQuery) { for (const rewrite of rewrites) { const matcher = getCustomRouteMatcher(rewrite.source) - const params = matcher(parsedUrl.pathname) + let params = matcher(parsedUrl.pathname) + + if (rewrite.has && params) { + const hasParams = matchHas(req, rewrite.has, parsedUrl.query) + + if (hasParams) { + Object.assign(params, hasParams) + } else { + params = false + } + } if (params) { const { parsedDestination } = prepareDestination( diff --git a/packages/next/next-server/lib/router/utils/prepare-destination.ts b/packages/next/next-server/lib/router/utils/prepare-destination.ts index c0f80b2ec4f5e..67d967ae4466f 100644 --- a/packages/next/next-server/lib/router/utils/prepare-destination.ts +++ b/packages/next/next-server/lib/router/utils/prepare-destination.ts @@ -1,10 +1,80 @@ +import { IncomingMessage } from 'http' import { ParsedUrlQuery } from 'querystring' import { searchParamsToUrlQuery } from './querystring' import { parseRelativeUrl } from './parse-relative-url' import * as pathToRegexp from 'next/dist/compiled/path-to-regexp' +import { RouteHas } from '../../../../lib/load-custom-routes' type Params = { [param: string]: any } +// ensure only a-zA-Z are used for param names for proper interpolating +// with path-to-regexp +const getSafeParamName = (paramName: string) => { + let newParamName = '' + + for (let i = 0; i < paramName.length; i++) { + const charCode = paramName.charCodeAt(i) + + if ( + (charCode > 64 && charCode < 91) || // A-Z + (charCode > 96 && charCode < 123) // a-z + ) { + newParamName += paramName[i] + } + } + return newParamName +} + +export function matchHas( + req: IncomingMessage, + has: RouteHas[], + query: Params +): false | Params { + const params: Params = {} + const allMatch = has.every((hasItem) => { + let value: undefined | string + let key = hasItem.key + + if (hasItem.type === 'header') { + key = key.toLowerCase() + value = req.headers[key] as string + } else if (hasItem.type === 'cookie') { + value = (req as any).cookies[hasItem.key] + } else if (hasItem.type === 'query') { + value = query[key] + } + + if (!hasItem.value && value) { + params[getSafeParamName(key)] = value + return true + } else if (value) { + const matcher = new RegExp(`^${hasItem.value}$`) + const matches = value.match(matcher) + + if (matches) { + if (matches.groups) { + Object.keys(matches.groups).forEach((groupKey) => { + const safeKey = getSafeParamName(groupKey) + + if (safeKey && matches.groups![groupKey]) { + params[safeKey] = matches.groups![groupKey] + } + }) + } else { + params[getSafeParamName(key)] = matches[0] + } + return true + } + } + return false + }) + + if (allMatch) { + return params + } + return false +} + export function compileNonPath(value: string, params: Params): string { if (!value.includes(':')) { return value diff --git a/packages/next/next-server/server/api-utils.ts b/packages/next/next-server/server/api-utils.ts index 5d7c5c9d2a768..6bd0c497a9169 100644 --- a/packages/next/next-server/server/api-utils.ts +++ b/packages/next/next-server/server/api-utils.ts @@ -7,7 +7,6 @@ import { Stream } from 'stream' import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils' import { decryptWithSecret, encryptWithSecret } from './crypto-utils' import { interopDefault } from './load-components' -import { Params } from './router' import { sendEtagResponse } from './send-payload' import generateETag from 'etag' @@ -517,7 +516,6 @@ export function sendError( interface LazyProps { req: NextApiRequest - params?: Params | boolean } /** @@ -527,7 +525,7 @@ interface LazyProps { * @param getter function to get data */ export function setLazyProp( - { req, params }: LazyProps, + { req }: LazyProps, prop: string, getter: () => T ): void { @@ -537,10 +535,7 @@ export function setLazyProp( Object.defineProperty(req, prop, { ...opts, get: () => { - let value = getter() - if (params && typeof params !== 'boolean') { - value = { ...value, ...params } - } + const value = getter() // we set the property on the object to avoid recalculating it Object.defineProperty(req, prop, { ...optsReset, value }) return value diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 970ab91d2510b..3a5e315b47364 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -371,7 +371,7 @@ export default class Server { rewrites: this.customRoutes.rewrites, }) - utils.handleRewrites(parsedUrl) + utils.handleRewrites(req, parsedUrl) // interpolate dynamic params and normalize URL if needed if (pageIsDynamic) { @@ -799,6 +799,7 @@ export default class Server { const headerRoute = getCustomRoute(r, 'header') return { match: headerRoute.match, + has: headerRoute.has, type: headerRoute.type, name: `${headerRoute.type} ${headerRoute.source} header route`, fn: async (_req, res, params, _parsedUrl) => { @@ -841,6 +842,7 @@ export default class Server { internal: redirectRoute.internal, type: redirectRoute.type, match: redirectRoute.match, + has: redirectRoute.has, statusCode: redirectRoute.statusCode, name: `Redirect route ${redirectRoute.source}`, fn: async (req, res, params, parsedUrl) => { diff --git a/packages/next/next-server/server/router.ts b/packages/next/next-server/server/router.ts index e8b22b09d1eb6..add9ca42d1fc1 100644 --- a/packages/next/next-server/server/router.ts +++ b/packages/next/next-server/server/router.ts @@ -4,6 +4,8 @@ import { UrlWithParsedQuery } from 'url' import pathMatch from '../lib/router/utils/path-match' import { removePathTrailingSlash } from '../../client/normalize-trailing-slash' import { normalizeLocalePath } from '../lib/i18n/normalize-locale-path' +import { RouteHas } from '../../lib/load-custom-routes' +import { matchHas } from '../lib/router/utils/prepare-destination' export const route = pathMatch() @@ -19,6 +21,7 @@ type RouteResult = { export type Route = { match: RouteMatch + has?: RouteHas[] type: string check?: boolean statusCode?: number @@ -224,7 +227,17 @@ export default class Router { }` } - const newParams = testRoute.match(currentPathname) + let newParams = testRoute.match(currentPathname) + + if (testRoute.has && newParams) { + const hasParams = matchHas(req, testRoute.has, parsedUrlUpdated.query) + + if (hasParams) { + Object.assign(newParams, hasParams) + } else { + newParams = false + } + } // Check if the match function matched if (newParams) { diff --git a/test/integration/custom-routes/next.config.js b/test/integration/custom-routes/next.config.js index 661986582b25d..b0d0e3349491f 100644 --- a/test/integration/custom-routes/next.config.js +++ b/test/integration/custom-routes/next.config.js @@ -125,7 +125,7 @@ module.exports = { key: 'my-query', }, ], - destination: '/with-params?value=:my-query', + destination: '/with-params?value=:myquery', }, { source: '/has-rewrite-3', @@ -269,7 +269,7 @@ module.exports = { key: 'my-query', }, ], - destination: '/another?value=:my-query', + destination: '/another?value=:myquery', permanent: false, }, { diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 419f0dc5124cb..834a41c076a0b 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -616,6 +616,184 @@ const runTests = (isDev = false) => { ) }) + it('should match has header rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-rewrite-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + }) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + + expect(JSON.parse($('#query').text())).toEqual({ + myHeader: 'hello world!!', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-rewrite-1') + expect(res2.status).toBe(404) + }) + + it('should match has query rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-rewrite-2', { + 'my-query': 'hellooo', + }) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + + expect(JSON.parse($('#query').text())).toEqual({ + 'my-query': 'hellooo', + myquery: 'hellooo', + value: 'hellooo', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-rewrite-2') + expect(res2.status).toBe(404) + }) + + it('should match has cookie rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-rewrite-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + }) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + + expect(JSON.parse($('#query').text())).toEqual({ + loggedIn: 'true', + authorized: '1', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-rewrite-3') + expect(res2.status).toBe(404) + }) + + it('should match has header redirect correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-redirect-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + redirect: 'manual', + }) + + expect(res.status).toBe(307) + const parsed = url.parse(res.headers.get('location'), true) + + expect(parsed.pathname).toBe('/another') + expect(parsed.query).toEqual({ + myHeader: 'hello world!!', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-redirect-1', undefined, { + redirect: 'manual', + }) + expect(res2.status).toBe(404) + }) + + it('should match has query redirect correctly', async () => { + const res = await fetchViaHTTP( + appPort, + '/has-redirect-2', + { + 'my-query': 'hellooo', + }, + { + redirect: 'manual', + } + ) + + expect(res.status).toBe(307) + const parsed = url.parse(res.headers.get('location'), true) + + expect(parsed.pathname).toBe('/another') + expect(parsed.query).toEqual({ + value: 'hellooo', + 'my-query': 'hellooo', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-redirect-2', undefined, { + redirect: 'manual', + }) + expect(res2.status).toBe(404) + }) + + it('should match has cookie redirect correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-redirect-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + redirect: 'manual', + }) + + expect(res.status).toBe(307) + const parsed = url.parse(res.headers.get('location'), true) + + expect(parsed.pathname).toBe('/another') + expect(parsed.query).toEqual({ + authorized: '1', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-redirect-3', undefined, { + redirect: 'manual', + }) + expect(res2.status).toBe(404) + }) + + it('should match has header for header correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-header-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + redirect: 'manual', + }) + + expect(res.headers.get('x-another')).toBe('header') + + const res2 = await fetchViaHTTP(appPort, '/has-header-1', undefined, { + redirect: 'manual', + }) + expect(res2.headers.get('x-another')).toBe(null) + }) + + it('should match has query for header correctly', async () => { + const res = await fetchViaHTTP( + appPort, + '/has-header-2', + { + 'my-query': 'hellooo', + }, + { + redirect: 'manual', + } + ) + + expect(res.headers.get('x-added')).toBe('value') + + const res2 = await fetchViaHTTP(appPort, '/has-header-2', undefined, { + redirect: 'manual', + }) + expect(res2.headers.get('x-another')).toBe(null) + }) + + it('should match has cookie for header correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-header-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + redirect: 'manual', + }) + + expect(res.headers.get('x-is-user')).toBe('yuuuup') + + const res2 = await fetchViaHTTP(appPort, '/has-header-3', undefined, { + redirect: 'manual', + }) + expect(res2.headers.get('x-is-user')).toBe(null) + }) + if (!isDev) { it('should output routes-manifest successfully', async () => { const manifest = await fs.readJSON( @@ -798,7 +976,7 @@ const runTests = (isDev = false) => { statusCode: 307, }, { - destination: '/another?value=:my-query', + destination: '/another?value=:myquery', has: [ { key: 'my-query', @@ -1178,7 +1356,7 @@ const runTests = (isDev = false) => { source: '/has-rewrite-1', }, { - destination: '/with-params?value=:my-query', + destination: '/with-params?value=:myquery', has: [ { key: 'my-query', From 66ad1fb279903d511948548d70842f01b1d5a9d7 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 19 Feb 2021 11:14:40 -0600 Subject: [PATCH 04/11] Add docs for has field --- docs/api-reference/next.config.js/headers.md | 77 +++++++++++++++++++ .../api-reference/next.config.js/redirects.md | 65 ++++++++++++++++ docs/api-reference/next.config.js/rewrites.md | 62 +++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/docs/api-reference/next.config.js/headers.md b/docs/api-reference/next.config.js/headers.md index 73623b08a2893..1faa30dba415b 100644 --- a/docs/api-reference/next.config.js/headers.md +++ b/docs/api-reference/next.config.js/headers.md @@ -149,6 +149,83 @@ module.exports = { } ``` +## Header, Cookie, and Query Matching + +To only apply a header when either header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the header to be applied. + +`has` items have the following fields: + +- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `key`: `String` - the key from the selected type to match against. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. + +```js +module.exports = { + async headers() { + return [ + // if the header `x-add-header` is present, + // the `x-another-header` header will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-add-header', + }, + ], + headers: [ + { + key: 'x-another-header', + value: 'hello', + }, + ], + }, + // if the source, query, and cookie are matched, + // the `x-authorized` header will be applied + { + source: '/specific/:path*', + has: [ + { + type: 'query', + key: 'page', + value: 'home', + }, + { + type: 'cookie', + key: 'authorized', + value: 'true', + }, + ], + headers: [ + { + key: 'x-authorized', + value: ':authorized', + }, + ], + }, + // if the header `x-authorized` is present and + // contains a matching value, the `x-another-header` will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-authorized', + value: '(?yes|true)', + }, + ], + headers: [ + { + key: 'x-another-header', + value: ':authorized', + }, + ], + }, + ] + }, +} +``` + ### Headers with basePath support When leveraging [`basePath` support](/docs/api-reference/next.config.js/basepath.md) with headers each `source` is automatically prefixed with the `basePath` unless you add `basePath: false` to the header: diff --git a/docs/api-reference/next.config.js/redirects.md b/docs/api-reference/next.config.js/redirects.md index 4ee91c3fc48f9..39bb55d8c258b 100644 --- a/docs/api-reference/next.config.js/redirects.md +++ b/docs/api-reference/next.config.js/redirects.md @@ -93,6 +93,71 @@ module.exports = { } ``` +## Header, Cookie, and Query Matching + +To only match a redirect when header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the redirect to be applied. + +`has` items have the following fields: + +- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `key`: `String` - the key from the selected type to match against. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. + +```js +module.exports = { + async redirects() { + return [ + // if the header `x-redirect-me` is present, + // this redirect will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-redirect-me', + }, + ], + permanent: false, + destination: '/another-page', + }, + // if the source, query, and cookie are matched, + // this redirect will be applied + { + source: '/specific/:path*', + has: [ + { + type: 'query', + key: 'page', + value: 'home', + }, + { + type: 'cookie', + key: 'authorized', + value: 'true', + }, + ], + permanent: false, + destination: '/:path*/:page', + }, + // if the header `x-authorized` is present and + // contains a matching value, this redirect will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-authorized', + value: '(?yes|true)', + }, + ], + permanent: false, + destination: '/home?authorized=:authorized', + }, + ] + }, +} +``` + ### Redirects with basePath support When leveraging [`basePath` support](/docs/api-reference/next.config.js/basepath.md) with redirects each `source` and `destination` is automatically prefixed with the `basePath` unless you add `basePath: false` to the redirect: diff --git a/docs/api-reference/next.config.js/rewrites.md b/docs/api-reference/next.config.js/rewrites.md index a6f4efcfcf364..c45f2b2b3c7d5 100644 --- a/docs/api-reference/next.config.js/rewrites.md +++ b/docs/api-reference/next.config.js/rewrites.md @@ -140,6 +140,68 @@ module.exports = { } ``` +## Header, Cookie, and Query Matching + +To only match a rewrite when header, cookie, or query values also match the `has` field can be used. Both the `source` and all `has` items must match for the rewrite to be applied. + +`has` items have the following fields: + +- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `key`: `String` - the key from the selected type to match against. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. + +```js +module.exports = { + async rewrites() { + return [ + // if the header `x-rewrite-me` is present, + // this rewrite will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-rewrite-me', + }, + ], + destination: '/another-page', + }, + // if the source, query, and cookie are matched, + // this rewrite will be applied + { + source: '/specific/:path*', + has: [ + { + type: 'query', + key: 'page', + value: 'home', + }, + { + type: 'cookie', + key: 'authorized', + value: 'true', + }, + ], + destination: '/:path*/:page', + }, + // if the header `x-authorized` is present and + // contains a matching value, this rewrite will be applied + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'x-authorized', + value: '(?yes|true)', + }, + ], + destination: '/home?authorized=:authorized', + }, + ] + }, +} +``` + ## Rewriting to an external URL
From 0919aea13052e59a15f2d2ffc6e56dc3c1156efa Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 26 Feb 2021 11:22:04 -0600 Subject: [PATCH 05/11] keep track of has usage --- packages/next/build/index.ts | 3 +++ packages/next/telemetry/events/build.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index a0dc62462b8ac..da19d02cffd25 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1327,6 +1327,9 @@ export default async function build( rewritesCount: rewrites.length, headersCount: headers.length, redirectsCount: redirects.length - 1, // reduce one for trailing slash + headersWithHasCount: headers.filter((r) => !!r.has).length, + rewritesWithHasCount: rewrites.filter((r) => !!r.has).length, + redirectsWithHasCount: redirects.filter((r) => !!r.has).length, }) ) diff --git a/packages/next/telemetry/events/build.ts b/packages/next/telemetry/events/build.ts index 50931ec2ec6b9..8a1a6dd95fb62 100644 --- a/packages/next/telemetry/events/build.ts +++ b/packages/next/telemetry/events/build.ts @@ -48,6 +48,9 @@ type EventBuildOptimized = { headersCount: number rewritesCount: number redirectsCount: number + headersWithHasCount: number + rewritesWithHasCount: number + redirectsWithHasCount: number } export function eventBuildOptimize( From 678058a061cc9ef0a11359c3160a74bc75e4f037 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 26 Feb 2021 11:51:43 -0600 Subject: [PATCH 06/11] Add warning for has usage while experimental --- packages/next/lib/load-custom-routes.ts | 9 +++++++++ test/integration/custom-routes/test/index.test.js | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 9ef5a3782b5fe..a33bf9f76bca8 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -6,6 +6,8 @@ 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' export type RouteHas = { type: 'header' | 'query' | 'cookie' @@ -146,6 +148,12 @@ function tryParsePath(route: string, handleUrl?: boolean): ParseAttemptResult { 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 @@ -223,6 +231,7 @@ function checkCustomRoutes( 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) { diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 834a41c076a0b..78f577161fe19 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -1523,6 +1523,12 @@ describe('Custom routes', () => { `rewrites, redirects, and headers are not applied when exporting your application detected` ) }) + + it('should show warning for experimental has usage', async () => { + expect(stderr).toContain( + "'has' route field support is still experimental and not covered by semver, use at your own risk." + ) + }) }) describe('export', () => { From 61ff56b2e2509ab73fe2f32a572995d0bf065e48 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 11 Mar 2021 15:14:11 -0600 Subject: [PATCH 07/11] fix type --- packages/next/build/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index c7dccfd3a658e..2bbf2baad9927 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1314,9 +1314,9 @@ export default async function build( rewritesCount: rewrites.length, headersCount: headers.length, redirectsCount: redirects.length - 1, // reduce one for trailing slash - headersWithHasCount: headers.filter((r) => !!r.has).length, - rewritesWithHasCount: rewrites.filter((r) => !!r.has).length, - redirectsWithHasCount: redirects.filter((r) => !!r.has).length, + headersWithHasCount: headers.filter((r: any) => !!r.has).length, + rewritesWithHasCount: rewrites.filter((r: any) => !!r.has).length, + redirectsWithHasCount: redirects.filter((r: any) => !!r.has).length, }) ) From 089cdce414fd811ddd25806aca1962df70d55862 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 11 Mar 2021 16:16:02 -0600 Subject: [PATCH 08/11] Handle has rewrites client-side --- .../lib/router/utils/resolve-rewrites.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/next/next-server/lib/router/utils/resolve-rewrites.ts b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts index 4ed3987999ab9..bad37f210cc1f 100644 --- a/packages/next/next-server/lib/router/utils/resolve-rewrites.ts +++ b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts @@ -1,6 +1,6 @@ import { ParsedUrlQuery } from 'querystring' import pathMatch from './path-match' -import prepareDestination from './prepare-destination' +import prepareDestination, { matchHas } from './prepare-destination' import { Rewrite } from '../../../../lib/load-custom-routes' import { removePathTrailingSlash } from '../../../../client/normalize-trailing-slash' import { normalizeLocalePath } from '../../i18n/normalize-locale-path' @@ -32,7 +32,29 @@ export default function resolveRewrites( if (!pages.includes(fsPathname)) { for (const rewrite of rewrites) { const matcher = customRouteMatcher(rewrite.source) - const params = matcher(parsedAs.pathname) + let params = matcher(parsedAs.pathname) + + if (rewrite.has && params) { + const hasParams = matchHas( + { + headers: {}, + cookies: Object.fromEntries( + document.cookie.split('; ').map((item) => { + const [key, ...value] = item.split('=') + return [key, value.join('=')] + }) + ), + } as any, + rewrite.has, + parsedAs.query + ) + + if (hasParams) { + Object.assign(params, hasParams) + } else { + params = false + } + } if (params) { if (!rewrite.destination) { From 2427ec824a4f3eeb66a8a37878a2e7cdc245179f Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 22 Mar 2021 18:32:49 -0500 Subject: [PATCH 09/11] Support host has type --- packages/next/lib/load-custom-routes.ts | 23 +++-- .../lib/router/utils/prepare-destination.ts | 35 +++++-- .../lib/router/utils/resolve-rewrites.ts | 4 +- .../next/next-server/server/next-server.ts | 2 +- test/integration/custom-routes/next.config.js | 36 +++++++ .../custom-routes/test/index.test.js | 98 +++++++++++++++++++ 6 files changed, 180 insertions(+), 18 deletions(-) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 5d85798047fcd..4b813f204a15b 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -9,11 +9,17 @@ import { import { execOnce } from '../next-server/lib/utils' import * as Log from '../build/output/log' -export type RouteHas = { - type: 'header' | 'query' | 'cookie' - key: string - value?: string -} +export type RouteHas = + | { + type: 'header' | 'query' | 'cookie' + key: string + value?: string + } + | { + type: 'host' + key?: undefined + value: string + } export type Rewrite = { source: string @@ -38,7 +44,7 @@ export type Redirect = Rewrite & { } export const allowedStatusCodes = new Set([301, 302, 303, 307, 308]) -const allowedHasTypes = new Set(['header', 'cookie', 'query']) +const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host']) export function getRedirectStatus(route: { statusCode?: number @@ -240,7 +246,7 @@ function checkCustomRoutes( if (!allowedHasTypes.has(hasItem.type)) { invalidHasParts.push(`invalid type "${hasItem.type}"`) } - if (typeof hasItem.key !== 'string') { + if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') { invalidHasParts.push(`invalid key "${hasItem.key}"`) } if ( @@ -249,6 +255,9 @@ function checkCustomRoutes( ) { 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( diff --git a/packages/next/next-server/lib/router/utils/prepare-destination.ts b/packages/next/next-server/lib/router/utils/prepare-destination.ts index 67d967ae4466f..c5395b228ad44 100644 --- a/packages/next/next-server/lib/router/utils/prepare-destination.ts +++ b/packages/next/next-server/lib/router/utils/prepare-destination.ts @@ -35,17 +35,34 @@ export function matchHas( let value: undefined | string let key = hasItem.key - if (hasItem.type === 'header') { - key = key.toLowerCase() - value = req.headers[key] as string - } else if (hasItem.type === 'cookie') { - value = (req as any).cookies[hasItem.key] - } else if (hasItem.type === 'query') { - value = query[key] + switch (hasItem.type) { + case 'header': { + key = key!.toLowerCase() + value = req.headers[key] as string + break + } + case 'cookie': { + value = (req as any).cookies[hasItem.key] + break + } + case 'query': { + value = query[key!] + break + } + case 'host': { + const { host } = req?.headers || {} + // remove port from host if present + const hostname = host?.split(':')[0].toLowerCase() + value = hostname + break + } + default: { + break + } } if (!hasItem.value && value) { - params[getSafeParamName(key)] = value + params[getSafeParamName(key!)] = value return true } else if (value) { const matcher = new RegExp(`^${hasItem.value}$`) @@ -61,7 +78,7 @@ export function matchHas( } }) } else { - params[getSafeParamName(key)] = matches[0] + params[getSafeParamName(key || 'host')] = matches[0] } return true } diff --git a/packages/next/next-server/lib/router/utils/resolve-rewrites.ts b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts index bad37f210cc1f..799009b62fd90 100644 --- a/packages/next/next-server/lib/router/utils/resolve-rewrites.ts +++ b/packages/next/next-server/lib/router/utils/resolve-rewrites.ts @@ -37,7 +37,9 @@ export default function resolveRewrites( if (rewrite.has && params) { const hasParams = matchHas( { - headers: {}, + headers: { + host: document.location.hostname, + }, cookies: Object.fromEntries( document.cookie.split('; ').map((item) => { const [key, ...value] = item.split('=') diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 60ce46e962abb..c5b055a8a9c87 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -434,7 +434,7 @@ export default class Server { : detectedLocale const { host } = req?.headers || {} - // remove port from host and remove port if present + // remove port from host if present const hostname = host?.split(':')[0].toLowerCase() const detectedDomain = detectDomainLocale(i18n.domains, hostname) diff --git a/test/integration/custom-routes/next.config.js b/test/integration/custom-routes/next.config.js index b0d0e3349491f..ac952024c231f 100644 --- a/test/integration/custom-routes/next.config.js +++ b/test/integration/custom-routes/next.config.js @@ -138,6 +138,16 @@ module.exports = { ], destination: '/with-params?authorized=1', }, + { + source: '/has-rewrite-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + destination: '/with-params?host=1', + }, ] }, async redirects() { @@ -284,6 +294,17 @@ module.exports = { destination: '/another?authorized=1', permanent: false, }, + { + source: '/has-redirect-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + destination: '/another?host=1', + permanent: false, + }, ] }, @@ -474,6 +495,21 @@ module.exports = { }, ], }, + { + source: '/has-header-4', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + headers: [ + { + key: 'x-is-host', + value: 'yuuuup', + }, + ], + }, ] }, } diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 78f577161fe19..0131ef75ce055 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -671,6 +671,27 @@ const runTests = (isDev = false) => { expect(res2.status).toBe(404) }) + it('should match has host rewrite correctly', async () => { + const res1 = await fetchViaHTTP(appPort, '/has-rewrite-4') + expect(res1.status).toBe(404) + + const res = await fetchViaHTTP(appPort, '/has-rewrite-4', undefined, { + headers: { + host: 'example.com', + }, + }) + + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + + expect(JSON.parse($('#query').text())).toEqual({ + host: '1', + }) + + const res2 = await fetchViaHTTP(appPort, '/has-rewrite-3') + expect(res2.status).toBe(404) + }) + it('should match has header redirect correctly', async () => { const res = await fetchViaHTTP(appPort, '/has-redirect-1', undefined, { headers: { @@ -742,6 +763,28 @@ const runTests = (isDev = false) => { expect(res2.status).toBe(404) }) + it('should match has host redirect correctly', async () => { + const res1 = await fetchViaHTTP(appPort, '/has-redirect-4', undefined, { + redirect: 'manual', + }) + expect(res1.status).toBe(404) + + const res = await fetchViaHTTP(appPort, '/has-redirect-4', undefined, { + headers: { + host: 'example.com', + }, + redirect: 'manual', + }) + + expect(res.status).toBe(307) + const parsed = url.parse(res.headers.get('location'), true) + + expect(parsed.pathname).toBe('/another') + expect(parsed.query).toEqual({ + host: '1', + }) + }) + it('should match has header for header correctly', async () => { const res = await fetchViaHTTP(appPort, '/has-header-1', undefined, { headers: { @@ -794,6 +837,22 @@ const runTests = (isDev = false) => { expect(res2.headers.get('x-is-user')).toBe(null) }) + it('should match has host for header correctly', async () => { + const res = await fetchViaHTTP(appPort, '/has-header-4', undefined, { + headers: { + host: 'example.com', + }, + redirect: 'manual', + }) + + expect(res.headers.get('x-is-host')).toBe('yuuuup') + + const res2 = await fetchViaHTTP(appPort, '/has-header-4', undefined, { + redirect: 'manual', + }) + expect(res2.headers.get('x-is-host')).toBe(null) + }) + if (!isDev) { it('should output routes-manifest successfully', async () => { const manifest = await fs.readJSON( @@ -1000,6 +1059,18 @@ const runTests = (isDev = false) => { source: '/has-redirect-3', statusCode: 307, }, + { + destination: '/another?host=1', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + regex: normalizeRegEx('^\\/has-redirect-4$'), + source: '/has-redirect-4', + statusCode: 307, + }, ], headers: [ { @@ -1207,6 +1278,22 @@ const runTests = (isDev = false) => { regex: normalizeRegEx('^\\/has-header-3$'), source: '/has-header-3', }, + { + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + headers: [ + { + key: 'x-is-host', + value: 'yuuuup', + }, + ], + regex: normalizeRegEx('^\\/has-header-4$'), + source: '/has-header-4', + }, ], rewrites: [ { @@ -1378,6 +1465,17 @@ const runTests = (isDev = false) => { regex: normalizeRegEx('^\\/has-rewrite-3$'), source: '/has-rewrite-3', }, + { + destination: '/with-params?host=1', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + regex: '^\\/has-rewrite-4$', + source: '/has-rewrite-4', + }, ], dynamicRoutes: [ { From 0d66bd645dc407dfe116b63bcbcbb17770a1a6e0 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 24 Mar 2021 10:51:37 -0500 Subject: [PATCH 10/11] Apply suggestions from code review Co-authored-by: Luis Alvarez D. --- docs/api-reference/next.config.js/headers.md | 2 +- docs/api-reference/next.config.js/redirects.md | 2 +- docs/api-reference/next.config.js/rewrites.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-reference/next.config.js/headers.md b/docs/api-reference/next.config.js/headers.md index 9889526c0176c..411d58b116f2d 100644 --- a/docs/api-reference/next.config.js/headers.md +++ b/docs/api-reference/next.config.js/headers.md @@ -157,7 +157,7 @@ To only apply a header when either header, cookie, or query values also match th - `type`: `String` - must be either `header`, `cookie`, or `query`. - `key`: `String` - the key from the selected type to match against. -- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. ```js module.exports = { diff --git a/docs/api-reference/next.config.js/redirects.md b/docs/api-reference/next.config.js/redirects.md index 18e3e8b4c084b..c3530bb6f3eca 100644 --- a/docs/api-reference/next.config.js/redirects.md +++ b/docs/api-reference/next.config.js/redirects.md @@ -101,7 +101,7 @@ To only match a redirect when header, cookie, or query values also match the `ha - `type`: `String` - must be either `header`, `cookie`, or `query`. - `key`: `String` - the key from the selected type to match against. -- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. ```js module.exports = { diff --git a/docs/api-reference/next.config.js/rewrites.md b/docs/api-reference/next.config.js/rewrites.md index 4696dc47e35b8..0e5d4ae635d47 100644 --- a/docs/api-reference/next.config.js/rewrites.md +++ b/docs/api-reference/next.config.js/rewrites.md @@ -148,7 +148,7 @@ To only match a rewrite when header, cookie, or query values also match the `has - `type`: `String` - must be either `header`, `cookie`, or `query`. - `key`: `String` - the key from the selected type to match against. -- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture or a specific part of the value e.g. with `first-(?.*)` and the value `first-second` `second` will be usable in the destination with `:paramName`. +- `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. ```js module.exports = { From 000884edbcf4b0dc237edc1e234e2d48e5372d4e Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 24 Mar 2021 11:18:52 -0500 Subject: [PATCH 11/11] Add host type to docs --- docs/api-reference/next.config.js/headers.md | 19 ++++++++++++++++++- .../api-reference/next.config.js/redirects.md | 14 +++++++++++++- docs/api-reference/next.config.js/rewrites.md | 14 +++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/api-reference/next.config.js/headers.md b/docs/api-reference/next.config.js/headers.md index 411d58b116f2d..40860417e2be9 100644 --- a/docs/api-reference/next.config.js/headers.md +++ b/docs/api-reference/next.config.js/headers.md @@ -155,7 +155,7 @@ To only apply a header when either header, cookie, or query values also match th `has` items have the following fields: -- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`. - `key`: `String` - the key from the selected type to match against. - `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. @@ -221,6 +221,23 @@ module.exports = { }, ], }, + // if the host is `example.com`, + // this header will be applied + { + source: '/:path*', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + headers: [ + { + key: 'x-another-header', + value: ':authorized', + }, + ], + }, ] }, } diff --git a/docs/api-reference/next.config.js/redirects.md b/docs/api-reference/next.config.js/redirects.md index c3530bb6f3eca..02f0cbcb57825 100644 --- a/docs/api-reference/next.config.js/redirects.md +++ b/docs/api-reference/next.config.js/redirects.md @@ -99,7 +99,7 @@ To only match a redirect when header, cookie, or query values also match the `ha `has` items have the following fields: -- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`. - `key`: `String` - the key from the selected type to match against. - `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. @@ -153,6 +153,18 @@ module.exports = { permanent: false, destination: '/home?authorized=:authorized', }, + // if the host is `example.com`, + // this redirect will be applied + { + source: '/:path*', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + destination: '/another-page', + }, ] }, } diff --git a/docs/api-reference/next.config.js/rewrites.md b/docs/api-reference/next.config.js/rewrites.md index 0e5d4ae635d47..3be108927df12 100644 --- a/docs/api-reference/next.config.js/rewrites.md +++ b/docs/api-reference/next.config.js/rewrites.md @@ -146,7 +146,7 @@ To only match a rewrite when header, cookie, or query values also match the `has `has` items have the following fields: -- `type`: `String` - must be either `header`, `cookie`, or `query`. +- `type`: `String` - must be either `header`, `cookie`, `host`, or `query`. - `key`: `String` - the key from the selected type to match against. - `value`: `String` or `undefined` - the value to check for, if undefined any value will match. A regex like string can be used to capture a specific part of the value, e.g. if the value `first-(?.*)` is used for `first-second` then `second` will be usable in the destination with `:paramName`. @@ -197,6 +197,18 @@ module.exports = { ], destination: '/home?authorized=:authorized', }, + // if the host is `example.com`, + // this rewrite will be applied + { + source: '/:path*', + has: [ + { + type: 'host', + value: 'example.com', + }, + ], + destination: '/another-page', + }, ] }, }