@novasamatech/host-substrate-chain-connection 0.7.5 → 0.7.7

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
@@ -24,12 +24,16 @@ import {
24
24
  } from '@novasamatech/host-substrate-chain-connection';
25
25
  import { dot } from '@polkadot-api/descriptors';
26
26
 
27
- const polkadot: ChainConfig = {
27
+ // `ChainConfig` only requires `genesisHash` — extend it with whatever fields
28
+ // your app needs (here, the WebSocket endpoints to dial).
29
+ type Chain = ChainConfig & { nodes: string[] };
30
+
31
+ const polkadot: Chain = {
28
32
  genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
29
33
  nodes: ['wss://rpc.polkadot.io'],
30
34
  };
31
35
 
32
- const chains = createChainConnection({
36
+ const chains = createChainConnection<Chain>({
33
37
  createProvider: (chain, onStatusChanged) =>
34
38
  createWsJsonRpcProvider({
35
39
  endpoints: chain.nodes,
@@ -51,8 +55,10 @@ unlock();
51
55
  - [`lockApi`](#lockapichain)
52
56
  - [`getProvider`](#getproviderchain)
53
57
  - [`status` / `onStatusChanged`](#statusgenesishash--onstatuschangedgenesishash-callback)
58
+ - [`pauseAll` / `resumeAll`](#pauseall--resumeall)
54
59
  - [`createWsJsonRpcProvider`](#createwsjsonrpcprovideroptions)
55
60
  - [`createMetadataCache`](#createmetadatacacheoptions)
61
+ - [`withSubscriptionReplay`](#withsubscriptionreplayprovider-onreconnect)
56
62
  - [Recipes](#recipes)
57
63
  - [Custom resolve](#custom-resolve)
58
64
  - [Metadata caching](#metadata-caching)
@@ -88,10 +94,11 @@ function createChainConnection<C extends ChainConfig, T = PolkadotClient>(
88
94
  ```ts
89
95
  type ChainConfig = {
90
96
  genesisHash: string;
91
- nodes: string[];
92
97
  };
93
98
  ```
94
99
 
100
+ Extend it with whatever fields your `createProvider` / `resolve` callbacks need (e.g. `nodes`, `name`, `specName`).
101
+
95
102
  Returns a [`ChainConnection<C, T>`](#lockapichain) with the methods below.
96
103
 
97
104
  ---
@@ -176,19 +183,66 @@ unsubscribe();
176
183
 
177
184
  ---
178
185
 
186
+ ### `pauseAll` / `resumeAll`
187
+
188
+ Drops the inner socket of every active provider that supports pausing (e.g. providers built via `createWsJsonRpcProvider`). Pooled clients and ref counts are preserved; tracked subscriptions are re-sent on `resumeAll()` via the replay wrapper.
189
+
190
+ Use this when the host process goes to background (mobile / OS suspend) and you want to release sockets without tearing down callers.
191
+
192
+ **Signature:**
193
+
194
+ ```ts
195
+ function pauseAll(): void
196
+ function resumeAll(): void
197
+ ```
198
+
199
+ **Example:**
200
+
201
+ ```ts
202
+ document.addEventListener('visibilitychange', () => {
203
+ if (document.visibilityState === 'hidden') {
204
+ chains.pauseAll();
205
+ } else {
206
+ chains.resumeAll();
207
+ }
208
+ });
209
+ ```
210
+
211
+ ---
212
+
179
213
  ### `createWsJsonRpcProvider(options)`
180
214
 
181
215
  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.
182
216
 
217
+ The returned provider is **pausable**: `pause()` drops the inner socket and `resume()` reopens it. The connection pool calls these via `pauseAll()` / `resumeAll()`.
218
+
183
219
  **Signature:**
184
220
 
185
221
  ```ts
186
222
  function createWsJsonRpcProvider(options: {
187
223
  endpoints: string[];
188
224
  onStatusChanged?: (status: ConnectionStatus) => void;
189
- }): JsonRpcProvider;
225
+ websocketClass?: typeof WebSocket;
226
+ heartbeatTimeout?: number;
227
+ connectionTimeout?: number;
228
+ logger?: SocketLoggerFn;
229
+ }): PausableJsonRpcProvider;
230
+
231
+ type PausableJsonRpcProvider = JsonRpcProvider & {
232
+ pause(): void;
233
+ resume(): void;
234
+ };
190
235
  ```
191
236
 
237
+ | Field | Description |
238
+ |---|---|
239
+ | `endpoints` | One or more WebSocket URLs. Failover is handled by the underlying `getWsProvider`. |
240
+ | `onStatusChanged` | Optional. Called with `'connecting' \| 'connected' \| 'disconnected'` on socket events. |
241
+ | `websocketClass` | Optional. Override the WebSocket implementation (e.g. `ws` in Node). |
242
+ | `heartbeatTimeout` | Optional. Milliseconds without a server message before the socket is considered dead. Defaults to the underlying library's 40 s. |
243
+ | `connectionTimeout` | Optional. Milliseconds to wait for the initial connection before giving up. |
244
+ | `logger` | Optional. A `SocketLoggerFn` from `@polkadot-api/ws-provider` for tracing socket events. |
245
+
192
246
  **Example:**
193
247
 
194
248
  ```ts
@@ -226,11 +280,30 @@ import { createLocalStorageAdapter } from '@novasamatech/storage-adapter';
226
280
  const cache = createMetadataCache();
227
281
 
228
282
  // With localStorage persistence (survives page reloads)
229
- const cache = createMetadataCache({
283
+ const persistedCache = createMetadataCache({
230
284
  storage: createLocalStorageAdapter('chain-metadata'),
231
285
  });
232
286
  ```
233
287
 
288
+ ---
289
+
290
+ ### `withSubscriptionReplay(provider, onReconnect)`
291
+
292
+ Wraps any `JsonRpcProvider` so that active JSON-RPC subscriptions are automatically re-sent after the underlying transport reconnects. `createWsJsonRpcProvider` applies this internally — use it directly only when building a custom provider that needs the same behavior.
293
+
294
+ **Signature:**
295
+
296
+ ```ts
297
+ function withSubscriptionReplay(
298
+ provider: JsonRpcProvider,
299
+ onReconnect: (callback: VoidFunction) => VoidFunction,
300
+ ): JsonRpcProvider;
301
+ ```
302
+
303
+ `onReconnect` is a subscription primitive: call the supplied `callback` whenever your transport finishes reconnecting, and return a teardown function that removes the listener.
304
+
305
+ > **Note:** After a reconnect the server assigns new subscription IDs. Always unsubscribe with the most recently received ID — stale IDs from a previous connection silently fail.
306
+
234
307
  ## Recipes
235
308
 
236
309
  ### Custom resolve
@@ -241,12 +314,14 @@ The `resolve` callback transforms the raw `PolkadotClient` into whatever your ap
241
314
  import { type PolkadotClient, type TypedApi } from 'polkadot-api';
242
315
  import { dot, type DotDescriptor } from '@polkadot-api/descriptors';
243
316
 
317
+ type Chain = ChainConfig & { nodes: string[] };
318
+
244
319
  type ResolvedApi = {
245
320
  api: TypedApi<DotDescriptor>;
246
321
  client: PolkadotClient;
247
322
  };
248
323
 
249
- const chains = createChainConnection<ChainConfig, ResolvedApi>({
324
+ const chains = createChainConnection<Chain, ResolvedApi>({
250
325
  createProvider(chain, onStatusChanged) {
251
326
  return createWsJsonRpcProvider({
252
327
  endpoints: chain.nodes,
@@ -275,11 +350,13 @@ unlock();
275
350
  ```ts
276
351
  import { createLocalStorageAdapter } from '@novasamatech/storage-adapter';
277
352
 
353
+ type Chain = ChainConfig & { nodes: string[] };
354
+
278
355
  const metadataCache = createMetadataCache({
279
356
  storage: createLocalStorageAdapter('chain-metadata'),
280
357
  });
281
358
 
282
- const chains = createChainConnection({
359
+ const chains = createChainConnection<Chain>({
283
360
  createProvider: (chain, onStatusChanged) =>
284
361
  createWsJsonRpcProvider({
285
362
  endpoints: chain.nodes,
@@ -302,6 +379,7 @@ import { type Client as SmoldotClient, start as startSmoldot } from 'polkadot-ap
302
379
 
303
380
  type MyChain = ChainConfig & {
304
381
  name: string;
382
+ nodes: string[];
305
383
  lightClient?: boolean;
306
384
  };
307
385
 
@@ -361,11 +439,16 @@ Common additions beyond `api` and `client`:
361
439
 
362
440
  ```ts
363
441
  import { dot, ksm, type DotDescriptor, type KsmDescriptor } from '@polkadot-api/descriptors';
364
- import { type ChainDefinition, type CompatibilityToken, type TypedApi, getTypedCodecs } from 'polkadot-api';
442
+ import { type ChainDefinition, type CompatibilityToken, type PolkadotClient, type TypedApi, getTypedCodecs } from 'polkadot-api';
443
+
444
+ type Chain = ChainConfig & { nodes: string[] };
445
+
446
+ const POLKADOT_GENESIS = '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3';
447
+ const KUSAMA_GENESIS = '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe';
365
448
 
366
449
  const descriptorMap: Record<string, ChainDefinition> = {
367
- [polkadot.genesisHash]: dot,
368
- [kusama.genesisHash]: ksm,
450
+ [POLKADOT_GENESIS]: dot,
451
+ [KUSAMA_GENESIS]: ksm,
369
452
  };
370
453
 
371
454
  type ResolvedApi = {
@@ -375,7 +458,7 @@ type ResolvedApi = {
375
458
  compatibilityToken: CompatibilityToken;
376
459
  };
377
460
 
378
- const chains = createChainConnection<MyChain, ResolvedApi>({
461
+ const chains = createChainConnection<Chain, ResolvedApi>({
379
462
  createProvider: (chain, onStatusChanged) =>
380
463
  createWsJsonRpcProvider({
381
464
  endpoints: chain.nodes,
@@ -491,6 +574,7 @@ import { dot, dot_ah, dot_ppl, ksm, ksm_ah, wnd, wnd_ah } from '@polkadot-api/de
491
574
 
492
575
  type Chain = ChainConfig & {
493
576
  name: string;
577
+ nodes: string[];
494
578
  specName: string;
495
579
  };
496
580
 
@@ -106,15 +106,31 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
106
106
  },
107
107
  getProvider(chain) {
108
108
  return getSyncProvider(onResult => {
109
+ let teardownCalled = false;
110
+ let pendingUnlock = null;
111
+ // Idempotent: subsequent calls (e.g. teardown after disconnect) are no-ops.
112
+ const releaseUnlock = () => {
113
+ const unlock = pendingUnlock;
114
+ pendingUnlock = null;
115
+ unlock?.();
116
+ };
109
117
  rawAcquire(chain)
110
118
  .then(({ pooled, unlock }) => {
111
- onResult((onMessage, _onHalt) => pooled.provider.branch(unlock)(onMessage));
119
+ if (teardownCalled) {
120
+ unlock();
121
+ onResult(null);
122
+ return;
123
+ }
124
+ pendingUnlock = unlock;
125
+ onResult((onMessage, _onHalt) => pooled.provider.branch(releaseUnlock)(onMessage));
112
126
  })
113
127
  .catch(() => {
114
128
  onResult(null);
115
129
  });
130
+ // Covers teardown without disconnect, and teardown before acquire resolves.
116
131
  return () => {
117
- /* empty */
132
+ teardownCalled = true;
133
+ releaseUnlock();
118
134
  };
119
135
  });
120
136
  },
@@ -39,7 +39,10 @@ export const createMetadataCache = (options) => {
39
39
  setMetadata(key, metadata) {
40
40
  const k = cacheKey(chainId, key);
41
41
  memory.set(k, metadata);
42
- storage?.write(k, bytesToBase64(metadata));
42
+ // setMetadata is fire-and-forget by contract; log persist failures.
43
+ storage?.write(k, bytesToBase64(metadata)).orTee(error => {
44
+ console.error(`[metadataCache] failed to persist metadata for ${k}:`, error);
45
+ });
43
46
  },
44
47
  };
45
48
  },
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { createMetadataCache } from './metadataCache.js';
4
4
  const createMockStorage = () => ({
5
5
  read: vi.fn(),
6
- write: vi.fn(),
7
- clear: vi.fn(),
6
+ write: vi.fn(() => okAsync(undefined)),
7
+ clear: vi.fn(() => okAsync(undefined)),
8
8
  // eslint-disable-next-line @typescript-eslint/no-empty-function
9
9
  subscribe: vi.fn(() => () => { }),
10
10
  });
@@ -69,6 +69,15 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
69
69
  onMessage(message);
70
70
  });
71
71
  const unsubReconnect = onReconnect(() => {
72
+ // Replay unconfirmed subscribes first: their `reconnectFor === null`, so
73
+ // they're disjoint from the entries inserted by the active-replay loop
74
+ // below (which all carry a non-null `reconnectFor`).
75
+ for (const [, pending] of pendingSubscriptions) {
76
+ if (pending.reconnectFor === null)
77
+ conn.send(pending.payload);
78
+ }
79
+ // Replay confirmed subscriptions: the server returns a fresh subId, which
80
+ // we map back to the consumer's stable subId.
72
81
  for (const [consumerSubId, sub] of activeSubscriptions) {
73
82
  pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
74
83
  conn.send(sub.payload);
@@ -61,15 +61,16 @@ describe('withSubscriptionReplay', () => {
61
61
  control.triggerReconnect();
62
62
  expect(mock.send).not.toHaveBeenCalled();
63
63
  });
64
- it('does not replay pending subscriptions (no server response yet) on reconnect', () => {
64
+ it('replays pending subscriptions (no server response yet) on reconnect', () => {
65
65
  const mock = createMockProvider();
66
66
  const control = createReconnectControl();
67
67
  const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
68
- conn.send(req(1, 'statement_subscribeStatement'));
68
+ const subscribeMsg = req(1, 'statement_subscribeStatement');
69
+ conn.send(subscribeMsg);
69
70
  // intentionally no simulateMessage — subscription not confirmed by server
70
71
  mock.send.mockClear();
71
72
  control.triggerReconnect();
72
- expect(mock.send).not.toHaveBeenCalled();
73
+ expect(mock.send).toHaveBeenCalledWith(subscribeMsg);
73
74
  });
74
75
  it('replays active subscriptions on reconnect', () => {
75
76
  const mock = createMockProvider();
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.7.5",
4
+ "version": "0.7.7",
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,7 +25,7 @@
25
25
  "README.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@novasamatech/storage-adapter": "0.7.5",
28
+ "@novasamatech/storage-adapter": "0.7.7",
29
29
  "@polkadot-api/ws-provider": "^0.9.0",
30
30
  "@polkadot-api/json-rpc-provider": "^0.2.0",
31
31
  "@polkadot-api/json-rpc-provider-proxy": "^0.4.0",