@omni-api/plugin-ratelimit 0.0.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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @omni/plugin-ratelimit
2
+
3
+ OmniAPI 限流中间件。固定窗口计数算法,可插拔后端(默认内存)。
4
+
5
+ ## 用法
6
+ ```ts
7
+ import { rateLimit, createMemoryStore } from '@omni/plugin-ratelimit';
8
+
9
+ defineProcedure({
10
+ name: 'order.create',
11
+ middleware: [rateLimit({ rpm: 30 })], // 每分钟 30 次
12
+ // ...
13
+ });
14
+
15
+ // 自定义 key(按租户)
16
+ rateLimit({ limit: 100, windowMs: 60_000, key: (ctx) => ctx.state.tenant as string });
17
+
18
+ // 自定义 store(如 Redis)
19
+ rateLimit({ rpm: 100, store: myRedisStore });
20
+ ```
21
+
22
+ ## 注意
23
+ - 默认共享 module-level 内存 store —— 单进程足够;多实例部署需替换为分布式 store。
24
+ - 算法是固定窗口,不保证严格平滑;需要平滑请用令牌桶/漏桶(社区扩展)。
25
+ - key 自动加上 `procedureName:` 前缀,不同 procedure 互不影响。
@@ -0,0 +1,78 @@
1
+ import { Context, Middleware } from '@omni-api/core';
2
+
3
+ /**
4
+ * 限流后端存储接口(可替换:内存 / Redis / Memcached…)
5
+ *
6
+ * incr 在原子操作中:
7
+ * 1. 增加计数器
8
+ * 2. 若是首次(count==1),设置 TTL = windowMs
9
+ * 3. 返回最新计数 + 剩余 TTL
10
+ */
11
+ interface RateLimitStore {
12
+ /** 增加 1,返回 [新计数, 剩余窗口毫秒数] */
13
+ incr(key: string, windowMs: number): Promise<{
14
+ count: number;
15
+ resetMs: number;
16
+ }>;
17
+ /** 重置某 key(可选实现) */
18
+ reset?(key: string): Promise<void>;
19
+ }
20
+ /**
21
+ * 单进程内存版 Store —— 默认实现。
22
+ *
23
+ * 适合单实例 / 开发期;分布式部署应替换为 Redis 实现。
24
+ *
25
+ * 自带懒清理:incr 时若 key 已过期则重置;不需要后台 timer。
26
+ */
27
+ declare function createMemoryStore(): RateLimitStore;
28
+
29
+ interface RateLimitOptions {
30
+ /** 每分钟请求数。与 limit/windowMs 互斥;二选一 */
31
+ rpm?: number;
32
+ /** 窗口内最大请求数(与 windowMs 配合)。优先级高于 rpm */
33
+ limit?: number;
34
+ /** 窗口时长(毫秒)。默认 60_000 */
35
+ windowMs?: number;
36
+ /** key 生成函数。默认 user.id ?? 'anon' */
37
+ key?: (ctx: Context) => string;
38
+ /** 限流后端,默认共享 module-level 内存实例 */
39
+ store?: RateLimitStore;
40
+ /** 自定义错误消息 */
41
+ message?: string;
42
+ }
43
+ /**
44
+ * 限流中间件。
45
+ *
46
+ * 算法:固定窗口计数。简单、零开销;不保证严格平滑。
47
+ * 需要平滑限流(令牌桶/漏桶)请用 Redis 版扩展。
48
+ */
49
+ declare function rateLimit(options?: RateLimitOptions): Middleware;
50
+
51
+ /**
52
+ * Redis 客户端的最小接口。
53
+ *
54
+ * ioredis / node-redis@4 都满足该接口(参数顺序略有差异,下面适配)。
55
+ */
56
+ interface RedisLike {
57
+ /**
58
+ * 执行 Lua 脚本。
59
+ *
60
+ * 不同客户端的 eval 签名不同,我们这里用最通用的形态:
61
+ * - ioredis: eval(script, numKeys, ...keys, ...args) → result
62
+ * - node-redis@4: client.eval(script, { keys, arguments }) → result
63
+ * 我们要求传入的 `eval` 已经是 ioredis 风格的 wrapper(业务方包一下即可)。
64
+ */
65
+ eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<unknown>;
66
+ }
67
+ interface RedisStoreOptions {
68
+ /** 客户端 */
69
+ redis: RedisLike;
70
+ /** key 前缀,避免和其他业务冲突。默认 'omni:rl:' */
71
+ keyPrefix?: string;
72
+ }
73
+ /**
74
+ * Redis 后端的限流 Store —— 真正的分布式限流(不像内存版会被 N 个 pod 放大)。
75
+ */
76
+ declare function createRedisStore(options: RedisStoreOptions): RateLimitStore;
77
+
78
+ export { type RateLimitOptions, type RateLimitStore, type RedisLike, type RedisStoreOptions, createMemoryStore, createRedisStore, rateLimit };
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ import { RateLimitError } from '@omni-api/core';
2
+
3
+ // src/middleware.ts
4
+
5
+ // src/store.ts
6
+ function createMemoryStore() {
7
+ const map = /* @__PURE__ */ new Map();
8
+ return {
9
+ async incr(key, windowMs) {
10
+ const now = Date.now();
11
+ const entry = map.get(key);
12
+ if (!entry || entry.expiresAt <= now) {
13
+ const fresh = { count: 1, expiresAt: now + windowMs };
14
+ map.set(key, fresh);
15
+ return { count: 1, resetMs: windowMs };
16
+ }
17
+ entry.count += 1;
18
+ return { count: entry.count, resetMs: entry.expiresAt - now };
19
+ },
20
+ async reset(key) {
21
+ map.delete(key);
22
+ }
23
+ };
24
+ }
25
+
26
+ // src/middleware.ts
27
+ var defaultStore = createMemoryStore();
28
+ function rateLimit(options = {}) {
29
+ const windowMs = options.windowMs ?? 6e4;
30
+ const limit = options.limit ?? options.rpm ?? 60;
31
+ const store = options.store ?? defaultStore;
32
+ const keyFn = options.key ?? defaultKeyFn;
33
+ if (limit <= 0) throw new Error("rateLimit: `limit` must be > 0");
34
+ if (windowMs <= 0) throw new Error("rateLimit: `windowMs` must be > 0");
35
+ return async (ctx, next) => {
36
+ const procedureName = ctx.procedure?.name ?? "unknown";
37
+ const key = `${procedureName}:${keyFn(ctx)}`;
38
+ const { count, resetMs } = await store.incr(key, windowMs);
39
+ if (count > limit) {
40
+ throw new RateLimitError(options.message ?? "Too many requests", {
41
+ retryAfterMs: resetMs,
42
+ details: { limit, windowMs, current: count }
43
+ });
44
+ }
45
+ return next();
46
+ };
47
+ }
48
+ function defaultKeyFn(ctx) {
49
+ return ctx.user?.id ?? "anon";
50
+ }
51
+
52
+ // src/redis-store.ts
53
+ var SCRIPT = `
54
+ local count = redis.call('INCR', KEYS[1])
55
+ if count == 1 then
56
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
57
+ end
58
+ local ttl = redis.call('PTTL', KEYS[1])
59
+ if ttl < 0 then ttl = tonumber(ARGV[1]) end
60
+ return {count, ttl}
61
+ `;
62
+ function createRedisStore(options) {
63
+ const { redis, keyPrefix = "omni:rl:" } = options;
64
+ return {
65
+ async incr(key, windowMs) {
66
+ const fullKey = keyPrefix + key;
67
+ try {
68
+ const result = await redis.eval(SCRIPT, 1, fullKey, windowMs);
69
+ const count = Number(Array.isArray(result) ? result[0] : 0);
70
+ const ttl = Number(Array.isArray(result) ? result[1] : windowMs);
71
+ return { count, resetMs: ttl > 0 ? ttl : windowMs };
72
+ } catch (err) {
73
+ console.error("[ratelimit] redis error, fail-open:", err);
74
+ return { count: 1, resetMs: windowMs };
75
+ }
76
+ },
77
+ async reset(key) {
78
+ const fullKey = keyPrefix + key;
79
+ try {
80
+ const r = redis;
81
+ if (typeof r.del === "function") {
82
+ await r.del(fullKey);
83
+ } else {
84
+ await redis.eval('return redis.call("DEL", KEYS[1])', 1, fullKey);
85
+ }
86
+ } catch (err) {
87
+ console.error("[ratelimit] redis reset error:", err);
88
+ }
89
+ }
90
+ };
91
+ }
92
+
93
+ export { createMemoryStore, createRedisStore, rateLimit };
94
+ //# sourceMappingURL=index.js.map
95
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store.ts","../src/middleware.ts","../src/redis-store.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * 限流后端存储接口(可替换:内存 / Redis / Memcached…)\n *\n * incr 在原子操作中:\n * 1. 增加计数器\n * 2. 若是首次(count==1),设置 TTL = windowMs\n * 3. 返回最新计数 + 剩余 TTL\n */\nexport interface RateLimitStore {\n /** 增加 1,返回 [新计数, 剩余窗口毫秒数] */\n incr(key: string, windowMs: number): Promise<{ count: number; resetMs: number }>;\n /** 重置某 key(可选实现) */\n reset?(key: string): Promise<void>;\n}\n\ninterface MemEntry {\n count: number;\n /** 窗口结束时刻(绝对毫秒) */\n expiresAt: number;\n}\n\n/**\n * 单进程内存版 Store —— 默认实现。\n *\n * 适合单实例 / 开发期;分布式部署应替换为 Redis 实现。\n *\n * 自带懒清理:incr 时若 key 已过期则重置;不需要后台 timer。\n */\nexport function createMemoryStore(): RateLimitStore {\n const map = new Map<string, MemEntry>();\n\n return {\n async incr(key, windowMs) {\n const now = Date.now();\n const entry = map.get(key);\n if (!entry || entry.expiresAt <= now) {\n const fresh: MemEntry = { count: 1, expiresAt: now + windowMs };\n map.set(key, fresh);\n return { count: 1, resetMs: windowMs };\n }\n entry.count += 1;\n return { count: entry.count, resetMs: entry.expiresAt - now };\n },\n async reset(key) {\n map.delete(key);\n },\n };\n}\n","import { RateLimitError, type Context, type Middleware } from '@omni-api/core';\nimport { createMemoryStore, type RateLimitStore } from './store.js';\n\nexport interface RateLimitOptions {\n /** 每分钟请求数。与 limit/windowMs 互斥;二选一 */\n rpm?: number;\n /** 窗口内最大请求数(与 windowMs 配合)。优先级高于 rpm */\n limit?: number;\n /** 窗口时长(毫秒)。默认 60_000 */\n windowMs?: number;\n\n /** key 生成函数。默认 user.id ?? 'anon' */\n key?: (ctx: Context) => string;\n\n /** 限流后端,默认共享 module-level 内存实例 */\n store?: RateLimitStore;\n\n /** 自定义错误消息 */\n message?: string;\n}\n\n/** module-level 共享 store,避免每次调用 rateLimit() 都新建一个独立的 map */\nconst defaultStore = createMemoryStore();\n\n/**\n * 限流中间件。\n *\n * 算法:固定窗口计数。简单、零开销;不保证严格平滑。\n * 需要平滑限流(令牌桶/漏桶)请用 Redis 版扩展。\n */\nexport function rateLimit(options: RateLimitOptions = {}): Middleware {\n const windowMs = options.windowMs ?? 60_000;\n const limit = options.limit ?? options.rpm ?? 60;\n const store = options.store ?? defaultStore;\n const keyFn = options.key ?? defaultKeyFn;\n\n if (limit <= 0) throw new Error('rateLimit: `limit` must be > 0');\n if (windowMs <= 0) throw new Error('rateLimit: `windowMs` must be > 0');\n\n return async (ctx, next) => {\n const procedureName = ctx.procedure?.name ?? 'unknown';\n const key = `${procedureName}:${keyFn(ctx)}`;\n const { count, resetMs } = await store.incr(key, windowMs);\n\n if (count > limit) {\n throw new RateLimitError(options.message ?? 'Too many requests', {\n retryAfterMs: resetMs,\n details: { limit, windowMs, current: count },\n });\n }\n\n return next();\n };\n}\n\nfunction defaultKeyFn(ctx: Context): string {\n return ctx.user?.id ?? 'anon';\n}\n","import type { RateLimitStore } from './store.js';\n\n/**\n * Redis 客户端的最小接口。\n *\n * ioredis / node-redis@4 都满足该接口(参数顺序略有差异,下面适配)。\n */\nexport interface RedisLike {\n /**\n * 执行 Lua 脚本。\n *\n * 不同客户端的 eval 签名不同,我们这里用最通用的形态:\n * - ioredis: eval(script, numKeys, ...keys, ...args) → result\n * - node-redis@4: client.eval(script, { keys, arguments }) → result\n * 我们要求传入的 `eval` 已经是 ioredis 风格的 wrapper(业务方包一下即可)。\n */\n eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<unknown>;\n}\n\n/**\n * 固定窗口限流的 Lua 脚本(原子操作)。\n *\n * 步骤:\n * 1. INCR key\n * 2. 如果是首次(count == 1),PEXPIRE key windowMs\n * 3. PTTL 返回剩余 ms\n *\n * 返回值:[count, remainingMs]\n */\nconst SCRIPT = `\nlocal count = redis.call('INCR', KEYS[1])\nif count == 1 then\n redis.call('PEXPIRE', KEYS[1], ARGV[1])\nend\nlocal ttl = redis.call('PTTL', KEYS[1])\nif ttl < 0 then ttl = tonumber(ARGV[1]) end\nreturn {count, ttl}\n`;\n\nexport interface RedisStoreOptions {\n /** 客户端 */\n redis: RedisLike;\n /** key 前缀,避免和其他业务冲突。默认 'omni:rl:' */\n keyPrefix?: string;\n}\n\n/**\n * Redis 后端的限流 Store —— 真正的分布式限流(不像内存版会被 N 个 pod 放大)。\n */\nexport function createRedisStore(options: RedisStoreOptions): RateLimitStore {\n const { redis, keyPrefix = 'omni:rl:' } = options;\n\n return {\n async incr(key, windowMs) {\n const fullKey = keyPrefix + key;\n try {\n const result = (await redis.eval(SCRIPT, 1, fullKey, windowMs)) as [number, number];\n // ioredis 返回 [Number, Number];node-redis 同;保险起见 toNumber\n const count = Number(Array.isArray(result) ? result[0] : 0);\n const ttl = Number(Array.isArray(result) ? result[1] : windowMs);\n return { count, resetMs: ttl > 0 ? ttl : windowMs };\n } catch (err) {\n // Redis 故障时降级:不阻塞业务(fail-open)\n // 调用方可能想 fail-close,但默认 open 更安全 —— 否则 redis 挂导致业务全挂\n console.error('[ratelimit] redis error, fail-open:', err);\n return { count: 1, resetMs: windowMs };\n }\n },\n async reset(key) {\n const fullKey = keyPrefix + key;\n try {\n // 直接 DEL;用 eval 也行,但 DEL 简单且通常各客户端都暴露\n const r = redis as unknown as { del?: (k: string) => Promise<number> };\n if (typeof r.del === 'function') {\n await r.del(fullKey);\n } else {\n await redis.eval('return redis.call(\"DEL\", KEYS[1])', 1, fullKey);\n }\n } catch (err) {\n console.error('[ratelimit] redis reset error:', err);\n }\n },\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@omni-api/plugin-ratelimit",
3
+ "version": "0.0.1",
4
+ "description": "Rate limit middleware for OmniAPI (in-memory, pluggable store)",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "typecheck": "tsc --noEmit",
21
+ "clean": "rm -rf dist .turbo coverage"
22
+ },
23
+ "dependencies": {
24
+ "@omni-api/core": "workspace:*"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.3.0",
28
+ "typescript": "^5.7.0",
29
+ "vitest": "^2.1.0",
30
+ "zod": "^3.23.8",
31
+ "@types/node": "^22.10.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }