@novasamatech/host-substrate-chain-connection 0.10.1 → 0.10.2

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.
@@ -25,5 +25,21 @@ export type ChainConnection<C extends ChainConfig, T = PolkadotClient> = {
25
25
  */
26
26
  pauseAll(): void;
27
27
  resumeAll(): void;
28
+ /**
29
+ * Rebuild the transport of the chains named by `genesisHashes`, or of every
30
+ * chain currently held when it is omitted. `createProvider` runs again, so a
31
+ * host that picks its transport from settings (a light client versus RPC
32
+ * nodes, say) applies the new choice here.
33
+ *
34
+ * Pooled clients, resolved apis and refcounts all survive: consumers keep the
35
+ * references they already hold and see the same interruption a dropped socket
36
+ * would have caused. A chain nobody holds is skipped — one the pool never
37
+ * connected to, and one still waiting out `destroyDelay` — and picks the new
38
+ * transport up when it is next acquired.
39
+ *
40
+ * The old transports close at once, the new ones come up on the reconnect
41
+ * backoff; see `restart` in `createRestartableProvider`.
42
+ */
43
+ reconnect(genesisHashes?: readonly string[]): void;
28
44
  };
29
45
  export declare const createChainConnection: <C extends ChainConfig, T = PolkadotClient>({ resolve, clientOptions, createProvider, createClient: makeClient, destroyDelay, }: ChainConnectionConfig<C, T>) => ChainConnection<C, T>;
@@ -3,7 +3,7 @@ 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
- import { isPausable } from './wsProvider.js';
6
+ import { createRestartableProvider } from './restartableProvider.js';
7
7
  export const createChainConnection = ({ resolve, clientOptions, createProvider, createClient: makeClient = createClient, destroyDelay = 0, }) => {
8
8
  const connections = createConnectionManager();
9
9
  const refCounter = createRefCounter();
@@ -23,7 +23,28 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
23
23
  const existing = existingClients.get(chain.genesisHash);
24
24
  if (existing)
25
25
  return existing;
26
- const rootProvider = createProvider(chain, status => connections.update(chain.genesisHash, status));
26
+ // A provider replaced by `reconnect` can still report a trailing
27
+ // `disconnected` while its successor is already connecting, so status is
28
+ // scoped to the generation that produced it and stale updates are dropped.
29
+ let generation = 0;
30
+ const rootProvider = createRestartableProvider(() => {
31
+ const current = ++generation;
32
+ const update = (status) => {
33
+ if (current === generation)
34
+ connections.update(chain.genesisHash, status);
35
+ };
36
+ try {
37
+ return createProvider(chain, update);
38
+ }
39
+ catch (error) {
40
+ // The bump above already muted the previous transport's updates, so a
41
+ // factory that throws would otherwise leave the chain reporting the
42
+ // status of a transport that no longer exists. `createRestartableProvider`
43
+ // retries, and the next attempt reports for itself.
44
+ update('disconnected');
45
+ throw error;
46
+ }
47
+ });
27
48
  const branchedProvider = createBranchedProvider(rootProvider);
28
49
  const client = makeClient(branchedProvider.branch(), clientOptions?.(chain));
29
50
  const pooled = { client, provider: branchedProvider, rootProvider };
@@ -142,14 +163,24 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
142
163
  },
143
164
  pauseAll() {
144
165
  for (const { rootProvider } of existingClients.values()) {
145
- if (isPausable(rootProvider))
146
- rootProvider.pause();
166
+ rootProvider.pause();
147
167
  }
148
168
  },
149
169
  resumeAll() {
150
170
  for (const { rootProvider } of existingClients.values()) {
151
- if (isPausable(rootProvider))
152
- rootProvider.resume();
171
+ rootProvider.resume();
172
+ }
173
+ },
174
+ reconnect(genesisHashes) {
175
+ for (const [genesisHash, { rootProvider }] of existingClients) {
176
+ if (genesisHashes && !genesisHashes.includes(genesisHash))
177
+ continue;
178
+ // Nobody holds this one any more — it is only waiting out `destroyDelay`.
179
+ // Rebuilding its transport would open a socket the destruction timer is
180
+ // about to throw away.
181
+ if (destructionTimers.has(genesisHash))
182
+ continue;
183
+ rootProvider.restart();
153
184
  }
154
185
  },
155
186
  };
@@ -1,16 +1,28 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { createChainConnection } from './connectionPool.js';
3
+ import { noop } from './helpers.js';
3
4
  // An in-memory stand-in for polkadot-api's `createClient`: one client object per
4
5
  // call, each counting its own `destroy()`.
