kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
856 lines (846 loc) • 27.9 kB
JavaScript
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 };