90 lines
4.3 KiB
TypeScript
Executable File
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();
|
|
};
|
|
}
|
|
}
|