UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

231 lines (213 loc) 8.22 kB
import type { ComposeCompilePartialData, ComposeCompileRequest, ComposeCompileResult, ComposeCompileSuccessData, ComposeManifest, } from '@lifi/compose-spec'; import type { GetZapPacksOptions, ZapPackOverview } from './discovery.js'; import { ComposeError, errorFromHttpResponse } from './errors.js'; // __SDK_VERSION__ is a compile-time constant injected by tsup (via `define` in tsup.config.ts) // and by vitest (via `define` in vitest.config.ts). Both read the version from package.json // at build/test time and replace this identifier with the literal string value. // It is sent as the `x-lifi-composer-sdk` request header so the server can identify the caller. // Falls back to 'dev' when running via tsx without tsup substitution (e.g. the example harness). declare const __SDK_VERSION__: string; const SDK_VERSION: string = typeof __SDK_VERSION__ !== 'undefined' ? __SDK_VERSION__ : 'dev'; /** * Configuration for creating a low-level Compose API client. */ export interface ComposeClientOptions { /** Base URL of the Compose API. */ readonly baseUrl: string; /** Optional custom `fetch` implementation. Defaults to `globalThis.fetch`. */ readonly fetch?: typeof globalThis.fetch; /** Optional LI.FI API key. When set, sent as the `x-lifi-api-key` header on every request. */ readonly apiKey?: string; } /** * Low-level HTTP client for the Compose API. * * Handles request serialization, SDK version headers, and error mapping. * Prefer using {@link ComposeSdk} for the full builder experience. Use this * directly when you need to decouple request building from submission — e.g. * build via `sdk.request()` then submit via `client.compile()` with custom * retry logic or request inspection. */ export interface ComposeClient { /** * Fetches the server's operation manifest describing all supported operations, * guards, materialisers, and preconditions. * @returns The manifest document. * @throws {@link ComposeError} on network, validation, or server errors. */ readonly getManifest: () => Promise<ComposeManifest>; /** * Submits a compile request and returns the result. * * When the caller passes `simulationPolicy: 'allow-revert'` and the transaction * reverts in simulation, the server responds with HTTP 206 and the SDK returns a * partial result (`status: 'partial'`) instead of throwing. The partial result * includes the transaction (without `gasLimit`) and revert diagnostics. * * @param request - The full compile request including flow and run inputs. * @returns A discriminated result: `status: 'success'` or `status: 'partial'`. * @throws {@link ComposeError} on network, validation, or server errors. */ readonly compile: ( request: ComposeCompileRequest, ) => Promise<ComposeCompileResult>; /** * Fetches the available routing edges grouped by protocol. * * The edge catalog is dynamic — it reflects the current state of the * backend's routing snapshot (protocols, chains, token blacklists). * Results are not cached by the SDK; callers should cache as appropriate. * * @param options - Optional filter to restrict results to specific protocols. * @returns An array of {@link ZapPackOverview} objects, one per protocol. * @throws {@link ComposeError} on network or server errors (503 when the * routing catalog is not yet initialized). */ readonly getZapPacks: ( options?: GetZapPacksOptions, ) => Promise<readonly ZapPackOverview[]>; } const bigintReplacer = (_key: string, value: unknown): unknown => typeof value === 'bigint' ? value.toString() : value; const isNonNullObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null; const parseBody = async <T>(res: Response, url: string): Promise<T> => { const body = await res.json().catch((_) => null); if (!isNonNullObject(body) || !('data' in body)) { throw new ComposeError('UNKNOWN_ERROR', 'Unexpected response format', { url, }); } return body.data as T; }; const parseCompileSuccessBody = async ( res: Response, url: string, ): Promise<ComposeCompileResult> => { const data = await parseBody<ComposeCompileSuccessData>(res, url); return { ...data, status: 'success' as const }; }; const parsePartialBody = async ( res: Response, url: string, ): Promise<ComposeCompileResult> => { const body = await res.json().catch((_) => null); if ( !isNonNullObject(body) || !('data' in body) || !isNonNullObject(body.data) || !('error' in body) || !isNonNullObject(body.error) ) { throw new ComposeError( 'UNKNOWN_ERROR', 'Unexpected partial response format', { url }, ); } const data = body.data as unknown as ComposeCompilePartialData; const error = body.error as unknown as { kind: string; message: string }; return { ...data, status: 'partial' as const, error }; }; /** * Creates a low-level Compose API client. * * @param options - Client configuration including the API base URL. * @returns A {@link ComposeClient} instance. */ export const createComposeClient = ( options: ComposeClientOptions, ): ComposeClient => { if (!options.baseUrl || !/^https?:\/\//i.test(options.baseUrl)) { throw new ComposeError( 'VALIDATION_ERROR', `Invalid baseUrl: expected an HTTP(S) URL, got "${options.baseUrl}"`, ); } const fetchFn = options.fetch ?? globalThis.fetch; const base = options.baseUrl.replace(/\/$/, ''); const trimmedApiKey = options.apiKey?.trim() || undefined; const baseHeaders: Record<string, string> = { Accept: 'application/json', 'x-lifi-composer-sdk': SDK_VERSION, ...(trimmedApiKey ? { 'x-lifi-api-key': trimmedApiKey } : {}), }; const getManifest = async (): Promise<ComposeManifest> => { const url = `${base}/compose/manifest`; let res: Response; try { res = await fetchFn(url, { method: 'GET', headers: { ...baseHeaders }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new ComposeError('NETWORK_ERROR', message, { cause: err }); } if (!res.ok) { const body = await res.text(); throw errorFromHttpResponse(res.status, body, url); } return await parseBody<ComposeManifest>(res, url); }; const compile = async ( request: ComposeCompileRequest, ): Promise<ComposeCompileResult> => { const url = `${base}/compose`; let res: Response; try { res = await fetchFn(url, { method: 'POST', headers: { ...baseHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(request, bigintReplacer), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new ComposeError('NETWORK_ERROR', message, { cause: err }); } if (res.status === 206) { return await parsePartialBody(res, url); } if (!res.ok) { const body = await res.text(); throw errorFromHttpResponse(res.status, body, url); } return await parseCompileSuccessBody(res, url); }; const getZapPacks = async ( options?: GetZapPacksOptions, ): Promise<readonly ZapPackOverview[]> => { const params = new URLSearchParams(); if (options?.protocols !== undefined) { // Backend expects a single comma-separated value, not repeated keys. const raw = options.protocols; const list = typeof raw === 'string' ? raw : raw.join(','); params.set('protocols', list); } const qs = params.toString(); const url = `${base}/compose/zap-packs${qs ? `?${qs}` : ''}`; let res: Response; try { res = await fetchFn(url, { method: 'GET', headers: { ...baseHeaders }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new ComposeError('NETWORK_ERROR', message, { cause: err }); } if (!res.ok) { const body = await res.text(); throw errorFromHttpResponse(res.status, body, url); } return await parseBody<readonly ZapPackOverview[]>(res, url); }; return { getManifest, compile, getZapPacks }; };