import type { ApiFailure, ApiIndex, ApiSuccess, AssetList, CanonicalAsset, CorporateActionList, CorporateActionStatus, CorporateActionType, HealthReport, Portfolio, PriceObservation, Quote, SandboxScenario, SimulateRequest, SimulationResult, TradePlan, TradeRequest, } from './types.js'; export type * from './types.js'; export { ROBINHOOD_CHAIN_ID } from './types.js'; /** The API version this SDK release targets. */ export const API_VERSION = 'v0' as const; /** Keep in sync with `package.json`; verified by the test suite. */ export const SDK_VERSION = '0.6.3'; export interface RailXRequestOptions { /** Cancel this request with an AbortController. */ signal?: AbortSignal; /** Override the client's per-attempt timeout. Set to 0 to disable. */ timeoutMs?: number; } export interface RailXTradePlanOptions extends RailXRequestOptions { /** Reuse this key when retrying the same intent. Generated when omitted. */ idempotencyKey?: string; } export interface RailXClientOptions { /** Base URL including the version segment, e.g. `https://host/api/v0`. */ baseUrl: string; /** Project key beginning with `railx_test_` or `railx_live_`. */ apiKey?: string; fetch?: typeof globalThis.fetch; /** * Force a sandbox scenario on every request. Only valid with test keys; * the API rejects it on live keys. */ scenario?: SandboxScenario; /** * Retries for safe GET requests and idempotent plan creation when they fail * with a retryable error. Other POST requests are never retried. Default 2. */ retries?: number; /** Per-attempt timeout in milliseconds. Set to 0 to disable. Default 30000. */ timeoutMs?: number; } export interface RailXResponseInfo { requestId: string | null; status: number; sandbox: boolean; plan: string | null; environment: 'test' | 'live' | null; rateLimit: { limit: number; remaining: number; resetAt: number } | null; monthly: { limit: number; remaining: number } | null; } export class RailXApiError extends Error { readonly status: number; readonly code: string; readonly retryable: boolean; readonly details: unknown; readonly requestId: string | null; constructor(options: { status: number; code: string; message: string; retryable?: boolean; details?: unknown; requestId?: string | null; }) { super(options.message); this.name = 'RailXApiError'; this.status = options.status; this.code = options.code; this.retryable = options.retryable ?? false; this.details = options.details ?? null; this.requestId = options.requestId ?? null; } } type Query = Record; function numberHeader(headers: Headers, name: string): number | null { const value = headers.get(name); if (value === null) return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } export function readResponseInfo(response: Response): RailXResponseInfo { const headers = response.headers; const environment = headers.get('x-railx-environment'); const limit = numberHeader(headers, 'x-ratelimit-limit'); const remaining = numberHeader(headers, 'x-ratelimit-remaining'); const resetAt = numberHeader(headers, 'x-ratelimit-reset'); const monthlyLimit = numberHeader(headers, 'x-railx-monthly-limit'); const monthlyRemaining = numberHeader(headers, 'x-railx-monthly-remaining'); return { requestId: headers.get('x-request-id'), status: response.status, sandbox: headers.get('x-railx-sandbox') === 'on', plan: headers.get('x-railx-plan'), environment: environment === 'test' || environment === 'live' ? environment : null, rateLimit: limit !== null && remaining !== null && resetAt !== null ? { limit, remaining, resetAt } : null, monthly: monthlyLimit !== null && monthlyRemaining !== null ? { limit: monthlyLimit, remaining: monthlyRemaining } : null, }; } function normalizedTimeout( value: number | undefined, fallback: number, ): number { const timeout = value ?? fallback; if (!Number.isSafeInteger(timeout) || timeout < 0) { throw new TypeError('timeoutMs must be a non-negative integer'); } return timeout; } function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason); return; } const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); reject(signal?.reason); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } export class RailXClient { readonly system: { index: (requestOptions?: RailXRequestOptions) => Promise; health: (requestOptions?: RailXRequestOptions) => Promise; }; readonly assets: { list: ( options?: { status?: 'active' | 'inactive' | 'all'; limit?: number; after?: string; }, requestOptions?: RailXRequestOptions, ) => Promise; get: ( symbol: string, requestOptions?: RailXRequestOptions, ) => Promise; }; readonly prices: { get: ( symbol: string, requestOptions?: RailXRequestOptions, ) => Promise; }; readonly corporateActions: { list: ( options?: { symbol?: string; type?: CorporateActionType; status?: CorporateActionStatus; limit?: number; }, requestOptions?: RailXRequestOptions, ) => Promise; }; readonly portfolios: { get: ( wallet: string, options?: { symbols?: string[]; includeZero?: boolean }, requestOptions?: RailXRequestOptions, ) => Promise; }; readonly quotes: { create: ( request: TradeRequest, options?: RailXRequestOptions, ) => Promise; }; readonly trades: { plan: ( request: TradeRequest, options?: RailXTradePlanOptions, ) => Promise; get: (planId: string, options?: RailXRequestOptions) => Promise; }; readonly transactions: { simulate: ( input: SimulateRequest, options?: RailXRequestOptions, ) => Promise; }; /** Metadata from the most recent response, including limits and sandbox state. */ lastResponse: RailXResponseInfo | null = null; private readonly baseUrl: string; private readonly apiKey?: string; private readonly scenario?: SandboxScenario; private readonly retries: number; private readonly timeoutMs: number; private readonly requestFetch: typeof globalThis.fetch; constructor(options: RailXClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.apiKey = options.apiKey; this.scenario = options.scenario; this.retries = options.retries ?? 2; if (!Number.isSafeInteger(this.retries) || this.retries < 0) { throw new TypeError('retries must be a non-negative integer'); } this.timeoutMs = normalizedTimeout(options.timeoutMs, 30_000); this.requestFetch = options.fetch ?? globalThis.fetch; this.system = { index: (requestOptions) => this.request('', { requestOptions }), health: (requestOptions) => this.request('/health', { requestOptions }), }; this.assets = { list: (options = {}, requestOptions) => this.request('/assets', { query: options, requestOptions }), get: (symbol, requestOptions) => this.request(`/assets/${encodeURIComponent(symbol)}`, { requestOptions, }), }; this.prices = { get: (symbol, requestOptions) => this.request( `/prices/${encodeURIComponent(symbol)}`, { requestOptions, }, ), }; this.corporateActions = { list: (options = {}, requestOptions) => this.request('/corporate-actions', { query: options, requestOptions, }), }; this.portfolios = { get: (wallet, options = {}, requestOptions) => this.request(`/portfolios/${encodeURIComponent(wallet)}`, { query: { symbols: options.symbols?.join(','), includeZero: options.includeZero, }, requestOptions, }), }; this.quotes = { create: (body, requestOptions) => this.request('/quotes', { method: 'POST', body, requestOptions, }), }; this.trades = { plan: (body, options = {}) => { const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID(); return this.request('/trades/plan', { method: 'POST', body, headers: { 'idempotency-key': idempotencyKey }, idempotent: true, requestOptions: options, }); }, get: (planId, requestOptions) => this.request(`/trade-plans/${encodeURIComponent(planId)}`, { requestOptions, }), }; this.transactions = { simulate: (body, requestOptions) => this.request('/transactions/simulate', { method: 'POST', body, requestOptions, }), }; } private async request( path: string, options: { method?: 'GET' | 'POST'; body?: unknown; query?: Query; headers?: Record; idempotent?: boolean; requestOptions?: RailXRequestOptions; } = {}, ): Promise { const method = options.method ?? 'GET'; const url = new URL(`${this.baseUrl}${path}`); for (const [key, value] of Object.entries(options.query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)); } const attempts = method === 'GET' || options.idempotent ? this.retries + 1 : 1; let lastError: RailXApiError | null = null; const timeoutMs = normalizedTimeout( options.requestOptions?.timeoutMs, this.timeoutMs, ); for (let attempt = 0; attempt < attempts; attempt += 1) { const timeoutSignal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined; const signal = timeoutSignal && options.requestOptions?.signal ? AbortSignal.any([timeoutSignal, options.requestOptions.signal]) : (timeoutSignal ?? options.requestOptions?.signal); let response: Response; let envelope: Partial & ApiFailure> | null = null; try { response = await this.requestFetch(url, { method, signal, headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}), ...(this.scenario ? { 'x-railx-scenario': this.scenario } : {}), ...options.headers, }, body: options.body ? JSON.stringify(options.body) : undefined, }); this.lastResponse = readResponseInfo(response); try { const body: unknown = await response.json(); if (body && typeof body === 'object' && !Array.isArray(body)) { envelope = body as Partial & ApiFailure>; } } catch (error) { if (signal?.aborted) throw error; } } catch (error) { if (!signal?.aborted) throw error; const cancelled = options.requestOptions?.signal?.aborted === true; lastError = new RailXApiError({ status: 0, code: cancelled ? 'REQUEST_ABORTED' : 'REQUEST_TIMEOUT', message: cancelled ? 'RailX request was cancelled' : 'RailX request timed out', retryable: !cancelled, }); const isLast = attempt === attempts - 1; if (!lastError.retryable || isLast) throw lastError; continue; } const info = readResponseInfo(response); this.lastResponse = info; if ( response.ok && envelope && Object.hasOwn(envelope, 'data') && !envelope.error ) { return envelope.data as T; } lastError = new RailXApiError({ status: response.status, code: envelope?.error?.code ?? 'API_ERROR', message: envelope?.error?.message ?? (response.ok ? 'RailX API returned an invalid response envelope' : `RailX API returned HTTP ${response.status}`), retryable: envelope?.error?.retryable ?? (response.status === 429 || response.status >= 500), details: envelope?.error?.details, requestId: envelope?.meta?.requestId ?? info.requestId, }); const isLast = attempt === attempts - 1; if (!lastError.retryable || isLast) throw lastError; const retryAfter = numberHeader(response.headers, 'retry-after'); try { await delay( retryAfter !== null ? retryAfter * 1000 : 250 * 2 ** attempt, options.requestOptions?.signal, ); } catch { throw new RailXApiError({ status: 0, code: 'REQUEST_ABORTED', message: 'RailX request was cancelled', }); } } throw lastError ?? new Error('unreachable'); } }