@superfunctions/middleware 0.1.0

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.
@@ -0,0 +1 @@
1
+ export * from './rate-limit.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './rate-limit.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createRateLimiter } from './rate-limit.js';
3
+ describe('middleware', () => {
4
+ it('should create rate limiter', async () => {
5
+ const limiter = createRateLimiter({ windowMs: 60000, maxRequests: 100 });
6
+ const result = await limiter.check({ key: 'test' });
7
+ expect(result.allowed).toBe(true);
8
+ });
9
+ it('should export RateLimiter interface', async () => {
10
+ const limiter = createRateLimiter({ windowMs: 60000, maxRequests: 100 });
11
+ expect(typeof limiter.check).toBe('function');
12
+ expect(typeof limiter.reset).toBe('function');
13
+ });
14
+ });
@@ -0,0 +1,30 @@
1
+ import type { Adapter, KVStoreAdapter } from '@superfunctions/db';
2
+ export { KVStoreAdapter };
3
+ export type LegacyWindowAlgorithm = 'sliding-window' | 'fixed-window';
4
+ export type RateLimitAlgorithm = LegacyWindowAlgorithm | 'token-bucket';
5
+ export interface RateLimitConfig {
6
+ windowMs: number;
7
+ maxRequests: number;
8
+ keyPrefix?: string;
9
+ persistence?: Adapter | KVStoreAdapter;
10
+ algorithm?: RateLimitAlgorithm;
11
+ now?: () => number;
12
+ }
13
+ export interface RateLimitResult {
14
+ allowed: boolean;
15
+ remaining: number;
16
+ resetAt: string;
17
+ total: number;
18
+ }
19
+ export interface CheckLimitInput {
20
+ key: string;
21
+ windowSeconds?: number;
22
+ limit?: number;
23
+ }
24
+ export interface RateLimiter {
25
+ check(input: CheckLimitInput): Promise<RateLimitResult>;
26
+ reset(key: string): Promise<void>;
27
+ }
28
+ declare function createInMemoryKVStore(now?: () => number): KVStoreAdapter;
29
+ export declare function createRateLimiter(config: RateLimitConfig): RateLimiter;
30
+ export { createInMemoryKVStore };
@@ -0,0 +1,202 @@
1
+ function createInMemoryKVStore(now = Date.now) {
2
+ const store = new Map();
3
+ return {
4
+ async get(key) {
5
+ const entry = store.get(key);
6
+ if (!entry)
7
+ return null;
8
+ if (entry.expiresAt && now() > entry.expiresAt) {
9
+ store.delete(key);
10
+ return null;
11
+ }
12
+ return entry.value;
13
+ },
14
+ async set(input) {
15
+ store.set(input.key, {
16
+ value: input.value,
17
+ expiresAt: input.ttlSeconds ? now() + input.ttlSeconds * 1000 : undefined,
18
+ });
19
+ },
20
+ async delete(key) {
21
+ store.delete(key);
22
+ },
23
+ };
24
+ }
25
+ function isAdapter(p) {
26
+ return !!p && typeof p === 'object' && 'findOne' in p && 'create' in p;
27
+ }
28
+ function ensureKV(p, now) {
29
+ if (isAdapter(p)) {
30
+ return {
31
+ async get(key) {
32
+ const res = await p.findOne({
33
+ model: 'rate_limits',
34
+ where: [{ field: 'key', operator: 'eq', value: key }],
35
+ });
36
+ if (!res)
37
+ return null;
38
+ if (res.expiresAt && new Date(res.expiresAt).getTime() < now()) {
39
+ await p.delete({ model: 'rate_limits', where: [{ field: 'key', operator: 'eq', value: key }] });
40
+ return null;
41
+ }
42
+ return res.value;
43
+ },
44
+ async set(input) {
45
+ const expiresAt = input.ttlSeconds
46
+ ? new Date(now() + input.ttlSeconds * 1000).toISOString()
47
+ : undefined;
48
+ await p.upsert({
49
+ model: 'rate_limits',
50
+ where: [{ field: 'key', operator: 'eq', value: input.key }],
51
+ create: { key: input.key, value: input.value, expiresAt },
52
+ update: { value: input.value, expiresAt },
53
+ });
54
+ },
55
+ async delete(key) {
56
+ await p.delete({ model: 'rate_limits', where: [{ field: 'key', operator: 'eq', value: key }] });
57
+ },
58
+ };
59
+ }
60
+ return p;
61
+ }
62
+ async function getState(kv, key) {
63
+ const data = await kv.get(key);
64
+ if (!data)
65
+ return null;
66
+ try {
67
+ return JSON.parse(data);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ async function setState(kv, key, state, ttlMs) {
74
+ if (ttlMs <= 0) {
75
+ return;
76
+ }
77
+ await kv.set({
78
+ key,
79
+ value: JSON.stringify(state),
80
+ ttlSeconds: Math.ceil(ttlMs / 1000),
81
+ });
82
+ }
83
+ export function createRateLimiter(config) {
84
+ const now = config.now ?? Date.now;
85
+ const { windowMs, maxRequests, keyPrefix = 'ratelimit:', persistence = createInMemoryKVStore(now), algorithm = 'fixed-window', } = config;
86
+ const kv = ensureKV(persistence, now);
87
+ const keyLocks = new Map();
88
+ async function withKeyLock(key, work) {
89
+ const prior = keyLocks.get(key) ?? Promise.resolve();
90
+ let resolveLock;
91
+ const current = new Promise((resolve) => {
92
+ resolveLock = resolve;
93
+ });
94
+ const queued = prior.then(() => current);
95
+ keyLocks.set(key, queued);
96
+ await prior;
97
+ try {
98
+ return await work();
99
+ }
100
+ finally {
101
+ resolveLock();
102
+ if (keyLocks.get(key) === queued) {
103
+ keyLocks.delete(key);
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ async check(input) {
109
+ const key = `${keyPrefix}${input.key}`;
110
+ return withKeyLock(key, async () => {
111
+ const effectiveWindowMs = (input.windowSeconds !== undefined ? input.windowSeconds * 1000 : undefined) ?? windowMs;
112
+ const effectiveLimit = input.limit ?? maxRequests;
113
+ const currentTime = now();
114
+ if (!Number.isFinite(effectiveWindowMs) || effectiveWindowMs <= 0) {
115
+ throw new Error('RATE_LIMIT_WINDOW_INVALID');
116
+ }
117
+ if (!Number.isFinite(effectiveLimit) || effectiveLimit < 0) {
118
+ throw new Error('RATE_LIMIT_LIMIT_INVALID');
119
+ }
120
+ if (algorithm === 'sliding-window') {
121
+ const state = (await getState(kv, key)) ?? { timestamps: [] };
122
+ const floor = currentTime - effectiveWindowMs;
123
+ state.timestamps = state.timestamps.filter((timestamp) => timestamp > floor);
124
+ const allowed = state.timestamps.length < effectiveLimit;
125
+ if (allowed) {
126
+ state.timestamps.push(currentTime);
127
+ }
128
+ const oldest = state.timestamps[0] ?? currentTime;
129
+ const resetAt = oldest + effectiveWindowMs;
130
+ const ttlMs = Math.max(1, resetAt - currentTime);
131
+ await setState(kv, key, state, ttlMs);
132
+ return {
133
+ allowed,
134
+ remaining: Math.max(0, effectiveLimit - state.timestamps.length),
135
+ resetAt: new Date(resetAt).toISOString(),
136
+ total: effectiveLimit,
137
+ };
138
+ }
139
+ if (algorithm === 'token-bucket') {
140
+ if (effectiveLimit <= 0) {
141
+ const resetAt = currentTime + Math.max(0, effectiveWindowMs);
142
+ return {
143
+ allowed: false,
144
+ remaining: 0,
145
+ resetAt: new Date(resetAt).toISOString(),
146
+ total: effectiveLimit,
147
+ };
148
+ }
149
+ const refillRatePerMs = effectiveLimit / effectiveWindowMs;
150
+ const state = (await getState(kv, key)) ?? {
151
+ tokens: effectiveLimit,
152
+ lastRefill: currentTime,
153
+ };
154
+ const elapsed = Math.max(0, currentTime - state.lastRefill);
155
+ state.tokens = Math.min(effectiveLimit, state.tokens + elapsed * refillRatePerMs);
156
+ state.lastRefill = currentTime;
157
+ const allowed = state.tokens >= 1;
158
+ if (allowed) {
159
+ state.tokens -= 1;
160
+ }
161
+ const msUntilNextToken = state.tokens >= 1 ? 0 : Math.ceil((1 - state.tokens) / refillRatePerMs);
162
+ const msUntilFull = Math.ceil((effectiveLimit - state.tokens) / refillRatePerMs);
163
+ const resetAt = currentTime + msUntilNextToken;
164
+ await setState(kv, key, state, Math.max(1, msUntilFull));
165
+ return {
166
+ allowed,
167
+ remaining: Math.max(0, Math.floor(state.tokens)),
168
+ resetAt: new Date(resetAt).toISOString(),
169
+ total: effectiveLimit,
170
+ };
171
+ }
172
+ const state = (await getState(kv, key)) ?? {
173
+ count: 0,
174
+ windowStart: currentTime,
175
+ };
176
+ if (currentTime >= state.windowStart + effectiveWindowMs) {
177
+ state.count = 0;
178
+ state.windowStart = currentTime;
179
+ }
180
+ const allowed = state.count < effectiveLimit;
181
+ if (allowed) {
182
+ state.count += 1;
183
+ }
184
+ const resetAt = state.windowStart + effectiveWindowMs;
185
+ await setState(kv, key, state, Math.max(1, resetAt - currentTime));
186
+ return {
187
+ allowed,
188
+ remaining: Math.max(0, effectiveLimit - state.count),
189
+ resetAt: new Date(resetAt).toISOString(),
190
+ total: effectiveLimit,
191
+ };
192
+ });
193
+ },
194
+ async reset(key) {
195
+ const namespacedKey = `${keyPrefix}${key}`;
196
+ await withKeyLock(namespacedKey, async () => {
197
+ await kv.delete(namespacedKey);
198
+ });
199
+ },
200
+ };
201
+ }
202
+ export { createInMemoryKVStore };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,200 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createRateLimiter, createInMemoryKVStore } from './rate-limit.js';
3
+ describe('rate-limit', () => {
4
+ beforeEach(() => {
5
+ vi.useRealTimers();
6
+ });
7
+ describe('createRateLimiter', () => {
8
+ it('allows requests within fixed-window limit', async () => {
9
+ const limiter = createRateLimiter({
10
+ windowMs: 60000,
11
+ maxRequests: 3,
12
+ algorithm: 'fixed-window',
13
+ });
14
+ const r1 = await limiter.check({ key: 'user1' });
15
+ expect(r1.allowed).toBe(true);
16
+ expect(r1.remaining).toBe(2);
17
+ const r2 = await limiter.check({ key: 'user1' });
18
+ expect(r2.allowed).toBe(true);
19
+ expect(r2.remaining).toBe(1);
20
+ const r3 = await limiter.check({ key: 'user1' });
21
+ expect(r3.allowed).toBe(true);
22
+ expect(r3.remaining).toBe(0);
23
+ const r4 = await limiter.check({ key: 'user1' });
24
+ expect(r4.allowed).toBe(false);
25
+ });
26
+ it('enforces sliding-window behavior based on timestamp eviction (TV-REUSE-011-P)', async () => {
27
+ vi.useFakeTimers();
28
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
29
+ const limiter = createRateLimiter({
30
+ windowMs: 1000,
31
+ maxRequests: 5,
32
+ algorithm: 'sliding-window',
33
+ });
34
+ for (let i = 0; i < 5; i += 1) {
35
+ const result = await limiter.check({ key: 'sliding-user' });
36
+ expect(result.allowed).toBe(true);
37
+ vi.setSystemTime(Date.now() + 100);
38
+ }
39
+ const blockedAt500 = await limiter.check({ key: 'sliding-user' });
40
+ expect(blockedAt500.allowed).toBe(false);
41
+ vi.setSystemTime(Date.now() + 500);
42
+ const allowedAt1000 = await limiter.check({ key: 'sliding-user' });
43
+ expect(allowedAt1000.allowed).toBe(true);
44
+ });
45
+ it('enforces token-bucket behavior distinctly from fixed-window (TV-REUSE-011-N)', async () => {
46
+ vi.useFakeTimers();
47
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
48
+ const tokenBucket = createRateLimiter({
49
+ windowMs: 1000,
50
+ maxRequests: 2,
51
+ algorithm: 'token-bucket',
52
+ });
53
+ expect((await tokenBucket.check({ key: 'tb-user' })).allowed).toBe(true);
54
+ expect((await tokenBucket.check({ key: 'tb-user' })).allowed).toBe(true);
55
+ expect((await tokenBucket.check({ key: 'tb-user' })).allowed).toBe(false);
56
+ // Token bucket refills continuously; fixed-window would still block at +500ms.
57
+ vi.setSystemTime(Date.now() + 500);
58
+ const tokenBucketAtHalfWindow = await tokenBucket.check({ key: 'tb-user' });
59
+ expect(tokenBucketAtHalfWindow.allowed).toBe(true);
60
+ const fixedWindow = createRateLimiter({
61
+ windowMs: 1000,
62
+ maxRequests: 2,
63
+ algorithm: 'fixed-window',
64
+ });
65
+ expect((await fixedWindow.check({ key: 'fw-user' })).allowed).toBe(true);
66
+ expect((await fixedWindow.check({ key: 'fw-user' })).allowed).toBe(true);
67
+ expect((await fixedWindow.check({ key: 'fw-user' })).allowed).toBe(false);
68
+ vi.setSystemTime(Date.now() + 500);
69
+ const fixedAtHalfWindow = await fixedWindow.check({ key: 'fw-user' });
70
+ expect(fixedAtHalfWindow.allowed).toBe(false);
71
+ });
72
+ it('returns a clean denial for zero-limit token buckets instead of crashing', async () => {
73
+ vi.useFakeTimers();
74
+ vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
75
+ const limiter = createRateLimiter({
76
+ windowMs: 1000,
77
+ maxRequests: 0,
78
+ algorithm: 'token-bucket',
79
+ });
80
+ await expect(limiter.check({ key: 'tb-zero' })).resolves.toMatchObject({
81
+ allowed: false,
82
+ remaining: 0,
83
+ total: 0,
84
+ });
85
+ });
86
+ it('rejects non-positive effective windows before computing bucket math', async () => {
87
+ const limiter = createRateLimiter({
88
+ windowMs: 1000,
89
+ maxRequests: 1,
90
+ algorithm: 'token-bucket',
91
+ });
92
+ await expect(limiter.check({ key: 'tb-invalid-window', windowSeconds: 0 })).rejects.toThrow('RATE_LIMIT_WINDOW_INVALID');
93
+ });
94
+ it('rejects non-finite or negative effective limits before rate-limit calculations', async () => {
95
+ const limiter = createRateLimiter({
96
+ windowMs: 1000,
97
+ maxRequests: 1,
98
+ algorithm: 'token-bucket',
99
+ });
100
+ await expect(limiter.check({ key: 'tb-invalid-limit', limit: Number.NaN })).rejects.toThrow('RATE_LIMIT_LIMIT_INVALID');
101
+ await expect(limiter.check({ key: 'tb-negative-limit', limit: -1 })).rejects.toThrow('RATE_LIMIT_LIMIT_INVALID');
102
+ });
103
+ it('tracks different keys separately', async () => {
104
+ const limiter = createRateLimiter({
105
+ windowMs: 60000,
106
+ maxRequests: 1,
107
+ });
108
+ expect((await limiter.check({ key: 'user1' })).allowed).toBe(true);
109
+ expect((await limiter.check({ key: 'user2' })).allowed).toBe(true);
110
+ expect((await limiter.check({ key: 'user1' })).allowed).toBe(false);
111
+ });
112
+ it('resets key state', async () => {
113
+ const limiter = createRateLimiter({
114
+ windowMs: 60000,
115
+ maxRequests: 1,
116
+ });
117
+ await limiter.check({ key: 'user1' });
118
+ expect((await limiter.check({ key: 'user1' })).allowed).toBe(false);
119
+ await limiter.reset('user1');
120
+ expect((await limiter.check({ key: 'user1' })).allowed).toBe(true);
121
+ });
122
+ it('includes reset time in result', async () => {
123
+ const now = Date.now();
124
+ const windowMs = 60000;
125
+ const limiter = createRateLimiter({
126
+ windowMs,
127
+ maxRequests: 10,
128
+ });
129
+ const result = await limiter.check({ key: 'user1' });
130
+ const resetAtTs = new Date(result.resetAt).getTime();
131
+ expect(resetAtTs).toBeGreaterThanOrEqual(now);
132
+ expect(resetAtTs).toBeLessThanOrEqual(now + windowMs + 100);
133
+ });
134
+ it('respects check overrides', async () => {
135
+ const limiter = createRateLimiter({
136
+ windowMs: 60000,
137
+ maxRequests: 10,
138
+ });
139
+ expect((await limiter.check({ key: 'user_override', limit: 1 })).allowed).toBe(true);
140
+ expect((await limiter.check({ key: 'user_override', limit: 1 })).allowed).toBe(false);
141
+ });
142
+ it('uses custom key prefix', async () => {
143
+ const store = createInMemoryKVStore();
144
+ const limiter = createRateLimiter({
145
+ windowMs: 60000,
146
+ maxRequests: 5,
147
+ keyPrefix: 'custom:',
148
+ persistence: store,
149
+ });
150
+ await limiter.check({ key: 'test' });
151
+ const value = await store.get('custom:test');
152
+ expect(value).not.toBeNull();
153
+ });
154
+ it('accepts external persistence', async () => {
155
+ const internalStore = new Map();
156
+ const customStore = {
157
+ async get(key) {
158
+ return internalStore.get(key) ?? null;
159
+ },
160
+ async set(input) {
161
+ internalStore.set(input.key, input.value);
162
+ },
163
+ async delete(key) {
164
+ internalStore.delete(key);
165
+ },
166
+ };
167
+ const limiter = createRateLimiter({
168
+ windowMs: 60000,
169
+ maxRequests: 2,
170
+ persistence: customStore,
171
+ });
172
+ await limiter.check({ key: 'user1' });
173
+ expect(internalStore.size).toBe(1);
174
+ });
175
+ });
176
+ describe('createInMemoryKVStore', () => {
177
+ let store;
178
+ beforeEach(() => {
179
+ store = createInMemoryKVStore();
180
+ });
181
+ it('gets and sets values', async () => {
182
+ await store.set({ key: 'key1', value: 'value1' });
183
+ expect(await store.get('key1')).toBe('value1');
184
+ });
185
+ it('returns null for missing keys', async () => {
186
+ expect(await store.get('missing')).toBeNull();
187
+ });
188
+ it('deletes keys', async () => {
189
+ await store.set({ key: 'key1', value: 'value1' });
190
+ await store.delete('key1');
191
+ expect(await store.get('key1')).toBeNull();
192
+ });
193
+ it('expires keys after TTL', async () => {
194
+ await store.set({ key: 'key1', value: 'value1', ttlSeconds: 0.01 });
195
+ expect(await store.get('key1')).toBe('value1');
196
+ await new Promise((resolve) => setTimeout(resolve, 20));
197
+ expect(await store.get('key1')).toBeNull();
198
+ });
199
+ });
200
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@superfunctions/middleware",
3
+ "version": "0.1.0",
4
+ "description": "Shared middleware utilities for Superfunctions",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./rate-limit": {
14
+ "types": "./dist/rate-limit.d.ts",
15
+ "default": "./dist/rate-limit.js"
16
+ }
17
+ },
18
+ "files": ["dist"],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest",
22
+ "test:watch": "vitest --watch",
23
+ "typecheck": "tsc --noEmit",
24
+ "clean": "rm -rf dist"
25
+ },
26
+ "keywords": ["middleware", "rate-limit", "superfunctions"],
27
+ "author": "21n",
28
+ "license": "MIT",
29
+ "bugs": { "url": "https://github.com/21nCo/super-functions/issues" },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/21nCo/super-functions.git",
33
+ "directory": "packages/middleware"
34
+ },
35
+ "publishConfig": { "access": "public" },
36
+ "peerDependencies": {
37
+ "@superfunctions/db": "0.1.4"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@superfunctions/db": { "optional": true }
41
+ },
42
+ "devDependencies": {
43
+ "@superfunctions/db": "^0.1.3",
44
+ "@types/node": "^22.0.0",
45
+ "typescript": "^5.6.0",
46
+ "vitest": "^3.2.4"
47
+ }
48
+ }