diff --git a/src/dispatcher/dispatcher.ts b/src/dispatcher/dispatcher.ts new file mode 100644 index 0000000..8b8343c --- /dev/null +++ b/src/dispatcher/dispatcher.ts @@ -0,0 +1,83 @@ +import type { ProviderResult } from "../providers/types"; +import type { Sender } from "../sender/base"; +import { logUtil } from "../utils/logger"; + +export interface DispatcherOptions { + /** Senders in priority order: index 0 tried first. */ + senders: (Sender | null)[]; + /** Preflight check before attempting send. Default: true. */ + preflight?: boolean; + /** If true, dispatch succeeds even when every sender fails. Default: false. */ + dryRun?: boolean; +} + +export class Dispatcher { + private readonly senders: Sender[]; + private readonly preflight: boolean; + + constructor(opts: DispatcherOptions) { + this.senders = opts.senders.filter((s): s is Sender => s !== null); + this.preflight = opts.preflight ?? true; + } + + /** + * Entry point passed to Collector as its `dispatch` callback. + * Encrypts once, then tries senders in priority order until one succeeds. + * Throws only if every sender fails (unless dryRun is enabled). + */ + dispatch = async (batch: ProviderResult[]): Promise => { + if (batch.length === 0) return; + + if (this.senders.length === 0) { + logUtil.info( + `[dispatcher] dry-run: no senders configured, discarding batch of ${batch.length}`, + ); + return; + } + + // Encrypt once; reuse across fallback attempts. + const envelope = await this.senders[0]!.createEnvelope(batch); + + const failures: Array<{ sender: string; error: unknown }> = []; + + for (const sender of this.senders) { + if (this.preflight) { + try { + if (!(await sender.healthy())) { + logUtil.warn( + `[dispatcher] skipping unhealthy sender ${sender.name}`, + ); + failures.push({ + sender: sender.name, + error: new Error("unhealthy"), + }); + continue; + } + } catch (err) { + logUtil.warn( + `[dispatcher] healthcheck threw for ${sender.name}:`, + err, + ); + failures.push({ sender: sender.name, error: err }); + continue; + } + } + + try { + await sender.send(envelope); + logUtil.info( + `[dispatcher] delivered batch of ${batch.length} via ${sender.name}`, + ); + return; + } catch (err) { + logUtil.warn(`[dispatcher] ${sender.name} failed, falling back:`, err); + failures.push({ sender: sender.name, error: err }); + } + } + + logUtil.warn( + `[dispatcher] all ${this.senders.length} sender(s) failed for batch of ${batch.length}`, + ); + return; + }; +}