import type { SmppLog } from './log.ts'; import type { VoidResult } from './result.ts'; export type LinkGateOptions = { log: SmppLog; now?: (() => number) | undefined; /** How long a request may wait for a link. 0 waits for as long as one may still arrive. */ timeout: number; }; type Waiter = (result: VoidResult) => void; function aborted(): Error { return new Error('Aborted while waiting for a link'); } function expired(): Error { return new Error('The link did not come back in time'); } function over(): Error { return new Error('Session is closed'); } /** Where a request with no link to go out on waits for the next one. */ export class LinkGate { private readonly log: SmppLog; private readonly now: () => number; private readonly timeout: number; private readonly waiting = new Set(); private returning = false; private up = true; constructor(options: LinkGateOptions) { this.log = options.log; this.now = options.now ?? Date.now; this.timeout = options.timeout; } /** Whether a request can go out right now. A link that is attached but not yet bound cannot. */ isUp(): boolean { return this.up; } /** Why the gate will never admit a request, or undefined while one may still get through. */ refusal(): Error | undefined { return this.up || this.returning ? undefined : over(); } /** When a hold starting now has to give up. 0 never does. */ deadline(): number { return this.timeout > 0 ? this.now() + this.timeout : 0; } /** A link is up and bound: everything held goes out on it. */ open(): void { this.up = true; this.returning = false; if (this.waiting.size > 0) { this.log.verbose('linkGate - sending what was held for a link', { held: this.waiting.size }); } this.release({}); } /** The link is gone. `returning` says whether another one is on its way. */ shut(returning: boolean): void { this.up = false; this.returning = returning; if (!returning) this.release({ err: over() }); } /** Resolves once a link can carry the request, or with the reason none ever will. */ wait(deadline: number, signal: AbortSignal | undefined): Promise { if (this.up) return Promise.resolve({}); const refused = this.refusal(); if (refused) return Promise.resolve({ err: refused }); if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); const left = deadline === 0 ? 0 : deadline - this.now(); if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); return this.hold(left, signal); } private hold(left: number, signal: AbortSignal | undefined): Promise { this.log.verbose('linkGate - holding a request until a link is back', { timeout: left }); return new Promise(resolve => { let timer: NodeJS.Timeout | undefined = undefined; const settle = (result: VoidResult): void => { if (timer) clearTimeout(timer); signal?.removeEventListener('abort', onAbort); this.waiting.delete(settle); resolve(result); }; const giveUp = (): void => { this.log.warn('linkGate - no link came back in time', { timeout: left }); settle({ err: expired() }); }; function onAbort(): void { settle({ err: aborted() }); } // Deliberately not unref()'d: a held request is awaited with a destroyed socket and no // other handle, so an unref'd timer lets the process exit without ever settling it. if (left > 0) timer = setTimeout(giveUp, left); signal?.addEventListener('abort', onAbort, { once: true }); this.waiting.add(settle); }); } private release(result: VoidResult): void { for (const settle of [...this.waiting]) { settle(result); } } }