Files
wikideck/server/resources.ts
T
2026-10-20 04:07:37 -04:00

90 lines
4.3 KiB
TypeScript
Executable File

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();
};
}
}