Skip to content

Commit 21e4bc5

Browse files
feat: match edge functions by header (#7439)
* feat: match edge functions by header * refactor: update checks * chore: fix formatting * refactor: remove unnecessary check * fix: surface build errors
1 parent 69134d2 commit 21e4bc5

File tree

8 files changed

+127
-6
lines changed

8 files changed

+127
-6
lines changed

src/lib/edge-functions/proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export const initializeProxy = async ({
165165
await registry.initialize()
166166

167167
const url = new URL(req.url!, `http://${LOCAL_HOST}:${mainPort}`)
168-
const { functionNames, invocationMetadata } = registry.matchURLPath(url.pathname, req.method!)
168+
const { functionNames, invocationMetadata } = registry.matchURLPath(url.pathname, req.method!, req.headers)
169169

170170
if (functionNames.length === 0) {
171171
return

src/lib/edge-functions/registry.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { FeatureFlags } from '../../utils/feature-flags.js'
2121
import { MultiMap } from '../../utils/multimap.js'
2222
import { getPathInProject } from '../settings.js'
2323

24-
import { INTERNAL_EDGE_FUNCTIONS_FOLDER } from './consts.js'
24+
import { DIST_IMPORT_MAP_PATH, INTERNAL_EDGE_FUNCTIONS_FOLDER } from './consts.js'
2525

2626
type DependencyCache = Record<string, string[]>
2727
type EdgeFunctionEvent = 'buildError' | 'loaded' | 'reloaded' | 'reloading' | 'removed'
@@ -166,8 +166,8 @@ export class EdgeFunctionsRegistry {
166166
this.functions.forEach((func) => {
167167
this.logEvent('loaded', { functionName: func.name, warnings: warnings[func.name] })
168168
})
169-
} catch {
170-
// no-op
169+
} catch (error) {
170+
this.logEvent('buildError', { buildError: error as NodeJS.ErrnoException })
171171
}
172172
}
173173

@@ -408,7 +408,7 @@ export class EdgeFunctionsRegistry {
408408
* Returns the functions in the registry that should run for a given URL path
409409
* and HTTP method, based on the routes registered for each function.
410410
*/
411-
matchURLPath(urlPath: string, method: string) {
411+
matchURLPath(urlPath: string, method: string, headers: Record<string, string | string[] | undefined>) {
412412
const functionNames: string[] = []
413413
const routeIndexes: number[] = []
414414

@@ -421,6 +421,34 @@ export class EdgeFunctionsRegistry {
421421
return
422422
}
423423

424+
if (route.headers) {
425+
const headerMatches = Object.entries(route.headers).every(([headerName, headerMatch]) => {
426+
const headerValueString = Array.isArray(headers[headerName])
427+
? headers[headerName].filter(Boolean).join(',')
428+
: headers[headerName]
429+
430+
if (headerMatch?.matcher === 'exists') {
431+
return headers[headerName] !== undefined
432+
}
433+
434+
if (headerMatch?.matcher === 'missing') {
435+
return headers[headerName] === undefined
436+
}
437+
438+
if (headerValueString && headerMatch?.matcher === 'regex') {
439+
const pattern = new RegExp(headerMatch.pattern)
440+
441+
return pattern.test(headerValueString)
442+
}
443+
444+
return false
445+
})
446+
447+
if (!headerMatches) {
448+
return
449+
}
450+
}
451+
424452
const isExcludedForFunction = this.manifest?.function_config[route.function]?.excluded_patterns?.some((pattern) =>
425453
new RegExp(pattern).test(urlPath),
426454
)
@@ -533,6 +561,10 @@ export class EdgeFunctionsRegistry {
533561
return join(this.projectDir, getPathInProject([INTERNAL_EDGE_FUNCTIONS_FOLDER]))
534562
}
535563

564+
private get internalImportMapPath() {
565+
return join(this.projectDir, getPathInProject([DIST_IMPORT_MAP_PATH]))
566+
}
567+
536568
private async readDeployConfig() {
537569
const manifestPath = join(this.internalDirectory, 'manifest.json')
538570
try {
@@ -615,7 +647,7 @@ export class EdgeFunctionsRegistry {
615647
}
616648

617649
private async setupWatcherForDirectory() {
618-
const ignored = [`${this.servePath}/**`]
650+
const ignored = [`${this.servePath}/**`, this.internalImportMapPath]
619651
const watcher = await watchDebounced(this.projectDir, {
620652
ignored,
621653
onAdd: () => this.checkForAddedOrDeletedFunctions(),
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[build]
2+
publish = "public"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default async (request) => {
2+
return new Response('header-exists-matched')
3+
}
4+
5+
export const config = {
6+
path: '/header-exists',
7+
header: {
8+
'x-test-header': true,
9+
},
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default async (request) => {
2+
return new Response('header-missing-matched')
3+
}
4+
5+
export const config = {
6+
path: '/header-missing',
7+
header: {
8+
'x-forbidden-header': false,
9+
},
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default async (request) => {
2+
return new Response('header-regex-matched')
3+
}
4+
5+
export const config = {
6+
path: '/header-regex',
7+
header: {
8+
'x-api-key': '^api-key-\\d+$',
9+
},
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Header Matching Test</title>
5+
</head>
6+
<body>
7+
<h1>Header Matching Test Site</h1>
8+
<p>This is a test site for header matching edge functions.</p>
9+
</body>
10+
</html>

tests/integration/commands/dev/edge-functions.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,51 @@ describe.skipIf(isWindows)('edge functions', async () => {
287287
})
288288
},
289289
)
290+
291+
await setupFixtureTests(
292+
'dev-server-with-header-matching-edge-functions',
293+
{ devServer: true, mockApi: { routes } },
294+
() => {
295+
test<FixtureTestContext>('should match edge functions with header exists condition', async ({ devServer }) => {
296+
// Request without header - should not match
297+
const responseWithoutHeader = await fetch(`http://localhost:${devServer!.port}/header-exists`)
298+
expect(responseWithoutHeader.status).toBe(404)
299+
300+
// Request with header - should match
301+
const responseWithHeader = await fetch(`http://localhost:${devServer!.port}/header-exists`, {
302+
headers: { 'x-test-header': 'any-value' },
303+
})
304+
expect(responseWithHeader.status).toBe(200)
305+
expect(await responseWithHeader.text()).toBe('header-exists-matched')
306+
})
307+
308+
test<FixtureTestContext>('should match edge functions with header missing condition', async ({ devServer }) => {
309+
// Request without header - should match
310+
const responseWithoutHeader = await fetch(`http://localhost:${devServer!.port}/header-missing`)
311+
expect(responseWithoutHeader.status).toBe(200)
312+
expect(await responseWithoutHeader.text()).toBe('header-missing-matched')
313+
314+
// Request with header - should not match
315+
const responseWithHeader = await fetch(`http://localhost:${devServer!.port}/header-missing`, {
316+
headers: { 'x-forbidden-header': 'any-value' },
317+
})
318+
expect(responseWithHeader.status).toBe(404)
319+
})
320+
321+
test<FixtureTestContext>('should match edge functions with header regex condition', async ({ devServer }) => {
322+
// Request with non-matching header - should not match
323+
const responseWithBadHeader = await fetch(`http://localhost:${devServer!.port}/header-regex`, {
324+
headers: { 'x-api-key': 'invalid-key' },
325+
})
326+
expect(responseWithBadHeader.status).toBe(404)
327+
328+
// Request with matching header - should match
329+
const responseWithGoodHeader = await fetch(`http://localhost:${devServer!.port}/header-regex`, {
330+
headers: { 'x-api-key': 'api-key-123' },
331+
})
332+
expect(responseWithGoodHeader.status).toBe(200)
333+
expect(await responseWithGoodHeader.text()).toBe('header-regex-matched')
334+
})
335+
},
336+
)
290337
})

0 commit comments

Comments
 (0)