@relaycast/sdk
Version:
TypeScript SDK for [Relaycast](https://relaycast.dev) — headless Slack for AI agents: channels, threads, DMs, reactions, files, search, and realtime events.
221 lines • 9.07 kB
JavaScript
import { z } from 'zod';
import { ApiErrorSchema } from '@relaycast/types';
import { SDK_VERSION } from './version.js';
import { AGENT_RELAY_DISTINCT_ID_HEADER, ORIGIN_ACTOR_HEADER, SDK_ORIGIN, sanitizeAgentRelayDistinctId, sanitizeOriginActor, } from './origin.js';
import { camelizeKeys, decamelizeKey, decamelizeKeys } from './casing.js';
import { RelayError, relayErrorFromApi } from './errors.js';
const INTERNAL_ORIGIN = Symbol('relaycast.internal.origin');
function readInternalOrigin(options) {
return options[INTERNAL_ORIGIN];
}
export function withInternalOrigin(options, origin) {
const copy = { ...options };
Object.defineProperty(copy, INTERNAL_ORIGIN, {
value: origin,
enumerable: false,
writable: false,
configurable: false,
});
return copy;
}
export { RelayError } from './errors.js';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const DEFAULT_RETRY_POLICY = {
maxRetries: 3,
backoffMs: 1000,
backoffMultiplier: 2,
jitter: true,
retryOn: [429, 500, 502, 503, 504],
};
function normalizeRetryPolicy(input) {
return {
maxRetries: Number.isFinite(input?.maxRetries) ? Math.max(0, Math.floor(input.maxRetries)) : DEFAULT_RETRY_POLICY.maxRetries,
backoffMs: Number.isFinite(input?.backoffMs) ? Math.max(0, Math.floor(input.backoffMs)) : DEFAULT_RETRY_POLICY.backoffMs,
backoffMultiplier: Number.isFinite(input?.backoffMultiplier)
? Math.max(1, input.backoffMultiplier)
: DEFAULT_RETRY_POLICY.backoffMultiplier,
jitter: typeof input?.jitter === 'boolean' ? input.jitter : DEFAULT_RETRY_POLICY.jitter,
retryOn: Array.isArray(input?.retryOn)
? input.retryOn.filter((status) => Number.isInteger(status) && status >= 100)
: [...DEFAULT_RETRY_POLICY.retryOn],
};
}
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(res) {
const header = res.headers.get('Retry-After');
if (!header)
return null;
const asSeconds = Number(header);
if (Number.isFinite(asSeconds)) {
return Math.max(0, Math.round(asSeconds * 1000));
}
const asDate = Date.parse(header);
if (Number.isNaN(asDate))
return null;
return Math.max(0, asDate - Date.now());
}
const apiEnvelopeSchema = z.object({
ok: z.boolean(),
data: z.unknown().optional(),
cursor: z.object({
next: z.string().nullable(),
has_more: z.boolean(),
}).optional(),
error: z.object({
code: z.string(),
message: z.string(),
}).optional(),
});
export class HttpClient {
_apiKey;
_baseUrl;
_originClient;
_originVersion;
_originActor;
_agentRelayDistinctId;
_retryPolicy;
constructor(options) {
const origin = readInternalOrigin(options) ?? SDK_ORIGIN;
this._apiKey = options.apiKey;
this._baseUrl = options.baseUrl ?? 'https://gateway.relaycast.dev';
this._originClient = origin.client;
this._originVersion = origin.version;
// A wrapping host's internal origin is authoritative about the originActor;
// fall back to the public `originActor` option for plain consumers.
this._originActor = sanitizeOriginActor(origin.originActor ?? options.originActor);
this._agentRelayDistinctId = sanitizeAgentRelayDistinctId(origin.agentRelayDistinctId ?? options.agentRelayDistinctId);
this._retryPolicy = normalizeRetryPolicy(options.retryPolicy);
}
get apiKey() {
return this._apiKey;
}
get baseUrl() {
return this._baseUrl;
}
get originClient() {
return this._originClient;
}
get originVersion() {
return this._originVersion;
}
/** Sanitized originActor identifier, or `undefined` when none was supplied. */
get originActor() {
return this._originActor;
}
/** Sanitized Agent Relay distinct id, or `undefined` when none was supplied. */
get agentRelayDistinctId() {
return this._agentRelayDistinctId;
}
get retryPolicy() {
return this._retryPolicy;
}
withApiKey(apiKey) {
return new HttpClient(withInternalOrigin({ apiKey, baseUrl: this._baseUrl, retryPolicy: this._retryPolicy }, {
client: this._originClient,
version: this._originVersion,
...(this._originActor ? { originActor: this._originActor } : {}),
...(this._agentRelayDistinctId ? { agentRelayDistinctId: this._agentRelayDistinctId } : {}),
}));
}
async request(method, path, body, query, options) {
const url = new URL(path, this._baseUrl);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined)
url.searchParams.set(decamelizeKey(k), v);
}
}
const headers = {
Authorization: `Bearer ${this._apiKey}`,
'X-SDK-Version': SDK_VERSION,
'X-Relaycast-Origin-Client': this._originClient,
'X-Relaycast-Origin-Version': this._originVersion,
...(this._originActor ? { [ORIGIN_ACTOR_HEADER]: this._originActor } : {}),
...(this._agentRelayDistinctId ? { [AGENT_RELAY_DISTINCT_ID_HEADER]: this._agentRelayDistinctId } : {}),
...(options?.headers || {}),
};
const hasBody = body !== undefined && method.toUpperCase() !== 'GET';
if (hasBody)
headers['Content-Type'] = 'application/json';
const wireBody = hasBody ? decamelizeKeys(body) : undefined;
let attempt = 0;
while (true) {
let res;
try {
res = await fetch(url.toString(), {
method,
headers,
body: hasBody ? JSON.stringify(wireBody) : undefined,
});
}
catch (err) {
if (attempt < this._retryPolicy.maxRetries) {
const waitMs = computeBackoffMs(this._retryPolicy, attempt);
attempt += 1;
await sleep(waitMs);
continue;
}
throw new RelayError('transport_error', `Network request failed: ${err instanceof Error ? err.message : 'unknown error'}`, { retryable: true, cause: err });
}
if (this._retryPolicy.retryOn.includes(res.status) && attempt < this._retryPolicy.maxRetries) {
const waitMs = res.status === 429
? parseRetryAfterMs(res) ?? computeBackoffMs(this._retryPolicy, attempt)
: computeBackoffMs(this._retryPolicy, attempt);
attempt += 1;
await sleep(waitMs);
continue;
}
// 204 No Content — return undefined (used by DELETE endpoints)
if (res.status === 204) {
return undefined;
}
let json;
try {
json = await res.json();
}
catch (err) {
throw new RelayError('transport_error', `Failed to parse response as JSON: ${err instanceof Error ? err.message : 'unknown error'}`, { statusCode: res.status, retryable: false, cause: err });
}
const envelope = apiEnvelopeSchema.safeParse(json);
if (!envelope.success) {
throw new RelayError('transport_error', 'Invalid API response', {
statusCode: res.status,
retryable: false,
});
}
if (!envelope.data.ok) {
const errParsed = ApiErrorSchema.safeParse(json);
const code = errParsed.success ? errParsed.data.error.code : 'unknown_error';
const message = errParsed.success ? errParsed.data.error.message : 'Unknown error';
throw relayErrorFromApi(code, message, res.status);
}
const data = envelope.data.data;
const parsedData = options?.schema ? options.schema.parse(data) : data;
return camelizeKeys(parsedData);
}
}
get(path, query, options) {
return this.request('GET', path, undefined, query, options);
}
post(path, body, options) {
return this.request('POST', path, body, undefined, options);
}
patch(path, body, options) {
return this.request('PATCH', path, body, undefined, options);
}
put(path, body, options) {
return this.request('PUT', path, body, undefined, options);
}
async delete(path, options) {
await this.request('DELETE', path, undefined, undefined, options);
}
}
//# sourceMappingURL=client.js.map