@novasamatech/host-substrate-chain-connection 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,33 +25,32 @@ import {
25
25
  import { dot } from '@polkadot-api/descriptors';
26
26
 
27
27
  const polkadot: ChainConfig = {
28
- chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
29
- nodes: [{ url: 'wss://rpc.polkadot.io' }],
28
+ genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
29
+ nodes: ['wss://rpc.polkadot.io'],
30
30
  };
31
31
 
32
32
  const chains = createChainConnection({
33
33
  createProvider: (chain, onStatusChanged) =>
34
34
  createWsJsonRpcProvider({
35
- endpoints: chain.nodes.map((n) => n.url),
35
+ endpoints: chain.nodes,
36
36
  onStatusChanged,
37
37
  }),
38
38
  });
39
39
 
40
- // One-shot query - connection is acquired and released automatically
41
- const account = await chains.requestApi(polkadot, async (client) => {
42
- const api = client.getTypedApi(dot);
43
- return api.query.System.Account.getValue('5GrwvaEF...');
44
- });
40
+ // Acquire a connection, run a query, then release
41
+ const { api: client, unlock } = await chains.lockApi(polkadot);
42
+ const api = client.getTypedApi(dot);
43
+ const account = await api.query.System.Account.getValue('5GrwvaEF...');
44
+ unlock();
45
45
  ```
46
46
 
47
47
  ## Table of contents
48
48
 
49
49
  - [API](#api)
50
50
  - [`createChainConnection`](#createchainconnectionconfig)
51
- - [`requestApi`](#requestapichain-callback)
52
51
  - [`lockApi`](#lockapichain)
53
52
  - [`getProvider`](#getproviderchain)
54
- - [`status` / `onStatusChanged`](#statuschainid--onstatuschangedchainid-callback)
53
+ - [`status` / `onStatusChanged`](#statusgenesishash--onstatuschangedgenesishash-callback)
55
54
  - [`createWsJsonRpcProvider`](#createwsjsonrpcprovideroptions)
56
55
  - [`createMetadataCache`](#createmetadatacacheoptions)
57
56
  - [Recipes](#recipes)
@@ -82,38 +81,18 @@ function createChainConnection<C extends ChainConfig, T = PolkadotClient>(
82
81
  | `createProvider` | `(chain: C, onStatusChanged: (status: ConnectionStatus) => void) => JsonRpcProvider` | Factory for the underlying JSON-RPC transport. Called once per chain. Use `onStatusChanged` to feed connection status back into the pool. |
83
82
  | `clientOptions` | `(chain: C) => ClientOptions` | Optional. Returns [polkadot-api client options](https://papi.how/) - typically metadata cache hooks (`getMetadata` / `setMetadata`). |
84
83
  | `resolve` | `(chain: C, client: PolkadotClient) => Promise<T>` | Optional. Transforms the raw `PolkadotClient` into your app's API type. The result is cached per chain. If omitted, `T` defaults to `PolkadotClient`. |
84
+ | `destroyDelay` | `number` | Optional. Milliseconds to wait before destroying a connection after the last caller releases. Defaults to `0` (destroy immediately). Useful to avoid reconnect churn when callers release and re-acquire in quick succession. |
85
85
 
86
86
  **`ChainConfig`** - minimum shape your chain objects must satisfy:
87
87
 
88
88
  ```ts
89
89
  type ChainConfig = {
90
- chainId: string;
91
- nodes: ReadonlyArray<{ url: string }>;
90
+ genesisHash: string;
91
+ nodes: string[];
92
92
  };
93
93
  ```
94
94
 
95
- Returns a [`ChainConnection<C, T>`](#requestapichain-callback) with the methods below.
96
-
97
- ---
98
-
99
- ### `requestApi(chain, callback)`
100
-
101
- Acquires a connection, runs the callback, and releases automatically when it settles. Best for one-shot queries.
102
-
103
- **Signature:**
104
-
105
- ```ts
106
- requestApi<Return>(chain: C, callback: (api: T) => Return): Promise<Awaited<Return>>
107
- ```
108
-
109
- **Example:**
110
-
111
- ```ts
112
- const account = await chains.requestApi(polkadot, async (client) => {
113
- const api = client.getTypedApi(dot);
114
- return api.query.System.Account.getValue('5GrwvaEF...');
115
- });
116
- ```
95
+ Returns a [`ChainConnection<C, T>`](#lockapichain) with the methods below.
117
96
 
118
97
  ---
119
98
 
@@ -124,7 +103,7 @@ Acquires a connection and holds it until `unlock()` is called. Use for subscript
124
103
  **Signature:**
125
104
 
126
105
  ```ts
127
- lockApi(chain: C): Promise<{ api: T; unlock: VoidFunction }>
106
+ function lockApi(chain: C): Promise<{ api: T; unlock: VoidFunction }>
128
107
  ```
129
108
 
130
109
  **Example:**
@@ -157,7 +136,7 @@ Useful for passing a provider to an iframe, webview, or any library that expects
157
136
  **Signature:**
158
137
 
159
138
  ```ts
160
- getProvider(chain: C): JsonRpcProvider
139
+ function getProvider(chain: C): JsonRpcProvider
161
140
  ```
162
141
 
163
142
  **Example:**
@@ -169,25 +148,25 @@ const provider = chains.getProvider(polkadot);
169
148
 
170
149
  ---
171
150
 
172
- ### `status(chainId)` / `onStatusChanged(chainId, callback)`
151
+ ### `status(genesisHash)` / `onStatusChanged(genesisHash, callback)`
173
152
 
174
- Read or subscribe to connection status. Both take a `chainId` string (the genesis hash). Returns `'disconnected'` for chains that have never been connected.
153
+ Read or subscribe to connection status. Both take a `genesisHash` string. Returns `'disconnected'` for chains that have never been connected.
175
154
 
176
155
  **Signature:**
177
156
 
178
157
  ```ts
179
- status(chainId: string): ConnectionStatus
158
+ function status(genesisHash: string): ConnectionStatus
180
159
  // ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
181
160
 
182
- onStatusChanged(chainId: string, callback: (status: ConnectionStatus) => void): VoidFunction
161
+ function onStatusChanged(genesisHash: string, callback: (status: ConnectionStatus) => void): VoidFunction
183
162
  ```
184
163
 
185
164
  **Example:**
186
165
 
187
166
  ```ts
188
- const currentStatus = chains.status(polkadot.chainId);
167
+ const currentStatus = chains.status(polkadot.genesisHash);
189
168
 
190
- const unsubscribe = chains.onStatusChanged(polkadot.chainId, (status) => {
169
+ const unsubscribe = chains.onStatusChanged(polkadot.genesisHash, (status) => {
191
170
  console.info('Polkadot:', status);
192
171
  });
193
172
 
@@ -199,7 +178,7 @@ unsubscribe();
199
178
 
200
179
  ### `createWsJsonRpcProvider(options)`
201
180
 
202
- WebSocket provider factory. Wraps polkadot-api's `getWsProvider` with `polkadot-sdk-compat` and translates WebSocket events to `ConnectionStatus`. Active JSON-RPC subscriptions are automatically replayed after a reconnect — consumers using `getProvider` don't need to handle reconnects manually.
181
+ WebSocket provider factory. Wraps polkadot-api's `getWsProvider` and translates WebSocket events to `ConnectionStatus`. Active JSON-RPC subscriptions are automatically replayed after a reconnect — consumers using `getProvider` don't need to handle reconnects manually.
203
182
 
204
183
  **Signature:**
205
184
 
@@ -231,7 +210,7 @@ Caches chain metadata in memory, with optional persistence via a `StorageAdapter
231
210
  function createMetadataCache(options?: { storage?: StorageAdapter }): MetadataCache;
232
211
 
233
212
  type MetadataCache = {
234
- forChain(chainId: string): ClientOptions;
213
+ forChain(genesisHash: string): ClientOptions;
235
214
  };
236
215
  ```
237
216
 
@@ -268,22 +247,25 @@ type ResolvedApi = {
268
247
  };
269
248
 
270
249
  const chains = createChainConnection<ChainConfig, ResolvedApi>({
271
- createProvider: (chain, onStatusChanged) =>
272
- createWsJsonRpcProvider({
273
- endpoints: chain.nodes.map((n) => n.url),
250
+ createProvider(chain, onStatusChanged) {
251
+ return createWsJsonRpcProvider({
252
+ endpoints: chain.nodes,
274
253
  onStatusChanged,
275
- }),
254
+ });
255
+ },
276
256
 
277
- resolve: async (_chain, client) => ({
278
- api: client.getTypedApi(dot),
279
- client,
280
- }),
257
+ async resolve (_chain, client) {
258
+ return {
259
+ api: client.getTypedApi(dot),
260
+ client,
261
+ };
262
+ },
281
263
  });
282
264
 
283
- // Now requestApi and lockApi return ResolvedApi instead of PolkadotClient
284
- const account = await chains.requestApi(polkadot, async ({ api }) => {
285
- return api.query.System.Account.getValue('5GrwvaEF...');
286
- });
265
+ // Now lockApi returns ResolvedApi instead of PolkadotClient
266
+ const { api: resolved, unlock } = await chains.lockApi(polkadot);
267
+ const account = await resolved.api.query.System.Account.getValue('5GrwvaEF...');
268
+ unlock();
287
269
  ```
288
270
 
289
271
  ---
@@ -300,10 +282,10 @@ const metadataCache = createMetadataCache({
300
282
  const chains = createChainConnection({
301
283
  createProvider: (chain, onStatusChanged) =>
302
284
  createWsJsonRpcProvider({
303
- endpoints: chain.nodes.map((n) => n.url),
285
+ endpoints: chain.nodes,
304
286
  onStatusChanged,
305
287
  }),
306
- clientOptions: (chain) => metadataCache.forChain(chain.chainId),
288
+ clientOptions: (chain) => metadataCache.forChain(chain.genesisHash),
307
289
  });
308
290
  ```
309
291
 
@@ -334,7 +316,7 @@ const lightClientChainSpecs: Record<string, () => Promise<{ chainSpec: string }>
334
316
  let smoldot: SmoldotClient | null = null;
335
317
 
336
318
  const createLightClientProvider = (chain: MyChain): JsonRpcProvider => {
337
- const getChainSpec = lightClientChainSpecs[chain.chainId];
319
+ const getChainSpec = lightClientChainSpecs[chain.genesisHash];
338
320
  if (!getChainSpec) {
339
321
  throw new Error(`Light client for chain "${chain.name}" is not supported`);
340
322
  }
@@ -351,14 +333,14 @@ const createLightClientProvider = (chain: MyChain): JsonRpcProvider => {
351
333
 
352
334
  const chains = createChainConnection<MyChain>({
353
335
  createProvider: (chain, onStatusChanged) => {
354
- if (chain.lightClient && chain.chainId in lightClientChainSpecs) {
336
+ if (chain.lightClient && chain.genesisHash in lightClientChainSpecs) {
355
337
  // Light clients report connected immediately - Smoldot handles syncing internally.
356
338
  onStatusChanged('connected');
357
339
  return createLightClientProvider(chain);
358
340
  }
359
341
 
360
342
  return createWsJsonRpcProvider({
361
- endpoints: chain.nodes.map((n) => n.url),
343
+ endpoints: chain.nodes,
362
344
  onStatusChanged,
363
345
  });
364
346
  },
@@ -382,8 +364,8 @@ import { dot, ksm, type DotDescriptor, type KsmDescriptor } from '@polkadot-api/
382
364
  import { type ChainDefinition, type CompatibilityToken, type TypedApi, getTypedCodecs } from 'polkadot-api';
383
365
 
384
366
  const descriptorMap: Record<string, ChainDefinition> = {
385
- [polkadot.chainId]: dot,
386
- [kusama.chainId]: ksm,
367
+ [polkadot.genesisHash]: dot,
368
+ [kusama.genesisHash]: ksm,
387
369
  };
388
370
 
389
371
  type ResolvedApi = {
@@ -396,12 +378,12 @@ type ResolvedApi = {
396
378
  const chains = createChainConnection<MyChain, ResolvedApi>({
397
379
  createProvider: (chain, onStatusChanged) =>
398
380
  createWsJsonRpcProvider({
399
- endpoints: chain.nodes.map((n) => n.url),
381
+ endpoints: chain.nodes,
400
382
  onStatusChanged,
401
383
  }),
402
384
 
403
385
  resolve: async (chain, client) => {
404
- const descriptor = descriptorMap[chain.chainId];
386
+ const descriptor = descriptorMap[chain.genesisHash];
405
387
  const api = client.getTypedApi(descriptor);
406
388
 
407
389
  // Pre-resolve once - these require async metadata fetches
@@ -454,7 +436,7 @@ graph LR
454
436
  end
455
437
 
456
438
  subgraph Host App
457
- HA["requestApi() / lockApi()"]
439
+ HA["lockApi()"]
458
440
  Container
459
441
  end
460
442
 
@@ -473,7 +455,7 @@ graph LR
473
455
 
474
456
  Products embedded in iframes or webviews don't connect to RPC nodes directly. They send `remote_chain_*` requests to the **Container**, which obtains a provider from the **Connection Pool** via `getProvider(chain)`.
475
457
 
476
- The host app's own code uses the same pool through `requestApi` and `lockApi`. Everyone shares the same underlying connections - one per chain. The pool opens a connection on first use and closes it when the last consumer releases.
458
+ The host app's own code uses the same pool through `lockApi` and `getProvider`. Everyone shares the same underlying connections - one per chain. The pool opens a connection on first use and closes it when the last consumer releases.
477
459
 
478
460
  ## Full example
479
461
 
@@ -515,7 +497,7 @@ type Chain = ChainConfig & {
515
497
  // ---------------------------------------------------------------------------
516
498
  // 2. Descriptor resolution
517
499
  //
518
- // Maps specName → default descriptor, with chainId overrides for
500
+ // Maps specName → default descriptor, with genesisHash overrides for
519
501
  // parachains that share a specName with their relay chain.
520
502
  // ---------------------------------------------------------------------------
521
503
 
@@ -539,7 +521,7 @@ const specNameDefaults: Record<string, Descriptor> = {
539
521
  };
540
522
 
541
523
  const getDescriptor = (chain: Chain): Descriptor => {
542
- return parachainOverrides[chain.chainId]
524
+ return parachainOverrides[chain.genesisHash]
543
525
  ?? specNameDefaults[chain.specName]
544
526
  ?? { type: 'dot', def: dot };
545
527
  };
@@ -558,8 +540,8 @@ const lightClientChainSpecs: Record<string, () => Promise<{ chainSpec: string }>
558
540
 
559
541
  let smoldot: SmoldotClient | null = null;
560
542
 
561
- const createLightClientProvider = (chainId: string): JsonRpcProvider => {
562
- const getChainSpec = lightClientChainSpecs[chainId]!;
543
+ const createLightClientProvider = (genesisHash: string): JsonRpcProvider => {
544
+ const getChainSpec = lightClientChainSpecs[genesisHash]!;
563
545
 
564
546
  const smoldotChain = getChainSpec().then(({ chainSpec }) => {
565
547
  if (!smoldot) {
@@ -600,18 +582,18 @@ type TypedClient = {
600
582
 
601
583
  const chains = createChainConnection<Chain, TypedClient>({
602
584
  createProvider: (chain, onStatusChanged) => {
603
- if (chain.chainId in lightClientChainSpecs) {
585
+ if (chain.genesisHash in lightClientChainSpecs) {
604
586
  onStatusChanged('connected');
605
- return createLightClientProvider(chain.chainId);
587
+ return createLightClientProvider(chain.genesisHash);
606
588
  }
607
589
 
608
590
  return createWsJsonRpcProvider({
609
- endpoints: chain.nodes.map((n) => n.url),
591
+ endpoints: chain.nodes,
610
592
  onStatusChanged,
611
593
  });
612
594
  },
613
595
 
614
- clientOptions: (chain) => metadataCache.forChain(chain.chainId),
596
+ clientOptions: (chain) => metadataCache.forChain(chain.genesisHash),
615
597
 
616
598
  resolve: async (chain, client) => {
617
599
  const { type, def } = getDescriptor(chain);
@@ -631,16 +613,16 @@ const chains = createChainConnection<Chain, TypedClient>({
631
613
  // ---------------------------------------------------------------------------
632
614
 
633
615
  const polkadot: Chain = {
634
- chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
616
+ genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
635
617
  specName: 'polkadot',
636
618
  name: 'Polkadot',
637
- nodes: [{ url: 'wss://rpc.polkadot.io' }, { url: 'wss://polkadot-rpc.dwellir.com' }],
619
+ nodes: ['wss://rpc.polkadot.io', 'wss://polkadot-rpc.dwellir.com'],
638
620
  };
639
621
 
640
622
  // One-shot query
641
- const account = await chains.requestApi(polkadot, async ({ api }) => {
642
- return api.query.System.Account.getValue('5GrwvaEF...');
643
- });
623
+ const { api: queried, unlock: unlockQuery } = await chains.lockApi(polkadot);
624
+ const account = await queried.api.query.System.Account.getValue('5GrwvaEF...');
625
+ unlockQuery();
644
626
 
645
627
  // Long-lived subscription
646
628
  const { api: resolved, unlock } = await chains.lockApi(polkadot);
@@ -654,7 +636,7 @@ sub.unsubscribe();
654
636
  unlock();
655
637
 
656
638
  // Connection status
657
- const unsubscribe = chains.onStatusChanged(polkadot.chainId, (status) => {
639
+ const unsubscribe = chains.onStatusChanged(polkadot.genesisHash, (status) => {
658
640
  console.info(`${polkadot.name}:`, status);
659
641
  });
660
642
 
@@ -1,3 +1,7 @@
1
1
  import type { JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
2
2
  import type { BranchedProvider } from './types.js';
3
- export declare const createBranchedProvider: (provider: JsonRpcProvider) => BranchedProvider;
3
+ type Params = {
4
+ enhanceBranch?(branch: JsonRpcProvider): JsonRpcProvider;
5
+ };
6
+ export declare const createBranchedProvider: (provider: JsonRpcProvider, params?: Params) => BranchedProvider;
7
+ export {};
@@ -1,12 +1,14 @@
1
1
  import { createNanoEvents } from 'nanoevents';
2
+ import { id } from './helpers.js';
2
3
  import { createRefCounter } from './refCounter.js';
3
- export const createBranchedProvider = (provider) => {
4
+ export const createBranchedProvider = (provider, params) => {
5
+ const enhancer = params?.enhanceBranch ?? id;
4
6
  const messages = createNanoEvents();
5
7
  const refs = createRefCounter();
6
8
  let connection = null;
7
9
  return {
8
10
  branch(onDisconnect) {
9
- return onMessage => {
11
+ return enhancer(onMessage => {
10
12
  if (!connection) {
11
13
  connection = provider(message => messages.emit('incoming', message));
12
14
  }
@@ -25,7 +27,7 @@ export const createBranchedProvider = (provider) => {
25
27
  unsub();
26
28
  },
27
29
  };
28
- };
30
+ });
29
31
  },
30
32
  };
31
33
  };
@@ -99,4 +99,12 @@ describe('createBranchedProvider', () => {
99
99
  conn.send('{"method":"test"}');
100
100
  expect(mock.send).toHaveBeenCalledWith('{"method":"test"}');
101
101
  });
102
+ it('calls enhanceBranch for each new branch independently', () => {
103
+ const mock = createMockProvider();
104
+ const enhance = vi.fn((p) => p);
105
+ const branched = createBranchedProvider(mock.provider, { enhanceBranch: enhance });
106
+ branched.branch()(vi.fn());
107
+ branched.branch()(vi.fn());
108
+ expect(enhance).toHaveBeenCalledTimes(2);
109
+ });
102
110
  });
@@ -3,18 +3,18 @@ import type { PolkadotClient } from 'polkadot-api';
3
3
  import { createClient } from 'polkadot-api';
4
4
  import type { ChainConfig, ConnectionStatus } from './types.js';
5
5
  export type ChainConnectionConfig<C extends ChainConfig, T = PolkadotClient> = {
6
- createProvider: (chain: C, onStatusChanged: (status: ConnectionStatus) => void) => JsonRpcProvider;
7
- clientOptions?: (chain: C) => Parameters<typeof createClient>[1];
8
- resolve?: (chain: C, client: PolkadotClient) => Promise<T>;
6
+ createProvider(chain: C, onStatusChanged: (status: ConnectionStatus) => void): JsonRpcProvider;
7
+ clientOptions?(chain: C): Parameters<typeof createClient>[1];
8
+ resolve?(chain: C, client: PolkadotClient): Promise<T>;
9
+ destroyDelay?: number;
9
10
  };
10
11
  export type ChainConnection<C extends ChainConfig, T = PolkadotClient> = {
11
12
  lockApi(chain: C): Promise<{
12
13
  api: T;
13
14
  unlock: VoidFunction;
14
15
  }>;
15
- requestApi<Return>(chain: C, callback: (api: T) => Return): Promise<Awaited<Return>>;
16
16
  getProvider(chain: C): JsonRpcProvider;
17
- status(chainId: string): ConnectionStatus;
18
- onStatusChanged(chainId: string, callback: (status: ConnectionStatus) => void): VoidFunction;
17
+ status(genesisHash: string): ConnectionStatus;
18
+ onStatusChanged(genesisHash: string, callback: (status: ConnectionStatus) => void): VoidFunction;
19
19
  };
20
- export declare const createChainConnection: <C extends ChainConfig, T = PolkadotClient>(config: ChainConnectionConfig<C, T>) => ChainConnection<C, T>;
20
+ export declare const createChainConnection: <C extends ChainConfig, T = PolkadotClient>({ resolve, clientOptions, createProvider, destroyDelay, }: ChainConnectionConfig<C, T>) => ChainConnection<C, T>;
@@ -3,73 +3,93 @@ import { createClient } from 'polkadot-api';
3
3
  import { createBranchedProvider } from './branchedProvider.js';
4
4
  import { createConnectionManager } from './connectionManager.js';
5
5
  import { createRefCounter } from './refCounter.js';
6
- export const createChainConnection = (config) => {
6
+ export const createChainConnection = ({ resolve, clientOptions, createProvider, destroyDelay = 0, }) => {
7
7
  const connections = createConnectionManager();
8
8
  const refCounter = createRefCounter();
9
9
  const existingClients = new Map();
10
10
  // Resolve cache (when config.resolve is provided)
11
11
  const resolvedApis = new Map();
12
12
  const pendingResolutions = new Map();
13
+ const destructionTimers = new Map();
14
+ const cancelDestructionTimer = (genesisHash) => {
15
+ const timer = destructionTimers.get(genesisHash);
16
+ if (timer !== undefined) {
17
+ clearTimeout(timer);
18
+ destructionTimers.delete(genesisHash);
19
+ }
20
+ };
13
21
  const getOrCreateClient = (chain) => {
14
- const existing = existingClients.get(chain.chainId);
22
+ const existing = existingClients.get(chain.genesisHash);
15
23
  if (existing)
16
24
  return existing;
17
- const provider = config.createProvider(chain, status => connections.update(chain.chainId, status));
25
+ const provider = createProvider(chain, status => connections.update(chain.genesisHash, status));
18
26
  const branchedProvider = createBranchedProvider(provider);
19
- const client = createClient(branchedProvider.branch(), config.clientOptions?.(chain));
27
+ const client = createClient(branchedProvider.branch(), clientOptions?.(chain));
20
28
  const pooled = { client, provider: branchedProvider };
21
- existingClients.set(chain.chainId, pooled);
29
+ existingClients.set(chain.genesisHash, pooled);
22
30
  return pooled;
23
31
  };
24
- const destroyClient = (chainId) => {
25
- const pooled = existingClients.get(chainId);
32
+ const destroyClient = (genesisHash) => {
33
+ cancelDestructionTimer(genesisHash);
34
+ const pooled = existingClients.get(genesisHash);
26
35
  if (pooled) {
27
- existingClients.delete(chainId);
28
- connections.update(chainId, 'disconnected');
36
+ existingClients.delete(genesisHash);
37
+ connections.update(genesisHash, 'disconnected');
29
38
  pooled.client.destroy();
30
39
  }
31
- resolvedApis.delete(chainId);
32
- pendingResolutions.delete(chainId);
40
+ resolvedApis.delete(genesisHash);
41
+ pendingResolutions.delete(genesisHash);
33
42
  };
34
43
  const rawAcquire = async (chain) => {
35
44
  try {
36
- refCounter.increment(chain.chainId);
45
+ if (destroyDelay > 0)
46
+ cancelDestructionTimer(chain.genesisHash);
47
+ refCounter.increment(chain.genesisHash);
37
48
  const pooled = getOrCreateClient(chain);
38
- await pooled.client.getBestBlocks();
39
49
  return {
40
50
  pooled,
41
51
  unlock() {
42
- refCounter.decrement(chain.chainId);
52
+ if (refCounter.decrement(chain.genesisHash) === 0) {
53
+ if (destroyDelay === 0) {
54
+ destroyClient(chain.genesisHash);
55
+ }
56
+ else {
57
+ const timer = setTimeout(() => {
58
+ destroyClient(chain.genesisHash);
59
+ }, destroyDelay);
60
+ destructionTimers.set(chain.genesisHash, timer);
61
+ }
62
+ }
43
63
  },
44
64
  };
45
65
  }
46
66
  catch (error) {
47
- if (refCounter.decrement(chain.chainId) === 0) {
48
- destroyClient(chain.chainId);
67
+ if (refCounter.decrement(chain.genesisHash) === 0) {
68
+ destroyClient(chain.genesisHash);
49
69
  }
50
70
  throw error;
51
71
  }
52
72
  };
53
73
  const resolveApi = async (chain, polkadotClient) => {
54
- if (!config.resolve)
74
+ if (!resolve)
55
75
  return polkadotClient;
56
- const existing = resolvedApis.get(chain.chainId);
76
+ const existing = resolvedApis.get(chain.genesisHash);
57
77
  if (existing && existing.polkadotClient === polkadotClient)
58
78
  return existing.resolved;
59
- const pending = pendingResolutions.get(chain.chainId);
79
+ const pending = pendingResolutions.get(chain.genesisHash);
60
80
  if (pending && pending.polkadotClient === polkadotClient)
61
81
  return pending.promise;
62
82
  const promise = (async () => {
63
83
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by early return above
64
- const resolved = await config.resolve(chain, polkadotClient);
65
- resolvedApis.set(chain.chainId, { resolved, polkadotClient });
84
+ const resolved = await resolve(chain, polkadotClient);
85
+ resolvedApis.set(chain.genesisHash, { resolved, polkadotClient });
66
86
  return resolved;
67
87
  })();
68
- pendingResolutions.set(chain.chainId, { promise, polkadotClient });
69
- promise.finally(() => pendingResolutions.delete(chain.chainId));
88
+ pendingResolutions.set(chain.genesisHash, { promise, polkadotClient });
89
+ promise.finally(() => pendingResolutions.delete(chain.genesisHash));
70
90
  return promise;
71
91
  };
72
- const connection = {
92
+ return {
73
93
  async lockApi(chain) {
74
94
  const { pooled, unlock } = await rawAcquire(chain);
75
95
  try {
@@ -78,32 +98,22 @@ export const createChainConnection = (config) => {
78
98
  }
79
99
  catch (error) {
80
100
  unlock();
81
- resolvedApis.delete(chain.chainId);
82
- pendingResolutions.delete(chain.chainId);
101
+ resolvedApis.delete(chain.genesisHash);
102
+ pendingResolutions.delete(chain.genesisHash);
83
103
  throw error;
84
104
  }
85
105
  },
86
- requestApi: (async (chain, callback) => {
87
- const { api, unlock } = await connection.lockApi(chain);
88
- try {
89
- return await callback(api);
90
- }
91
- finally {
92
- unlock();
93
- }
94
- }),
95
106
  getProvider(chain) {
96
107
  return getSyncProvider(async () => {
97
108
  const { pooled, unlock } = await rawAcquire(chain);
98
109
  return pooled.provider.branch(unlock);
99
110
  });
100
111
  },
101
- status(chainId) {
102
- return connections.getConnectionStatus(chainId);
112
+ status(genesisHash) {
113
+ return connections.getConnectionStatus(genesisHash);
103
114
  },
104
- onStatusChanged(chainId, callback) {
105
- return connections.onStatusChange(chainId, callback);
115
+ onStatusChanged(genesisHash, callback) {
116
+ return connections.onStatusChange(genesisHash, callback);
106
117
  },
107
118
  };
108
- return connection;
109
119
  };
@@ -1,12 +1,9 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { createChainConnection } from './connectionPool.js';
3
3
  vi.mock('polkadot-api', () => ({
4
4
  createClient: vi.fn((_provider, _options) => createMockClient()),
5
5
  }));
6
- const createMockClient = () => ({
7
- getBestBlocks: vi.fn().mockResolvedValue([]),
8
- destroy: vi.fn(),
9
- });
6
+ const createMockClient = () => ({ destroy: vi.fn() });
10
7
  const createMockProvider = () => {
11
8
  const send = vi.fn();
12
9
  const disconnect = vi.fn();
@@ -17,7 +14,7 @@ const createMockProvider = () => {
17
14
  };
18
15
  return { provider, send, disconnect, simulateMessage: (msg) => onMessage?.(msg) };
19
16
  };
20
- const testChain = (id) => ({ chainId: id, nodes: [{ url: 'wss://test' }] });
17
+ const testChain = (id) => ({ genesisHash: id, nodes: ['wss://test'] });
21
18
  const createTestConnection = (overrides) => {
22
19
  const mockProvider = createMockProvider();
23
20
  const connection = createChainConnection({
@@ -52,12 +49,6 @@ describe('createChainConnection', () => {
52
49
  u1();
53
50
  u2();
54
51
  });
55
- it('calls getBestBlocks to verify connectivity', async () => {
56
- const { connection } = createTestConnection();
57
- const { api, unlock } = await connection.lockApi(testChain('a'));
58
- expect(api.getBestBlocks).toHaveBeenCalled();
59
- unlock();
60
- });
61
52
  });
62
53
  describe('lockApi — with resolve', () => {
63
54
  it('calls resolve with chain and polkadotClient', async () => {
@@ -90,24 +81,6 @@ describe('createChainConnection', () => {
90
81
  });
91
82
  });
92
83
  describe('lockApi — error handling', () => {
93
- it('throws when getBestBlocks rejects', async () => {
94
- vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
95
- getBestBlocks: vi.fn().mockRejectedValue(new Error('connection failed')),
96
- destroy: vi.fn(),
97
- });
98
- const { connection } = createTestConnection();
99
- await expect(connection.lockApi(testChain('a'))).rejects.toThrow('connection failed');
100
- });
101
- it('destroys client on error when ref count reaches 0', async () => {
102
- const destroyFn = vi.fn();
103
- vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
104
- getBestBlocks: vi.fn().mockRejectedValue(new Error('fail')),
105
- destroy: destroyFn,
106
- });
107
- const { connection } = createTestConnection();
108
- await expect(connection.lockApi(testChain('a'))).rejects.toThrow();
109
- expect(destroyFn).toHaveBeenCalled();
110
- });
111
84
  it('throws when resolve rejects and calls unlock', async () => {
112
85
  const resolve = vi.fn().mockRejectedValue(new Error('resolve failed'));
113
86
  const { connection } = createTestConnection({ resolve });
@@ -127,32 +100,6 @@ describe('createChainConnection', () => {
127
100
  unlock();
128
101
  });
129
102
  });
130
- describe('requestApi', () => {
131
- it('calls callback with api and returns result', async () => {
132
- const { connection } = createTestConnection();
133
- const result = await connection.requestApi(testChain('a'), api => {
134
- expect(api).toBeDefined();
135
- return 42;
136
- });
137
- expect(result).toBe(42);
138
- });
139
- it('unlocks after callback completes', async () => {
140
- const { connection } = createTestConnection();
141
- await connection.requestApi(testChain('a'), () => 'done');
142
- // Subsequent request should work without issue
143
- const result = await connection.requestApi(testChain('a'), () => 'again');
144
- expect(result).toBe('again');
145
- });
146
- it('unlocks when callback throws', async () => {
147
- const { connection } = createTestConnection();
148
- await expect(connection.requestApi(testChain('a'), () => {
149
- throw new Error('callback error');
150
- })).rejects.toThrow('callback error');
151
- // Should still work after error
152
- const result = await connection.requestApi(testChain('a'), () => 'ok');
153
- expect(result).toBe('ok');
154
- });
155
- });
156
103
  describe('status / onStatusChanged', () => {
157
104
  it('returns disconnected for unknown chain', () => {
158
105
  const { connection } = createTestConnection();
@@ -189,4 +136,70 @@ describe('createChainConnection', () => {
189
136
  expect(callback).not.toHaveBeenCalled();
190
137
  });
191
138
  });
139
+ describe('lockApi — connection lifecycle', () => {
140
+ it('destroys client synchronously when last lock is released (no destroyDelay)', async () => {
141
+ const destroyFn = vi.fn();
142
+ vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
143
+ getBestBlocks: vi.fn().mockResolvedValue([]),
144
+ destroy: destroyFn,
145
+ });
146
+ const { connection } = createTestConnection();
147
+ const { unlock } = await connection.lockApi(testChain('a'));
148
+ unlock();
149
+ expect(destroyFn).toHaveBeenCalledOnce();
150
+ });
151
+ it('does not destroy while any lock is still held', async () => {
152
+ const destroyFn = vi.fn();
153
+ vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
154
+ getBestBlocks: vi.fn().mockResolvedValue([]),
155
+ destroy: destroyFn,
156
+ });
157
+ const { connection } = createTestConnection();
158
+ const chain = testChain('a');
159
+ const { unlock: u1 } = await connection.lockApi(chain);
160
+ const { unlock: u2 } = await connection.lockApi(chain);
161
+ u1();
162
+ expect(destroyFn).not.toHaveBeenCalled();
163
+ u2();
164
+ expect(destroyFn).toHaveBeenCalledOnce();
165
+ });
166
+ describe('with destroyDelay', () => {
167
+ beforeEach(() => {
168
+ vi.useFakeTimers();
169
+ });
170
+ afterEach(() => {
171
+ vi.useRealTimers();
172
+ });
173
+ it('defers destruction by destroyDelay ms', async () => {
174
+ const destroyFn = vi.fn();
175
+ vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
176
+ getBestBlocks: vi.fn().mockResolvedValue([]),
177
+ destroy: destroyFn,
178
+ });
179
+ const { connection } = createTestConnection({ destroyDelay: 1000 });
180
+ const { unlock } = await connection.lockApi(testChain('a'));
181
+ unlock();
182
+ expect(destroyFn).not.toHaveBeenCalled();
183
+ vi.advanceTimersByTime(1000);
184
+ expect(destroyFn).toHaveBeenCalledOnce();
185
+ });
186
+ it('cancels destruction timer when connection is re-acquired before delay elapses', async () => {
187
+ const destroyFn = vi.fn();
188
+ vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
189
+ getBestBlocks: vi.fn().mockResolvedValue([]),
190
+ destroy: destroyFn,
191
+ });
192
+ const { connection } = createTestConnection({ destroyDelay: 1000 });
193
+ const chain = testChain('a');
194
+ const { api: api1, unlock: u1 } = await connection.lockApi(chain);
195
+ u1();
196
+ // Re-acquire before timer fires — same client must be returned, no destruction
197
+ const { api: api2, unlock: u2 } = await connection.lockApi(chain);
198
+ vi.advanceTimersByTime(1000);
199
+ expect(destroyFn).not.toHaveBeenCalled();
200
+ expect(api1).toBe(api2);
201
+ u2();
202
+ });
203
+ });
204
+ });
192
205
  });
@@ -0,0 +1,2 @@
1
+ export declare function id<T>(x: T): T;
2
+ export declare function noop(): void;
@@ -0,0 +1,6 @@
1
+ export function id(x) {
2
+ return x;
3
+ }
4
+ export function noop() {
5
+ // empty
6
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { type ChainConnection, type ChainConnectionConfig, createChainConnection } from './connectionPool.js';
2
2
  export { type MetadataCache, createMetadataCache } from './metadataCache.js';
3
3
  export { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
- export { createWsJsonRpcProvider } from './providers.js';
4
+ export { createWsJsonRpcProvider } from './wsProvider.js';
5
5
  export { type ChainConfig, type ConnectionStatus } from './types.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createChainConnection } from './connectionPool.js';
2
2
  export { createMetadataCache } from './metadataCache.js';
3
3
  export { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
- export { createWsJsonRpcProvider } from './providers.js';
4
+ export { createWsJsonRpcProvider } from './wsProvider.js';
5
5
  export {} from './types.js';
package/dist/types.d.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
2
2
  import type { PolkadotClient } from 'polkadot-api';
3
3
  export type ChainConfig = {
4
- chainId: string;
5
- nodes: ReadonlyArray<{
6
- url: string;
7
- }>;
4
+ genesisHash: string;
5
+ nodes: string[];
8
6
  };
9
7
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
10
8
  export type BranchedProvider = {
@@ -0,0 +1,6 @@
1
+ import type { JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
2
+ import type { ConnectionStatus } from './types.js';
3
+ export declare const createWsJsonRpcProvider: (options: {
4
+ endpoints: string[];
5
+ onStatusChanged?: (status: ConnectionStatus) => void;
6
+ }) => JsonRpcProvider;
@@ -0,0 +1,35 @@
1
+ import { WsEvent, getWsProvider } from '@polkadot-api/ws-provider';
2
+ import { noop } from './helpers.js';
3
+ import { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
+ export const createWsJsonRpcProvider = (options) => {
5
+ let notifyReconnect = noop;
6
+ const onReconnect = (cb) => {
7
+ notifyReconnect = cb;
8
+ return () => {
9
+ notifyReconnect = noop;
10
+ };
11
+ };
12
+ return withSubscriptionReplay(getWsProvider(options.endpoints, {
13
+ heartbeatTimeout: Number.POSITIVE_INFINITY,
14
+ onStatusChanged: event => {
15
+ let status;
16
+ switch (event.type) {
17
+ case WsEvent.CONNECTING:
18
+ status = 'connecting';
19
+ break;
20
+ case WsEvent.CONNECTED:
21
+ notifyReconnect();
22
+ status = 'connected';
23
+ break;
24
+ case WsEvent.ERROR:
25
+ case WsEvent.CLOSE:
26
+ status = 'disconnected';
27
+ break;
28
+ default:
29
+ status = 'disconnected';
30
+ break;
31
+ }
32
+ options.onStatusChanged?.(status);
33
+ },
34
+ }), onReconnect);
35
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-substrate-chain-connection",
3
3
  "type": "module",
4
- "version": "0.6.11",
4
+ "version": "0.6.13",
5
5
  "description": "Chain connection pool with ref counting and provider branching for Polkadot API",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -25,9 +25,10 @@
25
25
  "README.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@novasamatech/storage-adapter": "0.6.11",
28
+ "@novasamatech/storage-adapter": "0.6.13",
29
29
  "@polkadot-api/json-rpc-provider": "^0.0.4",
30
30
  "@polkadot-api/json-rpc-provider-proxy": "^0.2.8",
31
+ "@polkadot-api/ws-provider": "^0.7.5",
31
32
  "nanoevents": "^9.1.0",
32
33
  "polkadot-api": "^1.23.3"
33
34
  },