feat: migrate from Next.js to pure Bun relay proxy
- Replace Next.js app router with standalone Bun.serve() entry point - Add middleware stack: rate limiter, body limiter, structured logger, SSRF protection - Add WebSocket bidirectional relay via x-relay-target header - Implement error classification (DNS→502, timeouts→504, SSRF→403, rate→429) - Remove all Next.js dependencies and config files - Update deploy workflow, tsconfig, wrangler config for Bun deployment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
124dab98a2
commit
f58a92e2aa
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Body limiter test suite.
|
||||
*
|
||||
* Covers default size limit, custom configuration,
|
||||
* Content-Length checks, and edge cases.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import {
|
||||
checkBodySize,
|
||||
getMaxBodySize,
|
||||
setMaxBodySize,
|
||||
} from "./body-limiter";
|
||||
|
||||
describe("body-limiter", () => {
|
||||
beforeEach(() => {
|
||||
// Reset to default before each test
|
||||
setMaxBodySize(1_048_576); // 1 MB
|
||||
});
|
||||
|
||||
describe("getMaxBodySize / setMaxBodySize", () => {
|
||||
test("should default to 1MB", () => {
|
||||
expect(getMaxBodySize()).toBe(1_048_576);
|
||||
});
|
||||
|
||||
test("should allow setting a custom max size", () => {
|
||||
setMaxBodySize(512);
|
||||
expect(getMaxBodySize()).toBe(512);
|
||||
});
|
||||
|
||||
test("should allow setting zero", () => {
|
||||
setMaxBodySize(0);
|
||||
expect(getMaxBodySize()).toBe(0);
|
||||
});
|
||||
|
||||
test("should throw for negative values", () => {
|
||||
expect(() => setMaxBodySize(-1)).toThrow(
|
||||
"maxBodySize must be a non-negative number",
|
||||
);
|
||||
});
|
||||
|
||||
test("should allow setting a very large size", () => {
|
||||
setMaxBodySize(100_000_000);
|
||||
expect(getMaxBodySize()).toBe(100_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBodySize", () => {
|
||||
test("should return null when Content-Length is under the default limit", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "500" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when Content-Length is exactly at the default limit", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "1048576" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should return 413 Response when Content-Length exceeds the default limit", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "1048577" },
|
||||
});
|
||||
const result = checkBodySize(request);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result!.status).toBe(413);
|
||||
expect(result!.headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
test("413 response should include error details as JSON", async () => {
|
||||
setMaxBodySize(100);
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "200" },
|
||||
});
|
||||
const result = checkBodySize(request);
|
||||
const body = await result!.json();
|
||||
expect(body.error).toBe("Payload Too Large");
|
||||
expect(body.maxSizeBytes).toBe(100);
|
||||
});
|
||||
|
||||
test("should return null when no Content-Length header is present", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null for malformed Content-Length", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "not-a-number" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null for negative Content-Length", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "-100" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should use custom max body size when set", () => {
|
||||
setMaxBodySize(500);
|
||||
const underLimit = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "499" },
|
||||
});
|
||||
expect(checkBodySize(underLimit)).toBeNull();
|
||||
|
||||
const overLimit = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "501" },
|
||||
});
|
||||
expect(checkBodySize(overLimit)!.status).toBe(413);
|
||||
});
|
||||
|
||||
test("should handle GET requests with Content-Length", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "GET",
|
||||
headers: { "Content-Length": "10" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle zero Content-Length", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Length": "0" },
|
||||
});
|
||||
expect(checkBodySize(request)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Request body size limiter.
|
||||
*
|
||||
* Checks the Content-Length header against a configurable maximum.
|
||||
* Returns a 413 Payload Too Large response when the body exceeds the limit.
|
||||
* Requests without a Content-Length header are passed through since
|
||||
* the body size cannot be determined upfront with streaming.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
let maxBodySize = DEFAULT_MAX_BODY_SIZE;
|
||||
|
||||
/**
|
||||
* Check whether the request body exceeds the configured size limit.
|
||||
*
|
||||
* Returns a 413 Response if the Content-Length header indicates the body
|
||||
* is too large. Returns `null` if the body is acceptable or if the size
|
||||
* cannot be determined (no Content-Length header).
|
||||
*/
|
||||
export function checkBodySize(request: Request): Response | null {
|
||||
const contentType = request.headers.get("content-length");
|
||||
if (contentType === null) {
|
||||
// Cannot determine size upfront — pass through (streaming body).
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentLength = Number.parseInt(contentType, 10);
|
||||
|
||||
if (Number.isNaN(contentLength) || contentLength < 0) {
|
||||
// Malformed Content-Length — pass through and let the server handle it.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentLength > maxBodySize) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Payload Too Large",
|
||||
message: `Request body exceeds the maximum allowed size of ${maxBodySize} bytes`,
|
||||
maxSizeBytes: maxBodySize,
|
||||
}),
|
||||
{
|
||||
status: 413,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the maximum allowed body size.
|
||||
*/
|
||||
export function setMaxBodySize(bytes: number): void {
|
||||
if (bytes < 0) {
|
||||
throw new Error("maxBodySize must be a non-negative number");
|
||||
}
|
||||
maxBodySize = bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current maximum allowed body size in bytes.
|
||||
*/
|
||||
export function getMaxBodySize(): number {
|
||||
return maxBodySize;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { checkBodySize, getMaxBodySize, setMaxBodySize } from "./body-limiter";
|
||||
export type { RelayLogEvent } from "./logger";
|
||||
export { createRequestLogger, logRelayEvent } from "./logger";
|
||||
export type { RateLimiter, RateLimiterOptions } from "./rate-limiter";
|
||||
export { createRateLimiter } from "./rate-limiter";
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Logger test suite.
|
||||
*
|
||||
* Covers structured logging, TTY vs JSON output,
|
||||
* and the request logger factory.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, spyOn } from "bun:test";
|
||||
import { logRelayEvent, createRequestLogger } from "./logger";
|
||||
|
||||
describe("logger", () => {
|
||||
describe("logRelayEvent", () => {
|
||||
test("should log all required fields without error", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
spy.mockImplementation(() => {});
|
||||
|
||||
logRelayEvent({
|
||||
method: "GET",
|
||||
url: "/health",
|
||||
status: 200,
|
||||
durationMs: 15,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should log event with error field", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
spy.mockImplementation(() => {});
|
||||
|
||||
logRelayEvent({
|
||||
method: "POST",
|
||||
url: "/relay",
|
||||
status: 502,
|
||||
durationMs: 5000,
|
||||
error: "DNS resolution failed",
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should log event with all optional fields", () => {
|
||||
const spy = spyOn(console, "log");
|
||||
spy.mockImplementation(() => {});
|
||||
|
||||
logRelayEvent({
|
||||
method: "GET",
|
||||
url: "/test",
|
||||
status: 200,
|
||||
durationMs: 42,
|
||||
targetUrl: "https://example.com/api",
|
||||
ip: "203.0.113.1",
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test("should not throw for any valid event shape", () => {
|
||||
expect(() =>
|
||||
logRelayEvent({
|
||||
method: "OPTIONS",
|
||||
url: "/cors-test",
|
||||
status: 204,
|
||||
durationMs: 0,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRequestLogger", () => {
|
||||
test("should return a function", () => {
|
||||
const logger = createRequestLogger();
|
||||
expect(typeof logger).toBe("function");
|
||||
});
|
||||
|
||||
test("returned function should log without error", () => {
|
||||
const logger = createRequestLogger();
|
||||
const req = new Request("http://localhost/test", {
|
||||
method: "POST",
|
||||
});
|
||||
const res = new Response("ok", { status: 200 });
|
||||
|
||||
expect(() => logger(req, res, performance.now())).not.toThrow();
|
||||
});
|
||||
|
||||
test("returned function should accept extra fields", () => {
|
||||
const logger = createRequestLogger();
|
||||
const req = new Request("http://localhost/relay");
|
||||
const res = new Response("relayed", { status: 200 });
|
||||
|
||||
expect(() =>
|
||||
logger(req, res, performance.now(), {
|
||||
targetUrl: "https://example.com",
|
||||
ip: "10.0.0.1",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Structured logger for relay proxy events.
|
||||
*
|
||||
* Logs events as JSON lines for machine parsing and provides
|
||||
* optional TTY colorization for local development.
|
||||
*/
|
||||
|
||||
export interface RelayLogEvent {
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
targetUrl?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
type LogLevel = "info" | "warn" | "error";
|
||||
|
||||
/** Determine log level from HTTP status code. */
|
||||
function levelFromStatus(status: number): LogLevel {
|
||||
if (status >= 500) return "error";
|
||||
if (status >= 400) return "warn";
|
||||
return "info";
|
||||
}
|
||||
|
||||
/** ANSI color codes for TTY output. */
|
||||
const TTY_COLORS: Record<LogLevel, string> = {
|
||||
info: "[32m", // green
|
||||
warn: "[33m", // yellow
|
||||
error: "[31m", // red
|
||||
};
|
||||
const TTY_RESET = "[0m";
|
||||
const TTY_DIM = "[2m";
|
||||
|
||||
/** Check if stdout is a TTY (for colorization). */
|
||||
function isTTY(): boolean {
|
||||
return process.stdout?.isTTY === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a structured relay event to stdout.
|
||||
*
|
||||
* When writing to a TTY the output includes ANSI colors for readability.
|
||||
* When writing to a pipe/file it produces clean JSON lines.
|
||||
*/
|
||||
export function logRelayEvent(event: RelayLogEvent): void {
|
||||
const { method, url, status, durationMs, error, targetUrl, ip } = event;
|
||||
const level = levelFromStatus(status);
|
||||
const timestamp = new Date().toISOString();
|
||||
const durationFormatted = `${durationMs}ms`;
|
||||
|
||||
const tty = isTTY();
|
||||
const color = tty ? (TTY_COLORS[level] ?? "") : "";
|
||||
const reset = tty ? TTY_RESET : "";
|
||||
const dim = tty ? TTY_DIM : "";
|
||||
|
||||
if (tty) {
|
||||
const statusColor =
|
||||
status >= 500 ? TTY_COLORS.error : status >= 400 ? TTY_COLORS.warn : "";
|
||||
const parts: string[] = [
|
||||
`${dim}${timestamp}${reset}`,
|
||||
`${color}[${level.toUpperCase()}]${reset}`,
|
||||
`${method}`,
|
||||
`${statusColor}${status}${reset}`,
|
||||
`${dim}${durationFormatted}${reset}`,
|
||||
url,
|
||||
];
|
||||
if (targetUrl) parts.push(`${dim}-> ${targetUrl}${reset}`);
|
||||
if (ip) parts.push(`${dim}(${ip})${reset}`);
|
||||
if (error) parts.push(`${color}${error}${reset}`);
|
||||
|
||||
console.log(parts.join(" "));
|
||||
} else {
|
||||
const logEntry: Record<string, unknown> = {
|
||||
timestamp,
|
||||
level,
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
durationMs: durationFormatted,
|
||||
};
|
||||
if (error) logEntry.error = error;
|
||||
if (targetUrl) logEntry.targetUrl = targetUrl;
|
||||
if (ip) logEntry.ip = ip;
|
||||
|
||||
console.log(JSON.stringify(logEntry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a middleware-compatible request logger.
|
||||
*
|
||||
* Example usage in a Bun.serve() handler:
|
||||
*
|
||||
* const requestLogger = createRequestLogger();
|
||||
* const start = performance.now();
|
||||
* // ... handle request ...
|
||||
* requestLogger(req, res, start, { targetUrl });
|
||||
*/
|
||||
export function createRequestLogger(): (
|
||||
req: Request,
|
||||
res: Response,
|
||||
startTime: number,
|
||||
extra?: Partial<RelayLogEvent>,
|
||||
) => void {
|
||||
return (req, res, startTime, extra) => {
|
||||
const durationMs = Math.round(performance.now() - startTime);
|
||||
|
||||
logRelayEvent({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.status,
|
||||
durationMs,
|
||||
error: extra?.error,
|
||||
targetUrl: extra?.targetUrl,
|
||||
ip: extra?.ip,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Rate limiter test suite.
|
||||
*
|
||||
* Covers default and custom options, window enforcement,
|
||||
* reset semantics, and edge cases.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { createRateLimiter } from "./rate-limiter";
|
||||
|
||||
describe("rate-limiter", () => {
|
||||
describe("createRateLimiter", () => {
|
||||
test("should allow requests up to the default limit", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 5, windowMs: 60_000 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = limiter.check("test-key");
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should block requests exceeding the limit", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.check("block-key").allowed).toBe(true);
|
||||
}
|
||||
const blocked = limiter.check("block-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterMs).toBeDefined();
|
||||
expect(typeof blocked.retryAfterMs).toBe("number");
|
||||
});
|
||||
|
||||
test("should return retryAfterMs when blocked", () => {
|
||||
const limiter = createRateLimiter({
|
||||
maxRequests: 1,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
limiter.check("retry-key");
|
||||
const blocked = limiter.check("retry-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfterMs).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
test("reset() should clear the counter", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
|
||||
limiter.check("reset-key");
|
||||
limiter.check("reset-key");
|
||||
// would be blocked, but...
|
||||
limiter.reset("reset-key");
|
||||
// ...should be allowed again
|
||||
expect(limiter.check("reset-key").allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("should isolate keys from each other", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
|
||||
expect(limiter.check("key-a").allowed).toBe(true);
|
||||
expect(limiter.check("key-a").allowed).toBe(true);
|
||||
expect(limiter.check("key-b").allowed).toBe(true); // key-b unaffected
|
||||
expect(limiter.check("key-a").allowed).toBe(false); // key-a blocked
|
||||
});
|
||||
|
||||
test("should create with default options", () => {
|
||||
const limiter = createRateLimiter();
|
||||
expect(limiter.check).toBeDefined();
|
||||
expect(limiter.reset).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle rapid sequential calls", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 100, windowMs: 60_000 });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(limiter.check("rapid-key").allowed).toBe(true);
|
||||
}
|
||||
expect(limiter.check("rapid-key").allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("should allow requests after reset", () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 });
|
||||
limiter.check("after-reset-key");
|
||||
const blocked = limiter.check("after-reset-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
limiter.reset("after-reset-key");
|
||||
expect(limiter.check("after-reset-key").allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* In-memory sliding window rate limiter for Bun relay proxy.
|
||||
*
|
||||
* Uses a Map<string, number[]> where each key maps to an array of
|
||||
* Unix-epoch millisecond timestamps. Older entries are purged on
|
||||
* every check() call and periodically via a background interval.
|
||||
*
|
||||
* Bun is single-threaded so no locking is required.
|
||||
*/
|
||||
|
||||
export interface RateLimiterOptions {
|
||||
maxRequests?: number;
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RateLimiter {
|
||||
check(key: string): { allowed: boolean; retryAfterMs?: number };
|
||||
reset(key: string): void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_REQUESTS = 100;
|
||||
const DEFAULT_WINDOW_MS = 60_000; // 1 minute
|
||||
const CLEANUP_INTERVAL_DIVISOR = 10;
|
||||
|
||||
export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
|
||||
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
||||
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
|
||||
// Map of key -> sorted array of timestamps (ascending)
|
||||
const store = new Map<string, number[]>();
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Remove timestamps outside the sliding window. Returns the pruned slice. */
|
||||
function prune(key: string, now: number): number[] {
|
||||
const timestamps = store.get(key);
|
||||
if (!timestamps) return [];
|
||||
|
||||
const cutoff = now - windowMs;
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (timestamps[i] >= cutoff) {
|
||||
result.push(timestamps[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
store.set(key, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Periodically sweep the entire store to free memory. */
|
||||
function periodicCleanup(): void {
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
for (const [key, timestamps] of store) {
|
||||
const pruned: number[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (timestamps[i] >= cutoff) {
|
||||
pruned.push(timestamps[i]);
|
||||
}
|
||||
}
|
||||
if (pruned.length === 0) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
store.set(key, pruned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule periodic cleanup (every windowMs / 10)
|
||||
const cleanupHandle = setInterval(
|
||||
periodicCleanup,
|
||||
windowMs / CLEANUP_INTERVAL_DIVISOR,
|
||||
);
|
||||
// Allow the process to exit even if the interval is still active
|
||||
if (
|
||||
cleanupHandle &&
|
||||
typeof cleanupHandle === "object" &&
|
||||
"unref" in cleanupHandle
|
||||
) {
|
||||
(cleanupHandle as NodeJS.Timeout).unref();
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
check(key: string): { allowed: boolean; retryAfterMs?: number } {
|
||||
const now = Date.now();
|
||||
const timestamps = prune(key, now);
|
||||
timestamps.push(now);
|
||||
store.set(key, timestamps);
|
||||
|
||||
if (timestamps.length <= maxRequests) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Not allowed — calculate retry-after from the oldest timestamp
|
||||
const oldest = timestamps[0];
|
||||
const retryAfterMs = oldest + windowMs - now;
|
||||
|
||||
console.warn(
|
||||
`[rate-limiter] Rate limit exceeded for key="${key}": ${timestamps.length} requests in ${windowMs}ms (max ${maxRequests})`,
|
||||
);
|
||||
|
||||
return { allowed: false, retryAfterMs };
|
||||
},
|
||||
|
||||
reset(key: string): void {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user