/** * FallbackBridgeTransport — composes a chain of bridges in priority order. * * On `connect`, tries each bridge in turn. The first one that completes * `connect` without throwing wins; later bridges are dropped on the floor. * If every bridge throws, the composed call re-throws the last error so the * caller sees a real failure rather than silent degradation. * * Fallback is sticky: once a transport is selected, it stays selected for * the lifetime of the instance. To re-evaluate the chain (e.g. after a * proxy goes down), instantiate a new `FallbackBridgeTransport`. */ import type { BridgeConnectOptions, BridgeTransport } from './types.js'; import { BridgeError } from './errors.js'; export class FallbackBridgeTransport implements BridgeTransport { readonly kind = 'fallback'; private active: BridgeTransport | null = null; private connectStarted = false; private disposed = false; private attemptedKinds: string[] = []; constructor(private readonly chain: BridgeTransport[]) { if (chain.length === 0) { throw new BridgeError('FallbackBridgeTransport requires a non-empty chain'); } } /** Stable identifier of the picked transport, e.g. "ws" / "sse" / "long-poll". */ get activeKind(): string | null { return this.active?.kind ?? null; } /** Read-only list of bridges that were attempted (in order) before success. */ get attempts(): readonly string[] { return this.attemptedKinds; } async connect(opts: BridgeConnectOptions): Promise { if (this.connectStarted) throw new BridgeError('FallbackBridgeTransport.connect already called'); this.connectStarted = true; let lastErr: Error | null = null; for (const bridge of this.chain) { this.attemptedKinds.push(bridge.kind); try { await bridge.connect(opts); if (this.disposed) { // disconnect was called concurrently — clean up the just-connected // bridge before returning. await bridge.disconnect().catch(() => {}); return; } this.active = bridge; return; } catch (err) { lastErr = err as Error; // Best-effort cleanup: the bridge may have started reconnect timers // even though `connect` rejected. try { await bridge.disconnect(); } catch { /* ignore */ } } } throw lastErr ?? new BridgeError('all bridges failed to connect'); } async disconnect(): Promise { this.disposed = true; if (this.active) { const cur = this.active; this.active = null; await cur.disconnect(); } } }