@devhuset-oss/ratelimit
Version:
A flexible rate limiting library with support for fixed and sliding windows using Valkey
192 lines (176 loc) • 5.94 kB
JavaScript
'use strict';
var Redis = require('iovalkey');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var Redis__default = /*#__PURE__*/_interopDefault(Redis);
// src/client.ts
var Valkey = class extends Redis__default.default {
constructor(arg1, arg2, arg3) {
if (typeof arg1 === "number" && typeof arg2 === "string" && arg3) {
super(arg1, arg2, arg3);
} else if (typeof arg1 === "string" && typeof arg2 === "object") {
super(arg1, arg2);
} else if (typeof arg1 === "number" && typeof arg2 === "object") {
super(arg1, arg2);
} else if (typeof arg1 === "number" && typeof arg2 === "string") {
super(arg1, arg2);
} else if (typeof arg1 === "object") {
super(arg1);
} else if (typeof arg1 === "number") {
super(arg1);
} else if (typeof arg1 === "string") {
super(arg1);
} else {
super();
}
this.defineCommand("slidingWindowRateLimit", {
numberOfKeys: 2,
lua: SLIDING_WINDOW_SCRIPT
});
}
};
var SLIDING_WINDOW_SCRIPT = `
local current_key = KEYS[1]
local previous_key = KEYS[2]
local tokens = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
local increment_by = tonumber(ARGV[4])
local current_count = tonumber(redis.call("GET", current_key) or "0")
local previous_count = tonumber(redis.call("GET", previous_key) or "0")
local time_in_current = now % window
local time_remaining_previous = window - time_in_current
local weighted_previous = (previous_count * time_remaining_previous) / window
local cumulative_count = math.floor(weighted_previous) + current_count + increment_by
if cumulative_count > tokens then
local needed = cumulative_count - tokens + increment_by
local retry_after = window - time_in_current
if previous_count > 0 then
local time_needed = (needed * window) / previous_count
retry_after = math.ceil(time_needed)
if retry_after > time_remaining_previous then
retry_after = time_remaining_previous
end
end
return { -1, retry_after }
end
current_count = current_count + increment_by
redis.call("SET", current_key, current_count)
redis.call("PEXPIRE", current_key, window * 2 + 1000)
return { tokens - (math.floor(weighted_previous) + current_count), 0 }
`;
// src/errors.ts
var ConfigurationError = class extends Error {
constructor(message) {
super(message);
this.name = "ConfigurationError";
}
};
var ValkeyError = class extends Error {
constructor(message, originalError) {
super(message);
this.originalError = originalError;
this.name = "ValkeyError";
this.stack = new Error().stack;
this.message = message;
this.originalError = originalError;
}
};
// src/ratelimit.ts
var Ratelimit = class {
constructor(valkey, options, time_provider = Date.now) {
this.options = options;
this.time_provider = time_provider;
this.valkey = valkey;
this.validateOptions(options);
}
static fixedWindow(params) {
return { type: "fixed", ...params };
}
static slidingWindow(params) {
return { type: "sliding", ...params };
}
async limit(identifier) {
try {
return this.options.type === "fixed" ? await this.fixedWindowLimit(identifier) : await this.slidingWindowLimit(identifier);
} catch (error) {
throw new ValkeyError(
"Failed to check rate limit",
error instanceof Error ? error : new Error(String(error))
);
}
}
validateOptions(options) {
if (options.limit <= 0) {
throw new ConfigurationError("Limit must be greater than 0");
}
if (options.window <= 0) {
throw new ConfigurationError("Time window must be greater than 0");
}
if (options.type !== "fixed" && options.type !== "sliding") {
throw new ConfigurationError(
'Type must be either "fixed" or "sliding"'
);
}
}
getKey(identifier, suffix) {
const prefix = this.options.prefix || "ratelimit";
return `${prefix}:${identifier}:${suffix}`;
}
async fixedWindowLimit(identifier) {
const now = this.time_provider();
const window_size = this.options.window;
const current_window = Math.floor(now / (window_size * 1e3));
const window_key = this.getKey(identifier, current_window.toString());
const window_end = (current_window + 1) * (window_size * 1e3);
const count = await this.valkey.incr(window_key);
if (count === 1) {
await this.valkey.expire(window_key, window_size);
}
if (count > this.options.limit) {
const ttl = await this.valkey.ttl(window_key);
return {
success: false,
limit: this.options.limit,
remaining: 0,
retry_after: Math.max(ttl * 1e3, 0),
reset: window_end
};
}
return {
success: true,
limit: this.options.limit,
remaining: this.options.limit - count,
retry_after: 0,
reset: window_end
};
}
async slidingWindowLimit(identifier) {
const now = this.time_provider();
const window = this.options.window * 1e3;
const current_window = Math.floor(now / window);
const previous_window = current_window - 1;
const current_key = this.getKey(identifier, current_window.toString());
const previous_key = this.getKey(
identifier,
previous_window.toString()
);
const [remaining, retry_after] = await this.valkey.slidingWindowRateLimit(
[current_key, previous_key],
this.options.limit.toString(),
now.toString(),
window.toString(),
"1"
);
return {
success: remaining >= 0,
limit: this.options.limit,
remaining: Math.max(0, remaining),
retry_after,
reset: this.time_provider() + this.options.window * 2e3
};
}
};
exports.ConfigurationError = ConfigurationError;
exports.Ratelimit = Ratelimit;
exports.Valkey = Valkey;
exports.ValkeyError = ValkeyError;