/** * Browser-side drainer for the pull-mode outbound queue. * * Background task that long-polls the server's `/queue` endpoint, * decodes each event, and dispatches it into the consumer's Shade * instance via `shade.acceptTransferEnvelope`. Same wire-shape as the * server-to-server case where the engine receives chunks via direct * HTTP POSTs — we just flip the direction so the browser pulls * instead of accepts. */ import type { ShadeBridge } from '../integration/shade-bridge.js'; export interface QueueDrainerOptions { /** * Server endpoint that hosts `transferQueueRoute()`. Typically: * `https://server.example.com/api/v1/shade-files/queue`. */ outboundQueueUrl: string; /** Peer the queue is pulled FROM (the server's address). */ peerAddress: string; /** Address we identify ourselves with on the long-poll. */ senderAddress: string; /** Optional `fetch` override. Default `globalThis.fetch`. */ fetch?: typeof globalThis.fetch; /** Extra headers applied to every poll request. */ headers?: Record; /** * Long-poll request timeout (server-side block). Default 30_000. * Server clamps to its own `maxBlockMs` (default 55_000). */ blockMs?: number; /** * Backoff after a network error before re-polling. Default 2_000. * Doubles up to `maxBackoffMs` on consecutive failures. */ initialBackoffMs?: number; maxBackoffMs?: number; /** * Called when a poll cycle fails. Defaults to logging via `console.error`. * Throwing from this hook does NOT stop the drainer — it backs off * and retries. */ onError?: (err: unknown) => void; } export interface QueueDrainerHandle { /** Stop the drainer. Pending fetch is aborted; the loop exits. */ stop(): void; /** Promise that resolves once the drainer has fully stopped. */ stopped: Promise; } interface PolledEvent { id: number; timestampMs: number; kind: 'envelope' | 'chunk'; bytesB64: string; meta?: { streamId: string; laneId: number; seq: number }; } interface PollResponseBody { events: PolledEvent[]; nextSince: number; } const DEFAULT_BLOCK_MS = 30_000; const DEFAULT_INITIAL_BACKOFF_MS = 2_000; const DEFAULT_MAX_BACKOFF_MS = 30_000; /** * Start a long-poll loop that drains queued envelopes + chunks from * the server and dispatches them into the local Shade instance. * * Returns a handle the caller can use to stop the drainer when the * `httpClient` is closed (e.g. on tab unload). */ export function startQueueDrainer( shade: ShadeBridge, options: QueueDrainerOptions, ): QueueDrainerHandle { if (shade.acceptTransferEnvelope === undefined) { throw new Error( 'startQueueDrainer: shade.acceptTransferEnvelope is required for pull-mode streams. The supplied ShadeBridge implementation must surface it.', ); } const accept = shade.acceptTransferEnvelope.bind(shade); const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); const blockMs = options.blockMs ?? DEFAULT_BLOCK_MS; const onError = options.onError ?? ((err: unknown) => console.error('[shade.files queue-drainer]', err)); const initialBackoffMs = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS; const maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS; const ac = new AbortController(); let stopped = false; let resolveStopped!: () => void; const stoppedPromise = new Promise((r) => { resolveStopped = r; }); void (async () => { let since = 0; let backoff = initialBackoffMs; while (!stopped) { try { const response = await fetchFn(options.outboundQueueUrl, { method: 'POST', signal: ac.signal, headers: { 'Content-Type': 'application/json', 'X-Shade-Sender-Address': options.senderAddress, ...(options.headers ?? {}), }, body: JSON.stringify({ since, blockMs }), }); if (!response.ok) { throw new Error(`queue poll → ${response.status} ${response.statusText}`); } const body = (await response.json()) as PollResponseBody; if (Array.isArray(body.events) && body.events.length > 0) { for (const event of body.events) { if (stopped) break; try { const bytes = base64ToBytes(event.bytesB64); await accept(options.peerAddress, bytes); } catch (err) { // Per-event dispatch failure should not kill the loop — // resume picks up missing chunks via @shade/transfer's // built-in lane-resume protocol. onError(err); } } } since = typeof body.nextSince === 'number' ? body.nextSince : since; backoff = initialBackoffMs; } catch (err) { if (stopped || ac.signal.aborted) break; onError(err); // Exponential backoff with jitter — caps at maxBackoffMs. const jitter = Math.floor(Math.random() * Math.min(backoff, 1_000)); await new Promise((r) => { const t = setTimeout(r, backoff + jitter); (t as unknown as { unref?: () => void }).unref?.(); ac.signal.addEventListener( 'abort', () => { clearTimeout(t); r(); }, { once: true }, ); }); backoff = Math.min(maxBackoffMs, backoff * 2); } } resolveStopped(); })(); return { stop(): void { if (stopped) return; stopped = true; ac.abort(new Error('queue drainer stopped')); }, stopped: stoppedPromise, }; } function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }