@lunora/ratelimit 1.0.0-alpha.2 → 1.0.0-alpha.21

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.
@@ -1,122 +0,0 @@
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 };
@@ -1,117 +0,0 @@
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 };
@@ -1,103 +0,0 @@
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 };
@@ -1,7 +0,0 @@
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 };
@@ -1,42 +0,0 @@
1
- const STATUS_BY_REASON = {
2
- deny: { code: "FORBIDDEN", status: 403 },
3
- rate: { code: "TOO_MANY_REQUESTS", status: 429 }
4
- };
5
- const defaultMessage = (name, reason, retryAfter) => {
6
- if (reason === "deny") {
7
- return `request denied for "${name}"`;
8
- }
9
- return retryAfter === void 0 ? `rate limit "${name}" exceeded` : `rate limit "${name}" exceeded; retry after ${String(retryAfter)}ms`;
10
- };
11
- const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
12
- let status;
13
- try {
14
- const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
15
- status = await resolved.limit(name, { count: options.count, key: options.key?.(ctx) });
16
- } catch (error) {
17
- console.error(`@lunora/ratelimit: rateLimit("${name}") threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
18
- if (options.failOpen) {
19
- return next();
20
- }
21
- throw Object.assign(new Error(`rate limiter unavailable for "${name}"`), {
22
- cause: error,
23
- code: "SERVICE_UNAVAILABLE",
24
- name: "LunoraError",
25
- status: 503
26
- });
27
- }
28
- if (!status.ok) {
29
- const reason = status.reason ?? "rate";
30
- const mapped = STATUS_BY_REASON[reason];
31
- const retryAfter = Number.isFinite(status.retryAfter) ? Math.ceil(status.retryAfter) : void 0;
32
- throw Object.assign(new Error(options.message ?? defaultMessage(name, reason, retryAfter)), {
33
- code: mapped.code,
34
- name: "LunoraError",
35
- retryAfter,
36
- status: mapped.status
37
- });
38
- }
39
- return next();
40
- };
41
-
42
- export { rateLimit };
@@ -1,12 +0,0 @@
1
- const ratelimitPlugin = (limiter) => {
2
- return {
3
- key: "ratelimit",
4
- middleware: async ({ ctx, next }) => {
5
- const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
6
- const existingApi = ctx.api ?? {};
7
- return next({ ctx: { api: { ...existingApi, ratelimit: resolved } } });
8
- }
9
- };
10
- };
11
-
12
- export { ratelimitPlugin };