5
6
  const createClientFactory = () => {
6
7
  const clients = [];
7
- const factory = ((_provider, _options) => {
8
+ const factory = ((provider, _options) => {
8
9
  const client = { destroyed: 0 };
9
10
  clients.push(client);
10
- return { destroy: () => client.destroyed++ };
11
+ // The real one connects up front and drops the connection on destroy; the
12
+ // pool only builds a transport once something actually connects.
13
+ const connection = provider(noop);
14
+ return {
15
+ destroy: () => {
16
+ client.destroyed++;
17
+ connection.disconnect();
18
+ },
19
+ };
11
20
  });
12
21
  return { createClient: factory, last: () => clients[clients.length - 1] };
13
22
  };
23
+ // The transport is built on the proxy's first connect attempt, which it
24
+ // schedules rather than runs inline.
25
+ const connected = () => new Promise(resolve => setTimeout(resolve, 0));
14
26
  const createMockProvider = () => {
15
27
  const send = vi.fn();
16
28
  const disconnect = vi.fn();
@@ -125,9 +137,13 @@ describe('createChainConnection', () => {
125
137
  return createMockProvider().provider;
126
138
  },
127
139
  });
128
- await connection.lockApi(testChain('a')).then(({ unlock }) => unlock());
140
+ // The lock is held across the wait: releasing it destroys the client, and
141
+ // a destroyed pool never gets round to building a transport at all.
142
+ const { unlock } = await connection.lockApi(testChain('a'));
143
+ await connected();
129
144
  statusCb('connected');
130
145
  expect(connection.status('a')).toBe('connected');
146
+ unlock();
131
147
  });
132
148
  it('onStatusChanged returns unsubscribe function', async () => {
133
149
  let statusCb;
@@ -137,7 +153,8 @@ describe('createChainConnection', () => {
137
153
  return createMockProvider().provider;
138
154
  },
139
155
  });
140
- await connection.lockApi(testChain('a')).then(({ unlock }) => unlock());
156
+ const { unlock } = await connection.lockApi(testChain('a'));
157
+ await connected();
141
158
  const callback = vi.fn();
142
159
  const unsub = connection.onStatusChanged('a', callback);
143
160
  statusCb('connected');
@@ -146,6 +163,7 @@ describe('createChainConnection', () => {
146
163
  callback.mockClear();
147
164
  statusCb('disconnected');
148
165
  expect(callback).not.toHaveBeenCalled();
166
+ unlock();
149
167
  });
150
168
  });
151
169
  describe('lockApi — connection lifecycle', () => {
@@ -214,6 +232,7 @@ describe('createChainConnection', () => {
214
232
  });
215
233
  const { unlock: u1 } = await connection.lockApi(testChain('a'));
216
234
  const { unlock: u2 } = await connection.lockApi(testChain('b'));
235
+ await connected();
217
236
  connection.pauseAll();
218
237
  expect(chainA.pause).toHaveBeenCalledTimes(1);
219
238
  expect(chainB.pause).toHaveBeenCalledTimes(1);
@@ -227,6 +246,7 @@ describe('createChainConnection', () => {
227
246
  createClient: createClientFactory().createClient,
228
247
  });
229
248
  const { unlock } = await connection.lockApi(testChain('a'));
249
+ await connected();
230
250
  connection.pauseAll();
231
251
  connection.resumeAll();
232
252
  expect(chainA.resume).toHaveBeenCalledTimes(1);
@@ -242,6 +262,7 @@ describe('createChainConnection', () => {
242
262
  });
243
263
  await connection.lockApi(testChain('a'));
244
264
  await connection.lockApi(testChain('b'));
265
+ await connected();
245
266
  connection.pauseAll();
246
267
  connection.resumeAll();
247
268
  expect(pausable.pause).toHaveBeenCalledTimes(1);
@@ -260,4 +281,101 @@ describe('createChainConnection', () => {
260
281
  expect(chainA.pause).not.toHaveBeenCalled();
261
282
  });
262
283
  });
