Skip to content

Commit 609ff78

Browse files
BlobMaster41Anakun
andauthored
feat(parser): @ratelimit(strategy, limit, window) route decorator (#8)
Recognize @ratelimit in the AST and lower it in buildRouteBlock to a RateLimitService.guard(routeId, tag, limit, window) call prepended to the route block, BEFORE the @auth guard (so it shields auth/body from a flood and rate-limits unauthenticated brute force). strategy is a RateLimit enum member or a bare int tag; limit/window must be integer literals, else no guard (fail-safe, like @cache). Program-wide route-id counter; the runtime + 429 response live in toiljs (RateLimitService). Co-authored-by: Anakun <anakun@opnet.org>
1 parent d207ae0 commit 609ff78

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

src/ast.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,8 @@ export enum DecoratorKind {
10491049
Options,
10501050
Cache,
10511051
Auth,
1052-
User
1052+
User,
1053+
Ratelimit
10531054
}
10541055

10551056
export namespace DecoratorKind {
@@ -1118,6 +1119,7 @@ export namespace DecoratorKind {
11181119
break;
11191120
}
11201121
case CharCode.r: {
1122+
if (nameStr == "ratelimit") return DecoratorKind.Ratelimit;
11211123
if (nameStr == "remote") return DecoratorKind.Remote;
11221124
if (nameStr == "rest") return DecoratorKind.Rest;
11231125
if (nameStr == "route") return DecoratorKind.Route;

src/parser.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ export class Parser extends DiagnosticEmitter {
215215
dependees: Map<string, Dependee> = new Map();
216216
/** Normalized paths whose `@rest` runtime import has already been injected. */
217217
restImportedSources: Set<string> = new Set();
218+
/** Monotonic id handed to each `@ratelimit` route so the edge can key one
219+
* shared limiter per route. Program-wide (one Parser per program), assigned
220+
* deterministically in route declaration order. */
221+
ratelimitRouteCounter: i32 = 0;
218222
/** An array of parsed sources. */
219223
sources: Source[];
220224
/** Current overridden module name. */
@@ -2232,6 +2236,11 @@ export class Parser extends DiagnosticEmitter {
22322236
let s = "if(__req.method==" + methodValue.toString() + "){";
22332237
s += "const __ctx=__toilMatch(" + JSON.stringify(fullPath) + ",__req);";
22342238
s += "if(__ctx!=null){";
2239+
// `@ratelimit(strategy, limit, window)`: reject (429 + Retry-After) before
2240+
// any other work, so it shields the auth/body path from a flood and rate-
2241+
// limits unauthenticated brute-force too. Keyed host-side by the unspoofable
2242+
// peer IP. Runs before the `@auth` guard.
2243+
s += this.ratelimitGuardOf(method);
22352244
// `@auth` (on the route or the controller): reject before the handler runs
22362245
// when there is no valid session. `AuthService` is an ambient global; it
22372246
// reads the current request's session cookie. The handler then calls
@@ -2290,6 +2299,54 @@ export class Parser extends DiagnosticEmitter {
22902299
return "";
22912300
}
22922301

2302+
/**
2303+
* The rate-limit guard to prepend to a route block when the handler carries
2304+
* `@ratelimit(strategy, limit, window)`, else `""`. `strategy` is a member of
2305+
* the `RateLimit` enum (`FixedWindow`/`SlidingWindow`/`TokenBucket`) or a bare
2306+
* integer tag; `limit` and `window` must be integer literals (mirrors
2307+
* `@cache`). Lowers to a single ambient `RateLimitService.guard(...)` call that
2308+
* returns a `429` `Response` when over the limit, or `null` to proceed. A
2309+
* malformed decorator yields `""` (no guard) rather than miscompiling.
2310+
*/
2311+
private ratelimitGuardOf(method: MethodDeclaration): string {
2312+
let decos = method.decorators;
2313+
if (decos == null) return "";
2314+
for (let i = 0, k = decos.length; i < k; ++i) {
2315+
if (decos[i].decoratorKind != DecoratorKind.Ratelimit) continue;
2316+
let args = decos[i].args;
2317+
if (args == null || args.length < 3) return ""; // need (strategy, limit, window)
2318+
let tag = this.ratelimitStrategyTag(args[0]);
2319+
if (tag.length == 0) return ""; // unrecognized strategy -> no guard
2320+
let limit = args[1];
2321+
let window = args[2];
2322+
if (!(limit instanceof IntegerLiteralExpression) || !(window instanceof IntegerLiteralExpression)) {
2323+
return ""; // non-literal limit/window -> fail safe (no guard)
2324+
}
2325+
let routeId = this.ratelimitRouteCounter++;
2326+
return "{const __rl=RateLimitService.guard(" + routeId.toString() + "," + tag + "," +
2327+
limit.range.toString() + "," + window.range.toString() + ");if(__rl!=null){return __rl;}}";
2328+
}
2329+
return "";
2330+
}
2331+
2332+
/**
2333+
* The integer strategy tag to emit for a `@ratelimit` strategy argument:
2334+
* `RateLimit.SlidingWindow` -> "1", `RateLimit.TokenBucket` -> "2", any other
2335+
* member (incl. `FixedWindow`) -> "0", an explicit integer literal passes
2336+
* through verbatim (the host clamps an unknown tag to FixedWindow). Returns
2337+
* "" when the argument is neither, so the caller emits no guard.
2338+
*/
2339+
private ratelimitStrategyTag(arg: Expression): string {
2340+
if (arg instanceof IntegerLiteralExpression) return arg.range.toString();
2341+
if (arg instanceof PropertyAccessExpression) {
2342+
let p = (<PropertyAccessExpression>arg).property.text;
2343+
if (p == "SlidingWindow") return "1";
2344+
if (p == "TokenBucket") return "2";
2345+
return "0"; // FixedWindow or any unknown member -> cheapest always-on default
2346+
}
2347+
return "";
2348+
}
2349+
22932350
/** Find an object-literal field value by name, or null. */
22942351
private objectField(obj: ObjectLiteralExpression, name: string): Expression | null {
22952352
let names = obj.names;

0 commit comments

Comments
 (0)