UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

856 lines (846 loc) 27.9 kB
import { u as requireMutationCtx } from "../api-entry-N3nBOlI2.js"; import { _ as CRPCError } from "../builder-DBgto1yn.js"; import { t as definePlugin } from "../middleware-Bg-PdtrI.js"; import { v } from "convex/values"; import { mutationGeneric, queryGeneric } from "convex/server"; //#region src/ratelimit/duration.ts const UNIT_TO_MS = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5 }; const DURATION_REGEX = /^(\d+(?:\.\d+)?)\s?(ms|s|m|h|d)$/; function toMs(duration) { if (typeof duration === "number") { if (!Number.isFinite(duration) || duration <= 0) throw new Error(`Invalid duration: ${duration}`); return duration; } const match = duration.trim().match(DURATION_REGEX); if (!match) throw new Error(`Unable to parse duration: ${duration}`); const milliseconds = Number.parseFloat(match[1]) * UNIT_TO_MS[match[2]]; if (!Number.isFinite(milliseconds) || milliseconds <= 0) throw new Error(`Invalid duration: ${duration}`); return milliseconds; } //#endregion //#region src/ratelimit/core/algorithms.ts const DEFAULT_SHARDS = 1; function fixedWindow(limit, window, options) { validatePositive(limit, "limit"); const shards = normalizeShards(options?.shards); const capacity = options?.capacity ?? limit; validatePositive(capacity, "capacity"); return { kind: "fixedWindow", limit, window: toMs(window), capacity, maxReserved: options?.maxReserved, start: options?.start, shards }; } function slidingWindow(limit, window, options) { validatePositive(limit, "limit"); return { kind: "slidingWindow", limit, window: toMs(window), maxReserved: options?.maxReserved, shards: normalizeShards(options?.shards) }; } function tokenBucket(refillRate, interval, maxTokens, options) { validatePositive(refillRate, "refillRate"); validatePositive(maxTokens, "maxTokens"); return { kind: "tokenBucket", refillRate, interval: toMs(interval), maxTokens, maxReserved: options?.maxReserved, shards: normalizeShards(options?.shards) }; } function applyDynamicLimit(algorithm, dynamicLimit) { if (!dynamicLimit || dynamicLimit <= 0) return algorithm; if (algorithm.kind === "tokenBucket") return { ...algorithm, refillRate: dynamicLimit, maxTokens: algorithm.maxTokens === algorithm.refillRate ? dynamicLimit : algorithm.maxTokens }; if (algorithm.kind === "fixedWindow") return { ...algorithm, limit: dynamicLimit, capacity: algorithm.capacity === algorithm.limit ? dynamicLimit : algorithm.capacity }; return { ...algorithm, limit: dynamicLimit }; } function normalizeShards(shards) { if (shards === void 0) return DEFAULT_SHARDS; const rounded = Math.round(shards); if (rounded < 1) throw new Error("shards must be >= 1"); return rounded; } function validatePositive(value, field) { if (!Number.isFinite(value) || value <= 0) throw new Error(`${field} must be a positive number`); } //#endregion //#region src/ratelimit/core/calculate-rate-limit.ts function calculateRatelimit(state, algorithm, now, count) { if (algorithm.kind === "fixedWindow") return calculateFixedWindow(state, algorithm, now, count); if (algorithm.kind === "tokenBucket") return calculateTokenBucket(state, algorithm, now, count); return calculateSlidingWindow(state, algorithm, now, count); } function calculateTokenBucket(state, config, now, count) { const ratePerMs = config.refillRate / config.interval; const initial = state ?? { value: config.maxTokens, ts: now }; const elapsed = Math.max(0, now - initial.ts); const nextValue = Math.min(initial.value + elapsed * ratePerMs, config.maxTokens) - count; const retryAfter = nextValue < 0 ? Math.ceil(-nextValue / ratePerMs) : void 0; return { state: { value: nextValue, ts: now }, retryAfter, remaining: Math.max(0, Math.floor(nextValue)), reset: retryAfter ? now + retryAfter : now, limit: config.maxTokens }; } function calculateFixedWindow(state, config, now, count) { const windowStart = alignWindowStart(now, config.window, config.start); const initial = state ?? { value: config.capacity, ts: windowStart }; const elapsedWindows = Math.max(0, Math.floor((now - initial.ts) / config.window)); const replenished = Math.min(initial.value + config.limit * elapsedWindows, config.capacity); const ts = initial.ts + elapsedWindows * config.window; const nextValue = replenished - count; const retryAfter = nextValue < 0 ? ts + config.window * Math.ceil(-nextValue / config.limit) - now : void 0; return { state: { value: nextValue, ts }, retryAfter, remaining: Math.max(0, Math.floor(nextValue)), reset: ts + config.window, limit: config.limit }; } function calculateSlidingWindow(state, config, now, count) { const windowStart = alignWindowStart(now, config.window); const previousWindowStart = windowStart - config.window; const elapsedInWindow = now - windowStart; const previousWeight = Math.max(0, (config.window - elapsedInWindow) / config.window); let currentCount = 0; let previousCount = 0; if (state) { if (state.ts === windowStart) { currentCount = Math.max(0, state.value); if (state.auxTs === previousWindowStart) previousCount = Math.max(0, state.auxValue ?? 0); } else if (state.ts === previousWindowStart) previousCount = Math.max(0, state.value); } const projectedCurrent = currentCount + count; const projectedUsed = projectedCurrent + previousCount * previousWeight; const remaining = config.limit - projectedUsed; const retryAfter = remaining < 0 ? Math.max(1, config.window - elapsedInWindow) : void 0; return { state: { value: projectedCurrent, ts: windowStart, auxValue: previousCount, auxTs: previousWindowStart }, retryAfter, remaining: Math.max(0, Math.floor(remaining)), reset: windowStart + config.window, limit: config.limit }; } function alignWindowStart(now, window, start = 0) { const offsetNow = now - start; return start + Math.floor(offsetNow / window) * window; } //#endregion //#region src/ratelimit/core/cache.ts var EphemeralBlockCache = class { constructor(cache) { this.cache = cache; } isBlocked(identifier) { const reset = this.cache.get(identifier); if (!reset) return { blocked: false, reset: 0 }; if (reset <= Date.now()) { this.cache.delete(identifier); return { blocked: false, reset: 0 }; } return { blocked: true, reset }; } blockUntil(identifier, reset) { this.cache.set(identifier, reset); } clear(identifier) { this.cache.delete(identifier); } size() { return this.cache.size; } }; function createReadDedupeCache() { const cache = /* @__PURE__ */ new Map(); return { get: (key) => cache.get(key), set: (key, value) => cache.set(key, value), delete: (key) => cache.delete(key), clear: () => cache.clear() }; } //#endregion //#region src/ratelimit/core/deny-list.ts const DEFAULT_BLOCK_MS = 6e4; const THRESHOLD_BLOCK_MS = 1440 * 60 * 1e3; const protectionState = /* @__PURE__ */ new Map(); function getState(prefix) { let state = protectionState.get(prefix); if (!state) { state = { hits: /* @__PURE__ */ new Map(), blockedUntil: /* @__PURE__ */ new Map() }; protectionState.set(prefix, state); } return state; } function pickDeniedValue(options) { const members = getMembers(options.identifier, options.request); const state = getState(options.prefix); for (const member of members) { const until = state.blockedUntil.get(member.value); if (until && until > Date.now()) return member.value; if (until && until <= Date.now()) state.blockedUntil.delete(member.value); } if (!options.lists) return; const listMatchers = [ { values: options.lists.identifiers, kind: "identifier" }, { values: options.lists.ips, kind: "ip" }, { values: options.lists.userAgents, kind: "userAgent" }, { values: options.lists.countries, kind: "country" } ]; for (const matcher of listMatchers) { if (!matcher.values || matcher.values.length === 0) continue; const valueSet = new Set(matcher.values); const hit = members.find((member) => member.kind === matcher.kind && valueSet.has(member.value)); if (hit) { state.blockedUntil.set(hit.value, Date.now() + DEFAULT_BLOCK_MS); return hit.value; } } } function recordRatelimitFailure(options) { const members = getMembers(options.identifier, options.request); const state = getState(options.prefix); for (const member of members) { const next = (state.hits.get(member.value) ?? 0) + 1; state.hits.set(member.value, next); if (next >= options.threshold) state.blockedUntil.set(member.value, Date.now() + THRESHOLD_BLOCK_MS); } } function clearProtection(prefix, identifier) { const state = getState(prefix); state.hits.delete(identifier); state.blockedUntil.delete(identifier); } function getMembers(identifier, request) { return [ { kind: "identifier", value: identifier }, { kind: "ip", value: request?.ip }, { kind: "userAgent", value: request?.userAgent }, { kind: "country", value: request?.country } ].filter((member) => Boolean(member.value)); } //#endregion //#region src/ratelimit/store/convex-store.ts const RATE_LIMIT_STATE_TABLE = "ratelimitState"; const RATE_LIMIT_DYNAMIC_TABLE = "ratelimitDynamicLimit"; const RATE_LIMIT_HIT_TABLE = "ratelimitProtectionHit"; const RATE_LIMIT_TABLE_NAMES = [ RATE_LIMIT_STATE_TABLE, RATE_LIMIT_DYNAMIC_TABLE, RATE_LIMIT_HIT_TABLE ]; const missingDbMessage = "Ratelimit requires a Convex db context. Pass `db` in constructor config or use hookAPI()."; const missingTableGuidance = "Ratelimit tables are missing. Scaffold and register `convex/lib/plugins/ratelimit/schema.ts`, or run `kitcn add ratelimit`."; var ConvexRatelimitStore = class ConvexRatelimitStore { dedupe = createReadDedupeCache(); listDedupe = createReadDedupeCache(); dynamicDedupe = createReadDedupeCache(); constructor(db) { this.db = db; } withDb(db) { return new ConvexRatelimitStore(db); } async getState(name, key, shard) { return this.withSetupGuidance(async () => { const db = this.getReader(); const cacheKey = stateCacheKey(name, key, shard); const cached = this.dedupe.get(cacheKey); if (cached) return cached; const query = db.query(RATE_LIMIT_STATE_TABLE).withIndex("by_name_key_shard", (q) => q.eq("name", name).eq("key", key).eq("shard", shard)).unique().then((row) => row ? row : null); this.dedupe.set(cacheKey, query); return query; }); } async listStates(name, key) { return this.withSetupGuidance(async () => { const db = this.getReader(); const cacheKey = listCacheKey(name, key); const cached = this.listDedupe.get(cacheKey); if (cached) return await cached ?? []; const query = db.query(RATE_LIMIT_STATE_TABLE).withIndex("by_name_key", (q) => q.eq("name", name).eq("key", key)).collect().then((rows) => rows); this.listDedupe.set(cacheKey, query); return query; }); } async upsertState(options) { await this.withSetupGuidance(async () => { const db = this.getWriter(); const existing = await this.getState(options.name, options.key, options.shard); if (existing) await db.patch(existing._id, { value: options.state.value, ts: options.state.ts, auxValue: options.state.auxValue, auxTs: options.state.auxTs }); else await db.insert(RATE_LIMIT_STATE_TABLE, { name: options.name, key: options.key, shard: options.shard, value: options.state.value, ts: options.state.ts, auxValue: options.state.auxValue, auxTs: options.state.auxTs }); this.invalidate(options.name, options.key, options.shard); }); } async deleteStates(name, key) { await this.withSetupGuidance(async () => { const db = this.getWriter(); const rows = await this.listStates(name, key); for (const row of rows) await db.delete(RATE_LIMIT_STATE_TABLE, row._id); this.invalidateAll(name, key); }); } async getDynamicLimit(prefix) { return this.withSetupGuidance(async () => { const db = this.getReader(); const cacheKey = dynamicCacheKey(prefix); const cached = this.dynamicDedupe.get(cacheKey); if (cached) { const row = await cached; return row ? row.limit : null; } const query = db.query(RATE_LIMIT_DYNAMIC_TABLE).withIndex("by_prefix", (q) => q.eq("prefix", prefix)).unique().then((row) => row ? row : null); this.dynamicDedupe.set(cacheKey, query); const row = await query; return row ? row.limit : null; }); } async setDynamicLimit(prefix, limit) { await this.withSetupGuidance(async () => { const db = this.getWriter(); const existing = await db.query(RATE_LIMIT_DYNAMIC_TABLE).withIndex("by_prefix", (q) => q.eq("prefix", prefix)).unique(); if (limit === false) { if (existing?._id) await db.delete(RATE_LIMIT_DYNAMIC_TABLE, existing._id); this.dynamicDedupe.delete(dynamicCacheKey(prefix)); return; } if (existing?._id) await db.patch(existing._id, { limit, updatedAt: Date.now() }); else await db.insert(RATE_LIMIT_DYNAMIC_TABLE, { prefix, limit, updatedAt: Date.now() }); this.dynamicDedupe.delete(dynamicCacheKey(prefix)); }); } invalidate(name, key, shard) { this.dedupe.delete(stateCacheKey(name, key, shard)); this.listDedupe.delete(listCacheKey(name, key)); } invalidateAll(name, key) { this.listDedupe.delete(listCacheKey(name, key)); this.dedupe.clear(); } getReader() { if (!this.db) throw new Error(missingDbMessage); return this.db; } getWriter() { if (!this.db || !("insert" in this.db) || !("patch" in this.db) || !("delete" in this.db)) throw new Error("Ratelimit write operation requires mutation context (db.insert/patch/delete)."); return this.db; } async withSetupGuidance(run) { try { return await run(); } catch (error) { throw withMissingTableGuidance(error); } } }; function stateCacheKey(name, key, shard) { return `state:${name}:${key ?? "__global__"}:${shard}`; } function listCacheKey(name, key) { return `list:${name}:${key ?? "__global__"}`; } function dynamicCacheKey(prefix) { return `dynamic:${prefix}`; } function withMissingTableGuidance(error) { const message = error instanceof Error ? error.message : String(error); const lower = message.toLowerCase(); if (!RATE_LIMIT_TABLE_NAMES.some((tableName) => { const normalizedTable = tableName.toLowerCase(); return lower.includes(normalizedTable) && (lower.includes("table") || lower.includes("does not exist") || lower.includes("not found") || lower.includes("unknown")); })) return error instanceof Error ? error : new Error(message); return new Error(`${missingTableGuidance} Original error: ${message}`, { cause: error instanceof Error ? error : void 0 }); } //#endregion //#region src/ratelimit/ratelimit.ts const DEFAULT_PREFIX = "kitcn/ratelimit"; const DEFAULT_TIMEOUT_MS = 5e3; const DEFAULT_THRESHOLD = 30; const MIN_POWER_OF_TWO_CHOICES = 3; var Ratelimit = class Ratelimit { static fixedWindow = fixedWindow; static slidingWindow = slidingWindow; static tokenBucket = tokenBucket; store; prefix; timeout; dynamicLimits; failureMode; enableProtection; denyListThreshold; denyList; limiter; blockCache; blockCacheSource; checkCache = createReadDedupeCache(); constructor(config) { this.config = config; this.store = new ConvexRatelimitStore(config.db); this.prefix = config.prefix ?? DEFAULT_PREFIX; this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; this.dynamicLimits = config.dynamicLimits ?? false; this.failureMode = config.failureMode ?? "closed"; this.enableProtection = config.enableProtection ?? false; this.denyListThreshold = config.denyListThreshold ?? DEFAULT_THRESHOLD; this.denyList = config.denyList; this.limiter = config.limiter; if (config.ephemeralCache !== false) { this.blockCacheSource = config.ephemeralCache ?? /* @__PURE__ */ new Map(); this.blockCache = new EphemeralBlockCache(this.blockCacheSource); } } async limit(identifier, request) { return this.runWithTimeout(() => this.evaluate(identifier, request, true)); } async check(identifier, request) { return this.runWithTimeout(() => this.evaluate(identifier, request, false)); } async blockUntilReady(identifier, timeoutMs) { if (timeoutMs <= 0) throw new Error("timeout must be positive"); const deadline = Date.now() + timeoutMs; let latest = this.timeoutResponse(false); while (Date.now() <= deadline) { latest = await this.limit(identifier); if (latest.success) return latest; await sleep(Math.max(1, Math.min(latest.reset, deadline) - Date.now())); } return latest; } async resetUsedTokens(identifier) { await this.store.deleteStates(this.prefix, identifier); this.checkCache.clear(); if (this.blockCache) this.blockCache.clear(identifier); clearProtection(this.prefix, identifier); } async getRemaining(identifier) { const value = await this.getValue(identifier, { sampleShards: this.limiter.shards }); const evaluated = calculateRatelimit({ value: value.value, ts: value.ts }, value.config, Date.now(), 0); return { remaining: Math.max(0, evaluated.remaining), reset: evaluated.reset, limit: evaluated.limit }; } async getValue(identifier, options) { const cacheKey = `${identifier}:${options?.sampleShards ?? 0}`; const cached = this.checkCache.get(cacheKey); if (cached) { const snapshot = await cached; if (snapshot) return snapshot; } const algorithm = await this.resolveAlgorithm(); const sampleShards = Math.max(1, Math.min(options?.sampleShards ?? 1, algorithm.shards)); const shards = pickSampleShards(algorithm.shards, sampleShards); const now = Date.now(); let best = null; for (const shard of shards) { const evaluated = calculateRatelimit(normalizeState(await this.store.getState(this.prefix, identifier, shard)), algorithm, now, 0); const current = { value: algorithm.kind === "slidingWindow" ? evaluated.remaining : evaluated.state.value, ts: evaluated.state.ts, shard, config: algorithm }; if (!best || current.value > best.value) best = current; } const result = best ?? { value: algorithm.kind === "tokenBucket" ? algorithm.maxTokens : algorithm.limit, ts: now, shard: 0, config: algorithm }; this.checkCache.set(cacheKey, Promise.resolve(result)); return result; } async setDynamicLimit(options) { if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use setDynamicLimit()"); await this.store.setDynamicLimit(this.prefix, options.limit); } async getDynamicLimit() { if (!this.dynamicLimits) throw new Error("dynamicLimits must be enabled in the Ratelimit constructor to use getDynamicLimit()"); return { dynamicLimit: await this.store.getDynamicLimit(this.prefix) }; } hookAPI(options) { return { getRatelimit: queryGeneric({ args: { identifier: v.optional(v.string()), sampleShards: v.optional(v.number()) }, returns: v.object({ value: v.number(), ts: v.number(), shard: v.number(), config: v.any() }), handler: async (ctx, args) => { const identifier = await resolveIdentifier(options?.identifier, ctx, args.identifier); return this.withDb(ctx.db).getValue(identifier, { sampleShards: args.sampleShards ?? options?.sampleShards }); } }), getServerTime: mutationGeneric({ args: {}, returns: v.number(), handler: async () => Date.now() }) }; } withDb(db) { return new Ratelimit({ ...this.config, db, ephemeralCache: this.blockCacheSource }); } async evaluate(identifier, request, consume) { const deniedValue = this.enableProtection ? pickDeniedValue({ prefix: this.prefix, identifier, request, lists: this.denyList }) : void 0; if (deniedValue) return { success: false, ok: false, limit: this.rawLimit(this.limiter), remaining: 0, reset: Date.now() + 6e4, pending: Promise.resolve(), reason: "denyList", deniedValue }; const algorithm = await this.resolveAlgorithm(); const count = consume ? normalizeCount(request) : 0; const reserveRequested = consume && Boolean(request?.reserve); if (this.blockCache && count > 0) { const cacheKey = `${this.prefix}:${identifier}`; const blocked = this.blockCache.isBlocked(cacheKey); if (blocked.blocked) return { success: false, ok: false, limit: this.rawLimit(algorithm), remaining: 0, reset: blocked.reset, pending: Promise.resolve(), reason: "cacheBlock" }; } const now = Date.now(); const candidates = await this.evaluateCandidates(identifier, algorithm, now, count, reserveRequested); const successful = candidates.filter((candidate) => candidate.success); if (successful.length > 0) { const best = successful.sort((a, b) => b.evaluated.remaining - a.evaluated.remaining)[0]; if (consume && count !== 0) await this.store.upsertState({ name: this.prefix, key: identifier, shard: best.shard, state: best.evaluated.state }); if (this.blockCache) this.blockCache.clear(`${this.prefix}:${identifier}`); clearProtection(this.prefix, identifier); this.checkCache.clear(); return { success: true, ok: true, limit: best.evaluated.limit, remaining: best.evaluated.remaining, reset: best.evaluated.reset, pending: Promise.resolve() }; } const failure = candidates.filter((candidate) => candidate.evaluated.retryAfter !== void 0).sort((a, b) => (a.evaluated.retryAfter ?? Number.MAX_SAFE_INTEGER) - (b.evaluated.retryAfter ?? Number.MAX_SAFE_INTEGER))[0] ?? candidates[0]; const reset = now + (failure.evaluated.retryAfter ?? 1); if (consume && this.blockCache && count > 0) this.blockCache.blockUntil(`${this.prefix}:${identifier}`, reset); if (consume && this.enableProtection) recordRatelimitFailure({ prefix: this.prefix, identifier, request, threshold: this.denyListThreshold }); return { success: false, ok: false, limit: failure.evaluated.limit, remaining: 0, reset, pending: Promise.resolve() }; } async evaluateCandidates(identifier, algorithm, now, count, reserveRequested) { const shards = pickCandidateShards(algorithm.shards); const result = []; for (const shard of shards) { const state = normalizeState(await this.store.getState(this.prefix, identifier, shard)); const evaluated = calculateRatelimit(state, algorithm, now, count); const canReserve = reserveRequested && evaluated.retryAfter !== void 0 && algorithm.kind !== "slidingWindow" && (algorithm.maxReserved === void 0 || Math.abs(evaluated.state.value) <= algorithm.maxReserved); const success = evaluated.retryAfter === void 0 || canReserve; result.push({ shard, state, evaluated, success }); } return result; } async resolveAlgorithm() { if (!this.dynamicLimits) return this.limiter; const dynamicLimit = await this.store.getDynamicLimit(this.prefix); return applyDynamicLimit(this.limiter, dynamicLimit); } rawLimit(algorithm) { if (algorithm.kind === "tokenBucket") return algorithm.maxTokens; return algorithm.limit; } async runWithTimeout(operation) { if (this.timeout <= 0) return operation(); const startedAt = Date.now(); try { const result = await operation(); if (Date.now() - startedAt > this.timeout) return this.timeoutResponse(this.failureMode === "open"); return result; } catch (error) { if (Date.now() - startedAt > this.timeout) return this.timeoutResponse(this.failureMode === "open"); throw error; } } timeoutResponse(success) { return { success, ok: success, limit: 0, remaining: 0, reset: Date.now(), pending: Promise.resolve(), reason: "timeout" }; } }; function normalizeCount(request) { if (!request) return 1; const value = request.rate ?? request.count ?? 1; if (!Number.isFinite(value)) throw new Error("count/rate must be a finite number"); return value; } function normalizeState(row) { if (!row) return null; return { value: row.value, ts: row.ts, auxValue: row.auxValue, auxTs: row.auxTs }; } function pickCandidateShards(shards) { const first = Math.floor(Math.random() * shards); if (shards < MIN_POWER_OF_TWO_CHOICES) return [first]; return [first, (first + 1 + Math.floor(Math.random() * (shards - 1))) % shards]; } function pickSampleShards(total, sample) { const all = Array.from({ length: total }, (_, index) => index); const selected = []; while (all.length > 0 && selected.length < sample) { const randomIndex = Math.floor(Math.random() * all.length); const [shard] = all.splice(randomIndex, 1); if (shard !== void 0) selected.push(shard); } return selected.length > 0 ? selected : [0]; } async function sleep(ms) { try { await new Promise((resolve) => { setTimeout(resolve, ms); }); } catch (error) { if (isTimerUnsupportedError(error)) throw new Error("blockUntilReady is not supported in Convex queries/mutations. Use an action or non-Convex runtime."); throw error; } } function isTimerUnsupportedError(error) { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return message.includes("can't use settimeout in queries and mutations") || message.includes("settimeout"); } async function resolveIdentifier(identifierOption, ctx, fromClient) { if (!identifierOption) { if (!fromClient) throw new Error("hookAPI requires identifier in options or request args"); return fromClient; } if (typeof identifierOption === "function") return await identifierOption(ctx, fromClient); return identifierOption; } //#endregion //#region src/ratelimit/plugin.ts const DEFAULT_RATELIMIT_MESSAGE = "Rate limit exceeded. Please try again later."; function resolveBucketLimiter(options, bucket, tier) { const bucketConfig = options.buckets[bucket]; if (!bucketConfig) throw new Error(`Unknown ratelimit bucket "${bucket}".`); const limiter = bucketConfig[tier]; if (!limiter) throw new Error(`Unknown ratelimit tier "${tier}" for bucket "${bucket}".`); return limiter; } function resolvePrefix(options, args) { if (typeof options.prefix === "function") return options.prefix(args); return options.prefix ?? `ratelimit:${args.bucket}:${args.tier}`; } function resolveMessage(options, args) { if (typeof options.message === "function") return options.message(args); return options.message ?? DEFAULT_RATELIMIT_MESSAGE; } const RatelimitPlugin = definePlugin("ratelimit", ({ options }) => { if (!options) throw new Error("RatelimitPlugin must be configured before use."); return options; }).extend(({ middleware }) => ({ middleware: () => middleware().pipe(async ({ ctx, meta, next }) => { const options = ctx.api.ratelimit; const mutationCtx = requireMutationCtx(ctx); const bucket = await options.getBucket({ ctx, meta }); const user = await options.getUser({ ctx, meta }); const tier = await options.getTier(user); const identifier = await options.getIdentifier({ ctx, meta, user, bucket }); const args = { ctx, meta, user, bucket, tier, identifier }; if (!(await new Ratelimit({ db: mutationCtx.db, prefix: await resolvePrefix(options, args), limiter: resolveBucketLimiter(options, bucket, tier), failureMode: options.failureMode, enableProtection: options.enableProtection, denyListThreshold: options.denyListThreshold }).limit(identifier, await options.getSignals(args))).success) throw new CRPCError({ code: "TOO_MANY_REQUESTS", message: await resolveMessage(options, args) }); return next({ ctx }); }) })); //#endregion //#region src/ratelimit/index.ts const SECOND = 1e3; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const WEEK = 7 * DAY; //#endregion export { DAY, HOUR, MINUTE, RATE_LIMIT_DYNAMIC_TABLE, RATE_LIMIT_HIT_TABLE, RATE_LIMIT_STATE_TABLE, Ratelimit, RatelimitPlugin, SECOND, WEEK, applyDynamicLimit, calculateRatelimit, fixedWindow, slidingWindow, toMs, tokenBucket };