UNPKG

@relaycast/sdk

Version:

TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.

361 lines 13.9 kB
import { ServerEventSchema } from '@relaycast/types'; import { AGENT_RELAY_DISTINCT_ID_QUERY, SDK_ORIGIN, sanitizeAgentRelayDistinctId, sanitizeOriginActor, } from './origin.js'; import { camelizeKeys, decamelizeKey } from './casing.js'; /** * How many recently-seen event ids to remember for replay dedupe. Sized above * the server's 500-event resync ring plus DB-fallback replay batches. */ const SEEN_EVENT_IDS_MAX = 2048; const INTERNAL_WS_ORIGIN = Symbol('relaycast.internal.ws-origin'); function readInternalWsOrigin(options) { return options[INTERNAL_WS_ORIGIN]; } export function withInternalWsOrigin(options, origin) { const copy = { ...options }; Object.defineProperty(copy, INTERNAL_WS_ORIGIN, { value: origin, enumerable: false, writable: false, configurable: false, }); return copy; } export class WsClient { token; baseUrl; ws = null; handlers = new Map(); isOpen = false; reconnectAttempt = 0; maxReconnectAttempts; reconnectJitter; reconnectBaseDelayMs; reconnectMaxDelayMs; circuitBreakerMaxAttempts; permanentlyDisconnected = false; reconnectTimer = null; connectTimer = null; connectTimeoutMs = 10_000; pingTimer = null; closed = false; debug; originClient; originVersion; originActor; agentRelayDistinctId; /** Highest `agent_seq` observed across delivered events; null until the first stamped event. */ lastSeenSeq = null; /** Receive time of the last seq-stamped event, used as `since` for DB-backed replay. */ lastEventAt = null; /** Recently dispatched event ids (insertion-ordered for LRU eviction) for replay dedupe. */ seenEventIds = new Set(); constructor(options) { const origin = readInternalWsOrigin(options) ?? SDK_ORIGIN; this.token = options.token; this.debug = options.debug ?? false; this.maxReconnectAttempts = Number.isFinite(options.maxReconnectAttempts) ? Math.max(0, Math.floor(options.maxReconnectAttempts)) : Number.POSITIVE_INFINITY; this.reconnectJitter = options.reconnectJitter ?? true; this.reconnectBaseDelayMs = Number.isFinite(options.reconnectBaseDelayMs) ? Math.max(1, Math.floor(options.reconnectBaseDelayMs)) : 1000; this.reconnectMaxDelayMs = Number.isFinite(options.reconnectMaxDelayMs) ? Math.max(this.reconnectBaseDelayMs, Math.floor(options.reconnectMaxDelayMs)) : 30_000; this.circuitBreakerMaxAttempts = Number.isFinite(options.circuitBreakerMaxAttempts) ? Math.max(1, Math.floor(options.circuitBreakerMaxAttempts)) : 30; const base = (options.baseUrl ?? 'https://gateway.relaycast.dev').replace(/\/+$/, ''); this.baseUrl = base.replace(/^http/, 'ws'); this.originClient = origin.client; this.originVersion = origin.version; this.originActor = sanitizeOriginActor(origin.originActor ?? options.originActor); this.agentRelayDistinctId = sanitizeAgentRelayDistinctId(origin.agentRelayDistinctId ?? options.agentRelayDistinctId); } connect() { if (this.ws) return; this.closed = false; this.permanentlyDisconnected = false; const wsUrl = new URL('/v1/ws', `${this.baseUrl}/`); wsUrl.searchParams.set('token', this.token); wsUrl.searchParams.set(decamelizeKey('originClient'), this.originClient); wsUrl.searchParams.set(decamelizeKey('originVersion'), this.originVersion); if (this.originActor) { wsUrl.searchParams.set('origin_actor', this.originActor); } if (this.agentRelayDistinctId) { wsUrl.searchParams.set(AGENT_RELAY_DISTINCT_ID_QUERY, this.agentRelayDistinctId); } const ws = new WebSocket(wsUrl.toString()); this.ws = ws; this.connectTimer = setTimeout(() => { if (this.ws !== ws || this.isOpen || this.closed) return; this.ws = null; this.emit('error', { type: 'error' }); this.scheduleReconnect(); try { ws.close(); } catch { // noop } }, this.connectTimeoutMs); ws.onopen = () => { if (this.ws !== ws) return; this.clearConnectTimer(); this.isOpen = true; this.reconnectAttempt = 0; this.permanentlyDisconnected = false; this.startPing(); const openEvent = { type: 'open' }; this.emit('open', openEvent); // After open handlers have re-subscribed, request replay of anything // missed while disconnected. A fresh client has no seq and sends nothing. this.sendResync(); }; ws.onmessage = (event) => { if (this.ws !== ws) return; try { const parsed = JSON.parse(String(event.data)); if (parsed !== null && typeof parsed === 'object') { // The server stamps every delivered event with a monotonic // `agent_seq` (stripped by schema parsing, so read it raw here). if (typeof parsed.agent_seq === 'number' && Number.isFinite(parsed.agent_seq)) { this.lastSeenSeq = parsed.agent_seq; this.lastEventAt = new Date().toISOString(); } if (parsed.type === 'resync_ack') { const resyncedEvent = { type: 'resynced', replayed: typeof parsed.replayed === 'number' ? parsed.replayed : 0, gapDetected: parsed.gap_detected === true, }; this.emit('resynced', resyncedEvent); return; } // Drop replays of events already dispatched (stable event id). if (typeof parsed.id === 'string' && parsed.id.length > 0 && typeof parsed.type === 'string') { const dedupeKey = `${parsed.type}:${parsed.id}`; if (this.seenEventIds.has(dedupeKey)) return; this.rememberEventId(dedupeKey); } } const result = ServerEventSchema.safeParse(parsed); if (result.success) { this.emit(result.data.type, camelizeKeys(result.data)); } else if (parsed !== null && typeof parsed === 'object' && typeof parsed.type === 'string') { // Forward unrecognized event types so wildcard listeners still fire this.emit(parsed.type, camelizeKeys(parsed)); } else if (this.debug) { console.warn('[relaycast] Dropped WebSocket message: missing or invalid "type" field', parsed); } } catch (err) { if (this.debug) { console.warn('[relaycast] Dropped malformed WebSocket message:', String(event.data).slice(0, 200), err); } } }; ws.onclose = () => { if (this.ws !== ws) return; this.clearConnectTimer(); this.isOpen = false; this.stopPing(); this.ws = null; if (!this.closed) { this.scheduleReconnect(); } const closeEvent = { type: 'close' }; this.emit('close', closeEvent); }; ws.onerror = () => { if (this.ws !== ws) return; const errorEvent = { type: 'error' }; this.emit('error', errorEvent); // Some runtimes report handshake failures via `error` without a // corresponding `close`. Ensure reconnect still progresses. if (!this.isOpen && !this.closed) { this.clearConnectTimer(); this.ws = null; this.scheduleReconnect(); } }; } disconnect() { this.closed = true; this.isOpen = false; this.permanentlyDisconnected = false; this.clearConnectTimer(); this.stopPing(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { // Null out handlers before closing to prevent the stale onclose // callback from clobbering a new connection (e.g. React strict mode). this.ws.onopen = null; this.ws.onmessage = null; this.ws.onclose = null; this.ws.onerror = null; this.ws.close(); this.ws = null; } } reconnect() { this.closed = false; this.isOpen = false; this.permanentlyDisconnected = false; this.reconnectAttempt = 0; this.clearConnectTimer(); this.stopPing(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; this.ws.onclose = null; this.ws.onerror = null; try { this.ws.close(); } catch { // noop } this.ws = null; } this.connect(); } subscribe(channels) { this.sendJson({ type: 'subscribe', channels }); } unsubscribe(channels) { this.sendJson({ type: 'unsubscribe', channels }); } on(event, handler) { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); } this.handlers.get(event).add(handler); // If the socket is already connected, immediately notify late open listeners. if (event === 'open' && this.isOpen) { queueMicrotask(() => { if (this.handlers.get('open')?.has(handler)) { handler({ type: 'open' }); } }); } return () => { this.handlers.get(event)?.delete(handler); }; } off(event, handler) { this.handlers.get(event)?.delete(handler); } get connected() { return this.isOpen; } emit(event, data) { this.handlers.get(event)?.forEach((h) => h(data)); this.handlers.get('*')?.forEach((h) => h(data)); } sendJson(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } /** * Request replay of events missed while disconnected. The server replays its * resync ring past `last_seen_seq`, falls back to a DB-backed replay from * `since` when the gap exceeds the ring, and answers with `resync_ack`. * No-op until at least one seq-stamped event has been received. */ sendResync() { if (this.lastSeenSeq === null) return; this.sendJson({ type: 'resync', last_seen_seq: this.lastSeenSeq, ...(this.lastEventAt ? { since: this.lastEventAt } : {}), }); } rememberEventId(key) { this.seenEventIds.add(key); if (this.seenEventIds.size > SEEN_EVENT_IDS_MAX) { const oldest = this.seenEventIds.values().next().value; if (oldest !== undefined) this.seenEventIds.delete(oldest); } } startPing() { this.stopPing(); this.pingTimer = setInterval(() => { this.sendJson({ type: 'ping' }); }, 30_000); } stopPing() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } clearConnectTimer() { if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; } } computeReconnectDelayMs(attempt) { const exponential = Math.min(this.reconnectBaseDelayMs * Math.pow(2, Math.max(0, attempt - 1)), this.reconnectMaxDelayMs); if (!this.reconnectJitter) return exponential; const jitterFactor = 0.5 + Math.random(); return Math.max(1, Math.round(exponential * jitterFactor)); } tripCircuitBreaker() { if (this.permanentlyDisconnected) return; this.permanentlyDisconnected = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } const event = { type: 'permanently_disconnected', attempt: this.reconnectAttempt, }; this.emit('permanently_disconnected', event); } scheduleReconnect() { if (this.reconnectTimer || this.closed || this.permanentlyDisconnected) return; if (this.reconnectAttempt >= this.maxReconnectAttempts || this.reconnectAttempt >= this.circuitBreakerMaxAttempts) { this.tripCircuitBreaker(); return; } this.reconnectAttempt += 1; const reconnectingEvent = { type: 'reconnecting', attempt: this.reconnectAttempt }; this.emit('reconnecting', reconnectingEvent); const delay = this.computeReconnectDelayMs(this.reconnectAttempt); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(); }, delay); } } //# sourceMappingURL=ws.js.map