Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ratelimit config from source #583

Merged
merged 4 commits into from Mar 12, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 62 additions & 4 deletions node/config.test.ts
Expand Up @@ -13,6 +13,7 @@ import { bundle } from './bundler.js'
import { FunctionConfig, getFunctionConfig } from './config.js'
import type { Declaration } from './declaration.js'
import { ImportMap } from './import_map.js'
import { RateLimitAction, RateLimitAggregator } from './rate_limit.js'

const importMapFile = {
baseURL: new URL('file:///some/path/import-map.json'),
Expand Down Expand Up @@ -83,7 +84,7 @@ const functions: TestFunctions[] = [
},
{
testName: 'config with wrong onError',
name: 'func7',
name: 'func6',
source: `
export default async () => new Response("Hello from function two")
export const config = { onError: "foo" }
Expand All @@ -93,7 +94,7 @@ const functions: TestFunctions[] = [
{
testName: 'config with `path`',
expectedConfig: { path: '/home' },
name: 'func6',
name: 'func7',
source: `
export default async () => new Response("Hello from function three")

Expand All @@ -108,17 +109,74 @@ const functions: TestFunctions[] = [
name: 'a displayName',
onError: 'bypass',
},
name: 'func6',
name: 'func8',
source: `
export default async () => new Response("Hello from function three")

export const config = { path: "/home",
export const config = {
path: "/home",
generator: '@netlify/fake-plugin@1.0.0',
name: 'a displayName',
onError: 'bypass',
}
`,
},
{
testName: 'config with ratelimit',
expectedConfig: {
path: '/ratelimit',
name: 'a limit rate',
rateLimit: {
windowSize: 10,
windowLimit: 100,
aggregateBy: [RateLimitAggregator.IP, RateLimitAggregator.Domain],
},
},
name: 'func9',
source: `
export default async () => new Response("Rate my limits")

export const config = {
path: "/ratelimit",
rateLimit: {
windowSize: 10,
windowLimit: 100,
aggregateBy: ["ip", "domain"],
},
name: 'a limit rate',
}
`,
},
{
testName: 'config with rewrite',
expectedConfig: {
path: '/rewrite',
name: 'a limit rewrite',
rateLimit: {
action: RateLimitAction.Rewrite,
to: '/rewritten',
windowSize: 20,
windowLimit: 200,
aggregateBy: [RateLimitAggregator.IP, RateLimitAggregator.Domain],
},
},
name: 'func9',
source: `
export default async () => new Response("Rate my limits")

export const config = {
path: "/rewrite",
rateLimit: {
action: "rewrite",
to: "/rewritten",
windowSize: 20,
windowLimit: 200,
aggregateBy: ["ip", "domain"],
},
name: 'a limit rewrite',
}
`,
},
]
describe('`getFunctionConfig` extracts configuration properties from function file', () => {
test.each(functions)('$testName', async (func) => {
Expand Down
2 changes: 2 additions & 0 deletions node/config.ts
Expand Up @@ -10,6 +10,7 @@ import { EdgeFunction } from './edge_function.js'
import { ImportMap } from './import_map.js'
import { Logger } from './logger.js'
import { getPackagePath } from './package_json.js'
import { RateLimit } from './rate_limit.js'

enum ConfigExitCode {
Success = 0,
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface FunctionConfig {
name?: string
generator?: string
method?: HTTPMethod | HTTPMethod[]
rateLimit?: RateLimit
}

const getConfigExtractor = () => {
Expand Down
86 changes: 86 additions & 0 deletions node/manifest.test.ts
Expand Up @@ -9,6 +9,7 @@ import { BundleError } from './bundle_error.js'
import { Cache, FunctionConfig } from './config.js'
import { Declaration } from './declaration.js'
import { generateManifest } from './manifest.js'
import { RateLimitAction, RateLimitAggregator } from './rate_limit.js'

test('Generates a manifest with different bundles', () => {
const bundle1 = {
Expand Down Expand Up @@ -486,3 +487,88 @@ test('Returns functions without a declaration and unrouted functions', () => {
expect(declarationsWithoutFunction).toEqual(['func-3'])
expect(unroutedFunctions).toEqual(['func-2', 'func-4'])
})

test('Generates a manifest with rate limit config', () => {
const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }]
const declarations: Declaration[] = [{ function: 'func-1', path: '/f1/*' }]

const userFunctionConfig: Record<string, FunctionConfig> = {
'func-1': { rateLimit: { windowLimit: 100, windowSize: 60 } },
}
const { manifest } = generateManifest({
bundles: [],
declarations,
functions,
userFunctionConfig,
})

const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [], path: '/f1/*' }]
const expectedFunctionConfig = {
'func-1': {
traffic_rules: {
action: {
type: 'rate_limit',
config: {
rate_limit_config: {
window_limit: 100,
window_size: 60,
algorithm: 'sliding_window',
},
aggregate: {
keys: [{ type: 'domain' }],
},
},
},
},
},
}
expect(manifest.routes).toEqual(expectedRoutes)
expect(manifest.function_config).toEqual(expectedFunctionConfig)
})

test('Generates a manifest with rewrite config', () => {
const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }]
const declarations: Declaration[] = [{ function: 'func-1', path: '/f1/*' }]

const userFunctionConfig: Record<string, FunctionConfig> = {
'func-1': {
rateLimit: {
action: RateLimitAction.Rewrite,
to: '/new_path',
windowLimit: 100,
windowSize: 60,
aggregateBy: [RateLimitAggregator.Domain, RateLimitAggregator.IP],
},
},
}
const { manifest } = generateManifest({
bundles: [],
declarations,
functions,
userFunctionConfig,
})

const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [], path: '/f1/*' }]
const expectedFunctionConfig = {
'func-1': {
traffic_rules: {
action: {
type: 'rewrite',
config: {
to: '/new_path',
rate_limit_config: {
window_limit: 100,
window_size: 60,
algorithm: 'sliding_window',
},
aggregate: {
keys: [{ type: 'domain' }, { type: 'ip' }],
},
},
},
},
},
}
expect(manifest.routes).toEqual(expectedRoutes)
expect(manifest.function_config).toEqual(expectedFunctionConfig)
})
65 changes: 61 additions & 4 deletions node/manifest.ts
Expand Up @@ -9,6 +9,7 @@ import { EdgeFunction } from './edge_function.js'
import { FeatureFlags } from './feature_flags.js'
import { Layer } from './layer.js'
import { getPackageVersion } from './package_json.js'
import { RateLimit, RateLimitAction, RateLimitAlgorithm, RateLimitAggregator } from './rate_limit.js'
import { nonNullable } from './utils/non_nullable.js'
import { ExtendedURLPattern } from './utils/urlpattern.js'

Expand All @@ -20,12 +21,33 @@ interface Route {
methods?: string[]
}

interface TrafficRules {
action: {
type: string
config: {
rate_limit_config: {
algorithm: string
window_size: number
window_limit: number
}
aggregate: {
keys: {
type: string
}[]
}
to?: string
}
}
}

export interface EdgeFunctionConfig {
excluded_patterns: string[]
on_error?: string
generator?: string
name?: string
traffic_rules?: TrafficRules
}

interface Manifest {
bundler_version: string
bundles: { asset: string; format: string }[]
Expand Down Expand Up @@ -122,26 +144,35 @@ const generateManifest = ({
const routedFunctions = new Set<string>()
const declarationsWithoutFunction = new Set<string>()

for (const [name, { excludedPath, onError }] of Object.entries(userFunctionConfig)) {
for (const [name, { excludedPath, onError, rateLimit }] of Object.entries(userFunctionConfig)) {
// If the config block is for a function that is not defined, discard it.
if (manifestFunctionConfig[name] === undefined) {
continue
}

addExcludedPatterns(name, manifestFunctionConfig, excludedPath)

manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError }
manifestFunctionConfig[name] = {
...manifestFunctionConfig[name],
on_error: onError,
traffic_rules: getTrafficRulesConfig(rateLimit),
}
}

for (const [name, { excludedPath, path, onError, ...rest }] of Object.entries(internalFunctionConfig)) {
for (const [name, { excludedPath, path, onError, rateLimit, ...rest }] of Object.entries(internalFunctionConfig)) {
// If the config block is for a function that is not defined, discard it.
if (manifestFunctionConfig[name] === undefined) {
continue
}

addExcludedPatterns(name, manifestFunctionConfig, excludedPath)

manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError, ...rest }
manifestFunctionConfig[name] = {
...manifestFunctionConfig[name],
on_error: onError,
traffic_rules: getTrafficRulesConfig(rateLimit),
...rest,
}
}

declarations.forEach((declaration) => {
Expand Down Expand Up @@ -202,6 +233,32 @@ const generateManifest = ({
return { declarationsWithoutFunction: [...declarationsWithoutFunction], manifest, unroutedFunctions }
}

const getTrafficRulesConfig = (rl: RateLimit | undefined) => {
if (rl === undefined) {
return
}

const rateLimitAgg = Array.isArray(rl.aggregateBy) ? rl.aggregateBy : [RateLimitAggregator.Domain]
const rewriteConfig = 'to' in rl && typeof rl.to === 'string' ? { to: rl.to } : undefined

return {
action: {
type: rl.action || RateLimitAction.Limit,
config: {
...rewriteConfig,
rate_limit_config: {
window_limit: rl.windowLimit,
window_size: rl.windowSize,
algorithm: RateLimitAlgorithm.SlidingWindow,
},
aggregate: {
keys: rateLimitAgg.map((agg) => ({ type: agg })),
},
},
},
}
}

const pathToRegularExpression = (path: string) => {
if (!path) {
return null
Expand Down
30 changes: 30 additions & 0 deletions node/rate_limit.ts
@@ -0,0 +1,30 @@
export enum RateLimitAlgorithm {
SlidingWindow = 'sliding_window',
}

export enum RateLimitAggregator {
Domain = 'domain',
IP = 'ip',
}

export enum RateLimitAction {
Limit = 'rate_limit',
Rewrite = 'rewrite',
}

interface SlidingWindow {
windowLimit: number
windowSize: number
}

export type RewriteActionConfig = SlidingWindow & {
to: string
}

interface RateLimitConfig {
action?: RateLimitAction
aggregateBy?: RateLimitAggregator | RateLimitAggregator[]
algorithm?: RateLimitAlgorithm
}

export type RateLimit = RateLimitConfig & (SlidingWindow | RewriteActionConfig)