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.

507 lines 19.8 kB
import { parseFleetRelaycastToBrokerMessage, } from '@relaycast/types'; import { SDK_VERSION } from './version.js'; /** Thrown when the engine rejects the provider's registration. */ export class NodeRegistrationError extends Error { code; constructor(message, code) { super(message); this.code = code; this.name = 'NodeRegistrationError'; } } function randomId() { const cryptoObj = globalThis.crypto; if (cryptoObj?.randomUUID) return cryptoObj.randomUUID(); return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } export class NodeProviderClient { baseUrl; nodeToken; nodeId; nodeName; providerName; initialInstanceId; maxAgents; tags; version; machineId; heartbeatIntervalMs; reconnectBaseDelayMs; reconnectMaxDelayMs; reconnectJitter; maxReconnectAttempts; onError; debug; capabilities = new Map(); pending = new Map(); invokeTasks = new Set(); ws = null; instanceId = null; heartbeatTimer = null; reconnectTimer = null; reconnectAttempt = 0; stopped = false; registered = false; servePromise = null; resolveServe = null; rejectServe = null; registerPromise = null; resolveRegister = null; rejectRegister = null; constructor(options) { this.baseUrl = (options.baseUrl ?? 'https://cast.agentrelay.com').replace(/\/+$/, ''); this.nodeToken = options.nodeToken; this.nodeId = options.nodeId; this.nodeName = options.nodeName; this.providerName = options.provider.name; this.initialInstanceId = options.provider.instanceId; this.maxAgents = options.maxAgents ?? 0; this.tags = options.tags ?? []; this.version = options.version ?? SDK_VERSION; this.machineId = options.machineId; this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 15_000; this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 500; this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 30_000; this.reconnectJitter = options.reconnectJitter ?? true; this.maxReconnectAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY; this.onError = options.onError; this.debug = options.debug ?? false; for (const [name, value] of Object.entries(options.capabilities ?? {})) { if (typeof value === 'function') { this.capability(name, value); } else { const { handler, ...opts } = value; this.capability(name, opts, handler); } } } capability(name, optionsOrHandler, handler) { const options = typeof optionsOrHandler === 'function' ? {} : optionsOrHandler; const fn = typeof optionsOrHandler === 'function' ? optionsOrHandler : handler; if (!fn) throw new Error(`capability "${name}" requires a handler`); this.capabilities.set(name, { options, handler: fn }); return this; } get connected() { return this.registered && this.ws?.readyState === 1; } /** Resolves once the provider is registered and its capabilities accepted. */ whenRegistered() { if (!this.registerPromise) { this.registerPromise = new Promise((resolve, reject) => { this.resolveRegister = resolve; this.rejectRegister = reject; }); } return this.registerPromise; } /** * Connect, register, and dispatch invokes until {@link stop} is called. * Reconnects with backoff on unexpected drops, re-registering with a fresh * instance id. The returned promise resolves on graceful {@link stop} and * rejects if the initial registration is rejected by the engine. */ serve() { if (this.servePromise) return this.servePromise; this.stopped = false; this.servePromise = new Promise((resolve, reject) => { this.resolveServe = resolve; this.rejectServe = reject; }); // `whenRegistered()` mirrors the same rejection; keep it from surfacing as // an unhandled rejection when a caller only awaits serve(). this.whenRegistered().catch(() => { }); this.openSocket(); return this.servePromise; } settleServe(err) { if (err) this.rejectServe?.(err); else this.resolveServe?.(); this.resolveServe = null; this.rejectServe = null; } /** Gracefully deregister the provider and close the connection. */ async stop() { this.stopped = true; this.clearReconnect(); this.stopHeartbeat(); // Let in-flight invoke handlers finish (and send their results) before the // socket closes; bound the wait so a slow handler can't hang shutdown. if (this.invokeTasks.size > 0) { let drainTimer; const timeout = new Promise((resolve) => { drainTimer = setTimeout(resolve, 5000); }); await Promise.race([Promise.allSettled([...this.invokeTasks]), timeout]); if (drainTimer) clearTimeout(drainTimer); } if (this.ws && this.ws.readyState === 1 && this.registered && this.instanceId) { this.sendFrame({ type: 'node.deregister', provider: this.providerIdentity() }); } // A stop() before registration completed must not leave whenRegistered() // hanging. this.rejectRegister?.(new Error('node provider stopped')); this.rejectRegister = null; this.resolveRegister = null; this.rejectPending(new Error('node provider stopped')); this.closeSocket(); this.registered = false; this.settleServe(); } providerIdentity() { return { name: this.providerName, instance_id: this.instanceId ?? '' }; } openSocket() { if (this.stopped) return; // A fresh instance id per connection: reconnecting with a new id replaces the // previous attachment (the engine's reconnect-vs-duplicate arbitration). this.instanceId = this.reconnectAttempt === 0 && this.initialInstanceId ? this.initialInstanceId : randomId(); this.registered = false; const url = new URL('/v1/node/ws', `${this.baseUrl.replace(/^http/, 'ws')}/`); url.searchParams.set('token', this.nodeToken); url.searchParams.set('origin_client', '@relaycast/sdk'); url.searchParams.set('origin_version', SDK_VERSION); let ws; try { ws = new WebSocket(url.toString()); } catch (err) { this.reportError(err); this.scheduleReconnect(); return; } this.ws = ws; ws.onopen = () => { if (this.ws !== ws) return; this.sendRegister(); }; ws.onmessage = (event) => { if (this.ws !== ws) return; this.handleFrame(String(event.data)); }; ws.onclose = () => { if (this.ws !== ws) return; this.ws = null; this.stopHeartbeat(); const wasRegistered = this.registered; this.registered = false; this.rejectPending(new Error('node provider connection closed')); if (this.stopped) { this.settleServe(); return; } // A rejected registration is deterministic — do not reconnect into the same // rejection; surface it and stop. if (this.rejectRegister && !wasRegistered && this.registrationRejected) { return; } this.scheduleReconnect(); }; ws.onerror = () => { if (this.ws !== ws) return; this.reportError(new Error('node provider websocket error')); }; } registrationRejected = false; sendRegister() { this.registrationRejected = false; const id = randomId(); const capabilities = [...this.capabilities.entries()].map(([name, cap]) => ({ name, ...(cap.options.kind ? { kind: cap.options.kind } : {}), ...(cap.options.global ? { global: true } : {}), ...(cap.options.queue ? { queue: true } : {}), ...(cap.options.metadata ? { metadata: cap.options.metadata } : {}), })); this.request(id, { type: 'node.register', name: this.nodeName, node_id: this.nodeId, provider: this.providerIdentity(), capabilities, max_agents: this.maxAgents, tags: this.tags, version: this.version, ...(this.machineId ? { machine_id: this.machineId } : {}), resume_cursor: null, }) .then((data) => this.onRegistered(data, capabilities.map((c) => c.name))) .catch((err) => this.onRegisterFailed(err)); } onRegistered(data, sentNames) { // The reply must carry a well-formed acceptance list (the engine always // does). A missing/corrupt reply (including a null `data`) means acceptance // can't be confirmed, so fail registration rather than assume success or // throw; an entry missing `accepted` counts as rejected (fail-safe). const list = (data ?? {}).accepted_capabilities; if (!Array.isArray(list)) { this.onRegisterFailed(new NodeRegistrationError('Registration reply is missing accepted_capabilities', 'invalid_register_reply')); return; } // Every capability the register frame ADVERTISED must be acknowledged. // Validate against the snapshot that was sent, not the live map — a // `capability()` added after the frame was sent isn't in this reply. const acknowledged = new Set(list.filter((c) => !!c && typeof c.name === 'string').map((c) => c.name)); const missing = sentNames.filter((name) => !acknowledged.has(name)); if (missing.length > 0) { this.onRegisterFailed(new NodeRegistrationError(`Registration reply omitted acceptance for: ${missing.join(', ')}`, 'invalid_register_reply')); return; } const rejected = list.filter((c) => !c || c.accepted !== true); if (rejected.length > 0) { const detail = rejected.map((c) => `${c?.name ?? '<unknown>'}${c?.reason ? ` (${c.reason})` : ''}`).join(', '); this.onRegisterFailed(new NodeRegistrationError(`Capabilities rejected: ${detail}`, 'capabilities_rejected')); return; } this.registered = true; this.reconnectAttempt = 0; this.startHeartbeat(); this.resolveRegister?.(data); this.resolveRegister = null; this.rejectRegister = null; } onRegisterFailed(err) { // Only a protocol rejection (a rejected capability or an engine `error` // frame) is deterministic and must not be retried. A transport drop during // the handshake is a normal disconnect — reconnect like any other drop. if (!(err instanceof NodeRegistrationError)) { this.reportError(err); if (!this.stopped) { this.closeSocket(); this.scheduleReconnect(); } return; } this.registrationRejected = true; this.rejectRegister?.(err); this.rejectRegister = null; this.resolveRegister = null; this.reportError(err); // Close without reconnect; the onclose handler observes registrationRejected. this.closeSocket(); if (!this.stopped) { this.stopped = true; this.settleServe(err); } } handleFrame(raw) { let message; try { message = parseFleetRelaycastToBrokerMessage(JSON.parse(raw)); } catch (err) { if (this.debug) this.reportError(err); return; } switch (message.type) { case 'reply': { const p = this.pending.get(message.id); if (p) { this.pending.delete(message.id); p.resolve(message.data); } return; } case 'error': { const p = this.pending.get(message.id); if (p) { this.pending.delete(message.id); p.reject(new NodeRegistrationError(message.message, message.code)); } return; } case 'action.invoke': { // Stop accepting new invokes once shutting down/draining; the engine // reschedules an undispatched invocation when the node goes offline. if (this.stopped) return; const task = this.dispatchInvoke(message.invocation_id, message.action, message.input); this.invokeTasks.add(task); void task.finally(() => this.invokeTasks.delete(task)); return; } default: // deliver / context.update / ping carry no work for a provider client. return; } } async dispatchInvoke(invocationId, action, input) { const cap = this.capabilities.get(action); if (!cap) { this.sendFrame({ type: 'action.result', invocation_id: invocationId, error: `No handler registered for action "${action}"` }); return; } // A handler failure becomes an error result; the invocation is never dropped. let output; let handlerError; try { output = await cap.handler(input, this.makeContext(invocationId)); } catch (err) { handlerError = err; } if (handlerError !== undefined) { this.sendFrame({ type: 'action.result', invocation_id: invocationId, error: errorMessage(handlerError) }); return; } this.sendFrame({ type: 'action.result', invocation_id: invocationId, output: (output ?? null) }); } makeContext(invocationId) { return { node: { name: this.nodeName, capabilities: [...this.capabilities.keys()] }, invocationId, sendMessage: (input) => this.postChannelMessage(input), // Capacity-direct: the engine always targets this connection's own node, // so the frame carries no node target. spawnAgent: (input) => this.request(randomId(), { type: 'node.spawn', input, }), }; } /** * Post a channel message through the canonical message route with the node * token, attributed to `input.from`. Node-attributed posts share the route's * delivery routing, observer events, and triggers by construction. */ async postChannelMessage(input) { // The base may be given as ws(s):// (also accepted by the socket); normalize // it back to http(s):// for the REST call. const httpBase = this.baseUrl.replace(/^ws(s?):\/\//, 'http$1://'); const url = `${httpBase}/v1/channels/${encodeURIComponent(input.to)}/messages`; const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.nodeToken}` }, body: JSON.stringify({ text: input.text, from: input.from, ...(input.mode ? { mode: input.mode } : {}), ...(input.data ? { data: input.data } : {}), }), }); const body = await res.json().catch(() => ({})); if (!res.ok) { const code = body.error?.code ?? `http_${res.status}`; throw new Error(`sendMessage to "${input.to}" failed (${code})${body.error?.message ? `: ${body.error.message}` : ''}`); } return body.data ?? body; } /** Send a request frame keyed by `id` and await its reply/error. */ request(id, frame) { if (!this.ws || this.ws.readyState !== 1) { return Promise.reject(new Error('node provider websocket is not open')); } return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); try { this.ws.send(JSON.stringify({ v: 1, id, ...frame })); } catch (err) { this.pending.delete(id); reject(err instanceof Error ? err : new Error(String(err))); } }); } sendFrame(frame) { if (!this.ws || this.ws.readyState !== 1) return; try { this.ws.send(JSON.stringify({ v: 1, ...frame })); } catch (err) { if (this.debug) this.reportError(err); } } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { this.sendFrame({ type: 'node.heartbeat', provider: this.providerIdentity(), load: 0, active_agents: 0, handlers_live: true, }); }, this.heartbeatIntervalMs); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } scheduleReconnect() { if (this.stopped || this.reconnectTimer) return; if (this.reconnectAttempt >= this.maxReconnectAttempts) { const err = new Error('node provider exhausted reconnect attempts'); this.reportError(err); this.stopped = true; // If registration never completed, reject its waiter too — otherwise // whenRegistered() would hang forever after reconnects are exhausted. this.rejectRegister?.(err); this.rejectRegister = null; this.resolveRegister = null; this.settleServe(err); return; } this.reconnectAttempt += 1; const exp = Math.min(this.reconnectBaseDelayMs * 2 ** (this.reconnectAttempt - 1), this.reconnectMaxDelayMs); const delay = this.reconnectJitter ? Math.round(exp * (0.5 + Math.random())) : exp; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.openSocket(); }, delay); } clearReconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } rejectPending(err) { for (const p of this.pending.values()) p.reject(err); this.pending.clear(); } closeSocket() { if (this.ws) { this.ws.onopen = null; this.ws.onmessage = null; this.ws.onclose = null; this.ws.onerror = null; try { this.ws.close(); } catch { // already closed } this.ws = null; } } reportError(err) { const error = err instanceof Error ? err : new Error(String(err)); if (this.onError) this.onError(error); else if (this.debug) console.warn('[relaycast node-provider]', error); } } function errorMessage(err) { return err instanceof Error ? err.message : String(err); } //# sourceMappingURL=node-provider.js.map