interface QueueEntry { reject: (reason?: unknown) => void; resolve: (value: unknown) => void; task: () => Promise | unknown; } /** * Small FIFO executor for browser-side async work that must not overlap. * * The first task starts synchronously so unload/visibility handlers can begin * a keepalive fetch before returning. Later tasks wait without allowing a * rejection to poison the queue. */ export class SerializedAsyncQueue { private active = false; private readonly entries: QueueEntry[] = []; get busy(): boolean { return this.active || this.entries.length > 0; } run(task: () => Promise | T): Promise { return new Promise((resolve, reject) => { this.entries.push({ task, resolve: resolve as (value: unknown) => void, reject, }); this.startNext(); }); } private startNext(): void { if (this.active) return; const entry = this.entries.shift(); if (!entry) return; this.active = true; let result: Promise | unknown; try { result = entry.task(); } catch (error) { this.active = false; this.startNext(); entry.reject(error); return; } void Promise.resolve(result).then( (value) => { this.active = false; this.startNext(); entry.resolve(value); }, (error) => { this.active = false; this.startNext(); entry.reject(error); }, ); } }