diff --git a/packages/edge-bundler/node/config.test.ts b/packages/edge-bundler/node/config.test.ts index 3d92ab326d..c0d9e47d88 100644 --- a/packages/edge-bundler/node/config.test.ts +++ b/packages/edge-bundler/node/config.test.ts @@ -291,6 +291,18 @@ test('Loads function paths from the in-source `config` function', async () => { pattern: '^/user-func1/?$', excluded_patterns: [], path: '/user-func1', + headers: { + 'x-must-be-there': { + style: 'exists', + }, + 'x-must-match': { + pattern: '^(foo|bar)$', + style: 'regex', + }, + 'x-must-not-be-there': { + style: 'missing', + }, + }, }) expect(routes[7]).toEqual({ function: 'user-func3', diff --git a/packages/edge-bundler/node/config.ts b/packages/edge-bundler/node/config.ts index b65370257b..cc64c1df12 100644 --- a/packages/edge-bundler/node/config.ts +++ b/packages/edge-bundler/node/config.ts @@ -40,8 +40,11 @@ export const isValidOnError = (value: unknown): value is OnError => { return value === 'fail' || value === 'bypass' || value.startsWith('/') } +export type HeadersConfig = Record + interface BaseFunctionConfig { cache?: Cache + header?: HeadersConfig onError?: OnError name?: string generator?: string diff --git a/packages/edge-bundler/node/declaration.ts b/packages/edge-bundler/node/declaration.ts index d112a12f5b..00dd536d40 100644 --- a/packages/edge-bundler/node/declaration.ts +++ b/packages/edge-bundler/node/declaration.ts @@ -1,9 +1,14 @@ -import { FunctionConfig, FunctionConfigWithAllPossibleFields, HTTPMethod, Path } from './config.js' +import { BundleError } from './bundle_error.js' +import { FunctionConfig, FunctionConfigWithAllPossibleFields, HeadersConfig, HTTPMethod, Path } from './config.js' import { FeatureFlags } from './feature_flags.js' +export type HeaderMatch = { pattern: string; style: 'regex' } | { style: 'exists' | 'missing' } +type HeaderMatchers = Record + interface BaseDeclaration { cache?: string function: string + header?: HeadersConfig method?: HTTPMethod | HTTPMethod[] // todo: remove these two after a while and only support in-source config for non-route related configs name?: string @@ -104,7 +109,7 @@ const createDeclarationsFromFunctionConfigs = ( if (!functionsVisited.has(name)) { // If we have a pattern specified, create a declaration for each pattern. if ('pattern' in functionConfig && functionConfig.pattern) { - const { pattern, excludedPattern } = functionConfig + const { header, pattern, excludedPattern } = functionConfig const patterns = Array.isArray(pattern) ? pattern : [pattern] patterns.forEach((singlePattern) => { const declaration: Declaration = { function: name, pattern: singlePattern } @@ -117,12 +122,15 @@ const createDeclarationsFromFunctionConfigs = ( if (excludedPattern) { declaration.excludedPattern = excludedPattern } + if (header) { + declaration.header = header + } declarations.push(declaration) }) } // If we don't have a pattern but we have a path specified, create a declaration for each path. else if ('path' in functionConfig && functionConfig.path) { - const { path, excludedPath } = functionConfig + const { header, path, excludedPath } = functionConfig const paths = Array.isArray(path) ? path : [path] paths.forEach((singlePath) => { @@ -136,6 +144,9 @@ const createDeclarationsFromFunctionConfigs = ( if (excludedPath) { declaration.excludedPath = excludedPath } + if (header) { + declaration.header = header + } declarations.push(declaration) }) } @@ -165,3 +176,29 @@ export const normalizePattern = (pattern: string) => { // Strip leading and forward slashes. return regexp.toString().slice(1, -1) } + +const headerConfigError = `The 'header' configuration property must be an object where keys are strings and values are either booleans or strings (e.g. { "x-header-present": true, "x-header-absent": false, "x-header-matching": "^prefix" }).` + +export const getHeaderMatchers = (headers?: HeadersConfig): HeaderMatchers => { + const matchers: HeaderMatchers = {} + + if (!headers) { + return matchers + } + + if (Object.getPrototypeOf(headers) !== Object.prototype) { + throw new BundleError(new Error(headerConfigError)) + } + + for (const header in headers) { + if (typeof headers[header] === 'boolean') { + matchers[header] = { style: headers[header] ? 'exists' : 'missing' } + } else if (typeof headers[header] === 'string') { + matchers[header] = { style: 'regex', pattern: normalizePattern(headers[header]) } + } else { + throw new BundleError(new Error(headerConfigError)) + } + } + + return matchers +} diff --git a/packages/edge-bundler/node/manifest.test.ts b/packages/edge-bundler/node/manifest.test.ts index f3e94ddfaf..eb2b5a8c33 100644 --- a/packages/edge-bundler/node/manifest.test.ts +++ b/packages/edge-bundler/node/manifest.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest' +import { describe, test, expect } from 'vitest' // @ts-expect-error current tsconfig.json doesn't allow this, but I don't want to change it import { version } from '../package.json' assert { type: 'json' } @@ -572,3 +572,97 @@ test('Generates a manifest with rewrite config', () => { expect(manifest.routes).toEqual(expectedRoutes) expect(manifest.function_config).toEqual(expectedFunctionConfig) }) + +describe('Header matching', () => { + test('Throws a bundling error if the type is incorrect', () => { + const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }] + + expect(() => + generateManifest({ + bundles: [], + + // @ts-expect-error Incorrect type + declarations: [{ function: 'func-1', path: '/f1/*', header: 'foo' }], + functions, + }), + ).toThrowError(BundleError) + + expect(() => + generateManifest({ + bundles: [], + + declarations: [ + { + function: 'func-1', + path: '/f1/*', + header: { + 'x-correct': true, + + // @ts-expect-error Incorrect type + 'x-not-correct': { + problem: true, + }, + }, + }, + ], + functions, + }), + ).toThrowError(BundleError) + }) + + test('Writes header matching rules to the manifest', () => { + const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }] + const declarations: Declaration[] = [ + { + function: 'func-1', + path: '/f1/*', + header: { + 'x-present': true, + 'x-also-present': true, + 'x-absent': false, + 'x-match-prefix': '^prefix(.*)', + 'x-match-exact': 'exact', + 'x-match-suffix': '(.*)suffix$', + }, + }, + ] + const { manifest } = generateManifest({ + bundles: [], + declarations, + functions, + }) + + const expectedRoutes = [ + { + function: 'func-1', + pattern: '^/f1(?:/(.*))/?$', + excluded_patterns: [], + path: '/f1/*', + headers: { + 'x-absent': { + style: 'missing', + }, + 'x-also-present': { + style: 'exists', + }, + 'x-match-exact': { + pattern: '^exact$', + style: 'regex', + }, + 'x-match-prefix': { + pattern: '^prefix(.*)$', + style: 'regex', + }, + 'x-match-suffix': { + pattern: '^(.*)suffix$', + style: 'regex', + }, + 'x-present': { + style: 'exists', + }, + }, + }, + ] + expect(manifest.routes).toEqual(expectedRoutes) + }) +}) diff --git a/packages/edge-bundler/node/manifest.ts b/packages/edge-bundler/node/manifest.ts index 0a7dcad1ee..cc958b3b37 100644 --- a/packages/edge-bundler/node/manifest.ts +++ b/packages/edge-bundler/node/manifest.ts @@ -4,7 +4,7 @@ import { join } from 'path' import type { Bundle } from './bundle.js' import { wrapBundleError } from './bundle_error.js' import { Cache, FunctionConfig, FunctionConfigWithAllPossibleFields, Path } from './config.js' -import { Declaration, normalizePattern } from './declaration.js' +import { Declaration, type HeaderMatch, getHeaderMatchers, normalizePattern } from './declaration.js' import { EdgeFunction } from './edge_function.js' import { FeatureFlags } from './feature_flags.js' import { Layer } from './layer.js' @@ -15,6 +15,7 @@ import { ExtendedURLPattern } from './utils/urlpattern.js' interface Route { function: string + headers?: Record pattern: string excluded_patterns: string[] path?: string @@ -232,6 +233,10 @@ const generateManifest = ({ route.methods = normalizeMethods(declaration.method, func.name) } + if ('header' in declaration) { + route.headers = getHeaderMatchers(declaration.header) + } + if ('path' in declaration) { route.path = declaration.path } diff --git a/packages/edge-bundler/test/fixtures/with_config/netlify/edge-functions/user-func1.ts b/packages/edge-bundler/test/fixtures/with_config/netlify/edge-functions/user-func1.ts index 2577db6dd8..6991c035e3 100644 --- a/packages/edge-bundler/test/fixtures/with_config/netlify/edge-functions/user-func1.ts +++ b/packages/edge-bundler/test/fixtures/with_config/netlify/edge-functions/user-func1.ts @@ -13,5 +13,10 @@ export default async () => { } export const config: Config = { + header: { + "x-must-be-there": true, + "x-must-not-be-there": false, + "x-must-match": "^(foo|bar)$", + }, path: '/user-func1', }