Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export {
bigintSafeStringify,
} from './utils.js';

// RPC server lifecycle
export { getServer, clearServerCache } from './soroban.js';

export {
formatAddress,
formatAmount,
Expand Down
35 changes: 33 additions & 2 deletions src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SorobanRpc.Server>();

/**
* 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<Network, string> = {
mainnet: 'https://soroban-mainnet.stellar.org',
testnet: 'https://soroban-testnet.stellar.org',
Expand Down Expand Up @@ -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',
Expand Down
118 changes: 118 additions & 0 deletions src/tests/soroban-server-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});