Skip to content
This repository was archived by the owner on Mar 20, 2025. It is now read-only.

Commit 4f3a3d4

Browse files
authored
feat: ratelimit config from source (#583)
* feat: ratelimit config from source * feat: config consistent with lambda * chore: ratelimit -> rate_limit
1 parent 50edb15 commit 4f3a3d4

File tree

5 files changed

+241
-8
lines changed

5 files changed

+241
-8
lines changed

Diff for: node/config.test.ts

+62-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { bundle } from './bundler.js'
1313
import { FunctionConfig, getFunctionConfig } from './config.js'
1414
import type { Declaration } from './declaration.js'
1515
import { ImportMap } from './import_map.js'
16+
import { RateLimitAction, RateLimitAggregator } from './rate_limit.js'
1617

1718
const importMapFile = {
1819
baseURL: new URL('file:///some/path/import-map.json'),
@@ -83,7 +84,7 @@ const functions: TestFunctions[] = [
8384
},
8485
{
8586
testName: 'config with wrong onError',
86-
name: 'func7',
87+
name: 'func6',
8788
source: `
8889
export default async () => new Response("Hello from function two")
8990
export const config = { onError: "foo" }
@@ -93,7 +94,7 @@ const functions: TestFunctions[] = [
9394
{
9495
testName: 'config with `path`',
9596
expectedConfig: { path: '/home' },
96-
name: 'func6',
97+
name: 'func7',
9798
source: `
9899
export default async () => new Response("Hello from function three")
99100
@@ -108,17 +109,74 @@ const functions: TestFunctions[] = [
108109
name: 'a displayName',
109110
onError: 'bypass',
110111
},
111-
name: 'func6',
112+
name: 'func8',
112113
source: `
113114
export default async () => new Response("Hello from function three")
114115
115-
export const config = { path: "/home",
116+
export const config = {
117+
path: "/home",
116118
generator: '@netlify/[email protected]',
117119
name: 'a displayName',
118120
onError: 'bypass',
119121
}
120122
`,
121123
},
124+
{
125+
testName: 'config with ratelimit',
126+
expectedConfig: {
127+
path: '/ratelimit',
128+
name: 'a limit rate',
129+
rateLimit: {
130+
windowSize: 10,
131+
windowLimit: 100,
132+
aggregateBy: [RateLimitAggregator.IP, RateLimitAggregator.Domain],
133+
},
134+
},
135+
name: 'func9',
136+
source: `
137+
export default async () => new Response("Rate my limits")
138+
139+
export const config = {
140+
path: "/ratelimit",
141+
rateLimit: {
142+
windowSize: 10,
143+
windowLimit: 100,
144+
aggregateBy: ["ip", "domain"],
145+
},
146+
name: 'a limit rate',
147+
}
148+
`,
149+
},
150+
{
151+
testName: 'config with rewrite',
152+
expectedConfig: {
153+
path: '/rewrite',
154+
name: 'a limit rewrite',
155+
rateLimit: {
156+
action: RateLimitAction.Rewrite,
157+
to: '/rewritten',
158+
windowSize: 20,
159+
windowLimit: 200,
160+
aggregateBy: [RateLimitAggregator.IP, RateLimitAggregator.Domain],
161+
},
162+
},
163+
name: 'func9',
164+
source: `
165+
export default async () => new Response("Rate my limits")
166+
167+
export const config = {
168+
path: "/rewrite",
169+
rateLimit: {
170+
action: "rewrite",
171+
to: "/rewritten",
172+
windowSize: 20,
173+
windowLimit: 200,
174+
aggregateBy: ["ip", "domain"],
175+
},
176+
name: 'a limit rewrite',
177+
}
178+
`,
179+
},
122180
]
123181
describe('`getFunctionConfig` extracts configuration properties from function file', () => {
124182
test.each(functions)('$testName', async (func) => {

Diff for: node/config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { EdgeFunction } from './edge_function.js'
1010
import { ImportMap } from './import_map.js'
1111
import { Logger } from './logger.js'
1212
import { getPackagePath } from './package_json.js'
13+
import { RateLimit } from './rate_limit.js'
1314

1415
enum ConfigExitCode {
1516
Success = 0,
@@ -46,6 +47,7 @@ export interface FunctionConfig {
4647
name?: string
4748
generator?: string
4849
method?: HTTPMethod | HTTPMethod[]
50+
rateLimit?: RateLimit
4951
}
5052

5153
const getConfigExtractor = () => {

Diff for: node/manifest.test.ts

+86
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BundleError } from './bundle_error.js'
99
import { Cache, FunctionConfig } from './config.js'
1010
import { Declaration } from './declaration.js'
1111
import { generateManifest } from './manifest.js'
12+
import { RateLimitAction, RateLimitAggregator } from './rate_limit.js'
1213

1314
test('Generates a manifest with different bundles', () => {
1415
const bundle1 = {
@@ -486,3 +487,88 @@ test('Returns functions without a declaration and unrouted functions', () => {
486487
expect(declarationsWithoutFunction).toEqual(['func-3'])
487488
expect(unroutedFunctions).toEqual(['func-2', 'func-4'])
488489
})
490+
491+
test('Generates a manifest with rate limit config', () => {
492+
const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }]
493+
const declarations: Declaration[] = [{ function: 'func-1', path: '/f1/*' }]
494+
495+
const userFunctionConfig: Record<string, FunctionConfig> = {
496+
'func-1': { rateLimit: { windowLimit: 100, windowSize: 60 } },
497+
}
498+
const { manifest } = generateManifest({
499+
bundles: [],
500+
declarations,
501+
functions,
502+
userFunctionConfig,
503+
})
504+
505+
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [], path: '/f1/*' }]
506+
const expectedFunctionConfig = {
507+
'func-1': {
508+
traffic_rules: {
509+
action: {
510+
type: 'rate_limit',
511+
config: {
512+
rate_limit_config: {
513+
window_limit: 100,
514+
window_size: 60,
515+
algorithm: 'sliding_window',
516+
},
517+
aggregate: {
518+
keys: [{ type: 'domain' }],
519+
},
520+
},
521+
},
522+
},
523+
},
524+
}
525+
expect(manifest.routes).toEqual(expectedRoutes)
526+
expect(manifest.function_config).toEqual(expectedFunctionConfig)
527+
})
528+
529+
test('Generates a manifest with rewrite config', () => {
530+
const functions = [{ name: 'func-1', path: '/path/to/func-1.ts' }]
531+
const declarations: Declaration[] = [{ function: 'func-1', path: '/f1/*' }]
532+
533+
const userFunctionConfig: Record<string, FunctionConfig> = {
534+
'func-1': {
535+
rateLimit: {
536+
action: RateLimitAction.Rewrite,
537+
to: '/new_path',
538+
windowLimit: 100,
539+
windowSize: 60,
540+
aggregateBy: [RateLimitAggregator.Domain, RateLimitAggregator.IP],
541+
},
542+
},
543+
}
544+
const { manifest } = generateManifest({
545+
bundles: [],
546+
declarations,
547+
functions,
548+
userFunctionConfig,
549+
})
550+
551+
const expectedRoutes = [{ function: 'func-1', pattern: '^/f1(?:/(.*))/?$', excluded_patterns: [], path: '/f1/*' }]
552+
const expectedFunctionConfig = {
553+
'func-1': {
554+
traffic_rules: {
555+
action: {
556+
type: 'rewrite',
557+
config: {
558+
to: '/new_path',
559+
rate_limit_config: {
560+
window_limit: 100,
561+
window_size: 60,
562+
algorithm: 'sliding_window',
563+
},
564+
aggregate: {
565+
keys: [{ type: 'domain' }, { type: 'ip' }],
566+
},
567+
},
568+
},
569+
},
570+
},
571+
}
572+
expect(manifest.routes).toEqual(expectedRoutes)
573+
expect(manifest.function_config).toEqual(expectedFunctionConfig)
574+
})

Diff for: node/manifest.ts

+61-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { EdgeFunction } from './edge_function.js'
99
import { FeatureFlags } from './feature_flags.js'
1010
import { Layer } from './layer.js'
1111
import { getPackageVersion } from './package_json.js'
12+
import { RateLimit, RateLimitAction, RateLimitAlgorithm, RateLimitAggregator } from './rate_limit.js'
1213
import { nonNullable } from './utils/non_nullable.js'
1314
import { ExtendedURLPattern } from './utils/urlpattern.js'
1415

@@ -20,12 +21,33 @@ interface Route {
2021
methods?: string[]
2122
}
2223

24+
interface TrafficRules {
25+
action: {
26+
type: string
27+
config: {
28+
rate_limit_config: {
29+
algorithm: string
30+
window_size: number
31+
window_limit: number
32+
}
33+
aggregate: {
34+
keys: {
35+
type: string
36+
}[]
37+
}
38+
to?: string
39+
}
40+
}
41+
}
42+
2343
export interface EdgeFunctionConfig {
2444
excluded_patterns: string[]
2545
on_error?: string
2646
generator?: string
2747
name?: string
48+
traffic_rules?: TrafficRules
2849
}
50+
2951
interface Manifest {
3052
bundler_version: string
3153
bundles: { asset: string; format: string }[]
@@ -122,26 +144,35 @@ const generateManifest = ({
122144
const routedFunctions = new Set<string>()
123145
const declarationsWithoutFunction = new Set<string>()
124146

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

131153
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
132154

133-
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError }
155+
manifestFunctionConfig[name] = {
156+
...manifestFunctionConfig[name],
157+
on_error: onError,
158+
traffic_rules: getTrafficRulesConfig(rateLimit),
159+
}
134160
}
135161

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

142168
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
143169

144-
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError, ...rest }
170+
manifestFunctionConfig[name] = {
171+
...manifestFunctionConfig[name],
172+
on_error: onError,
173+
traffic_rules: getTrafficRulesConfig(rateLimit),
174+
...rest,
175+
}
145176
}
146177

147178
declarations.forEach((declaration) => {
@@ -202,6 +233,32 @@ const generateManifest = ({
202233
return { declarationsWithoutFunction: [...declarationsWithoutFunction], manifest, unroutedFunctions }
203234
}
204235

236+
const getTrafficRulesConfig = (rl: RateLimit | undefined) => {
237+
if (rl === undefined) {
238+
return
239+
}
240+
241+
const rateLimitAgg = Array.isArray(rl.aggregateBy) ? rl.aggregateBy : [RateLimitAggregator.Domain]
242+
const rewriteConfig = 'to' in rl && typeof rl.to === 'string' ? { to: rl.to } : undefined
243+
244+
return {
245+
action: {
246+
type: rl.action || RateLimitAction.Limit,
247+
config: {
248+
...rewriteConfig,
249+
rate_limit_config: {
250+
window_limit: rl.windowLimit,
251+
window_size: rl.windowSize,
252+
algorithm: RateLimitAlgorithm.SlidingWindow,
253+
},
254+
aggregate: {
255+
keys: rateLimitAgg.map((agg) => ({ type: agg })),
256+
},
257+
},
258+
},
259+
}
260+
}
261+
205262
const pathToRegularExpression = (path: string) => {
206263
if (!path) {
207264
return null

Diff for: node/rate_limit.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export enum RateLimitAlgorithm {
2+
SlidingWindow = 'sliding_window',
3+
}
4+
5+
export enum RateLimitAggregator {
6+
Domain = 'domain',
7+
IP = 'ip',
8+
}
9+
10+
export enum RateLimitAction {
11+
Limit = 'rate_limit',
12+
Rewrite = 'rewrite',
13+
}
14+
15+
interface SlidingWindow {
16+
windowLimit: number
17+
windowSize: number
18+
}
19+
20+
export type RewriteActionConfig = SlidingWindow & {
21+
to: string
22+
}
23+
24+
interface RateLimitConfig {
25+
action?: RateLimitAction
26+
aggregateBy?: RateLimitAggregator | RateLimitAggregator[]
27+
algorithm?: RateLimitAlgorithm
28+
}
29+
30+
export type RateLimit = RateLimitConfig & (SlidingWindow | RewriteActionConfig)

0 commit comments

Comments
 (0)