@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
JavaScript
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