Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default createRouter({
accountId: '12345',
zoneId: 'abcdef',
routes: [
'*example.com/*',
'https://*.example.com/*',
],
auth: [
{
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createRouter } from './src/cloudflare-router'
export { BasicAuthMethod, IPAuthMethod, Deployment, Config, Routes, DEFAULT_CONFIG } from './src/config'
export { CompiledDeployment } from './src/utils/deployment-for-request'
6 changes: 5 additions & 1 deletion src/cloudflare-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { Config } from './config'
import handleRequest from './utils/handle-request'
import normalizeRequest from './utils/normalize-request'
import { withAuth } from './utils/with-auth'
import { compileDeployments } from './utils/deployment-for-request'

export const createRouter = (config: Config) => {
// Pre-compile URLPattern instances once at creation time
const compiledDeployments = compileDeployments(config.deployments ?? [])

return {
async fetch(request: Request, _env: Record<string, unknown>, _ctx: ExecutionContext): Promise<Response> {
return withAuth(request, config, async () => {
return withAuth(request, config, compiledDeployments, async () => {
const { request: normalizedReq, cache } = normalizeRequest(request, config.routes, config.isS3Site)
const edgeCacheTtl = cache && config.edgeCacheTtl ? config.edgeCacheTtl : 0
return handleRequest(normalizedReq, edgeCacheTtl)
Expand Down
36 changes: 26 additions & 10 deletions src/utils/deployment-for-request.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { Config, Deployment } from '../config'
import { Deployment } from '../config'

export const deploymentForRequest = (request: Request, config: Config): Deployment | undefined => {
return (config.deployments ?? []).find(deployment => {
return deployment.routes.find(route => {
const sanitizedRoute = route.replace(/[^a-zA-Z0-9*\.\-\/]/g, '') // We only really want to allow the pattern *.example.com/*
const regexedRoute = sanitizedRoute.replace(/\./g, '\\.').replace(/\*/g, '(.*)')
const normalizedUrl = request.url.replace(/https?:\/\//, '')
return normalizedUrl.match(new RegExp(`^${regexedRoute}$`))
})
})
export interface CompiledDeployment {
deployment: Deployment
patterns: URLPattern[]
}

/**
* Pre-compiles deployment route patterns into URLPattern instances.
* Called once at router creation time to avoid per-request compilation.
*/
export const compileDeployments = (deployments: Deployment[]): CompiledDeployment[] => {
return deployments.map(deployment => ({
deployment,
patterns: deployment.routes.map(route => new URLPattern(route)),
}))
}

/**
* Finds the deployment matching the incoming request URL.
* Uses pre-compiled URLPattern instances for safe, fast matching.
*/
export const deploymentForRequest = (request: Request, compiledDeployments: CompiledDeployment[]): Deployment | undefined => {
const match = compiledDeployments.find(({ patterns }) =>
patterns.some(pattern => pattern.test(request.url))
)
return match?.deployment
}
8 changes: 4 additions & 4 deletions src/utils/with-auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Config } from '../config'
import { deploymentForRequest } from './deployment-for-request'
import { CompiledDeployment, deploymentForRequest } from './deployment-for-request'

const getCredentialsFromAuthorizationHeader = (authorizationHeader: string | undefined | null) => {
const encoded = (authorizationHeader || '').replace('Basic ', '')
Expand Down Expand Up @@ -40,15 +40,15 @@ async function timingSafeEqual(a: string, b: string): Promise<boolean> {
}

// Ensures requests are authenticated before executing the callback
export const withAuth = async (request: Request, config: Config, callback: (request: Request) => Promise<Response> | Response): Promise<Response> => {
export const withAuth = async (request: Request, config: Config, compiledDeployments: CompiledDeployment[], callback: (request: Request) => Promise<Response> | Response): Promise<Response> => {
// If no deployments are defined, then just allow all requests to passthrough
// We also allow options requests here to get cors headers from origin
if (!config.deployments?.length || request.method === 'OPTIONS') {
if (compiledDeployments.length === 0 || request.method === 'OPTIONS') {
return callback(request)
}

// Look for a deployment to ensure we have a valid config
const deployment = deploymentForRequest(request, config)
const deployment = deploymentForRequest(request, compiledDeployments)
if (deployment === undefined) {
return new Response('Unknown deployment', { status: 404 })
}
Expand Down
66 changes: 39 additions & 27 deletions test/utils/deployment-for-request.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { test, expect } from 'vitest'
import { Config, Deployment } from '../../src/config'
import { deploymentForRequest } from '../../src/utils/deployment-for-request'
import { compileDeployments, deploymentForRequest } from '../../src/utils/deployment-for-request'

const MOCK_DEPLOYMENT_1: Deployment = {
accountId: '12345',
zoneId: '12345',
routes: [
'*example.com/*',
'https://*.example.com/*',
],
auth: [
{
Expand All @@ -18,43 +18,55 @@ const MOCK_DEPLOYMENT_1: Deployment = {
}

test('it finds a deployment for matching request', () => {
const request = new Request('https://example.com/explore')
const config: Config = {
deployments: [
MOCK_DEPLOYMENT_1,
],
routes: {},
edgeCacheTtl: 360
}
const request = new Request('https://app.example.com/explore')
const compiled = compileDeployments([MOCK_DEPLOYMENT_1])

const deployment = deploymentForRequest(request, config)
const deployment = deploymentForRequest(request, compiled)
expect(deployment).toEqual(MOCK_DEPLOYMENT_1)
})

test('it finds a deployment for matching subdomain', () => {
const request = new Request('https://api.example.com/explore')
const config: Config = {
deployments: [
MOCK_DEPLOYMENT_1,
],
routes: {},
edgeCacheTtl: 360
}
const compiled = compileDeployments([MOCK_DEPLOYMENT_1])

const deployment = deploymentForRequest(request, config)
const deployment = deploymentForRequest(request, compiled)
expect(deployment).toEqual(MOCK_DEPLOYMENT_1)
})

test('it returns undefined when there is no matching request', () => {
const request = new Request('https://example.co.uk/explore')
const config: Config = {
deployments: [
MOCK_DEPLOYMENT_1,
],
routes: {},
edgeCacheTtl: 360
}
const compiled = compileDeployments([MOCK_DEPLOYMENT_1])

const deployment = deploymentForRequest(request, config)
const deployment = deploymentForRequest(request, compiled)
expect(deployment).toEqual(undefined)
})

test('it matches exact hostname pattern', () => {
const deployment: Deployment = {
accountId: '1',
zoneId: '1',
routes: ['https://exact.example.com/*'],
}
const compiled = compileDeployments([deployment])

expect(deploymentForRequest(new Request('https://exact.example.com/page'), compiled)).toEqual(deployment)
expect(deploymentForRequest(new Request('https://other.example.com/page'), compiled)).toBeUndefined()
})

test('it selects the correct deployment from multiple deployments', () => {
const deploymentA: Deployment = {
accountId: '1',
zoneId: '1',
routes: ['https://*.alpha.com/*'],
}
const deploymentB: Deployment = {
accountId: '2',
zoneId: '2',
routes: ['https://*.beta.com/*'],
}
const compiled = compileDeployments([deploymentA, deploymentB])

expect(deploymentForRequest(new Request('https://app.alpha.com/dashboard'), compiled)).toEqual(deploymentA)
expect(deploymentForRequest(new Request('https://app.beta.com/dashboard'), compiled)).toEqual(deploymentB)
expect(deploymentForRequest(new Request('https://app.gamma.com/dashboard'), compiled)).toBeUndefined()
})
Loading
Loading