import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { withRegistryLock, writeFileAtomic } from 'soma-core/server/registry'; import type { RateLimitDecision, RateLimitRule } from './RateLimiter'; interface StoredBucket { count: number; startedAt: number; windowMs: number; } interface StoredRateLimits { version: 1; buckets: Record; } export interface RateLimitBackend { hit(key: string, rule: RateLimitRule, at: number): Promise; reset?(): Promise | void; } export class MemoryRateLimitBackend implements RateLimitBackend { private readonly buckets = new Map(); async hit(key: string, rule: RateLimitRule, at: number): Promise { const decision = updateBucket(this.buckets.get(key), rule, at); this.buckets.set(key, decision.bucket); if (this.buckets.size % 256 === 0) { for (const [storedKey, bucket] of this.buckets) { if (at - bucket.startedAt >= bucket.windowMs) this.buckets.delete(storedKey); } } return decision.value; } reset(): void { this.buckets.clear(); } } /** * 多进程共享文件后端。调用方把 filePath 放在所有实例可见的共享文件系统上; * hardlink 写锁与同目录 rename 保证一次 hit 是“读最新 → 计数 → 原子落盘”。 */ export class SharedFileRateLimitBackend implements RateLimitBackend { constructor(private readonly filePath: string) { if (!path.isAbsolute(filePath)) { throw new Error('SOMA_RATE_LIMIT_STORE 必须是绝对路径'); } } async hit(key: string, rule: RateLimitRule, at: number): Promise { const baseDir = path.dirname(this.filePath); return withRegistryLock(baseDir, 'rate-limits', () => { const document = this.read(); prune(document, at); const bucketKey = createHash('sha256').update(key, 'utf8').digest('hex'); const decision = updateBucket(document.buckets[bucketKey], rule, at); document.buckets[bucketKey] = decision.bucket; writeFileAtomic(this.filePath, `${JSON.stringify(document)}\n`); return decision.value; }); } async reset(): Promise { const baseDir = path.dirname(this.filePath); withRegistryLock(baseDir, 'rate-limits', () => { try { fs.unlinkSync(this.filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } }); } private read(): StoredRateLimits { try { const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as Partial; if (parsed.version === 1 && parsed.buckets && typeof parsed.buckets === 'object') { return { version: 1, buckets: parsed.buckets }; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw new Error(`共享限流文件损坏:${this.filePath}`, { cause: error }); } } return { version: 1, buckets: {} }; } } function updateBucket( current: StoredBucket | undefined, rule: RateLimitRule, at: number, ): { bucket: StoredBucket; value: RateLimitDecision } { if (!current || at - current.startedAt >= current.windowMs || current.windowMs !== rule.windowMs) { return { bucket: { count: 1, startedAt: at, windowMs: rule.windowMs }, value: { allowed: true, remaining: rule.limit - 1, retryAfterSeconds: 0 }, }; } const bucket = { ...current, count: current.count + 1 }; if (bucket.count > rule.limit) { return { bucket, value: { allowed: false, remaining: 0, retryAfterSeconds: Math.max(1, Math.ceil((rule.windowMs - (at - bucket.startedAt)) / 1000)), }, }; } return { bucket, value: { allowed: true, remaining: rule.limit - bucket.count, retryAfterSeconds: 0 }, }; } function prune(document: StoredRateLimits, at: number): void { for (const [key, bucket] of Object.entries(document.buckets)) { if (at - bucket.startedAt >= bucket.windowMs) delete document.buckets[key]; } const entries = Object.entries(document.buckets); if (entries.length <= 10_000) return; entries .sort((left, right) => left[1].startedAt - right[1].startedAt) .slice(0, entries.length - 10_000) .forEach(([key]) => delete document.buckets[key]); }