@relaycast/sdk
Version:
TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.
315 lines • 11.9 kB
JavaScript
import { ApiResponseSchema, CreateWorkspaceResponseSchema, WorkspaceLookupSchema, } from '@relaycast/types';
import { camelizeKeys } from './casing.js';
import { AgentClient } from './agent.js';
import { HttpClient } from './client.js';
import { Relay } from './communicate/relay.js';
import { SDK_ORIGIN } from './origin.js';
import { RelayCast } from './relay.js';
import { AgentNotRegisteredError, MalformedApiResponseError, MissingApiKeyError, RelaycastApiError, } from './setup-errors.js';
const DEFAULT_CLOUD_BASE_URL = 'https://gateway.relaycast.dev';
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BASE_DELAY_MS = 500;
const DEFAULT_BACKOFF_MULTIPLIER = 2;
const RETRY_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function normalizeRetryPolicy(input) {
const maxRetries = Number.isFinite(input?.maxRetries)
? Math.max(0, Math.floor(input.maxRetries))
: DEFAULT_MAX_RETRIES;
const backoffMs = Number.isFinite(input?.baseDelayMs)
? Math.max(0, Math.floor(input.baseDelayMs))
: DEFAULT_BASE_DELAY_MS;
return {
normalized: {
maxRetries,
backoffMs,
backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
jitter: true,
},
clientInput: {
maxRetries,
backoffMs,
backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
jitter: true,
retryOn: [...RETRY_STATUS_CODES],
},
};
}
function computeBackoffMs(policy, retryAttempt) {
const exponential = policy.backoffMs * (policy.backoffMultiplier ** retryAttempt);
if (!policy.jitter) {
return Math.max(0, Math.round(exponential));
}
const jitterFactor = 0.5 + Math.random();
return Math.max(0, Math.round(exponential * jitterFactor));
}
function parseRetryAfterMs(response) {
const header = response.headers.get('Retry-After');
if (!header) {
return null;
}
const seconds = Number(header);
if (Number.isFinite(seconds)) {
return Math.max(0, Math.round(seconds * 1000));
}
const asDate = Date.parse(header);
if (Number.isNaN(asDate)) {
return null;
}
return Math.max(0, asDate - Date.now());
}
function extractMalformedField(responseBody, fallback = 'response') {
return ApiResponseSchema(CreateWorkspaceResponseSchema)
.safeParse(responseBody).error?.issues[0]?.path.filter((segment) => typeof segment === 'string').at(-1)
?? fallback;
}
function toLookupMalformedField(responseBody, fallback = 'response') {
return ApiResponseSchema(WorkspaceLookupSchema)
.safeParse(responseBody).error?.issues[0]?.path.filter((segment) => typeof segment === 'string').at(-1)
?? fallback;
}
function normalizeBaseUrl(options) {
if (isNonEmptyString(options?.baseUrl)) {
return options.baseUrl.trim();
}
return DEFAULT_CLOUD_BASE_URL;
}
function normalizeStatus(status) {
return status === 'online' || status === 'away' ? status : 'offline';
}
export class WorkspaceHandle {
info;
workspaceId;
apiKey;
retryPolicyInput;
relayCastInstance = null;
agentRecords = new Map();
registrationOrder = [];
relays = new Map();
constructor(info, config) {
this.info = Object.freeze({ ...info });
this.workspaceId = info.workspaceId;
this.apiKey = info.apiKey;
this.retryPolicyInput = config.retryPolicyInput;
}
relayCast() {
if (!this.relayCastInstance) {
this.relayCastInstance = new RelayCast({
apiKey: this.apiKey,
baseUrl: this.info.baseUrl,
retryPolicy: this.retryPolicyInput,
});
}
return this.relayCastInstance;
}
as(token) {
return new AgentClient(new HttpClient({
apiKey: token,
baseUrl: this.info.baseUrl,
retryPolicy: this.retryPolicyInput,
}));
}
relay(agentName) {
const existingRelay = this.relays.get(agentName);
if (existingRelay) {
return existingRelay;
}
const record = this.agentRecords.get(agentName);
if (!record) {
throw new AgentNotRegisteredError(agentName);
}
const relay = new Relay(this.as(record.token));
this.relays.set(agentName, relay);
return relay;
}
async registerAgent(opts) {
const created = await this.relayCast().agents.register(opts);
const record = {
...created,
type: opts.type ?? 'agent',
status: normalizeStatus(created.status),
};
const existingIndex = this.registrationOrder.findIndex((agent) => agent.name === record.name);
if (existingIndex >= 0) {
this.registrationOrder[existingIndex] = record;
}
else {
this.registrationOrder.push(record);
}
this.agentRecords.set(record.name, record);
this.relays.delete(record.name);
return record;
}
getAgentToken(name) {
return this.agentRecords.get(name)?.token;
}
listRegisteredAgents() {
return [...this.registrationOrder];
}
getApiKey() {
return this.apiKey;
}
}
export class RelaycastSetup {
config;
constructor(options = {}) {
const retryPolicy = normalizeRetryPolicy(options.retry);
this.config = {
apiKey: options.apiKey,
baseUrl: normalizeBaseUrl(options),
requestTimeoutMs: Number.isFinite(options.requestTimeoutMs)
? Math.max(1, Math.floor(options.requestTimeoutMs))
: DEFAULT_REQUEST_TIMEOUT_MS,
retryPolicy: retryPolicy.normalized,
retryPolicyInput: retryPolicy.clientInput,
};
}
async createWorkspace(options) {
const workspace = await this.requestWorkspace({
method: 'POST',
path: '/v1/workspaces',
schema: CreateWorkspaceResponseSchema,
body: { name: options.name },
requireApiKey: true,
});
if (!workspace) {
throw new MalformedApiResponseError('workspace_id', workspace);
}
return new WorkspaceHandle({
workspaceId: workspace.workspaceId,
apiKey: workspace.apiKey,
baseUrl: this.config.baseUrl,
createdAt: workspace.createdAt,
name: options.name,
}, { retryPolicyInput: this.config.retryPolicyInput });
}
async joinWorkspace(workspaceId, apiKey, _options = {}) {
if (!isNonEmptyString(apiKey)) {
throw new MissingApiKeyError();
}
return new WorkspaceHandle({
workspaceId,
apiKey: apiKey.trim(),
baseUrl: this.config.baseUrl,
}, { retryPolicyInput: this.config.retryPolicyInput });
}
async lookupWorkspace(name) {
return this.requestWorkspace({
method: 'GET',
path: `/v1/workspaces/by-name/${encodeURIComponent(name)}`,
schema: WorkspaceLookupSchema,
allowNotFound: true,
});
}
async resolveApiKey() {
const source = this.config.apiKey;
if (typeof source === 'function') {
const resolved = await source();
return isNonEmptyString(resolved) ? resolved.trim() : undefined;
}
return isNonEmptyString(source) ? source.trim() : undefined;
}
async fetchWithRetry(method, path, body) {
const url = new URL(path, this.config.baseUrl);
const apiKey = await this.resolveApiKey();
const headers = {
Accept: 'application/json',
'X-SDK-Version': SDK_ORIGIN.version,
'X-Relaycast-Origin-Client': SDK_ORIGIN.client,
'X-Relaycast-Origin-Version': SDK_ORIGIN.version,
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
if (method === 'POST') {
headers['Content-Type'] = 'application/json';
}
let attempt = 0;
while (true) {
try {
const response = await fetch(url.toString(), {
method,
headers,
body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
if (RETRY_STATUS_CODES.has(response.status) && attempt < this.config.retryPolicy.maxRetries) {
const waitMs = response.status === 429
? parseRetryAfterMs(response) ?? computeBackoffMs(this.config.retryPolicy, attempt)
: computeBackoffMs(this.config.retryPolicy, attempt);
attempt += 1;
await sleep(waitMs);
continue;
}
return response;
}
catch (error) {
if (attempt >= this.config.retryPolicy.maxRetries) {
throw error;
}
const waitMs = computeBackoffMs(this.config.retryPolicy, attempt);
attempt += 1;
await sleep(waitMs);
}
}
}
async requestWorkspace({ method, path, schema, body, allowNotFound = false, requireApiKey = false, }) {
const response = await this.fetchWithRetry(method, path, body);
if (allowNotFound && response.status === 404) {
return null;
}
const responseBody = await this.parseResponseBody(response);
if (!response.ok) {
throw new RelaycastApiError(response.status, responseBody, this.extractApiErrorMessage(response.status, responseBody));
}
const parsedEnvelope = ApiResponseSchema(schema).safeParse(responseBody);
if (!parsedEnvelope.success) {
const fallback = schema === WorkspaceLookupSchema ? toLookupMalformedField(responseBody) : extractMalformedField(responseBody);
throw new MalformedApiResponseError(fallback, responseBody);
}
if (!parsedEnvelope.data.ok) {
throw new RelaycastApiError(response.status, responseBody, parsedEnvelope.data.error.message);
}
const data = camelizeKeys(parsedEnvelope.data.data);
if (requireApiKey) {
const candidate = data;
if (!isNonEmptyString(candidate.apiKey)) {
throw new MalformedApiResponseError('api_key', responseBody);
}
}
return data;
}
async parseResponseBody(response) {
const text = await response.text();
if (text.length === 0) {
return null;
}
try {
return JSON.parse(text);
}
catch {
if (response.ok) {
throw new MalformedApiResponseError('response', text, 'Relaycast API response is not valid JSON');
}
return text;
}
}
extractApiErrorMessage(status, responseBody) {
const parsedWorkspace = ApiResponseSchema(CreateWorkspaceResponseSchema).safeParse(responseBody);
if (parsedWorkspace.success && !parsedWorkspace.data.ok) {
return parsedWorkspace.data.error.message;
}
const parsedLookup = ApiResponseSchema(WorkspaceLookupSchema).safeParse(responseBody);
if (parsedLookup.success && !parsedLookup.data.ok) {
return parsedLookup.data.error.message;
}
return `Relaycast API request failed with status ${status}`;
}
}
//# sourceMappingURL=setup.js.map