284
+ describe('reconnect', () => {
285
+ // A restart rides the proxy's halt path, which backs off when halts land on
286
+ // top of each other. Running fake time past that window keeps these cases
287
+ // about the swap rather than about the backoff.
288
+ beforeEach(() => vi.useFakeTimers());
289
+ afterEach(() => vi.useRealTimers());
290
+ const settle = () => vi.advanceTimersByTimeAsync(1_000);
291
+ it('asks createProvider for a new transport and closes the old one', async () => {
292
+ const first = createMockProvider();
293
+ const second = createMockProvider();
294
+ const transports = [first, second];
295
+ const createProvider = vi.fn(() => transports.shift().provider);
296
+ const { connection } = createTestConnection({ createProvider });
297
+ const { unlock } = await connection.lockApi(testChain('a'));
298
+ await settle();
299
+ connection.reconnect();
300
+ await settle();
301
+ expect(createProvider).toHaveBeenCalledTimes(2);
302
+ expect(first.disconnect).toHaveBeenCalledTimes(1);
303
+ unlock();
304
+ });
305
+ it('keeps the pooled client — the point is not restarting the app', async () => {
306
+ const { connection, clients } = createTestConnection();
307
+ const { api, unlock } = await connection.lockApi(testChain('a'));
308
+ await settle();
309
+ connection.reconnect();
310
+ await settle();
311
+ const { api: after, unlock: unlockAfter } = await connection.lockApi(testChain('a'));
312
+ expect(clients.last().destroyed).toBe(0);
313
+ expect(after).toBe(api);
314
+ unlock();
315
+ unlockAfter();
316
+ });
317
+ it('rebuilds only the chains it was given', async () => {
318
+ const built = [];
319
+ const connection = createChainConnection({
320
+ createProvider: chain => {
321
+ built.push(chain.genesisHash);
322
+ return createMockProvider().provider;
323
+ },
324
+ createClient: createClientFactory().createClient,
325
+ });
326
+ const { unlock: unlockA } = await connection.lockApi(testChain('a'));
327
+ const { unlock: unlockB } = await connection.lockApi(testChain('b'));
328
+ await settle();
329
+ expect(built).toEqual(['a', 'b']);
330
+ connection.reconnect(['a']);
331
+ await settle();
332
+ expect(built).toEqual(['a', 'b', 'a']);
333
+ unlockA();
334
+ unlockB();
335
+ });
336
+ it('ignores a chain the pool is not holding', async () => {
337
+ const createProvider = vi.fn(() => createMockProvider().provider);
338
+ const connection = createChainConnection({
339
+ createProvider,
340
+ createClient: createClientFactory().createClient,
341
+ });
342
+ expect(() => connection.reconnect(['nothing-here'])).not.toThrow();
343
+ expect(createProvider).not.toHaveBeenCalled();
344
+ });
345
+ it('skips a chain that is only waiting out destroyDelay', async () => {
346
+ const createProvider = vi.fn(() => createMockProvider().provider);
347
+ const connection = createChainConnection({
348
+ createProvider,
349
+ createClient: createClientFactory().createClient,
350
+ destroyDelay: 10_000,
351
+ });
352
+ const { unlock } = await connection.lockApi(testChain('a'));
353
+ await settle();
354
+ unlock();
355
+ connection.reconnect();
356
+ await settle();
357
+ // Nobody holds it; the destruction timer is about to discard the client,
358
+ // so a fresh transport would be opened only to be thrown away.
359
+ expect(createProvider).toHaveBeenCalledTimes(1);
360
+ });
361
+ it('drops a status the replaced transport reports after the swap', async () => {
362
+ const statusCallbacks = [];
363
+ const { connection } = createTestConnection({
364
+ createProvider: (_chain, onStatusChanged) => {
365
+ statusCallbacks.push(onStatusChanged);
366
+ return createMockProvider().provider;
367
+ },
368
+ });
369
+ const { unlock } = await connection.lockApi(testChain('a'));
370
+ await settle();
371
+ connection.reconnect();
372
+ await settle();
373
+ statusCallbacks[1]('connected');
374
+ // The transport that was replaced has no say any more — otherwise its
375
+ // parting 'disconnected' would land on top of its successor's 'connected'.
376
+ statusCallbacks[0]('disconnected');
377
+ expect(connection.status('a')).toBe('connected');
378
+ unlock();
379
+ });
380
+ });
263
381
  });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { type ChainConnection, type ChainConnectionConfig, createChainConnection } from './connectionPool.js';
2
2
  export { type MetadataCache, createMetadataCache } from './metadataCache.js';
