@lunora/ratelimit 0.0.0 → 1.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +128 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +143 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.mjs +11 -0
- package/dist/packem_shared/RateLimitError-YOUfRzXc.mjs +18 -0
- package/dist/packem_shared/RateLimiter-CeElVjB0.mjs +122 -0
- package/dist/packem_shared/availableAt-D5GyJtxT.mjs +117 -0
- package/dist/packem_shared/createDbStore-L1kD1g1n.mjs +103 -0
- package/dist/packem_shared/dbRateLimit-C4nMF5cN.mjs +7 -0
- package/dist/packem_shared/rateLimit-uQxVMZfh.mjs +42 -0
- package/dist/packem_shared/ratelimitPlugin-D5_-KV1T.mjs +12 -0
- package/package.json +37 -17
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Middleware, Plugin } from '@lunora/server';
|
|
2
|
+
import { Id } from '@lunora/values';
|
|
3
|
+
type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
|
|
4
|
+
type RateLimitReason = "deny" | "rate";
|
|
5
|
+
interface RateLimitConfig {
|
|
6
|
+
capacity?: number;
|
|
7
|
+
kind: RateLimitKind;
|
|
8
|
+
period: number;
|
|
9
|
+
rate: number;
|
|
10
|
+
shards?: number;
|
|
11
|
+
start?: number;
|
|
12
|
+
}
|
|
13
|
+
type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
|
|
14
|
+
interface RateLimitValue {
|
|
15
|
+
prev?: number;
|
|
16
|
+
ts: number;
|
|
17
|
+
value: number;
|
|
18
|
+
}
|
|
19
|
+
interface RateLimitStatus {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
reason?: RateLimitReason;
|
|
22
|
+
retryAfter: number;
|
|
23
|
+
}
|
|
24
|
+
interface RateLimitArgs {
|
|
25
|
+
count?: number;
|
|
26
|
+
key?: string;
|
|
27
|
+
reserve?: boolean;
|
|
28
|
+
throws?: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface RateLimitStore {
|
|
31
|
+
delete: (storageKey: string) => Promise<void> | void;
|
|
32
|
+
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
33
|
+
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
34
|
+
}
|
|
35
|
+
interface EvaluateOptions {
|
|
36
|
+
consume: boolean;
|
|
37
|
+
count: number;
|
|
38
|
+
now: number;
|
|
39
|
+
reserve: boolean;
|
|
40
|
+
}
|
|
41
|
+
interface EvaluateResult {
|
|
42
|
+
status: RateLimitStatus;
|
|
43
|
+
value: RateLimitValue | undefined;
|
|
44
|
+
}
|
|
45
|
+
declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
|
|
46
|
+
ts: number;
|
|
47
|
+
value: number;
|
|
48
|
+
};
|
|
49
|
+
declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
|
|
50
|
+
interface RateLimiterOptions<Names extends string> {
|
|
51
|
+
config: RateLimitConfigMap<Names>;
|
|
52
|
+
denyList?: Iterable<string>;
|
|
53
|
+
normalize?: (key: string) => string;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
random?: () => number;
|
|
56
|
+
store?: RateLimitStore;
|
|
57
|
+
}
|
|
58
|
+
declare class RateLimiter<Names extends string = string> {
|
|
59
|
+
private readonly config;
|
|
60
|
+
private readonly denyList;
|
|
61
|
+
private readonly normalize;
|
|
62
|
+
private readonly now;
|
|
63
|
+
private readonly store;
|
|
64
|
+
constructor(options: RateLimiterOptions<Names>);
|
|
65
|
+
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
66
|
+
getValue(name: Names, args?: {
|
|
67
|
+
key?: string;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
config: RateLimitConfig;
|
|
70
|
+
ts: number;
|
|
71
|
+
value: number;
|
|
72
|
+
}>;
|
|
73
|
+
limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
|
|
74
|
+
reset(name: Names, args?: {
|
|
75
|
+
key?: string;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
private resolve;
|
|
78
|
+
private run;
|
|
79
|
+
}
|
|
80
|
+
type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
|
|
81
|
+
interface RateLimitMiddlewareOptions<Context> {
|
|
82
|
+
count?: number;
|
|
83
|
+
failOpen?: boolean;
|
|
84
|
+
key?: (context: Context) => string | undefined;
|
|
85
|
+
message?: string;
|
|
86
|
+
}
|
|
87
|
+
declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
|
|
88
|
+
declare const createMemoryStore: () => RateLimitStore;
|
|
89
|
+
interface SqlLike {
|
|
90
|
+
exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
|
|
91
|
+
toArray: () => Row[];
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
interface SqlStoreOptions {
|
|
95
|
+
sql: SqlLike;
|
|
96
|
+
table?: string;
|
|
97
|
+
}
|
|
98
|
+
declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
|
|
99
|
+
interface RateLimitDatabaseIndexRange {
|
|
100
|
+
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
101
|
+
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
102
|
+
gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
103
|
+
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
104
|
+
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
105
|
+
}
|
|
106
|
+
interface RateLimitDatabaseQuery {
|
|
107
|
+
first: () => Promise<Record<string, unknown> | null>;
|
|
108
|
+
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
109
|
+
}
|
|
110
|
+
interface RateLimitDatabase {
|
|
111
|
+
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
112
|
+
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
113
|
+
patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
|
|
114
|
+
query: (table: string) => RateLimitDatabaseQuery;
|
|
115
|
+
}
|
|
116
|
+
interface DatabaseStoreOptions {
|
|
117
|
+
db: RateLimitDatabase;
|
|
118
|
+
index?: string;
|
|
119
|
+
keyField?: string;
|
|
120
|
+
table?: string;
|
|
121
|
+
}
|
|
122
|
+
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
123
|
+
declare const databaseRateLimit: <Context extends {
|
|
124
|
+
db: RateLimitDatabase;
|
|
125
|
+
}, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
|
|
126
|
+
store?: Omit<DatabaseStoreOptions, "db">;
|
|
127
|
+
}) => Middleware<Context, Context>;
|
|
128
|
+
declare class RateLimitError extends Error {
|
|
129
|
+
readonly name = "RateLimitError";
|
|
130
|
+
readonly reason: RateLimitReason | undefined;
|
|
131
|
+
readonly retryAfter: number;
|
|
132
|
+
constructor(status: RateLimitStatus, message?: string);
|
|
133
|
+
}
|
|
134
|
+
interface RatelimitApiContext<Context> {
|
|
135
|
+
api: (Context extends {
|
|
136
|
+
api: infer A;
|
|
137
|
+
} ? A : Record<never, never>) & {
|
|
138
|
+
ratelimit: RateLimiter;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
142
|
+
declare const VERSION = "0.0.0";
|
|
143
|
+
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Middleware, Plugin } from '@lunora/server';
|
|
2
|
+
import { Id } from '@lunora/values';
|
|
3
|
+
type RateLimitKind = "fixed window" | "sliding window" | "token bucket";
|
|
4
|
+
type RateLimitReason = "deny" | "rate";
|
|
5
|
+
interface RateLimitConfig {
|
|
6
|
+
capacity?: number;
|
|
7
|
+
kind: RateLimitKind;
|
|
8
|
+
period: number;
|
|
9
|
+
rate: number;
|
|
10
|
+
shards?: number;
|
|
11
|
+
start?: number;
|
|
12
|
+
}
|
|
13
|
+
type RateLimitConfigMap<Names extends string = string> = Record<Names, RateLimitConfig>;
|
|
14
|
+
interface RateLimitValue {
|
|
15
|
+
prev?: number;
|
|
16
|
+
ts: number;
|
|
17
|
+
value: number;
|
|
18
|
+
}
|
|
19
|
+
interface RateLimitStatus {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
reason?: RateLimitReason;
|
|
22
|
+
retryAfter: number;
|
|
23
|
+
}
|
|
24
|
+
interface RateLimitArgs {
|
|
25
|
+
count?: number;
|
|
26
|
+
key?: string;
|
|
27
|
+
reserve?: boolean;
|
|
28
|
+
throws?: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface RateLimitStore {
|
|
31
|
+
delete: (storageKey: string) => Promise<void> | void;
|
|
32
|
+
get: (storageKey: string) => Promise<RateLimitValue | undefined> | RateLimitValue | undefined;
|
|
33
|
+
set: (storageKey: string, value: RateLimitValue) => Promise<void> | void;
|
|
34
|
+
}
|
|
35
|
+
interface EvaluateOptions {
|
|
36
|
+
consume: boolean;
|
|
37
|
+
count: number;
|
|
38
|
+
now: number;
|
|
39
|
+
reserve: boolean;
|
|
40
|
+
}
|
|
41
|
+
interface EvaluateResult {
|
|
42
|
+
status: RateLimitStatus;
|
|
43
|
+
value: RateLimitValue | undefined;
|
|
44
|
+
}
|
|
45
|
+
declare const availableAt: (config: RateLimitConfig, prior: RateLimitValue | undefined, now: number) => {
|
|
46
|
+
ts: number;
|
|
47
|
+
value: number;
|
|
48
|
+
};
|
|
49
|
+
declare const evaluate: (config: RateLimitConfig, prior: RateLimitValue | undefined, options: EvaluateOptions) => EvaluateResult;
|
|
50
|
+
interface RateLimiterOptions<Names extends string> {
|
|
51
|
+
config: RateLimitConfigMap<Names>;
|
|
52
|
+
denyList?: Iterable<string>;
|
|
53
|
+
normalize?: (key: string) => string;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
random?: () => number;
|
|
56
|
+
store?: RateLimitStore;
|
|
57
|
+
}
|
|
58
|
+
declare class RateLimiter<Names extends string = string> {
|
|
59
|
+
private readonly config;
|
|
60
|
+
private readonly denyList;
|
|
61
|
+
private readonly normalize;
|
|
62
|
+
private readonly now;
|
|
63
|
+
private readonly store;
|
|
64
|
+
constructor(options: RateLimiterOptions<Names>);
|
|
65
|
+
check(name: Names, args?: Omit<RateLimitArgs, "reserve" | "throws">): Promise<RateLimitStatus>;
|
|
66
|
+
getValue(name: Names, args?: {
|
|
67
|
+
key?: string;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
config: RateLimitConfig;
|
|
70
|
+
ts: number;
|
|
71
|
+
value: number;
|
|
72
|
+
}>;
|
|
73
|
+
limit(name: Names, args?: RateLimitArgs): Promise<RateLimitStatus>;
|
|
74
|
+
reset(name: Names, args?: {
|
|
75
|
+
key?: string;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
private resolve;
|
|
78
|
+
private run;
|
|
79
|
+
}
|
|
80
|
+
type LimiterResolver<Context> = ((context: Context) => Promise<RateLimiter> | RateLimiter) | RateLimiter;
|
|
81
|
+
interface RateLimitMiddlewareOptions<Context> {
|
|
82
|
+
count?: number;
|
|
83
|
+
failOpen?: boolean;
|
|
84
|
+
key?: (context: Context) => string | undefined;
|
|
85
|
+
message?: string;
|
|
86
|
+
}
|
|
87
|
+
declare const rateLimit: <Context>(limiter: LimiterResolver<Context>, name: string, options?: RateLimitMiddlewareOptions<Context>) => Middleware<Context, Context>;
|
|
88
|
+
declare const createMemoryStore: () => RateLimitStore;
|
|
89
|
+
interface SqlLike {
|
|
90
|
+
exec: <Row = Record<string, unknown>>(query: string, ...params: unknown[]) => {
|
|
91
|
+
toArray: () => Row[];
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
interface SqlStoreOptions {
|
|
95
|
+
sql: SqlLike;
|
|
96
|
+
table?: string;
|
|
97
|
+
}
|
|
98
|
+
declare const createSqlStore: (options: SqlStoreOptions) => RateLimitStore;
|
|
99
|
+
interface RateLimitDatabaseIndexRange {
|
|
100
|
+
eq: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
101
|
+
gt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
102
|
+
gte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
103
|
+
lt: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
104
|
+
lte: (field: string, value: unknown) => RateLimitDatabaseIndexRange;
|
|
105
|
+
}
|
|
106
|
+
interface RateLimitDatabaseQuery {
|
|
107
|
+
first: () => Promise<Record<string, unknown> | null>;
|
|
108
|
+
withIndex: (indexName: string, range: (q: RateLimitDatabaseIndexRange) => RateLimitDatabaseIndexRange) => RateLimitDatabaseQuery;
|
|
109
|
+
}
|
|
110
|
+
interface RateLimitDatabase {
|
|
111
|
+
delete: <T extends string>(id: Id<T>) => Promise<void>;
|
|
112
|
+
insert: <T extends string>(table: T, document: Record<string, unknown>) => Promise<Id<T>>;
|
|
113
|
+
patch: <T extends string>(id: Id<T>, patch: Record<string, unknown>) => Promise<void>;
|
|
114
|
+
query: (table: string) => RateLimitDatabaseQuery;
|
|
115
|
+
}
|
|
116
|
+
interface DatabaseStoreOptions {
|
|
117
|
+
db: RateLimitDatabase;
|
|
118
|
+
index?: string;
|
|
119
|
+
keyField?: string;
|
|
120
|
+
table?: string;
|
|
121
|
+
}
|
|
122
|
+
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
123
|
+
declare const databaseRateLimit: <Context extends {
|
|
124
|
+
db: RateLimitDatabase;
|
|
125
|
+
}, Names extends string = string>(config: RateLimitConfigMap<Names>, name: Names, options?: RateLimitMiddlewareOptions<Context> & {
|
|
126
|
+
store?: Omit<DatabaseStoreOptions, "db">;
|
|
127
|
+
}) => Middleware<Context, Context>;
|
|
128
|
+
declare class RateLimitError extends Error {
|
|
129
|
+
readonly name = "RateLimitError";
|
|
130
|
+
readonly reason: RateLimitReason | undefined;
|
|
131
|
+
readonly retryAfter: number;
|
|
132
|
+
constructor(status: RateLimitStatus, message?: string);
|
|
133
|
+
}
|
|
134
|
+
interface RatelimitApiContext<Context> {
|
|
135
|
+
api: (Context extends {
|
|
136
|
+
api: infer A;
|
|
137
|
+
} ? A : Record<never, never>) & {
|
|
138
|
+
ratelimit: RateLimiter;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
declare const ratelimitPlugin: <Context = unknown>(limiter: LimiterResolver<Context>) => Plugin<Record<never, never>, Context, Context & RatelimitApiContext<Context>>;
|
|
142
|
+
declare const VERSION = "0.0.0";
|
|
143
|
+
export { type DatabaseStoreOptions as DbStoreOptions, type EvaluateOptions, type EvaluateResult, type LimiterResolver, type RateLimitArgs, type RateLimitConfig, type RateLimitConfigMap, type RateLimitDatabase as RateLimitDb, type RateLimitDatabaseIndexRange as RateLimitDbIndexRange, type RateLimitDatabaseQuery as RateLimitDbQuery, RateLimitError, type RateLimitKind, type RateLimitMiddlewareOptions, type RateLimitReason, type RateLimitStatus, type RateLimitStore, type RateLimitValue, RateLimiter, type RateLimiterOptions, type RatelimitApiContext, type SqlLike, type SqlStoreOptions, VERSION, availableAt, createDatabaseStore as createDbStore, createMemoryStore, createSqlStore, databaseRateLimit as dbRateLimit, evaluate, rateLimit, ratelimitPlugin };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { availableAt, evaluate } from './packem_shared/availableAt-D5GyJtxT.mjs';
|
|
2
|
+
export { default as dbRateLimit } from './packem_shared/dbRateLimit-C4nMF5cN.mjs';
|
|
3
|
+
export { default as RateLimitError } from './packem_shared/RateLimitError-YOUfRzXc.mjs';
|
|
4
|
+
export { rateLimit } from './packem_shared/rateLimit-uQxVMZfh.mjs';
|
|
5
|
+
export { ratelimitPlugin } from './packem_shared/ratelimitPlugin-D5_-KV1T.mjs';
|
|
6
|
+
export { RateLimiter } from './packem_shared/RateLimiter-CeElVjB0.mjs';
|
|
7
|
+
export { createDbStore, createMemoryStore, createSqlStore } from './packem_shared/createDbStore-L1kD1g1n.mjs';
|
|
8
|
+
|
|
9
|
+
const VERSION = "0.0.0";
|
|
10
|
+
|
|
11
|
+
export { VERSION };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const describe = (status) => {
|
|
2
|
+
if (status.reason === "deny") {
|
|
3
|
+
return "request denied (deny list)";
|
|
4
|
+
}
|
|
5
|
+
return Number.isFinite(status.retryAfter) ? `rate limit exceeded; retry after ${String(Math.ceil(status.retryAfter))}ms` : "rate limit exceeded";
|
|
6
|
+
};
|
|
7
|
+
class RateLimitError extends Error {
|
|
8
|
+
name = "RateLimitError";
|
|
9
|
+
reason;
|
|
10
|
+
retryAfter;
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(message ?? describe(status));
|
|
13
|
+
this.reason = status.reason;
|
|
14
|
+
this.retryAfter = status.retryAfter;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { RateLimitError as default };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { availableAt, evaluate } from './availableAt-D5GyJtxT.mjs';
|
|
2
|
+
import RateLimitError from './RateLimitError-YOUfRzXc.mjs';
|
|
3
|
+
import { createMemoryStore } from './createDbStore-L1kD1g1n.mjs';
|
|
4
|
+
|
|
5
|
+
const storageKeyFor = (name, key) => key === void 0 ? encodeURIComponent(name) : `${encodeURIComponent(name)}:${encodeURIComponent(key)}`;
|
|
6
|
+
const hashToShard = (storageKey, shards) => {
|
|
7
|
+
let hash = 0;
|
|
8
|
+
for (let index = 0; index < storageKey.length; index += 1) {
|
|
9
|
+
hash = hash * 31 + storageKey.charCodeAt(index) | 0;
|
|
10
|
+
}
|
|
11
|
+
return Math.abs(hash) % shards;
|
|
12
|
+
};
|
|
13
|
+
const perShardConfig = (config, shards) => shards > 1 ? { ...config, capacity: (config.capacity ?? config.rate) / shards, rate: config.rate / shards } : config;
|
|
14
|
+
const shardKeysFor = (name, key, shards) => {
|
|
15
|
+
const base = storageKeyFor(name, key);
|
|
16
|
+
return shards > 1 ? Array.from({ length: shards }, (_, shard) => `${base}#${String(shard)}`) : [base];
|
|
17
|
+
};
|
|
18
|
+
class RateLimiter {
|
|
19
|
+
config;
|
|
20
|
+
denyList;
|
|
21
|
+
normalize;
|
|
22
|
+
now;
|
|
23
|
+
store;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.config = options.config;
|
|
26
|
+
this.denyList = new Set(options.denyList);
|
|
27
|
+
this.normalize = options.normalize ?? ((key) => key);
|
|
28
|
+
this.now = options.now ?? Date.now;
|
|
29
|
+
this.store = options.store ?? createMemoryStore();
|
|
30
|
+
for (const [name, config] of Object.entries(this.config)) {
|
|
31
|
+
if (config.shards !== void 0 && (!Number.isInteger(config.shards) || config.shards < 1)) {
|
|
32
|
+
throw new Error(`rate limit "${name}": shards must be a positive integer`);
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isFinite(config.period) || config.period <= 0) {
|
|
35
|
+
throw new Error(`rate limit "${name}": period must be a positive number`);
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isFinite(config.rate) || config.rate <= 0) {
|
|
38
|
+
throw new Error(`rate limit "${name}": rate must be a positive number`);
|
|
39
|
+
}
|
|
40
|
+
if (config.capacity !== void 0 && (!Number.isFinite(config.capacity) || config.capacity < 0)) {
|
|
41
|
+
throw new Error(`rate limit "${name}": capacity must be a non-negative number`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Peek at whether a request would be permitted without consuming. */
|
|
46
|
+
async check(name, args = {}) {
|
|
47
|
+
return this.run(name, args, false);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read the current config and the units admittable right now for a
|
|
51
|
+
* `(name, key)` pair. The value is projected forward to the current clock
|
|
52
|
+
* (token-bucket refill, fixed-window rollover, sliding-window decay), not
|
|
53
|
+
* the last persisted figure. For a sharded limit it reads only the single
|
|
54
|
+
* shard `limit()`/`run()` would route this key to — the sibling shards are
|
|
55
|
+
* never touched by this key, so summing them would over-report.
|
|
56
|
+
*/
|
|
57
|
+
async getValue(name, args = {}) {
|
|
58
|
+
const config = this.resolve(name);
|
|
59
|
+
const shards = config.shards ?? 1;
|
|
60
|
+
const now = this.now();
|
|
61
|
+
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
62
|
+
if (shards > 1) {
|
|
63
|
+
const base = storageKeyFor(name, normalizedKey);
|
|
64
|
+
const storageKey = `${base}#${String(hashToShard(base, shards))}`;
|
|
65
|
+
const current2 = availableAt(perShardConfig(config, shards), await this.store.get(storageKey), now);
|
|
66
|
+
return { config, ts: current2.ts, value: current2.value };
|
|
67
|
+
}
|
|
68
|
+
const current = availableAt(config, await this.store.get(storageKeyFor(name, normalizedKey)), now);
|
|
69
|
+
return { config, ts: current.ts, value: current.value };
|
|
70
|
+
}
|
|
71
|
+
/** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
|
|
72
|
+
async limit(name, args = {}) {
|
|
73
|
+
return this.run(name, args, true);
|
|
74
|
+
}
|
|
75
|
+
/** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
|
|
76
|
+
async reset(name, args = {}) {
|
|
77
|
+
const shards = this.resolve(name).shards ?? 1;
|
|
78
|
+
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
79
|
+
await Promise.all(shardKeysFor(name, normalizedKey, shards).map((storageKey) => Promise.resolve(this.store.delete(storageKey))));
|
|
80
|
+
}
|
|
81
|
+
resolve(name) {
|
|
82
|
+
const config = this.config[name];
|
|
83
|
+
if (!config) {
|
|
84
|
+
throw new Error(`rate limit "${name}" is not configured`);
|
|
85
|
+
}
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
async run(name, args, consume) {
|
|
89
|
+
const config = this.resolve(name);
|
|
90
|
+
const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
|
|
91
|
+
if (normalizedKey !== void 0 && (this.denyList.has(normalizedKey) || this.denyList.has(args.key))) {
|
|
92
|
+
const status2 = { ok: false, reason: "deny", retryAfter: Number.POSITIVE_INFINITY };
|
|
93
|
+
if (args.throws) {
|
|
94
|
+
throw new RateLimitError(status2);
|
|
95
|
+
}
|
|
96
|
+
return status2;
|
|
97
|
+
}
|
|
98
|
+
const count = args.count ?? 1;
|
|
99
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
100
|
+
throw new Error(`rate limit "${name}": count must be a positive integer`);
|
|
101
|
+
}
|
|
102
|
+
const shards = config.shards ?? 1;
|
|
103
|
+
const base = storageKeyFor(name, normalizedKey);
|
|
104
|
+
const storageKey = shards > 1 ? `${base}#${String(hashToShard(base, shards))}` : base;
|
|
105
|
+
const prior = await this.store.get(storageKey);
|
|
106
|
+
const { status, value } = evaluate(perShardConfig(config, shards), prior, {
|
|
107
|
+
consume,
|
|
108
|
+
count,
|
|
109
|
+
now: this.now(),
|
|
110
|
+
reserve: args.reserve ?? false
|
|
111
|
+
});
|
|
112
|
+
if (value !== void 0) {
|
|
113
|
+
await this.store.set(storageKey, value);
|
|
114
|
+
}
|
|
115
|
+
if (!status.ok && args.throws) {
|
|
116
|
+
throw new RateLimitError(status);
|
|
117
|
+
}
|
|
118
|
+
return status;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { RateLimiter };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const capacityOf = (config) => config.capacity ?? config.rate;
|
|
2
|
+
const tokenBucket = (config, prior, options) => {
|
|
3
|
+
const capacity = capacityOf(config);
|
|
4
|
+
const ratePerMs = config.rate / config.period;
|
|
5
|
+
const base = prior ?? { ts: options.now, value: capacity };
|
|
6
|
+
const elapsed = Math.max(0, options.now - base.ts);
|
|
7
|
+
const available = Math.min(capacity, base.value + elapsed * ratePerMs);
|
|
8
|
+
if (available >= options.count) {
|
|
9
|
+
const value = { ts: options.now, value: available - options.count };
|
|
10
|
+
return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
|
|
11
|
+
}
|
|
12
|
+
const deficit = options.count - available;
|
|
13
|
+
const retryAfter = Math.ceil(deficit / ratePerMs);
|
|
14
|
+
if (options.consume && options.reserve && options.count <= capacity) {
|
|
15
|
+
return { status: { ok: true, retryAfter }, value: { ts: options.now, value: available - options.count } };
|
|
16
|
+
}
|
|
17
|
+
return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
|
|
18
|
+
};
|
|
19
|
+
const fixedWindow = (config, prior, options) => {
|
|
20
|
+
const capacity = capacityOf(config);
|
|
21
|
+
const start = config.start ?? 0;
|
|
22
|
+
const windowStart = start + Math.floor((options.now - start) / config.period) * config.period;
|
|
23
|
+
let base;
|
|
24
|
+
if (!prior || prior.ts < windowStart) {
|
|
25
|
+
const carry = prior && config.capacity !== void 0 ? Math.max(0, prior.value) : 0;
|
|
26
|
+
base = { ts: windowStart, value: Math.min(capacity, carry + config.rate) };
|
|
27
|
+
} else {
|
|
28
|
+
base = prior;
|
|
29
|
+
}
|
|
30
|
+
if (base.value >= options.count) {
|
|
31
|
+
const value = { ts: base.ts, value: base.value - options.count };
|
|
32
|
+
return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
|
|
33
|
+
}
|
|
34
|
+
const retryAfter = base.ts + config.period - options.now;
|
|
35
|
+
if (options.consume && options.reserve && options.count <= capacity) {
|
|
36
|
+
return { status: { ok: true, retryAfter }, value: { ts: base.ts, value: base.value - options.count } };
|
|
37
|
+
}
|
|
38
|
+
if (options.count > capacity) {
|
|
39
|
+
throw new Error(`@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
|
|
40
|
+
}
|
|
41
|
+
return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
|
|
42
|
+
};
|
|
43
|
+
const slidingWindow = (config, prior, options) => {
|
|
44
|
+
const limit = config.rate;
|
|
45
|
+
const { period } = config;
|
|
46
|
+
const start = config.start ?? 0;
|
|
47
|
+
const windowStart = start + Math.floor((options.now - start) / period) * period;
|
|
48
|
+
const elapsed = options.now - windowStart;
|
|
49
|
+
const weight = (period - elapsed) / period;
|
|
50
|
+
let previousCount = 0;
|
|
51
|
+
let currentCount = 0;
|
|
52
|
+
if (prior?.ts === windowStart) {
|
|
53
|
+
previousCount = prior.prev ?? 0;
|
|
54
|
+
currentCount = prior.value;
|
|
55
|
+
} else if (prior?.ts === windowStart - period) {
|
|
56
|
+
previousCount = prior.value;
|
|
57
|
+
}
|
|
58
|
+
const estimated = previousCount * weight + currentCount;
|
|
59
|
+
const admit = estimated + options.count <= limit;
|
|
60
|
+
const retryAfter = () => {
|
|
61
|
+
const headroomNow = limit - currentCount - options.count;
|
|
62
|
+
if (previousCount > 0 && headroomNow >= 0) {
|
|
63
|
+
return Math.ceil(period - elapsed - headroomNow * period / previousCount);
|
|
64
|
+
}
|
|
65
|
+
const headroomNext = limit - options.count;
|
|
66
|
+
const intoNext = currentCount > 0 ? Math.max(0, period - headroomNext * period / currentCount) : 0;
|
|
67
|
+
return Math.ceil(period - elapsed + intoNext);
|
|
68
|
+
};
|
|
69
|
+
if (admit || options.consume && options.reserve && options.count <= limit) {
|
|
70
|
+
const value = { prev: previousCount, ts: windowStart, value: currentCount + options.count };
|
|
71
|
+
return { status: { ok: true, retryAfter: admit ? 0 : retryAfter() }, value: options.consume ? value : void 0 };
|
|
72
|
+
}
|
|
73
|
+
if (options.count > limit) {
|
|
74
|
+
throw new Error(`@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(limit)}`);
|
|
75
|
+
}
|
|
76
|
+
return { status: { ok: false, reason: "rate", retryAfter: retryAfter() }, value: void 0 };
|
|
77
|
+
};
|
|
78
|
+
const availableAt = (config, prior, now) => {
|
|
79
|
+
if (config.kind === "token bucket") {
|
|
80
|
+
const capacity = capacityOf(config);
|
|
81
|
+
const ratePerMs = config.rate / config.period;
|
|
82
|
+
const base = prior ?? { ts: now, value: capacity };
|
|
83
|
+
const elapsed = Math.max(0, now - base.ts);
|
|
84
|
+
return { ts: now, value: Math.min(capacity, base.value + elapsed * ratePerMs) };
|
|
85
|
+
}
|
|
86
|
+
const start = config.start ?? 0;
|
|
87
|
+
const windowStart = start + Math.floor((now - start) / config.period) * config.period;
|
|
88
|
+
if (config.kind === "sliding window") {
|
|
89
|
+
const elapsed = now - windowStart;
|
|
90
|
+
const weight = (config.period - elapsed) / config.period;
|
|
91
|
+
let previousCount = 0;
|
|
92
|
+
let currentCount = 0;
|
|
93
|
+
if (prior?.ts === windowStart) {
|
|
94
|
+
previousCount = prior.prev ?? 0;
|
|
95
|
+
currentCount = prior.value;
|
|
96
|
+
} else if (prior?.ts === windowStart - config.period) {
|
|
97
|
+
previousCount = prior.value;
|
|
98
|
+
}
|
|
99
|
+
return { ts: windowStart, value: Math.max(0, config.rate - (previousCount * weight + currentCount)) };
|
|
100
|
+
}
|
|
101
|
+
if (!prior || prior.ts < windowStart) {
|
|
102
|
+
const carry = prior && config.capacity !== void 0 ? Math.max(0, prior.value) : 0;
|
|
103
|
+
return { ts: windowStart, value: Math.min(capacityOf(config), carry + config.rate) };
|
|
104
|
+
}
|
|
105
|
+
return { ts: prior.ts, value: prior.value };
|
|
106
|
+
};
|
|
107
|
+
const evaluate = (config, prior, options) => {
|
|
108
|
+
if (config.kind === "token bucket") {
|
|
109
|
+
return tokenBucket(config, prior, options);
|
|
110
|
+
}
|
|
111
|
+
if (config.kind === "sliding window") {
|
|
112
|
+
return slidingWindow(config, prior, options);
|
|
113
|
+
}
|
|
114
|
+
return fixedWindow(config, prior, options);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export { availableAt, evaluate };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const createMemoryStore = () => {
|
|
2
|
+
const map = /* @__PURE__ */ new Map();
|
|
3
|
+
return {
|
|
4
|
+
delete: (storageKey) => {
|
|
5
|
+
map.delete(storageKey);
|
|
6
|
+
},
|
|
7
|
+
get: (storageKey) => map.get(storageKey),
|
|
8
|
+
set: (storageKey, value) => {
|
|
9
|
+
map.set(storageKey, value);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
const runSql = (sql, query, ...params) => {
|
|
14
|
+
const runner = sql.exec;
|
|
15
|
+
return runner.call(sql, query, ...params).toArray();
|
|
16
|
+
};
|
|
17
|
+
const createSqlStore = (options) => {
|
|
18
|
+
const { sql } = options;
|
|
19
|
+
const table = options.table ?? "_lunora_rate_limits";
|
|
20
|
+
runSql(sql, `CREATE TABLE IF NOT EXISTS "${table}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`);
|
|
21
|
+
return {
|
|
22
|
+
delete: (storageKey) => {
|
|
23
|
+
runSql(sql, `DELETE FROM "${table}" WHERE k = ?`, storageKey);
|
|
24
|
+
},
|
|
25
|
+
get: (storageKey) => {
|
|
26
|
+
const rows = runSql(sql, `SELECT value, ts, prev FROM "${table}" WHERE k = ?`, storageKey);
|
|
27
|
+
const row = rows[0];
|
|
28
|
+
if (!row) {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
const value = { ts: row.ts, value: row.value };
|
|
32
|
+
if (row.prev !== null) {
|
|
33
|
+
value.prev = row.prev;
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
},
|
|
37
|
+
set: (storageKey, value) => {
|
|
38
|
+
runSql(
|
|
39
|
+
sql,
|
|
40
|
+
`INSERT INTO "${table}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,
|
|
41
|
+
storageKey,
|
|
42
|
+
value.value,
|
|
43
|
+
value.ts,
|
|
44
|
+
// SQL bind: a missing `prev` must bind as SQL NULL, not undefined.
|
|
45
|
+
// eslint-disable-next-line unicorn/no-null -- SQLite/D1 bind parameters require null for a NULL column
|
|
46
|
+
value.prev ?? null
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const createDatabaseStore = (options) => {
|
|
52
|
+
const { db } = options;
|
|
53
|
+
const table = options.table ?? "rateLimits";
|
|
54
|
+
const index = options.index ?? "by_key";
|
|
55
|
+
const keyField = options.keyField ?? "key";
|
|
56
|
+
const idCache = /* @__PURE__ */ new Map();
|
|
57
|
+
const find = async (storageKey) => {
|
|
58
|
+
const row = await db.query(table).withIndex(index, (q) => q.eq(keyField, storageKey)).first();
|
|
59
|
+
idCache.set(storageKey, row ? row._id : void 0);
|
|
60
|
+
return row;
|
|
61
|
+
};
|
|
62
|
+
const resolveId = async (storageKey) => {
|
|
63
|
+
if (idCache.has(storageKey)) {
|
|
64
|
+
return idCache.get(storageKey);
|
|
65
|
+
}
|
|
66
|
+
await find(storageKey);
|
|
67
|
+
return idCache.get(storageKey);
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
delete: async (storageKey) => {
|
|
71
|
+
const id = await resolveId(storageKey);
|
|
72
|
+
if (id !== void 0) {
|
|
73
|
+
await db.delete(id);
|
|
74
|
+
}
|
|
75
|
+
idCache.delete(storageKey);
|
|
76
|
+
},
|
|
77
|
+
get: async (storageKey) => {
|
|
78
|
+
const row = await find(storageKey);
|
|
79
|
+
if (!row) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
const value = { ts: row.ts, value: row.value };
|
|
83
|
+
if (row.prev !== null && row.prev !== void 0) {
|
|
84
|
+
value.prev = row.prev;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
},
|
|
88
|
+
set: async (storageKey, value) => {
|
|
89
|
+
const id = await resolveId(storageKey);
|
|
90
|
+
const document = { [keyField]: storageKey, ts: value.ts, value: value.value };
|
|
91
|
+
if (value.prev !== void 0) {
|
|
92
|
+
document.prev = value.prev;
|
|
93
|
+
}
|
|
94
|
+
if (id === void 0) {
|
|
95
|
+
idCache.set(storageKey, await db.insert(table, document));
|
|
96
|
+
} else {
|
|
97
|
+
await db.patch(id, document);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export { createDatabaseStore as createDbStore, createMemoryStore, createSqlStore };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { rateLimit } from './rateLimit-uQxVMZfh.mjs';
|
|
2
|
+
import { RateLimiter } from './RateLimiter-CeElVjB0.mjs';
|
|
3
|
+
import { createDbStore as createDatabaseStore } from './createDbStore-L1kD1g1n.mjs';
|
|
4
|
+
|
|
5
|
+
const databaseRateLimit = (config, name, options = {}) => rateLimit((context) => new RateLimiter({ config, store: createDatabaseStore({ db: context.db, ...options.store }) }), name, options);
|
|
6
|
+
|
|
7
|
+
export { databaseRateLimit as default };
|