@relaycast/sdk
Version:
TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.
585 lines • 23.1 kB
JavaScript
import { ServerEventSchema } from '@relaycast/types';
import { SDK_ORIGIN, applyAgentRelayIdentityQuery, resolveAgentRelayIdentity, sanitizeOriginActor, } from './origin.js';
import { camelizeKeys, decamelizeKey } from './casing.js';
import { stableRelaycastEventId } from './event-id.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];
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
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;
path;
nodeRegistration;
autoAckDeliveries;
nodeMode;
lastNodeRegistration = null;
ws = null;
connecting = false;
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;
identity;
/** 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.path = options.path ?? '/v1/ws';
this.nodeRegistration = options.nodeRegistration;
this.autoAckDeliveries = options.autoAckDeliveries ?? false;
this.nodeMode = this.path === '/v1/node/ws' || !!options.nodeRegistration;
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://cast.agentrelay.com').replace(/\/+$/, '');
this.baseUrl = base.replace(/^http/, 'ws');
this.originClient = origin.client;
this.originVersion = origin.version;
this.originActor = sanitizeOriginActor(origin.originActor ?? options.originActor);
this.identity = resolveAgentRelayIdentity(origin, options);
}
connect() {
if (this.ws || this.connecting)
return;
this.closed = false;
this.permanentlyDisconnected = false;
this.connecting = true;
void this.openSocket().catch((err) => {
this.connecting = false;
if (this.closed)
return;
if (this.debug) {
console.warn('[relaycast] Failed to open WebSocket:', err);
}
this.emit('error', { type: 'error' });
this.scheduleReconnect();
});
}
async openSocket() {
const token = typeof this.token === 'function' ? await this.token() : this.token;
if (this.closed) {
this.connecting = false;
return;
}
const wsUrl = new URL(this.path, `${this.baseUrl}/`);
wsUrl.searchParams.set('token', 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);
}
applyAgentRelayIdentityQuery(wsUrl, this.identity);
const ws = new WebSocket(wsUrl.toString());
this.ws = ws;
this.connecting = false;
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;
void this.handleOpen(ws).catch((err) => {
if (this.ws !== ws || this.closed)
return;
if (this.debug) {
console.warn('[relaycast] WebSocket open handler failed:', err);
}
this.emit('error', { type: 'error' });
try {
ws.close();
}
catch {
// noop
}
});
};
ws.onmessage = (event) => {
if (this.ws !== ws)
return;
try {
const parsed = JSON.parse(String(event.data));
if (this.nodeMode && this.handleNodeMessage(parsed)) {
return;
}
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();
}
};
}
async handleOpen(ws) {
if (this.ws !== ws)
return;
this.clearConnectTimer();
this.isOpen = true;
this.reconnectAttempt = 0;
this.permanentlyDisconnected = false;
if (this.nodeRegistration) {
const registration = await this.nodeRegistration();
if (this.ws !== ws || this.closed)
return;
this.lastNodeRegistration = registration;
this.sendJson(registration);
}
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();
}
disconnect() {
this.closed = true;
this.isOpen = false;
this.connecting = 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.connecting = 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) {
if (this.nodeMode)
return;
this.sendJson({ type: 'subscribe', channels });
}
unsubscribe(channels) {
if (this.nodeMode)
return;
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));
}
handleNodeMessage(parsed) {
if (!isRecord(parsed) || typeof parsed.type !== 'string')
return false;
if (parsed.type === 'deliver') {
const payload = isRecord(parsed.payload) ? parsed.payload : null;
if (payload && typeof payload.type === 'string') {
const data = isRecord(payload.data) ? payload.data : {};
this.emitServerLikeEvent(payload.type, data);
}
if (this.autoAckDeliveries &&
typeof parsed.agent === 'string' &&
typeof parsed.seq === 'number' &&
Number.isFinite(parsed.seq)) {
this.sendJson({
v: 1,
type: 'delivery.ack',
agent: parsed.agent,
up_to_seq: parsed.seq,
});
}
return true;
}
if (parsed.type === 'context.update') {
const eventType = typeof parsed.event === 'string' ? parsed.event : null;
if (eventType) {
this.emitServerLikeEvent(eventType, isRecord(parsed.data) ? parsed.data : {});
}
return true;
}
if (parsed.type === 'action.invoke') {
this.emitServerLikeEvent('action.invoked', {
invocation_id: typeof parsed.invocation_id === 'string' ? parsed.invocation_id : '',
action_name: typeof parsed.action === 'string' ? parsed.action : '',
caller_name: 'node',
handler_agent_id: typeof parsed.agent_id === 'string' ? parsed.agent_id : '',
handler_agent_name: typeof parsed.agent_name === 'string' ? parsed.agent_name : undefined,
input: isRecord(parsed.input) ? parsed.input : {},
});
return true;
}
// Node control replies/errors are transport-level acknowledgements for the
// direct node client. They are intentionally not surfaced as app events.
return parsed.type === 'reply' || parsed.type === 'error' || parsed.type === 'pong' || parsed.type === 'resync_ack';
}
emitServerLikeEvent(eventType, data) {
const transformed = this.transformServerLikeEvent(eventType, data);
// Node delivery is at-least-once, so redelivered frames must be deduped
// the same way the standard onmessage flow dedupes server events.
if (typeof transformed.id === 'string' &&
transformed.id.length > 0 &&
typeof transformed.type === 'string') {
const dedupeKey = `${transformed.type}:${transformed.id}`;
if (this.seenEventIds.has(dedupeKey))
return;
this.rememberEventId(dedupeKey);
}
const result = ServerEventSchema.safeParse(transformed);
if (result.success) {
this.emit(result.data.type, camelizeKeys(result.data));
return;
}
if (typeof transformed.type === 'string') {
this.emit(transformed.type, camelizeKeys(transformed));
}
else if (this.debug) {
console.warn('[relaycast] Dropped node event: missing or invalid "type" field', transformed);
}
}
transformServerLikeEvent(eventType, data) {
switch (eventType) {
case 'message.created':
return {
id: stableRelaycastEventId(String(data.id ?? '')),
type: 'message.created',
channel: data.channel_name,
message: {
id: data.id,
agent_id: data.agent_id,
agent_name: data.from_name,
text: data.text,
attachments: Array.isArray(data.attachments) ? data.attachments : [],
...(typeof data.injection_mode === 'string' ? { injection_mode: data.injection_mode } : {}),
},
};
case 'thread.reply':
return {
id: stableRelaycastEventId(String(data.id ?? '')),
type: 'thread.reply',
channel: data.channel_name,
parent_id: data.thread_id,
message: {
id: data.id,
agent_id: data.agent_id,
agent_name: data.from_name,
text: data.text,
},
};
case 'message.updated':
return {
type: 'message.updated',
channel: data.channel_name,
message: {
id: data.id,
agent_id: data.agent_id,
agent_name: data.from_name,
text: data.text,
},
};
case 'message.reacted':
return {
type: 'message.reacted',
message_id: data.message_id,
emoji: data.emoji,
agent_name: data.agent_name,
action: data.action ?? 'added',
};
case 'dm.received':
case 'group_dm.received': {
const message = isRecord(data.message) ? data.message : {};
const attachments = Array.isArray(message.attachments)
? message.attachments
: Array.isArray(data.attachments)
? data.attachments
: [];
const messageId = String(message.id ?? data.id ?? '');
return {
id: stableRelaycastEventId(messageId),
type: eventType,
conversation_id: data.conversation_id,
message: {
id: message.id ?? data.id,
agent_id: message.agent_id ?? data.from_agent_id ?? data.agent_id,
agent_name: message.agent_name ?? data.from_name,
text: message.text ?? data.text,
...((message.injection_mode ?? data.injection_mode) ? { injection_mode: message.injection_mode ?? data.injection_mode } : {}),
...(attachments.length ? { attachments } : {}),
},
};
}
default:
return { type: eventType, ...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.nodeMode)
return;
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(() => {
if (this.nodeMode) {
const nodeId = typeof this.lastNodeRegistration?.node_id === 'string'
? this.lastNodeRegistration.node_id
: undefined;
this.sendJson({
v: 1,
type: 'node.heartbeat',
...(nodeId ? { node_id: nodeId } : {}),
load: 0,
active_agents: 1,
handlers_live: false,
});
return;
}
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