import { LIMITS, REFUSALS } from "./spec.js"; const MB = 1024 * 1024; const HOURS = /^([01]\d|2[0-3]):([0-5]\d)$/; const CAP_KEYS = [ "max_frame_bytes", "max_body_bytes", "max_concurrent_per_node", "job_timeout_seconds", "ping_seconds", "url_max_chars", ]; function isCount(value) { return typeof value === "number" && Number.isInteger(value) && value >= 1; } function clampInt(value, low, high, fallback) { if (!isCount(value)) { return fallback; } return Math.min(high, Math.max(low, value)); } function minutes(text) { const match = HOURS.exec(text); return match ? Number(match[1]) * 60 + Number(match[2]) : null; } export class Limits { constructor(options = {}) { this.baked = options.baked || LIMITS; this.clock = options.clock || (() => new Date()); this.caps = {}; for (const key of CAP_KEYS) { this.caps[key] = this.baked[key]; } this.reconnectMin = this.baked.reconnect_min_seconds; this.reconnectMax = this.baked.reconnect_max_seconds; this.serverBandwidthMb = this.baked.max_donor_mb_per_day; this.serverConcurrent = this.baked.max_concurrent_per_node; this.local = Limits.normaliseLocal({}, this.baked); this.inflight = 0; if (options.local) { this.setLocal(options.local); } } static normaliseLocal(values, baked = LIMITS) { const from = minutes(values.activeFrom || ""); const to = minutes(values.activeTo || ""); const both = from !== null && to !== null; return { bandwidthMb: clampInt( values.bandwidthMb, 1, baked.max_donor_mb_per_day, baked.default_donor_mb_per_day, ), maxConcurrent: clampInt( values.maxConcurrent, 1, baked.max_concurrent_per_node, baked.max_concurrent_per_node, ), activeFrom: both ? values.activeFrom : "", activeTo: both ? values.activeTo : "", }; } setLocal(values) { this.local = Limits.normaliseLocal({ ...this.local, ...values }, this.baked); } applyWelcome(welcomeLimits, serverSettings) { const incoming = welcomeLimits && typeof welcomeLimits === "object" ? welcomeLimits : {}; for (const key of CAP_KEYS) { this.caps[key] = Math.min(this.baked[key], isCount(incoming[key]) ? incoming[key] : this.baked[key]); } const ceiling = this.baked.reconnect_max_seconds; this.reconnectMin = clampInt(incoming.reconnect_min_seconds, 1, ceiling, this.baked.reconnect_min_seconds); this.reconnectMax = clampInt(incoming.reconnect_max_seconds, this.reconnectMin, ceiling, ceiling); this.applyServerSettings(serverSettings); } applyServerSettings(settings) { const values = settings && typeof settings === "object" ? settings : {}; this.serverBandwidthMb = clampInt( values.bandwidth_mb_day, 1, this.baked.max_donor_mb_per_day, this.baked.max_donor_mb_per_day, ); this.serverConcurrent = clampInt( values.max_concurrent, 1, this.baked.max_concurrent_per_node, this.baked.max_concurrent_per_node, ); } get maxBodyBytes() { return this.caps.max_body_bytes; } get maxFrameBytes() { return this.caps.max_frame_bytes; } get urlMaxChars() { return this.caps.url_max_chars; } get pingSeconds() { return this.caps.ping_seconds; } get concurrency() { return Math.min(this.caps.max_concurrent_per_node, this.local.maxConcurrent, this.serverConcurrent); } get bandwidthBytes() { return Math.min(this.local.bandwidthMb, this.serverBandwidthMb) * MB; } timeoutSeconds(requested) { const asked = isCount(requested) ? requested : this.caps.job_timeout_seconds; return Math.min(asked, this.caps.job_timeout_seconds); } helloLimits() { return { max_concurrent: this.concurrency }; } remainingBytes(usedBytes) { return Math.max(0, this.bandwidthBytes - usedBytes); } inActiveHours() { const from = minutes(this.local.activeFrom); const to = minutes(this.local.activeTo); if (from === null || to === null || from === to) { return true; } const now = this.clock(); const current = now.getHours() * 60 + now.getMinutes(); return from < to ? current >= from && current < to : current >= from || current < to; } admit(usedBytes) { if (!this.inActiveHours()) { return REFUSALS.OUTSIDE_HOURS; } if (this.remainingBytes(usedBytes) <= 0) { return REFUSALS.BANDWIDTH_EXHAUSTED; } if (this.inflight >= this.concurrency) { return REFUSALS.CONCURRENCY_EXCEEDED; } this.inflight += 1; return null; } release() { this.inflight = Math.max(0, this.inflight - 1); } }