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

Commit 2069e19

Browse files
committed
feat: ratelimit config from source
1 parent f9950b2 commit 2069e19

File tree

5 files changed

+247
-8
lines changed

5 files changed

+247
-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 './ratelimit.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 './ratelimit.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 './ratelimit.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_config: {
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_config: {
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

+67-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ 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 {
13+
Ratelimit,
14+
RewriteActionConfig,
15+
RatelimitAction,
16+
RatelimitAlgorithm,
17+
RatelimitAggregator,
18+
} from './ratelimit.js'
1219
import { nonNullable } from './utils/non_nullable.js'
1320
import { ExtendedURLPattern } from './utils/urlpattern.js'
1421

@@ -20,12 +27,33 @@ interface Route {
2027
methods?: string[]
2128
}
2229

30+
interface TrafficRulesConfig {
31+
action: {
32+
type: string
33+
config: {
34+
rate_limit_config: {
35+
algorithm: string
36+
window_size: number
37+
window_limit: number
38+
}
39+
aggregate: {
40+
keys: {
41+
type: string
42+
}[]
43+
}
44+
to?: string
45+
}
46+
}
47+
}
48+
2349
export interface EdgeFunctionConfig {
2450
excluded_patterns: string[]
2551
on_error?: string
2652
generator?: string
2753
name?: string
54+
traffic_rules_config?: TrafficRulesConfig
2855
}
56+
2957
interface Manifest {
3058
bundler_version: string
3159
bundles: { asset: string; format: string }[]
@@ -122,26 +150,35 @@ const generateManifest = ({
122150
const routedFunctions = new Set<string>()
123151
const declarationsWithoutFunction = new Set<string>()
124152

125-
for (const [name, { excludedPath, onError }] of Object.entries(userFunctionConfig)) {
153+
for (const [name, { excludedPath, onError, ratelimit }] of Object.entries(userFunctionConfig)) {
126154
// If the config block is for a function that is not defined, discard it.
127155
if (manifestFunctionConfig[name] === undefined) {
128156
continue
129157
}
130158

131159
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
132160

133-
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError }
161+
manifestFunctionConfig[name] = {
162+
...manifestFunctionConfig[name],
163+
on_error: onError,
164+
traffic_rules_config: getTrafficRulesConfig(ratelimit),
165+
}
134166
}
135167

136-
for (const [name, { excludedPath, path, onError, ...rest }] of Object.entries(internalFunctionConfig)) {
168+
for (const [name, { excludedPath, path, onError, ratelimit, ...rest }] of Object.entries(internalFunctionConfig)) {
137169
// If the config block is for a function that is not defined, discard it.
138170
if (manifestFunctionConfig[name] === undefined) {
139171
continue
140172
}
141173

142174
addExcludedPatterns(name, manifestFunctionConfig, excludedPath)
143175

144-
manifestFunctionConfig[name] = { ...manifestFunctionConfig[name], on_error: onError, ...rest }
176+
manifestFunctionConfig[name] = {
177+
...manifestFunctionConfig[name],
178+
on_error: onError,
179+
traffic_rules_config: getTrafficRulesConfig(ratelimit),
180+
...rest,
181+
}
145182
}
146183

147184
declarations.forEach((declaration) => {
@@ -202,6 +239,32 @@ const generateManifest = ({
202239
return { declarationsWithoutFunction: [...declarationsWithoutFunction], manifest, unroutedFunctions }
203240
}
204241

242+
const getTrafficRulesConfig = (rl: Ratelimit | undefined) => {
243+
if (rl === undefined) {
244+
return
245+
}
246+
247+
const ratelimitAgg = Array.isArray(rl.aggregateBy) ? rl.aggregateBy : [RatelimitAggregator.Domain]
248+
const rewriteConfig = (rl as RewriteActionConfig).to ? { to: (rl as RewriteActionConfig).to } : undefined
249+
250+
return {
251+
action: {
252+
type: rl.action || RatelimitAction.Limit,
253+
config: {
254+
...rewriteConfig,
255+
rate_limit_config: {
256+
window_limit: rl.windowLimit,
257+
window_size: rl.windowSize,
258+
algorithm: RatelimitAlgorithm.SlidingWindow,
259+
},
260+
aggregate: {
261+
keys: ratelimitAgg.map((agg) => ({ type: agg })),
262+
},
263+
},
264+
},
265+
}
266+
}
267+
205268
const pathToRegularExpression = (path: string) => {
206269
if (!path) {
207270
return null

Diff for: node/ratelimit.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)