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.

315 lines 11.9 kB
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