import type { Handler, PubSub, PubSubDriver } from "./index.ts"; export interface MessageEnvelope { id: string; topic: string; data: T; timestamp: number; attempts: number; } export interface ResilientPubSubOptions { retries?: number; retryDelayMs?: number; onError?: (error: unknown, envelope: MessageEnvelope) => void; } export function createResilientPubSub( driver: PubSubDriver, options: ResilientPubSubOptions = {}, ): PubSub { const retries = options.retries ?? 2; const retryDelayMs = options.retryDelayMs ?? 100; if (!Number.isInteger(retries) || retries < 0) { throw new RangeError("pubsub retries must be a non-negative integer"); } if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) { throw new RangeError("pubsub retryDelayMs must be a non-negative number"); } let closed = false; return { async publish(topic, message) { if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); if (!topic.trim()) throw new TypeError("pubsub topic cannot be empty"); const envelope: MessageEnvelope = { id: crypto.randomUUID(), topic, data: message, timestamp: Date.now(), attempts: 0, }; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { envelope.attempts = attempt + 1; try { await driver.publish(topic, envelope); return; } catch (error) { lastError = error; if (attempt < retries && retryDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt)); } } } options.onError?.(lastError, envelope); throw lastError; }, subscribe(pattern: string, handler: Handler) { if (closed) throw new Error("WRN-PUBSUB-CLOSED: pubsub is closed"); if (!pattern.trim()) throw new TypeError("pubsub pattern cannot be empty"); return driver.subscribe(pattern, async (value, topic) => { const envelope = value as MessageEnvelope; if (envelope && typeof envelope === "object" && "data" in envelope) { await handler(envelope.data, topic); } else { await handler(value as T, topic); } }); }, async close() { if (closed) return; closed = true; await driver.close?.(); }, }; } export interface PresenceMember { id: string; metadata?: Record; joinedAt: number; expiresAt: number; } export class PresenceChannel { readonly #members = new Map(); constructor( private readonly ttlMs = 30_000, private readonly now: () => number = Date.now, ) { if (!Number.isFinite(ttlMs) || ttlMs <= 0) { throw new RangeError("presence ttlMs must be a positive number"); } } touch(id: string, metadata?: Record): PresenceMember { if (!id.trim()) throw new TypeError("presence member id cannot be empty"); const current = this.#members.get(id); const timestamp = this.now(); const member = { id, metadata: metadata ?? current?.metadata, joinedAt: current?.joinedAt ?? timestamp, expiresAt: timestamp + this.ttlMs, }; this.#members.set(id, member); return structuredClone(member); } leave(id: string): boolean { return this.#members.delete(id); } list(): PresenceMember[] { this.prune(); return [...this.#members.values()].map((member) => structuredClone(member)); } prune(): number { const timestamp = this.now(); let removed = 0; for (const [id, member] of this.#members) { if (member.expiresAt <= timestamp) { this.#members.delete(id); removed++; } } return removed; } }