import type { RequestHandler } from 'express'; export class RpcError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.status = status; } } export type ResourceConfig = { requestsPerSecond: number; writesPerSecond: number; active: number; responseBytes: number; bytesPerSecond: number; legacyBlocks: number }; export function resourceConfig(env: NodeJS.ProcessEnv = process.env): ResourceConfig { const number = (name: string, fallback: number, min: number, max: number) => { const text = env[name] ?? String(fallback), n = Number(text); if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`); return n; }; return { requestsPerSecond: number('WIKIDECK_RPC_REQUESTS_PER_SECOND', 120, 1, 1000), writesPerSecond: number('WIKIDECK_RPC_WRITES_PER_SECOND', 10, 1, 100), active: number('WIKIDECK_RPC_MAX_ACTIVE', 32, 1, 256), responseBytes: number('WIKIDECK_RPC_RESPONSE_BYTES', 2 * 1024 * 1024, 128 * 1024, 2 * 1024 * 1024), bytesPerSecond: number('WIKIDECK_RPC_BYTES_PER_SECOND', 8 * 1024 * 1024, 2 * 1024 * 1024, 128 * 1024 * 1024), legacyBlocks: number('WIKIDECK_RPC_LEGACY_BLOCKS', 128, 0, 128) }; } /** Bounded, constant-storage global budgets. No per-client map or retry queue. * Peer traffic has its own lane so a saturated browser lane cannot consume its tokens. */ export class RpcBudget { private tokens: number; private writeTokens: number; private byteTokens: number; private updated: number; private active = 0; private rejected = 0; readonly config: ResourceConfig; constructor(config: ResourceConfig, privateClock: () => number = () => performance.now()) { this.config = config; this.clock = privateClock; this.tokens = config.requestsPerSecond; this.writeTokens = config.writesPerSecond; this.byteTokens = config.bytesPerSecond; this.updated = this.clock(); } private readonly clock: () => number; private refill() { const now = this.clock(), elapsed = Math.max(0, now - this.updated) / 1000; this.updated = now; this.tokens = Math.min(this.config.requestsPerSecond, this.tokens + elapsed * this.config.requestsPerSecond); this.writeTokens = Math.min(this.config.writesPerSecond, this.writeTokens + elapsed * this.config.writesPerSecond); this.byteTokens = Math.min(this.config.bytesPerSecond, this.byteTokens + elapsed * this.config.bytesPerSecond); } acquire(write: boolean): () => void { this.refill(); if (this.active >= this.config.active || this.tokens < 1 || (write && this.writeTokens < 1)) { this.rejected++; throw new RpcError(503, 'Node RPC admission budget exhausted; retry later.'); } this.tokens--; if (write) this.writeTokens--; this.active++; let released = false; return () => { if (!released) { this.active--; released = true; } }; } encode(value: unknown): string { const encoded = JSON.stringify(value), bytes = Buffer.byteLength(encoded); if (bytes > this.config.responseBytes) { this.rejected++; throw new RpcError(413, 'RPC response limit exceeded; use smaller paged requests.'); } this.refill(); if (bytes > this.byteTokens) { this.rejected++; throw new RpcError(503, 'Node RPC response-byte budget exhausted; retry later.'); } this.byteTokens -= bytes; return encoded; } status() { return { active: this.active, rejected: this.rejected }; } middleware(): RequestHandler { return (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); let release: () => void; try { release = this.acquire(req.method !== 'GET' && req.method !== 'HEAD'); } catch (error) { res.setHeader('Retry-After', '1'); res.status(503).json({ error: (error as Error).message }); return; } res.once('finish', release); res.once('close', release); // Express.json normally serializes before send; intercept here so no oversized payload is sent. const originalJson = res.json.bind(res); res.json = (value: unknown) => { try { const encoded = this.encode(value); return res.type('application/json').send(encoded); } catch (error) { res.json = originalJson; throw error; } }; next(); }; } }