Skip to content
This repository was archived by the owner on May 22, 2024. It is now read-only.

Commit f947eb3

Browse files
authored
feat: ratelimit config from source (#1714)
* feat: extra ratelimit config from source * feat: refactor to a more general config structure * chore: address PR comments * chore: ratelimit -> rate_limit
1 parent 51f5e9e commit f947eb3

File tree

8 files changed

+184
-0
lines changed

8 files changed

+184
-0
lines changed

src/manifest.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ import type { InvocationMode } from './function.js'
66
import type { FunctionResult } from './utils/format_result.js'
77
import type { Route } from './utils/routes.js'
88

9+
export interface TrafficRules {
10+
action: {
11+
type: string
12+
config: {
13+
rateLimitConfig: {
14+
algorithm: string
15+
windowSize: number
16+
windowLimit: number
17+
}
18+
aggregate: {
19+
keys: {
20+
type: string
21+
}[]
22+
}
23+
to?: string
24+
}
25+
}
26+
}
27+
928
interface ManifestFunction {
1029
buildData?: Record<string, unknown>
1130
invocationMode?: InvocationMode
@@ -20,6 +39,7 @@ interface ManifestFunction {
2039
bundler?: string
2140
generator?: string
2241
priority?: number
42+
trafficRules?: TrafficRules
2343
}
2444

2545
export interface Manifest {
@@ -55,6 +75,7 @@ const formatFunctionForManifest = ({
5575
name,
5676
path,
5777
priority,
78+
trafficRules,
5879
routes,
5980
runtime,
6081
runtimeVersion,
@@ -70,6 +91,7 @@ const formatFunctionForManifest = ({
7091
mainFile,
7192
name,
7293
priority,
94+
trafficRules,
7395
runtimeVersion,
7496
path: resolve(path),
7597
runtime,

src/rate_limit.ts

Lines changed: 30 additions & 0 deletions
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)

src/runtimes/node/in_source_config/index.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types'
22

33
import { InvocationMode, INVOCATION_MODE } from '../../../function.js'
4+
import { TrafficRules } from '../../../manifest.js'
5+
import { RateLimitAction, RateLimitAggregator, RateLimitAlgorithm } from '../../../rate_limit.js'
46
import { FunctionBundlingUserError } from '../../../utils/error.js'
57
import { nonNullable } from '../../../utils/non_nullable.js'
68
import { getRoutes, Route } from '../../../utils/routes.js'
@@ -20,6 +22,7 @@ export type ISCValues = {
2022
routes?: Route[]
2123
schedule?: string
2224
methods?: string[]
25+
trafficRules?: TrafficRules
2326
}
2427

2528
export interface StaticAnalysisResult extends ISCValues {
@@ -71,6 +74,60 @@ const normalizeMethods = (input: unknown, name: string): string[] | undefined =>
7174
})
7275
}
7376

77+
/**
78+
* Extracts the `ratelimit` configuration from the exported config.
79+
*/
80+
const getTrafficRulesConfig = (input: unknown, name: string): TrafficRules | undefined => {
81+
if (typeof input !== 'object' || input === null) {
82+
throw new FunctionBundlingUserError(
83+
`Could not parse ratelimit declaration of function '${name}'. Expecting an object, got ${input}`,
84+
{
85+
functionName: name,
86+
runtime: RUNTIME.JAVASCRIPT,
87+
bundler: NODE_BUNDLER.ESBUILD,
88+
},
89+
)
90+
}
91+
92+
const { windowSize, windowLimit, algorithm, aggregateBy, action } = input as Record<string, unknown>
93+
94+
if (
95+
typeof windowSize !== 'number' ||
96+
typeof windowLimit !== 'number' ||
97+
!Number.isInteger(windowSize) ||
98+
!Number.isInteger(windowLimit)
99+
) {
100+
throw new FunctionBundlingUserError(
101+
`Could not parse ratelimit declaration of function '${name}'. Expecting 'windowSize' and 'limitSize' integer properties, got ${input}`,
102+
{
103+
functionName: name,
104+
runtime: RUNTIME.JAVASCRIPT,
105+
bundler: NODE_BUNDLER.ESBUILD,
106+
},
107+
)
108+
}
109+
110+
const rateLimitAgg = Array.isArray(aggregateBy) ? aggregateBy : [RateLimitAggregator.Domain]
111+
const rewriteConfig = 'to' in input && typeof input.to === 'string' ? { to: input.to } : undefined
112+
113+
return {
114+
action: {
115+
type: (action as RateLimitAction) || RateLimitAction.Limit,
116+
config: {
117+
...rewriteConfig,
118+
rateLimitConfig: {
119+
windowLimit,
120+
windowSize,
121+
algorithm: (algorithm as RateLimitAlgorithm) || RateLimitAlgorithm.SlidingWindow,
122+
},
123+
aggregate: {
124+
keys: rateLimitAgg.map((agg) => ({ type: agg })),
125+
},
126+
},
127+
},
128+
}
129+
}
130+
74131
/**
75132
* Loads a file at a given path, parses it into an AST, and returns a series of
76133
* data points, such as in-source configuration properties and other metadata.
@@ -131,6 +188,10 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration
131188
preferStatic: configExport.preferStatic === true,
132189
})
133190

191+
if (configExport.rateLimit !== undefined) {
192+
result.trafficRules = getTrafficRulesConfig(configExport.rateLimit, functionName)
193+
}
194+
134195
return result
135196
}
136197

src/runtimes/node/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ const zipFunction: ZipFunction = async function ({
141141
invocationMode = INVOCATION_MODE.Background
142142
}
143143

144+
const { trafficRules } = staticAnalysisResult
145+
144146
const outputModuleFormat =
145147
extname(finalMainFile) === MODULE_FILE_EXTENSION.MJS ? MODULE_FORMAT.ESM : MODULE_FORMAT.COMMONJS
146148
const priority = isInternal ? Priority.GeneratedFunction : Priority.UserFunction
@@ -160,6 +162,7 @@ const zipFunction: ZipFunction = async function ({
160162
nativeNodeModules,
161163
path: zipPath.path,
162164
priority,
165+
trafficRules,
163166
runtimeVersion:
164167
runtimeAPIVersion === 2 ? getNodeRuntimeForV2(config.nodeVersion) : getNodeRuntime(config.nodeVersion),
165168
}

src/runtimes/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { FunctionConfig } from '../config.js'
33
import type { FeatureFlags } from '../feature_flags.js'
44
import type { FunctionSource, InvocationMode, SourceFile } from '../function.js'
55
import type { ModuleFormat } from '../main.js'
6+
import { TrafficRules } from '../manifest.js'
67
import { ObjectValues } from '../types/utils.js'
78
import type { RuntimeCache } from '../utils/cache.js'
89
import { Logger } from '../utils/logger.js'
@@ -54,6 +55,7 @@ export interface ZipFunctionResult {
5455
nativeNodeModules?: object
5556
path: string
5657
priority?: number
58+
trafficRules?: TrafficRules
5759
runtimeVersion?: string
5860
staticAnalysisResult?: StaticAnalysisResult
5961
entryFilename: string
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Config, Context } from "@netlify/functions";
2+
3+
export default async (req: Request, context: Context) => {
4+
return new Response(`Something!`);
5+
};
6+
7+
export const config: Config = {
8+
path: "/ratelimited",
9+
rateLimit: {
10+
windowLimit: 60,
11+
windowSize: 50,
12+
aggregateBy: ["ip", "domain"],
13+
}
14+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Config, Context } from "@netlify/functions";
2+
3+
export default async (req: Request, context: Context) => {
4+
return new Response(`Something!`);
5+
};
6+
7+
export const config: Config = {
8+
path: "/rewrite",
9+
rateLimit: {
10+
action: "rewrite",
11+
to: "/rewritten",
12+
windowSize: 20,
13+
windowLimit: 200,
14+
aggregateBy: ["ip", "domain"],
15+
}
16+
};

tests/main.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2898,3 +2898,39 @@ test('Adds a `priority` field to the generated manifest file', async () => {
28982898
const generatedFunction1 = manifest.functions.find((fn) => fn.name === 'function_internal')
28992899
expect(generatedFunction1.priority).toBe(0)
29002900
})
2901+
2902+
test('Adds a `ratelimit` field to the generated manifest file', async () => {
2903+
const { path: tmpDir } = await getTmpDir({ prefix: 'zip-it-test' })
2904+
const fixtureName = 'ratelimit'
2905+
const manifestPath = join(tmpDir, 'manifest.json')
2906+
const path = `${fixtureName}/netlify/functions`
2907+
2908+
await zipFixture(path, {
2909+
length: 2,
2910+
opts: { manifest: manifestPath },
2911+
})
2912+
2913+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'))
2914+
2915+
expect(manifest.version).toBe(1)
2916+
expect(manifest.system.arch).toBe(arch)
2917+
expect(manifest.system.platform).toBe(platform)
2918+
expect(manifest.timestamp).toBeTypeOf('number')
2919+
2920+
const ratelimitFunction = manifest.functions.find((fn) => fn.name === 'ratelimit')
2921+
const { type: ratelimitType, config: ratelimitConfig } = ratelimitFunction.trafficRules.action
2922+
expect(ratelimitType).toBe('rate_limit')
2923+
expect(ratelimitConfig.rateLimitConfig.windowLimit).toBe(60)
2924+
expect(ratelimitConfig.rateLimitConfig.windowSize).toBe(50)
2925+
expect(ratelimitConfig.rateLimitConfig.algorithm).toBe('sliding_window')
2926+
expect(ratelimitConfig.aggregate.keys).toStrictEqual([{ type: 'ip' }, { type: 'domain' }])
2927+
2928+
const rewriteFunction = manifest.functions.find((fn) => fn.name === 'rewrite')
2929+
const { type: rewriteType, config: rewriteConfig } = rewriteFunction.trafficRules.action
2930+
expect(rewriteType).toBe('rewrite')
2931+
expect(rewriteConfig.to).toBe('/rewritten')
2932+
expect(rewriteConfig.rateLimitConfig.windowLimit).toBe(200)
2933+
expect(rewriteConfig.rateLimitConfig.windowSize).toBe(20)
2934+
expect(rewriteConfig.rateLimitConfig.algorithm).toBe('sliding_window')
2935+
expect(rewriteConfig.aggregate.keys).toStrictEqual([{ type: 'ip' }, { type: 'domain' }])
2936+
})

0 commit comments

Comments
 (0)