3
+ export { type RestartableJsonRpcProvider, createRestartableProvider } from './restartableProvider.js';
3
4
  export { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
5
  export { createWsJsonRpcProvider } from './wsProvider.js';
5
6
  export { type ChainConfig, type ConnectionStatus } from './types.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createChainConnection } from './connectionPool.js';
2
2
  export { createMetadataCache } from './metadataCache.js';
3
+ export { createRestartableProvider } from './restartableProvider.js';
3
4
  export { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
5
  export { createWsJsonRpcProvider } from './wsProvider.js';
5
6
  export {} from './types.js';
@@ -0,0 +1,33 @@
1
+ import type { JsonRpcProvider } from 'polkadot-api';
2
+ export type RestartableJsonRpcProvider = JsonRpcProvider & {
3
+ /**
4
+ * Throw the live transport away and build a new one from the factory, without
5
+ * tearing down the consumers hanging off this provider. Callers use it to move
6
+ * a chain onto a different transport — a light client to an RPC node, say —
7
+ * while clients, subscriptions and refcounts stay where they are.
8
+ *
9
+ * The old transport closes synchronously; the new one does not. The swap goes
10
+ * through the proxy's reconnect path, so the factory is called on its backoff
11
+ * — 500 ms after this returns, and a step further out for each restart that
12
+ * lands before the chain has delivered a message on the transport before it
13
+ * (`getSyncProvider` counts those as a flapping connection). Next to opening a
14
+ * socket or booting a light client the first step is noise, but a caller
15
+ * driving this from a UI toggle should not treat it as instantaneous.
16
+ */
17
+ restart(): void;
18
+ /** Forwarded to the current transport when it supports it; otherwise a no-op. */
19
+ pause(): void;
20
+ resume(): void;
21
+ };
22
+ /**
23
+ * A provider whose underlying transport can be replaced at any time.
24
+ *
25
+ * The swap is dressed up as a connection drop, which is the one event every
26
+ * layer above already knows how to survive: `getSyncProvider`'s proxy reports
27
+ * the active `chainHead` follows as stopped and re-sends the requests that were
28
+ * in flight, so polkadot-api re-follows on the new transport by itself, and the
29
+ * subscription-replay wrapper re-establishes the legacy subscriptions that
30
+ * `chainHead` bookkeeping does not cover. Consumers see the same interruption a
31
+ * dropped socket would have caused, and nothing above has to be rebuilt.
32
+ */
33
+ export declare const createRestartableProvider: (createTransport: () => JsonRpcProvider) => RestartableJsonRpcProvider;
@@ -0,0 +1,107 @@
1
+ import { getSyncProvider } from '@polkadot-api/json-rpc-provider-proxy';
2
+ import { noop } from './helpers.js';
3
+ import { withSubscriptionReplay } from './subscriptionReplayProvider.js';
4
+ import { isPausable } from './wsProvider.js';
5
+ /**
6
+ * A provider whose underlying transport can be replaced at any time.
7
+ *
8
+ * The swap is dressed up as a connection drop, which is the one event every
9
+ * layer above already knows how to survive: `getSyncProvider`'s proxy reports
10
+ * the active `chainHead` follows as stopped and re-sends the requests that were
11
+ * in flight, so polkadot-api re-follows on the new transport by itself, and the
12
+ * subscription-replay wrapper re-establishes the legacy subscriptions that
13
+ * `chainHead` bookkeeping does not cover. Consumers see the same interruption a
14
+ * dropped socket would have caused, and nothing above has to be rebuilt.
15
+ */
16
+ export const createRestartableProvider = (createTransport) => {
17
+ let active = null;
18
+ let notifyReconnect = noop;
19
+ // Survives a restart: a transport built while the host has the pool paused
20
+ // must come up paused too, or backgrounding the app would stop holding.
21
+ let paused = false;
22
+ const onReconnect = (callback) => {
23
+ notifyReconnect = callback;
24
+ return () => {
25
+ notifyReconnect = noop;
26
+ };
27
+ };
28
+ const core = getSyncProvider(onResult => {
29
+ let built = false;
30
+ onResult((onMessage, halt) => {
31
+ let provider;
32
+ let connection;
33
+ try {
34
+ provider = createTransport();
35
+ // Paused before the transport is handed its message callback: a
36
+ // transport that opens its connection right there (a light client, say)
37
+ // would otherwise be up for as long as it takes to pause it again,
38
+ // which is the socket-behind-a-backgrounded-app this guards against.
39
+ if (paused && isPausable(provider))
40
+ provider.pause();
41
+ connection = provider(onMessage);
42
+ }
43
+ catch (error) {
44
+ // The proxy is mid-connect and stores whatever we return here, so a
45
+ // factory that throws has to be reported through `halt`: it puts the
46
+ // proxy back to connecting — buffering sends rather than pushing them
47
+ // at a transport that was never built — and schedules the next attempt.
48
+ // Without it the chain would keep a live-looking client wired to
49
+ // nothing, for good.
50
+ halt(error);
51
+ return { send: noop, disconnect: noop };
52
+ }
53
+ // The transport's own halt channel is deliberately not wired up: a
54
+ // transport either recovers on its own (the ws provider proxies its
55
+ // reconnects internally) or reports `disconnected` and stays down. `halt`
56
+ // is kept for `restart`, which is the only reason this layer replaces one.
57
+ const transport = { provider, connection, halt };
58
+ active = transport;
59
+ built = true;
60
+ return {
61
+ send: message => connection.send(message),
62
+ disconnect: () => {
63
+ if (active === transport)
64
+ active = null;
65
+ connection.disconnect();
66
+ },
67
+ };
68
+ });
69
+ // `onResult` runs the proxy's connect synchronously, so by here the proxy is
70
+ // connected and the replayed subscribes go out on the new transport rather
71
+ // than into a buffer that a failed connect would discard. A failed attempt
72
+ // skips the replay entirely — the halt above has already queued another one,
73
+ // which will do it against a transport that exists.
74
+ if (built)
75
+ notifyReconnect();
76
+ // Nothing to cancel: `onResult` above already resolved this attempt.
77
+ return noop;
78
+ });
79
+ const provider = withSubscriptionReplay(core, onReconnect);
80
+ return Object.assign(provider, {
81
+ restart() {
82
+ const transport = active;
83
+ // Nothing to replace while the proxy is between transports — whatever it
84
+ // asks the factory for next is already built from the current settings.
85
+ if (!transport)
86
+ return;
87
+ active = null;
88
+ // Close before halting: the halt schedules the next `createTransport`, and
89
+ // a transport left open would keep its socket (or light client) running
90
+ // alongside its replacement.
91
+ transport.connection.disconnect();
92
+ transport.halt();
93
+ },
94
+ pause() {
95
+ paused = true;
96
+ const transport = active?.provider;
97
+ if (transport && isPausable(transport))
98
+ transport.pause();
99
+ },
100
+ resume() {
101
+ paused = false;
102
+ const transport = active?.provider;
103
+ if (transport && isPausable(transport))
104
+ transport.resume();
105
+ },
106
+ });
107
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,192 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createRestartableProvider } from './restartableProvider.js';
3
+ const createFakeTransport = ({ pausable = false } = {}) => {
4
+ const send = vi.fn();
5
+ const disconnect = vi.fn();
6
+ let onMessage = null;
7
+ const provider = cb => {
8
+ onMessage = cb;
9
+ return { send, disconnect };
10
+ };
11
+ const transport = { provider, send, disconnect, emit: message => onMessage?.(message) };
12
+ if (pausable) {
13
+ transport.pause = vi.fn();
14
+ transport.resume = vi.fn();
15
+ Object.assign(provider, { pause: transport.pause, resume: transport.resume });
16
+ }
17
+ return transport;
18
+ };
19
+ // `getSyncProvider` schedules every connect attempt on a timer, and treats
20
+ // halts that land on top of each other as a flapping connection worth backing
21
+ // off from. Letting fake time run past that window keeps these cases about the
22
+ // swap rather than about the backoff.
23
+ const settle = () => vi.advanceTimersByTimeAsync(1_000);
24
+ const connect = async (provider) => {
25
+ const messages = [];
26
+ const connection = provider(message => messages.push(message));
27
+ await settle();
28
+ return { connection, messages };
29
+ };
30
+ describe('createRestartableProvider', () => {
31
+ beforeEach(() => vi.useFakeTimers());
32
+ afterEach(() => vi.useRealTimers());
33
+ it('builds a transport lazily and routes traffic through it', async () => {
34
+ const transport = createFakeTransport();
35
+ const provider = createRestartableProvider(() => transport.provider);
36
+ const { connection, messages } = await connect(provider);
37
+ connection.send({ jsonrpc: '2.0', id: 1, method: 'system_chain', params: [] });
38
+ transport.emit({ jsonrpc: '2.0', id: 1, result: 'polkadot' });
39
+ expect(transport.send).toHaveBeenCalledOnce();
40
+ expect(messages).toContainEqual({ jsonrpc: '2.0', id: 1, result: 'polkadot' });
41
+ });
42
+ it('replaces the transport on restart, closing the old one exactly once', async () => {
43
+ const first = createFakeTransport();
44
+ const second = createFakeTransport();
45
+ const transports = [first, second];
46
+ const provider = createRestartableProvider(() => transports.shift().provider);
47
+ await connect(provider);
48
+ provider.restart();
49
+ await settle();
50
+ expect(first.disconnect).toHaveBeenCalledOnce();
51
+ expect(second.disconnect).not.toHaveBeenCalled();
52
+ });
53
+ it('keeps the consumer connection alive across a restart', async () => {
54
+ const first = createFakeTransport();
55
+ const second = createFakeTransport();
56
+ const transports = [first, second];
57
+ const provider = createRestartableProvider(() => transports.shift().provider);
58
+ const { connection, messages } = await connect(provider);
59
+ provider.restart();
60
+ await settle();
61
+ connection.send({ jsonrpc: '2.0', id: 2, method: 'system_chain', params: [] });
62
+ second.emit({ jsonrpc: '2.0', id: 2, result: 'kusama' });
63
+ expect(second.send).toHaveBeenCalledOnce();
64
+ expect(messages).toContainEqual({ jsonrpc: '2.0', id: 2, result: 'kusama' });
65
+ });
66
+ it('reports the active chainHead follows as stopped so the client re-follows', async () => {
67
+ const first = createFakeTransport();
68
+ const second = createFakeTransport();
69
+ const transports = [first, second];
70
+ const provider = createRestartableProvider(() => transports.shift().provider);
71
+ const { connection, messages } = await connect(provider);
72
+ connection.send({ jsonrpc: '2.0', id: 3, method: 'chainHead_v1_follow', params: [true] });
73
+ first.emit({ jsonrpc: '2.0', id: 3, result: 'follow-1' });
74
+ provider.restart();
75
+ await settle();
76
+ expect(messages).toContainEqual(expect.objectContaining({
77
+ method: 'chainHead_v1_follow',
78
+ params: { subscription: 'follow-1', result: { event: 'stop', internal: true } },
79
+ }));
80
+ });
81
+ it('re-establishes a legacy subscription on the new transport', async () => {
82
+ const first = createFakeTransport();
83
+ const second = createFakeTransport();
84
+ const transports = [first, second];
85
+ const provider = createRestartableProvider(() => transports.shift().provider);
86
+ const { connection, messages } = await connect(provider);
87
+ const subscribe = { jsonrpc: '2.0', id: 4, method: 'state_subscribeStorage', params: [['0x00']] };
88
+ connection.send(subscribe);
89
+ first.emit({ jsonrpc: '2.0', id: 4, result: 'sub-1' });
90
+ provider.restart();
91
+ await settle();
92
+ expect(second.send).toHaveBeenCalledWith(expect.objectContaining({ method: 'state_subscribeStorage' }));
93
+ // The consumer only ever learned `sub-1`, so notifications from the new
94
+ // transport's id have to arrive back under the one it is routing on.
95
+ second.emit({ jsonrpc: '2.0', id: 4, result: 'sub-2' });
96
+ second.emit({
97
+ jsonrpc: '2.0',
98
+ method: 'state_storage',
99
+ params: { subscription: 'sub-2', result: '0x01' },
100
+ });
101
+ expect(messages).toContainEqual(expect.objectContaining({ params: { subscription: 'sub-1', result: '0x01' } }));
102
+ // The re-confirmation is the consumer's own subscribe id — surfacing it
103
+ // would look like a second, unasked-for response.
104
+ expect(messages.filter(message => 'result' in message && message.result === 'sub-2')).toHaveLength(0);
105
+ });
106
+ it('retries instead of wedging when the transport factory throws', async () => {
107
+ const transport = createFakeTransport();
108
+ const created = vi.fn(() => {
109
+ if (created.mock.calls.length === 1)
110
+ throw new Error('no WebSocket class');
111
+ return transport.provider;
112
+ });
113
+ const provider = createRestartableProvider(created);
114
+ const { connection, messages } = await connect(provider);
115
+ connection.send({ jsonrpc: '2.0', id: 5, method: 'system_chain', params: [] });
116
+ transport.emit({ jsonrpc: '2.0', id: 5, result: 'polkadot' });
117
+ expect(created).toHaveBeenCalledTimes(2);
118
+ expect(transport.send).toHaveBeenCalledOnce();
119
+ expect(messages).toContainEqual({ jsonrpc: '2.0', id: 5, result: 'polkadot' });
120
+ });
121
+ it('does not replay subscriptions into a transport the factory failed to build', async () => {
122
+ const first = createFakeTransport();
123
+ const second = createFakeTransport();
124
+ const transports = [first, null, second];
125
+ const provider = createRestartableProvider(() => {
126
+ const next = transports.shift();
127
+ if (!next)
128
+ throw new Error('transport unavailable');
129
+ return next.provider;
130
+ });
131
+ const { connection } = await connect(provider);
132
+ const subscribe = { jsonrpc: '2.0', id: 6, method: 'state_subscribeStorage', params: [['0x00']] };
133
+ connection.send(subscribe);
134
+ first.emit({ jsonrpc: '2.0', id: 6, result: 'sub-1' });
135
+ provider.restart();
136
+ // Two windows: the failed attempt in between pushes the next one out by a
137
+ // further backoff step.
138
+ await settle();
139
+ await settle();
140
+ expect(second.send).toHaveBeenCalledTimes(1);
141
+ expect(second.send).toHaveBeenCalledWith(expect.objectContaining({ method: 'state_subscribeStorage' }));
142
+ });
143
+ it('is a no-op before anything has connected', async () => {
144
+ const transport = createFakeTransport();
145
+ const created = vi.fn(() => transport.provider);
146
+ const provider = createRestartableProvider(created);
147
+ provider.restart();
148
+ await connect(provider);
149
+ expect(created).toHaveBeenCalledOnce();
150
+ });
151
+ it('forwards pause and resume to a pausable transport', async () => {
152
+ const transport = createFakeTransport({ pausable: true });
153
+ const provider = createRestartableProvider(() => transport.provider);
154
+ await connect(provider);
155
+ provider.pause();
156
+ provider.resume();
157
+ expect(transport.pause).toHaveBeenCalledOnce();
158
+ expect(transport.resume).toHaveBeenCalledOnce();
159
+ });
160
+ it('pauses a transport built while paused before handing it the message callback', async () => {
161
+ const pause = vi.fn();
162
+ const pausedAtInvocation = [];
163
+ const transport = Object.assign(() => {
164
+ pausedAtInvocation.push(pause.mock.calls.length > 0);
165
+ return { send: vi.fn(), disconnect: vi.fn() };
166
+ }, { pause, resume: vi.fn() });
167
+ const provider = createRestartableProvider(() => transport);
168
+ provider.pause();
169
+ await connect(provider);
170
+ expect(pausedAtInvocation).toEqual([true]);
171
+ });
172
+ it('brings a transport built while paused up paused', async () => {
173
+ const first = createFakeTransport({ pausable: true });
174
+ const second = createFakeTransport({ pausable: true });
175
+ const transports = [first, second];
176
+ const provider = createRestartableProvider(() => transports.shift().provider);
177
+ await connect(provider);
178
+ provider.pause();
179
+ provider.restart();
180
+ await settle();
181
+ expect(second.pause).toHaveBeenCalledOnce();
182
+ });
183
+ it('tolerates pause and resume on a transport that cannot pause', async () => {
184
+ const transport = createFakeTransport();
185
+ const provider = createRestartableProvider(() => transport.provider);
186
+ await connect(provider);
187
+ expect(() => {
188
+ provider.pause();
189
+ provider.resume();
190
+ }).not.toThrow();
191
+ });
192
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createRestartableProvider } from './restartableProvider.js';
3
+ import { createWsJsonRpcProvider } from './wsProvider.js';
4
+ class FakeWebSocket {
5
+ static instances = [];
6
+ sent = [];
7
+ url;
8
+ listeners = new Map();
9
+ constructor(url) {
10
+ this.url = url;
11
+ FakeWebSocket.instances.push(this);
12
+ }
13
+ addEventListener(type, listener) {
14
+ const forType = this.listeners.get(type) ?? new Set();
15
+ forType.add(listener);
16
+ this.listeners.set(type, forType);
17
+ }
18
+ removeEventListener(type, listener) {
19
+ this.listeners.get(type)?.delete(listener);
20
+ }
21
+ send(data) {
22
+ this.sent.push(data);
23
+ }
24
+ close() {
25
+ this.emit('close', { type: 'close' });
26
+ }
27
+ open() {
28
+ this.emit('open', { type: 'open' });
29
+ }
30
+ deliver(message) {
31
+ this.emit('message', { data: JSON.stringify(message) });
32
+ }
33
+ requests(method) {
34
+ return this.sent.map(raw => JSON.parse(raw)).filter(message => message.method === method);
35
+ }
36
+ emit(type, event) {
37
+ for (const listener of [...(this.listeners.get(type) ?? [])])
38
+ listener(event);
39
+ }
40
+ }
41
+ const subscribe = { jsonrpc: '2.0', id: 1, method: 'state_subscribeStorage', params: [['0x00']] };
42
+ // The proxy schedules each connect attempt on a timer, and a restart lands on
43
+ // its reconnect backoff — one window covers both.
44
+ const settle = () => vi.advanceTimersByTimeAsync(1_000);
45
+ describe('createRestartableProvider × createWsJsonRpcProvider', () => {
46
+ beforeEach(() => {
47
+ vi.useFakeTimers();
48
+ FakeWebSocket.instances.length = 0;
49
+ });
50
+ afterEach(() => vi.useRealTimers());
51
+ it('re-establishes a legacy subscription exactly once on the new socket', async () => {
52
+ const provider = createRestartableProvider(() => createWsJsonRpcProvider({
53
+ endpoints: ['wss://example.test'],
54
+ websocketClass: FakeWebSocket,
55
+ }));
56
+ const messages = [];
57
+ const connection = provider(message => messages.push(message));
58
+ await settle();
59
+ const first = FakeWebSocket.instances[0];
60
+ first.open();
61
+ connection.send(subscribe);
62
+ first.deliver({ jsonrpc: '2.0', id: 1, result: 'sub-1' });
63
+ provider.restart();
64
+ await settle();
65
+ const second = FakeWebSocket.instances[1];
66
+ second.open();
67
+ // Twice would leave the second subId subscribed server-side with nothing
68
+ // holding it: the ws proxy drops the duplicate response, so neither replay
69
+ // layer ever learns that id and neither will unsubscribe it.
70
+ expect(second.requests('state_subscribeStorage')).toHaveLength(1);
71
+ // And the consumer keeps routing on the subId it was given.
72
+ second.deliver({ jsonrpc: '2.0', id: 1, result: 'sub-2' });
73
+ second.deliver({
74
+ jsonrpc: '2.0',
75
+ method: 'state_storage',
76
+ params: { subscription: 'sub-2', result: '0x01' },
77
+ });
78
+ expect(messages).toContainEqual(expect.objectContaining({ params: { subscription: 'sub-1', result: '0x01' } }));
79
+ });
80
+ });
@@ -69,16 +69,17 @@ 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
+ // Only confirmed subscriptions are replayed. An unconfirmed subscribe is
73
+ // still owned by the transport underneath every provider this wraps is
74
+ // built on `getSyncProvider`, whose proxy re-sends its in-flight requests
75
+ // across a reconnect and buffers them until the next one is up — so
76
+ // re-sending it here would put the same subscribe on the wire twice and
77
+ // leave the second, unmatched subId subscribed server-side forever.
78
+ // Its pending entry is kept: the response still has to be recognised.
81
79
  for (const [consumerSubId, sub] of activeSubscriptions) {
80
+ // The server returns a fresh subId, which we map back to the consumer's
81
+ // stable subId. `reconnectFor` keeps these entries apart from the
82
+ // unconfirmed ones already in the map, which all carry null.
82
83
  pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
83
84
  conn.send(sub.payload);
84
85
  }
@@ -61,7 +61,7 @@ describe('withSubscriptionReplay', () => {
61
61
  control.triggerReconnect();
62
62
  expect(mock.send).not.toHaveBeenCalled();
63
63
  });
64
- it('replays pending subscriptions (no server response yet) on reconnect', () => {
64
+ it('leaves unconfirmed subscribes to the transport underneath on reconnect', () => {
65
65
  const mock = createMockProvider();
66
66
  const control = createReconnectControl();
67
67
  const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
@@ -70,6 +70,24 @@ describe('withSubscriptionReplay', () => {
70
70
  // intentionally no simulateMessage — subscription not confirmed by server
71
71
  mock.send.mockClear();
72
72
  control.triggerReconnect();
73
+ // `getSyncProvider`'s proxy re-sends it on its own; a second copy from here
74
+ // would leave an unmatched subscription live on the server.
75
+ expect(mock.send).not.toHaveBeenCalled();
76
+ });
77
+ it('still recognises the response to an unconfirmed subscribe the transport re-sent', () => {
78
+ const mock = createMockProvider();
79
+ const control = createReconnectControl();
80
+ const onMessage = vi.fn();
81
+ const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(onMessage);
82
+ const subscribeMsg = req(1, 'statement_subscribeStatement');
83
+ conn.send(subscribeMsg);
84
+ control.triggerReconnect();
85
+ // The transport's own replay puts it back on the wire; the answer arrives
86
+ // against the id we are still holding a pending entry for.
87
+ mock.simulateMessage(res(1, 'sub-1'));
88
+ mock.send.mockClear();
89
+ control.triggerReconnect();
90
+ expect(onMessage).toHaveBeenCalledWith(res(1, 'sub-1'));
73
91
  expect(mock.send).toHaveBeenCalledWith(subscribeMsg);
74
92
  });
75
93
  it('replays active subscriptions on reconnect', () => {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { JsonRpcProvider, PolkadotClient } from 'polkadot-api';
2
+ import type { RestartableJsonRpcProvider } from './restartableProvider.js';
2
3
  export type ChainConfig = {
3
4
  genesisHash: string;
4
5
  };
@@ -9,5 +10,5 @@ export type BranchedProvider = {
9
10
  export type PooledClient = {
10
11
  client: PolkadotClient;
11
12
  provider: BranchedProvider;
12
- rootProvider: JsonRpcProvider;
13
+ rootProvider: RestartableJsonRpcProvider;
13
14
  };
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.10.1",
4
+ "version": "0.10.2",
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.10.1",
28
+ "@novasamatech/storage-adapter": "0.10.2",
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",