diff --git a/docs/api.md b/docs/api.md index edf5a21..cb8c572 100644 --- a/docs/api.md +++ b/docs/api.md @@ -264,6 +264,46 @@ withdrawableLocal(streamInfo) // → bigint (client-side estimate, no RPC call) --- +## RPC Server Lifecycle + +The SDK maintains an internal cache of `SorobanRpc.Server` instances keyed by URL. Reusing server +instances avoids creating new HTTP agents on every RPC call, which reduces TCP/TLS handshake +overhead, lowers GC pressure, and improves throughput — particularly for operations like +`client.streams.list()` that issue multiple RPC calls in quick succession. + +### `getServer(rpcUrl)` + +Returns a cached `SorobanRpc.Server` for the given URL. Subsequent calls with the same URL +return the same instance. This is the recommended way to obtain an RPC server when calling +low-level Soroban helpers directly. + +```typescript +import { getServer } from '@conduit-protocol/sdk'; + +const server = getServer('https://soroban-mainnet.stellar.org'); +``` + +### `clearServerCache()` + +Clears the internal server cache. Useful in test suites between test cases that switch +network configurations, or when you need to force a fresh server instance. + +```typescript +import { clearServerCache } from '@conduit-protocol/sdk'; + +clearServerCache(); +``` + +> **Internal usage:** All SDK functions that interact with the Soroban RPC (`buildContractCallTx`, +> `simulateReadOnly`, `invokeContract`, `StreamsModule`, `subscribeToStream`, etc.) build their +> server through an internal wrapper that calls `getServer` for the cached instance and adds +> automatic retry-with-backoff on rate-limit errors (HTTP 429/503). Calling `getServer` yourself +> gives you the cached-but-unwrapped instance — no automatic retry — so you do not need to call +> it yourself unless you are using the low-level Soroban helpers directly and want to manage +> retries on your own. + +--- + ## Fluent Builder API The SDK provides `StreamBuilder` and `ConduitBatcher` to construct and execute stream operations fluently and in batches. diff --git a/docs/architecture.md b/docs/architecture.md index 7ff8140..c29088a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,9 @@ client.ts — ConduitClient: owns config, instantiates the three modules ├─ streams.ts — StreamsModule: create/get/withdraw/cancel/pause/resume/topUp/clawback/list/subscribe ├─ factory.ts — FactoryModule: streamCount/streamAddress/streamsBySender/streamsByRecipient/protocolFeeBps └─ governor.ts — GovernorModule: (config reads — see docs/api.md) -soroban.ts — buildContractCallTx/simulateReadOnly + NETWORK_PASSPHRASE/DEFAULT_RPC tables +soroban.ts — buildContractCallTx/simulateReadOnly/getServer/clearServerCache + NETWORK_PASSPHRASE/DEFAULT_RPC tables + (getServer maintains a module-level cache of SorobanRpc.Server instances keyed by URL, + eliminating per-call HTTP agent creation; used internally by all RPC-calling code paths) events.ts — subscribeToStream: polls getEvents(), dispatches to typed handlers errors.ts — ConduitError + ErrorCode, mapped from on-chain contract error codes utils.ts — toStroops/fromStroops/calculateRate/streamProgress/withdrawableLocal (pure, no RPC) diff --git a/src/index.ts b/src/index.ts index 9384964..9ea0b0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,9 @@ export { bigintSafeStringify, } from './utils.js'; +// RPC server lifecycle +export { getServer, clearServerCache } from './soroban.js'; + export { formatAddress, formatAmount, diff --git a/src/soroban.ts b/src/soroban.ts index 07b508d..f52450f 100644 --- a/src/soroban.ts +++ b/src/soroban.ts @@ -17,6 +17,37 @@ import type { Network } from './types/index.js'; import type { Signer } from './signer.js'; import { RateLimitError } from './errors.js'; +// ── RPC Server cache ───────────────────────────────────────────────────────── +// Reusing SorobanRpc.Server instances avoids creating a new HTTP agent per +// call, which reduces TCP/TLS handshake overhead and GC pressure. This is +// safe because SorobanRpc.Server is stateless beyond its URL configuration. + +const _serverCache = new Map(); + +/** + * Returns a cached SorobanRpc.Server for the given URL. + * Subsequent calls with the same URL return the same instance, + * eliminating per-call HTTP agent creation overhead. + */ +export function getServer(rpcUrl: string): SorobanRpc.Server { + let server = _serverCache.get(rpcUrl); + if (!server) { + server = new SorobanRpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith('http://'), + }); + _serverCache.set(rpcUrl, server); + } + return server; +} + +/** + * Clear the RPC server cache. Useful in tests or when switching + * network configurations that should invalidate cached servers. + */ +export function clearServerCache(): void { + _serverCache.clear(); +} + export const DEFAULT_RPC: Record = { mainnet: 'https://soroban-mainnet.stellar.org', testnet: 'https://soroban-testnet.stellar.org', @@ -49,8 +80,8 @@ function normalizePollingOptions(options: ConfirmationPollingOptions = {}): Requ * Retries on HTTP 429 and 503 rate limits. */ export function createRpcServer(rpcUrl: string): SorobanRpc.Server { - const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') }); - + const server = getServer(rpcUrl); + const ASYNC_METHODS = [ 'getAccount', 'getEvents', diff --git a/src/tests/soroban-server-cache.test.ts b/src/tests/soroban-server-cache.test.ts new file mode 100644 index 0000000..93a040c --- /dev/null +++ b/src/tests/soroban-server-cache.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { getServer, clearServerCache } from '../soroban.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const MAINNET_RPC = 'https://soroban-mainnet.stellar.org'; +const TESTNET_RPC = 'https://soroban-testnet.stellar.org'; +const LOCAL_RPC = 'http://localhost:8000/soroban/rpc'; + +// --------------------------------------------------------------------------- +// getServer - cache behaviour +// --------------------------------------------------------------------------- + +describe('getServer', () => { + beforeEach(() => { + clearServerCache(); + }); + + it('returns a SorobanRpc.Server instance', () => { + const server = getServer(MAINNET_RPC); + expect(server).toBeInstanceOf(SorobanRpc.Server); + }); + + it('returns the same instance for the same URL on repeated calls', () => { + const a = getServer(MAINNET_RPC); + const b = getServer(MAINNET_RPC); + const c = getServer(MAINNET_RPC); + expect(a).toBe(b); + expect(b).toBe(c); + }); + + it('returns different instances for different URLs', () => { + const mainnet = getServer(MAINNET_RPC); + const testnet = getServer(TESTNET_RPC); + const local = getServer(LOCAL_RPC); + + expect(mainnet).not.toBe(testnet); + expect(testnet).not.toBe(local); + expect(local).not.toBe(mainnet); + }); + + it('preserves identity after interleaved calls with different URLs', () => { + const a1 = getServer(MAINNET_RPC); + const b1 = getServer(TESTNET_RPC); + const a2 = getServer(MAINNET_RPC); + const b2 = getServer(TESTNET_RPC); + expect(a1).toBe(a2); + expect(b1).toBe(b2); + expect(a1).not.toBe(b1); + }); + + it('handles trailing-slash URL variants independently (exact string match)', () => { + const withoutSlash = getServer('https://rpc.example.com'); + const withSlash = getServer('https://rpc.example.com/'); + expect(withoutSlash).not.toBe(withSlash); + }); +}); + +// --------------------------------------------------------------------------- +// clearServerCache +// --------------------------------------------------------------------------- + +describe('clearServerCache', () => { + beforeEach(() => { + clearServerCache(); + }); + + it('invalidates all cached server instances', () => { + const before = getServer(MAINNET_RPC); + clearServerCache(); + const after = getServer(MAINNET_RPC); + expect(after).not.toBe(before); + expect(after).toBeInstanceOf(SorobanRpc.Server); + }); + + it('is idempotent — calling it multiple times does not throw', () => { + getServer(MAINNET_RPC); + clearServerCache(); + clearServerCache(); + clearServerCache(); + // No throw = pass + }); + + it('allows caching again after clearing', () => { + getServer(MAINNET_RPC); + clearServerCache(); + const a = getServer(MAINNET_RPC); + const b = getServer(MAINNET_RPC); + expect(a).toBe(b); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: getServer used within buildContractCallTx path +// --------------------------------------------------------------------------- + +describe('server cache integration', () => { + beforeEach(() => { + clearServerCache(); + }); + + it('getServer is exported from the public API (index.ts)', async () => { + // Dynamic import to avoid circular deps during vitest bootstrap + const mod = await import('../index.js'); + expect(mod.getServer).toBe(getServer); + expect(mod.clearServerCache).toBe(clearServerCache); + }); + + it('cache does not leak between test suites when beforeEach clears', () => { + // This test itself is the assertion — beforeEach runs and the cache + // is clean. Verifying that getServer still works post-clear. + const server = getServer(MAINNET_RPC); + expect(server).toBeInstanceOf(SorobanRpc.Server); + }); +});