@novasamatech/host-substrate-chain-connection 0.7.0 → 0.7.1-0
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/dist/connectionPool.d.ts +8 -0
- package/dist/connectionPool.js +16 -3
- package/dist/connectionPool.spec.js +55 -0
- package/dist/nodeSubscriptionReplayMiddleware.d.ts +2 -0
- package/dist/nodeSubscriptionReplayMiddleware.js +105 -0
- package/dist/nodeSubscriptionReplayMiddleware.spec.d.ts +1 -0
- package/dist/nodeSubscriptionReplayMiddleware.spec.js +255 -0
- package/dist/pauseController.d.ts +8 -0
- package/dist/pauseController.js +67 -0
- package/dist/pauseController.spec.d.ts +1 -0
- package/dist/pauseController.spec.js +177 -0
- package/dist/subscriptionReplayProvider.js +10 -8
- package/dist/subscriptionReplayProvider.spec.js +13 -46
- package/dist/types.d.ts +2 -1
- package/dist/wsProvider.d.ts +10 -1
- package/dist/wsProvider.js +27 -20
- package/package.json +4 -2
package/dist/connectionPool.d.ts
CHANGED
|
@@ -15,5 +15,13 @@ export type ChainConnection<C extends ChainConfig, T = PolkadotClient> = {
|
|
|
15
15
|
getProvider(chain: C): JsonRpcProvider;
|
|
16
16
|
status(genesisHash: string): ConnectionStatus;
|
|
17
17
|
onStatusChanged(genesisHash: string, callback: (status: ConnectionStatus) => void): VoidFunction;
|
|
18
|
+
/**
|
|
19
|
+
* Drop the inner socket of every active provider that supports pausing
|
|
20
|
+
* (e.g. providers built via `createWsJsonRpcProvider`). Clients and
|
|
21
|
+
* refcounts are preserved; tracked subscriptions are re-sent on
|
|
22
|
+
* {@link resumeAll} via the replay wrapper.
|
|
23
|
+
*/
|
|
24
|
+
pauseAll(): void;
|
|
25
|
+
resumeAll(): void;
|
|
18
26
|
};
|
|
19
27
|
export declare const createChainConnection: <C extends ChainConfig, T = PolkadotClient>({ resolve, clientOptions, createProvider, destroyDelay, }: ChainConnectionConfig<C, T>) => ChainConnection<C, T>;
|
package/dist/connectionPool.js
CHANGED
|
@@ -3,6 +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
7
|
export const createChainConnection = ({ resolve, clientOptions, createProvider, destroyDelay = 0, }) => {
|
|
7
8
|
const connections = createConnectionManager();
|
|
8
9
|
const refCounter = createRefCounter();
|
|
@@ -22,10 +23,10 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
|
|
|
22
23
|
const existing = existingClients.get(chain.genesisHash);
|
|
23
24
|
if (existing)
|
|
24
25
|
return existing;
|
|
25
|
-
const
|
|
26
|
-
const branchedProvider = createBranchedProvider(
|
|
26
|
+
const rootProvider = createProvider(chain, status => connections.update(chain.genesisHash, status));
|
|
27
|
+
const branchedProvider = createBranchedProvider(rootProvider);
|
|
27
28
|
const client = createClient(branchedProvider.branch(), clientOptions?.(chain));
|
|
28
|
-
const pooled = { client, provider: branchedProvider };
|
|
29
|
+
const pooled = { client, provider: branchedProvider, rootProvider };
|
|
29
30
|
existingClients.set(chain.genesisHash, pooled);
|
|
30
31
|
return pooled;
|
|
31
32
|
};
|
|
@@ -123,5 +124,17 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
|
|
|
123
124
|
onStatusChanged(genesisHash, callback) {
|
|
124
125
|
return connections.onStatusChange(genesisHash, callback);
|
|
125
126
|
},
|
|
127
|
+
pauseAll() {
|
|
128
|
+
for (const { rootProvider } of existingClients.values()) {
|
|
129
|
+
if (isPausable(rootProvider))
|
|
130
|
+
rootProvider.pause();
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
resumeAll() {
|
|
134
|
+
for (const { rootProvider } of existingClients.values()) {
|
|
135
|
+
if (isPausable(rootProvider))
|
|
136
|
+
rootProvider.resume();
|
|
137
|
+
}
|
|
138
|
+
},
|
|
126
139
|
};
|
|
127
140
|
};
|
|
@@ -205,4 +205,59 @@ describe('createChainConnection', () => {
|
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
|
+
describe('pauseAll / resumeAll', () => {
|
|
209
|
+
const createPausableMockProvider = () => {
|
|
210
|
+
const pause = vi.fn();
|
|
211
|
+
const resume = vi.fn();
|
|
212
|
+
const provider = Object.assign(() => ({ send: vi.fn(), disconnect: vi.fn() }), {
|
|
213
|
+
pause,
|
|
214
|
+
resume,
|
|
215
|
+
});
|
|
216
|
+
return { provider, pause, resume };
|
|
217
|
+
};
|
|
218
|
+
it('calls pause on every pausable provider created so far', async () => {
|
|
219
|
+
const chainA = createPausableMockProvider();
|
|
220
|
+
const chainB = createPausableMockProvider();
|
|
221
|
+
const providerByChain = { a: chainA.provider, b: chainB.provider };
|
|
222
|
+
const connection = createChainConnection({
|
|
223
|
+
createProvider: chain => providerByChain[chain.genesisHash],
|
|
224
|
+
});
|
|
225
|
+
const { unlock: u1 } = await connection.lockApi(testChain('a'));
|
|
226
|
+
const { unlock: u2 } = await connection.lockApi(testChain('b'));
|
|
227
|
+
connection.pauseAll();
|
|
228
|
+
expect(chainA.pause).toHaveBeenCalledTimes(1);
|
|
229
|
+
expect(chainB.pause).toHaveBeenCalledTimes(1);
|
|
230
|
+
u1();
|
|
231
|
+
u2();
|
|
232
|
+
});
|
|
233
|
+
it('calls resume on every pausable provider', async () => {
|
|
234
|
+
const chainA = createPausableMockProvider();
|
|
235
|
+
const connection = createChainConnection({ createProvider: () => chainA.provider });
|
|
236
|
+
const { unlock } = await connection.lockApi(testChain('a'));
|
|
237
|
+
connection.pauseAll();
|
|
238
|
+
connection.resumeAll();
|
|
239
|
+
expect(chainA.resume).toHaveBeenCalledTimes(1);
|
|
240
|
+
unlock();
|
|
241
|
+
});
|
|
242
|
+
it('skips providers that do not expose pause/resume', async () => {
|
|
243
|
+
const { connection } = createTestConnection();
|
|
244
|
+
const { unlock } = await connection.lockApi(testChain('a'));
|
|
245
|
+
expect(() => {
|
|
246
|
+
connection.pauseAll();
|
|
247
|
+
connection.resumeAll();
|
|
248
|
+
}).not.toThrow();
|
|
249
|
+
unlock();
|
|
250
|
+
});
|
|
251
|
+
it('does not call pause on providers for chains that have been destroyed', async () => {
|
|
252
|
+
const chainA = createPausableMockProvider();
|
|
253
|
+
const connection = createChainConnection({
|
|
254
|
+
createProvider: () => chainA.provider,
|
|
255
|
+
destroyDelay: 0,
|
|
256
|
+
});
|
|
257
|
+
const { unlock } = await connection.lockApi(testChain('a'));
|
|
258
|
+
unlock();
|
|
259
|
+
connection.pauseAll();
|
|
260
|
+
expect(chainA.pause).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
208
263
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { isResponse } from '@polkadot-api/json-rpc-provider';
|
|
2
|
+
const isChainMethod = (method) => method.startsWith('chain_');
|
|
3
|
+
const isSubscribeMethod = (method) => {
|
|
4
|
+
if (isChainMethod(method))
|
|
5
|
+
return false;
|
|
6
|
+
const m = method.toLowerCase();
|
|
7
|
+
return m.includes('subscribe') && !m.includes('unsubscribe');
|
|
8
|
+
};
|
|
9
|
+
const isUnsubscribeMethod = (method) => !isChainMethod(method) && method.toLowerCase().includes('unsubscribe');
|
|
10
|
+
// Middleware that replays non-chain subscriptions across reconnects (chain_*
|
|
11
|
+
// subscriptions are handled by PAPI itself).
|
|
12
|
+
//
|
|
13
|
+
// Outgoing subscribe requests are remembered. When the inner provider hands
|
|
14
|
+
// over a new connection, every such request is re-sent so the server assigns
|
|
15
|
+
// fresh subscription ids. The new server ids are remapped back to the id the
|
|
16
|
+
// consumer first saw, keeping inbound notifications and outbound unsubscribes
|
|
17
|
+
// transparent across the drop.
|
|
18
|
+
export const nodeSubscriptionReplayMiddleware = base => {
|
|
19
|
+
const subs = new Map();
|
|
20
|
+
const remap = new Map();
|
|
21
|
+
const pendingReplays = new Map();
|
|
22
|
+
let counter = 0;
|
|
23
|
+
const nextReplayId = () => `__replay_${counter++}`;
|
|
24
|
+
const findReqIdByConsumerSub = (consumerSubId) => {
|
|
25
|
+
for (const [reqId, sub] of subs)
|
|
26
|
+
if (sub.consumerSubId === consumerSubId)
|
|
27
|
+
return reqId;
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
const consumerToServerSubId = (consumerSubId) => {
|
|
31
|
+
for (const [server, consumer] of remap)
|
|
32
|
+
if (consumer === consumerSubId)
|
|
33
|
+
return server;
|
|
34
|
+
return consumerSubId;
|
|
35
|
+
};
|
|
36
|
+
return (onMessage, onHalt) => {
|
|
37
|
+
const handleMessage = (raw) => {
|
|
38
|
+
let msg = raw;
|
|
39
|
+
if (isResponse(msg)) {
|
|
40
|
+
const pendingConsumerSubId = pendingReplays.get(msg.id);
|
|
41
|
+
if (pendingConsumerSubId !== undefined) {
|
|
42
|
+
pendingReplays.delete(msg.id);
|
|
43
|
+
if ('result' in msg && msg.result !== pendingConsumerSubId)
|
|
44
|
+
remap.set(msg.result, pendingConsumerSubId);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const sub = subs.get(msg.id);
|
|
48
|
+
if (sub) {
|
|
49
|
+
if ('result' in msg)
|
|
50
|
+
sub.consumerSubId = msg.result;
|
|
51
|
+
// subscribe failed — drop so we don't keep a zombie entry across reconnects
|
|
52
|
+
else
|
|
53
|
+
subs.delete(msg.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const consumerSubId = remap.get(msg.params?.subscription);
|
|
58
|
+
if (consumerSubId !== undefined)
|
|
59
|
+
msg = { ...msg, params: { ...msg.params, subscription: consumerSubId } };
|
|
60
|
+
}
|
|
61
|
+
onMessage(msg);
|
|
62
|
+
};
|
|
63
|
+
// Server sub ids from the prior connection are dead now.
|
|
64
|
+
remap.clear();
|
|
65
|
+
pendingReplays.clear();
|
|
66
|
+
const connection = base(handleMessage, onHalt);
|
|
67
|
+
for (const { req, consumerSubId } of subs.values()) {
|
|
68
|
+
if (consumerSubId === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const id = nextReplayId();
|
|
71
|
+
pendingReplays.set(id, consumerSubId);
|
|
72
|
+
connection.send({ ...req, id });
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
send(msg) {
|
|
76
|
+
if (msg.id != null) {
|
|
77
|
+
if (isSubscribeMethod(msg.method)) {
|
|
78
|
+
subs.set(msg.id, { req: msg });
|
|
79
|
+
}
|
|
80
|
+
else if (isUnsubscribeMethod(msg.method)) {
|
|
81
|
+
const consumerSubId = msg.params?.[0];
|
|
82
|
+
if (typeof consumerSubId === 'string') {
|
|
83
|
+
const reqId = findReqIdByConsumerSub(consumerSubId);
|
|
84
|
+
if (reqId !== null)
|
|
85
|
+
subs.delete(reqId);
|
|
86
|
+
const serverSubId = consumerToServerSubId(consumerSubId);
|
|
87
|
+
if (serverSubId !== consumerSubId) {
|
|
88
|
+
remap.delete(serverSubId);
|
|
89
|
+
connection.send({ ...msg, params: [serverSubId, ...msg.params.slice(1)] });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
connection.send(msg);
|
|
96
|
+
},
|
|
97
|
+
disconnect() {
|
|
98
|
+
subs.clear();
|
|
99
|
+
remap.clear();
|
|
100
|
+
pendingReplays.clear();
|
|
101
|
+
connection.disconnect();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { nodeSubscriptionReplayMiddleware } from './nodeSubscriptionReplayMiddleware.js';
|
|
3
|
+
const createMockBase = () => {
|
|
4
|
+
const invocations = [];
|
|
5
|
+
const base = (onMessage, onHalt) => {
|
|
6
|
+
const send = vi.fn();
|
|
7
|
+
const disconnect = vi.fn();
|
|
8
|
+
invocations.push({ onMessage: onMessage, onHalt: onHalt, send, disconnect });
|
|
9
|
+
return { send, disconnect };
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
base,
|
|
13
|
+
invocations,
|
|
14
|
+
get latest() {
|
|
15
|
+
const last = invocations[invocations.length - 1];
|
|
16
|
+
if (!last)
|
|
17
|
+
throw new Error('no base connection has been created yet');
|
|
18
|
+
return last;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const req = (id, method, params = []) => ({
|
|
23
|
+
jsonrpc: '2.0',
|
|
24
|
+
id,
|
|
25
|
+
method,
|
|
26
|
+
params,
|
|
27
|
+
});
|
|
28
|
+
const res = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
29
|
+
const notify = (method, subscription, result = {}) => ({
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
method,
|
|
32
|
+
params: { subscription, result },
|
|
33
|
+
});
|
|
34
|
+
const firstSent = (send) => {
|
|
35
|
+
const call = send.mock.calls[0];
|
|
36
|
+
if (!call)
|
|
37
|
+
throw new Error('expected send to have been called at least once');
|
|
38
|
+
return call[0];
|
|
39
|
+
};
|
|
40
|
+
describe('nodeSubscriptionReplayMiddleware', () => {
|
|
41
|
+
it('forwards incoming messages to onMessage unmodified', () => {
|
|
42
|
+
const mock = createMockBase();
|
|
43
|
+
const onMessage = vi.fn();
|
|
44
|
+
nodeSubscriptionReplayMiddleware(mock.base)(onMessage, vi.fn());
|
|
45
|
+
mock.latest.onMessage(res(1, '0xabc'));
|
|
46
|
+
expect(onMessage).toHaveBeenCalledWith(res(1, '0xabc'));
|
|
47
|
+
});
|
|
48
|
+
it('forwards send calls to the underlying connection', () => {
|
|
49
|
+
const mock = createMockBase();
|
|
50
|
+
const conn = nodeSubscriptionReplayMiddleware(mock.base)(vi.fn(), vi.fn());
|
|
51
|
+
conn.send(req(1, 'chain_getBlock'));
|
|
52
|
+
expect(mock.latest.send).toHaveBeenCalledWith(req(1, 'chain_getBlock'));
|
|
53
|
+
});
|
|
54
|
+
it('passes onHalt through to the base connection', () => {
|
|
55
|
+
const mock = createMockBase();
|
|
56
|
+
const onHalt = vi.fn();
|
|
57
|
+
nodeSubscriptionReplayMiddleware(mock.base)(vi.fn(), onHalt);
|
|
58
|
+
expect(mock.latest.onHalt).toBe(onHalt);
|
|
59
|
+
});
|
|
60
|
+
it('does not replay non-subscription requests on reconnect', () => {
|
|
61
|
+
const mock = createMockBase();
|
|
62
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
63
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
64
|
+
conn.send(req(1, 'chain_getBlock'));
|
|
65
|
+
inner(vi.fn(), vi.fn());
|
|
66
|
+
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it('does not replay pending subscriptions (no server response yet) on reconnect', () => {
|
|
69
|
+
const mock = createMockBase();
|
|
70
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
71
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
72
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
73
|
+
// intentionally no simulated response — subscription not confirmed by server
|
|
74
|
+
inner(vi.fn(), vi.fn());
|
|
75
|
+
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('replays an active subscription on reconnect with a fresh internal id', () => {
|
|
78
|
+
const mock = createMockBase();
|
|
79
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
80
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
81
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
82
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
83
|
+
inner(vi.fn(), vi.fn());
|
|
84
|
+
expect(mock.latest.send).toHaveBeenCalledTimes(1);
|
|
85
|
+
const resent = firstSent(mock.latest.send);
|
|
86
|
+
expect(resent.method).toBe('statement_subscribeStatement');
|
|
87
|
+
expect(resent.params).toEqual([]);
|
|
88
|
+
// internal replay id, distinct from the consumer's original id
|
|
89
|
+
expect(resent.id).not.toBe(1);
|
|
90
|
+
expect(typeof resent.id).toBe('string');
|
|
91
|
+
});
|
|
92
|
+
it('replays multiple active subscriptions on reconnect', () => {
|
|
93
|
+
const mock = createMockBase();
|
|
94
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
95
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
96
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
97
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
98
|
+
conn.send(req(2, 'state_subscribeStorage', [[]]));
|
|
99
|
+
mock.latest.onMessage(res(2, 'srv-2'));
|
|
100
|
+
inner(vi.fn(), vi.fn());
|
|
101
|
+
const methods = mock.latest.send.mock.calls.map(c => c[0].method);
|
|
102
|
+
expect(methods).toHaveLength(2);
|
|
103
|
+
expect(methods).toContain('statement_subscribeStatement');
|
|
104
|
+
expect(methods).toContain('state_subscribeStorage');
|
|
105
|
+
});
|
|
106
|
+
it('suppresses the replay subscribe response from reaching onMessage', () => {
|
|
107
|
+
const mock = createMockBase();
|
|
108
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
109
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
110
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
111
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
112
|
+
const onMessage2 = vi.fn();
|
|
113
|
+
inner(onMessage2, vi.fn());
|
|
114
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
115
|
+
mock.latest.onMessage(res(replayId, 'srv-2'));
|
|
116
|
+
expect(onMessage2).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
it('remaps notifications from the new server sub id to the original consumer id', () => {
|
|
119
|
+
const mock = createMockBase();
|
|
120
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
121
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
122
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
123
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
124
|
+
const onMessage2 = vi.fn();
|
|
125
|
+
inner(onMessage2, vi.fn());
|
|
126
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
127
|
+
mock.latest.onMessage(res(replayId, 'srv-2'));
|
|
128
|
+
mock.latest.onMessage(notify('chain_newHead', 'srv-2', { n: 42 }));
|
|
129
|
+
expect(onMessage2).toHaveBeenCalledWith(notify('chain_newHead', 'srv-1', { n: 42 }));
|
|
130
|
+
});
|
|
131
|
+
it('forwards notifications with no remap entry unchanged', () => {
|
|
132
|
+
const mock = createMockBase();
|
|
133
|
+
const onMessage = vi.fn();
|
|
134
|
+
const conn = nodeSubscriptionReplayMiddleware(mock.base)(onMessage, vi.fn());
|
|
135
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
136
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
137
|
+
onMessage.mockClear();
|
|
138
|
+
const n = notify('chain_newHead', 'srv-1', { n: 1 });
|
|
139
|
+
mock.latest.onMessage(n);
|
|
140
|
+
expect(onMessage).toHaveBeenCalledWith(n);
|
|
141
|
+
});
|
|
142
|
+
it('does not remap when the replay response returns the same sub id', () => {
|
|
143
|
+
const mock = createMockBase();
|
|
144
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
145
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
146
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
147
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
148
|
+
const onMessage2 = vi.fn();
|
|
149
|
+
inner(onMessage2, vi.fn());
|
|
150
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
151
|
+
// server assigns the same sub id after reconnect
|
|
152
|
+
mock.latest.onMessage(res(replayId, 'srv-1'));
|
|
153
|
+
const n = notify('chain_newHead', 'srv-1', { n: 1 });
|
|
154
|
+
mock.latest.onMessage(n);
|
|
155
|
+
expect(onMessage2).toHaveBeenCalledWith(n);
|
|
156
|
+
});
|
|
157
|
+
it('translates the consumer sub id to the current server sub id on unsubscribe', () => {
|
|
158
|
+
const mock = createMockBase();
|
|
159
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
160
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
161
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
162
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
163
|
+
const conn2 = inner(vi.fn(), vi.fn());
|
|
164
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
165
|
+
mock.latest.onMessage(res(replayId, 'srv-2'));
|
|
166
|
+
mock.latest.send.mockClear();
|
|
167
|
+
conn2.send(req(2, 'statement_unsubscribeStatement', ['srv-1']));
|
|
168
|
+
expect(mock.latest.send).toHaveBeenCalledWith(req(2, 'statement_unsubscribeStatement', ['srv-2']));
|
|
169
|
+
});
|
|
170
|
+
it('forwards unsubscribe unchanged when the sub id was never remapped', () => {
|
|
171
|
+
const mock = createMockBase();
|
|
172
|
+
const conn = nodeSubscriptionReplayMiddleware(mock.base)(vi.fn(), vi.fn());
|
|
173
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
174
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
175
|
+
mock.latest.send.mockClear();
|
|
176
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['srv-1']));
|
|
177
|
+
expect(mock.latest.send).toHaveBeenCalledWith(req(2, 'statement_unsubscribeStatement', ['srv-1']));
|
|
178
|
+
});
|
|
179
|
+
it('preserves extra unsubscribe params while rewriting only the sub id', () => {
|
|
180
|
+
const mock = createMockBase();
|
|
181
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
182
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
183
|
+
conn.send(req(1, 'some_subscribe'));
|
|
184
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
185
|
+
const conn2 = inner(vi.fn(), vi.fn());
|
|
186
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
187
|
+
mock.latest.onMessage(res(replayId, 'srv-2'));
|
|
188
|
+
mock.latest.send.mockClear();
|
|
189
|
+
conn2.send(req(2, 'some_unsubscribe', ['srv-1', 'extra-arg']));
|
|
190
|
+
expect(mock.latest.send).toHaveBeenCalledWith(req(2, 'some_unsubscribe', ['srv-2', 'extra-arg']));
|
|
191
|
+
});
|
|
192
|
+
it('does not replay a subscription after it has been unsubscribed', () => {
|
|
193
|
+
const mock = createMockBase();
|
|
194
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
195
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
196
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
197
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
198
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['srv-1']));
|
|
199
|
+
inner(vi.fn(), vi.fn());
|
|
200
|
+
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
it('removes the remap entry on unsubscribe so late notifications under the old server id pass through unchanged', () => {
|
|
203
|
+
const mock = createMockBase();
|
|
204
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
205
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
206
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
207
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
208
|
+
const onMessage2 = vi.fn();
|
|
209
|
+
const conn2 = inner(onMessage2, vi.fn());
|
|
210
|
+
const replayId = firstSent(mock.latest.send).id;
|
|
211
|
+
mock.latest.onMessage(res(replayId, 'srv-2'));
|
|
212
|
+
conn2.send(req(2, 'statement_unsubscribeStatement', ['srv-1']));
|
|
213
|
+
onMessage2.mockClear();
|
|
214
|
+
const straggler = notify('chain_newHead', 'srv-2', { n: 9 });
|
|
215
|
+
mock.latest.onMessage(straggler);
|
|
216
|
+
expect(onMessage2).toHaveBeenCalledWith(straggler);
|
|
217
|
+
});
|
|
218
|
+
it('clears all state on disconnect', () => {
|
|
219
|
+
const mock = createMockBase();
|
|
220
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
221
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
222
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
223
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
224
|
+
conn.disconnect();
|
|
225
|
+
expect(mock.latest.disconnect).toHaveBeenCalledTimes(1);
|
|
226
|
+
// a fresh invocation after disconnect should not replay anything
|
|
227
|
+
inner(vi.fn(), vi.fn());
|
|
228
|
+
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
it('clears remap across reconnects so stale ids from the prior connection do not leak', () => {
|
|
231
|
+
const mock = createMockBase();
|
|
232
|
+
const inner = nodeSubscriptionReplayMiddleware(mock.base);
|
|
233
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
234
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
235
|
+
mock.latest.onMessage(res(1, 'srv-1'));
|
|
236
|
+
// first reconnect: server re-assigns srv-2 (remap gains srv-2 -> srv-1)
|
|
237
|
+
const onMessage2 = vi.fn();
|
|
238
|
+
inner(onMessage2, vi.fn());
|
|
239
|
+
const replayId2 = firstSent(mock.latest.send).id;
|
|
240
|
+
mock.latest.onMessage(res(replayId2, 'srv-2'));
|
|
241
|
+
// second reconnect: remap is cleared BEFORE replay; server assigns srv-3
|
|
242
|
+
const onMessage3 = vi.fn();
|
|
243
|
+
inner(onMessage3, vi.fn());
|
|
244
|
+
const replayId3 = firstSent(mock.latest.send).id;
|
|
245
|
+
mock.latest.onMessage(res(replayId3, 'srv-3'));
|
|
246
|
+
// notification under srv-2 from the previous connection must not remap to srv-1
|
|
247
|
+
const stale = notify('chain_newHead', 'srv-2', { n: 0 });
|
|
248
|
+
mock.latest.onMessage(stale);
|
|
249
|
+
expect(onMessage3).toHaveBeenCalledWith(stale);
|
|
250
|
+
// notification under srv-3 is remapped to the consumer's original id (srv-1)
|
|
251
|
+
onMessage3.mockClear();
|
|
252
|
+
mock.latest.onMessage(notify('chain_newHead', 'srv-3', { n: 1 }));
|
|
253
|
+
expect(onMessage3).toHaveBeenCalledWith(notify('chain_newHead', 'srv-1', { n: 1 }));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Plugs into getWsProvider's `middleware` option. Pause closes the live socket
|
|
2
|
+
// and stalls auto-reconnect; resume opens a fresh socket and flushes buffered
|
|
3
|
+
// sends.
|
|
4
|
+
export const createPauseController = () => {
|
|
5
|
+
let paused = false;
|
|
6
|
+
let destroyed = false;
|
|
7
|
+
let base = null;
|
|
8
|
+
let onMessage = null;
|
|
9
|
+
let onHalt = null;
|
|
10
|
+
let real = null;
|
|
11
|
+
let buffer = [];
|
|
12
|
+
// a halt we fired already scheduled a middleware re-invocation — resume
|
|
13
|
+
// must defer to it rather than reusing the stale onMessage/onHalt pair
|
|
14
|
+
let reinvocationPending = false;
|
|
15
|
+
const connect = () => {
|
|
16
|
+
if (!base || !onMessage || !onHalt)
|
|
17
|
+
return;
|
|
18
|
+
real = base(onMessage, onHalt);
|
|
19
|
+
const q = buffer;
|
|
20
|
+
buffer = [];
|
|
21
|
+
for (const m of q)
|
|
22
|
+
real.send(m);
|
|
23
|
+
};
|
|
24
|
+
const middleware = b => {
|
|
25
|
+
base = b;
|
|
26
|
+
return (onMsg, onH) => {
|
|
27
|
+
reinvocationPending = false;
|
|
28
|
+
onMessage = onMsg;
|
|
29
|
+
onHalt = onH;
|
|
30
|
+
real = null;
|
|
31
|
+
if (!paused)
|
|
32
|
+
connect();
|
|
33
|
+
return {
|
|
34
|
+
send: m => (real ? real.send(m) : buffer.push(m)),
|
|
35
|
+
disconnect: () => {
|
|
36
|
+
destroyed = true;
|
|
37
|
+
paused = false;
|
|
38
|
+
buffer = [];
|
|
39
|
+
real?.disconnect();
|
|
40
|
+
real = null;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const pause = () => {
|
|
46
|
+
if (paused || destroyed)
|
|
47
|
+
return;
|
|
48
|
+
paused = true;
|
|
49
|
+
if (!real || !onHalt)
|
|
50
|
+
return;
|
|
51
|
+
reinvocationPending = true;
|
|
52
|
+
const r = real;
|
|
53
|
+
real = null;
|
|
54
|
+
// withSocket.disconnect detaches listeners before close — no halt fires
|
|
55
|
+
// from there, so trigger it manually to drive getProxy's replay.
|
|
56
|
+
r.disconnect();
|
|
57
|
+
onHalt({ type: 'paused' });
|
|
58
|
+
};
|
|
59
|
+
const resume = () => {
|
|
60
|
+
if (destroyed || !paused)
|
|
61
|
+
return;
|
|
62
|
+
paused = false;
|
|
63
|
+
if (!reinvocationPending)
|
|
64
|
+
connect();
|
|
65
|
+
};
|
|
66
|
+
return { middleware, pause, resume, isPaused: () => paused };
|
|
67
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createPauseController } from './pauseController.js';
|
|
3
|
+
const createMockBase = () => {
|
|
4
|
+
const invocations = [];
|
|
5
|
+
const base = (onMessage, onHalt) => {
|
|
6
|
+
const send = vi.fn();
|
|
7
|
+
const disconnect = vi.fn();
|
|
8
|
+
invocations.push({ onMessage: onMessage, onHalt: onHalt, send, disconnect });
|
|
9
|
+
return { send, disconnect };
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
base,
|
|
13
|
+
invocations,
|
|
14
|
+
get latest() {
|
|
15
|
+
const last = invocations[invocations.length - 1];
|
|
16
|
+
if (!last)
|
|
17
|
+
throw new Error('no base connection has been created yet');
|
|
18
|
+
return last;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const req = (id, method, params = []) => ({
|
|
23
|
+
jsonrpc: '2.0',
|
|
24
|
+
id,
|
|
25
|
+
method,
|
|
26
|
+
params,
|
|
27
|
+
});
|
|
28
|
+
describe('createPauseController', () => {
|
|
29
|
+
it('connects on first inner invocation and forwards sends', () => {
|
|
30
|
+
const mock = createMockBase();
|
|
31
|
+
const pc = createPauseController();
|
|
32
|
+
const inner = pc.middleware(mock.base);
|
|
33
|
+
expect(mock.invocations).toHaveLength(0);
|
|
34
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
35
|
+
expect(mock.invocations).toHaveLength(1);
|
|
36
|
+
expect(pc.isPaused()).toBe(false);
|
|
37
|
+
conn.send(req(1, 'foo'));
|
|
38
|
+
expect(mock.latest.send).toHaveBeenCalledWith(req(1, 'foo'));
|
|
39
|
+
});
|
|
40
|
+
it('forwards inbound messages from the base to the stored onMessage', () => {
|
|
41
|
+
const mock = createMockBase();
|
|
42
|
+
const pc = createPauseController();
|
|
43
|
+
const onMessage = vi.fn();
|
|
44
|
+
pc.middleware(mock.base)(onMessage, vi.fn());
|
|
45
|
+
const message = { jsonrpc: '2.0', id: 1, result: 'x' };
|
|
46
|
+
mock.latest.onMessage(message);
|
|
47
|
+
expect(onMessage).toHaveBeenCalledWith(message);
|
|
48
|
+
});
|
|
49
|
+
it('forwards halts from the base to the stored onHalt', () => {
|
|
50
|
+
const mock = createMockBase();
|
|
51
|
+
const pc = createPauseController();
|
|
52
|
+
const onHalt = vi.fn();
|
|
53
|
+
pc.middleware(mock.base)(vi.fn(), onHalt);
|
|
54
|
+
const haltReason = { type: 'socket-closed' };
|
|
55
|
+
mock.latest.onHalt(haltReason);
|
|
56
|
+
expect(onHalt).toHaveBeenCalledWith(haltReason);
|
|
57
|
+
});
|
|
58
|
+
it('pause disconnects the live socket and fires a paused halt', () => {
|
|
59
|
+
const mock = createMockBase();
|
|
60
|
+
const pc = createPauseController();
|
|
61
|
+
const onHalt = vi.fn();
|
|
62
|
+
pc.middleware(mock.base)(vi.fn(), onHalt);
|
|
63
|
+
pc.pause();
|
|
64
|
+
expect(pc.isPaused()).toBe(true);
|
|
65
|
+
expect(mock.latest.disconnect).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(onHalt).toHaveBeenCalledWith({ type: 'paused' });
|
|
67
|
+
});
|
|
68
|
+
it('pause is a no-op when already paused', () => {
|
|
69
|
+
const mock = createMockBase();
|
|
70
|
+
const pc = createPauseController();
|
|
71
|
+
const onHalt = vi.fn();
|
|
72
|
+
pc.middleware(mock.base)(vi.fn(), onHalt);
|
|
73
|
+
pc.pause();
|
|
74
|
+
onHalt.mockClear();
|
|
75
|
+
pc.pause();
|
|
76
|
+
expect(mock.latest.disconnect).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(onHalt).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
it('pause before the inner provider has been invoked does not fire a halt', () => {
|
|
80
|
+
const mock = createMockBase();
|
|
81
|
+
const pc = createPauseController();
|
|
82
|
+
pc.middleware(mock.base);
|
|
83
|
+
pc.pause();
|
|
84
|
+
expect(pc.isPaused()).toBe(true);
|
|
85
|
+
expect(mock.invocations).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
it('buffers sends while paused and flushes them in order after the post-halt re-invocation + resume', () => {
|
|
88
|
+
const mock = createMockBase();
|
|
89
|
+
const pc = createPauseController();
|
|
90
|
+
const inner = pc.middleware(mock.base);
|
|
91
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
92
|
+
pc.pause();
|
|
93
|
+
conn.send(req(1, 'a'));
|
|
94
|
+
conn.send(req(2, 'b'));
|
|
95
|
+
// simulate the re-invocation that onHalt → getProxy would drive
|
|
96
|
+
inner(vi.fn(), vi.fn());
|
|
97
|
+
expect(mock.invocations).toHaveLength(1); // still paused — no new base connection yet
|
|
98
|
+
pc.resume();
|
|
99
|
+
expect(mock.invocations).toHaveLength(2);
|
|
100
|
+
expect(mock.latest.send).toHaveBeenNthCalledWith(1, req(1, 'a'));
|
|
101
|
+
expect(mock.latest.send).toHaveBeenNthCalledWith(2, req(2, 'b'));
|
|
102
|
+
expect(pc.isPaused()).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
it('re-invocation while paused does not open a new socket', () => {
|
|
105
|
+
const mock = createMockBase();
|
|
106
|
+
const pc = createPauseController();
|
|
107
|
+
const inner = pc.middleware(mock.base);
|
|
108
|
+
inner(vi.fn(), vi.fn());
|
|
109
|
+
pc.pause();
|
|
110
|
+
inner(vi.fn(), vi.fn());
|
|
111
|
+
inner(vi.fn(), vi.fn());
|
|
112
|
+
expect(mock.invocations).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
it('resume without a pending re-invocation connects directly', () => {
|
|
115
|
+
const mock = createMockBase();
|
|
116
|
+
const pc = createPauseController();
|
|
117
|
+
const inner = pc.middleware(mock.base);
|
|
118
|
+
// pause before any real connection exists — no halt fires, so no re-invocation is pending
|
|
119
|
+
pc.pause();
|
|
120
|
+
inner(vi.fn(), vi.fn());
|
|
121
|
+
expect(mock.invocations).toHaveLength(0);
|
|
122
|
+
pc.resume();
|
|
123
|
+
expect(mock.invocations).toHaveLength(1);
|
|
124
|
+
expect(pc.isPaused()).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
it('resume is a no-op when not paused', () => {
|
|
127
|
+
const mock = createMockBase();
|
|
128
|
+
const pc = createPauseController();
|
|
129
|
+
const inner = pc.middleware(mock.base);
|
|
130
|
+
inner(vi.fn(), vi.fn());
|
|
131
|
+
pc.resume();
|
|
132
|
+
expect(mock.invocations).toHaveLength(1);
|
|
133
|
+
expect(pc.isPaused()).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
it('after a re-invocation, inbound messages from the new base reach the new onMessage', () => {
|
|
136
|
+
const mock = createMockBase();
|
|
137
|
+
const pc = createPauseController();
|
|
138
|
+
const inner = pc.middleware(mock.base);
|
|
139
|
+
const firstOnMessage = vi.fn();
|
|
140
|
+
inner(firstOnMessage, vi.fn());
|
|
141
|
+
pc.pause();
|
|
142
|
+
const secondOnMessage = vi.fn();
|
|
143
|
+
inner(secondOnMessage, vi.fn());
|
|
144
|
+
pc.resume();
|
|
145
|
+
const message = { jsonrpc: '2.0', id: 9, result: 'y' };
|
|
146
|
+
mock.latest.onMessage(message);
|
|
147
|
+
expect(secondOnMessage).toHaveBeenCalledWith(message);
|
|
148
|
+
expect(firstOnMessage).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
it('disconnect tears down and makes subsequent pause/resume no-ops', () => {
|
|
151
|
+
const mock = createMockBase();
|
|
152
|
+
const pc = createPauseController();
|
|
153
|
+
const inner = pc.middleware(mock.base);
|
|
154
|
+
const onHalt = vi.fn();
|
|
155
|
+
const conn = inner(vi.fn(), onHalt);
|
|
156
|
+
conn.disconnect();
|
|
157
|
+
expect(mock.latest.disconnect).toHaveBeenCalledTimes(1);
|
|
158
|
+
pc.pause();
|
|
159
|
+
expect(pc.isPaused()).toBe(false);
|
|
160
|
+
expect(onHalt).not.toHaveBeenCalled();
|
|
161
|
+
pc.resume();
|
|
162
|
+
expect(mock.invocations).toHaveLength(1);
|
|
163
|
+
});
|
|
164
|
+
it('disconnect drops buffered sends so a later reconnect does not flush stale messages', () => {
|
|
165
|
+
const mock = createMockBase();
|
|
166
|
+
const pc = createPauseController();
|
|
167
|
+
const inner = pc.middleware(mock.base);
|
|
168
|
+
const conn = inner(vi.fn(), vi.fn());
|
|
169
|
+
pc.pause();
|
|
170
|
+
conn.send(req(1, 'lost'));
|
|
171
|
+
conn.disconnect();
|
|
172
|
+
// a new controller wouldn't share buffer, but even a fresh inner call on the same
|
|
173
|
+
// controller (as might happen if getProxy retries) must not replay pre-disconnect buffer
|
|
174
|
+
inner(vi.fn(), vi.fn());
|
|
175
|
+
expect(mock.latest.send).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
1
|
+
import { isResponse } from '@polkadot-api/json-rpc-provider';
|
|
2
|
+
const isChainMethod = (method) => method.startsWith('chain_');
|
|
3
|
+
const isSubscribeMethod = (method) => {
|
|
4
|
+
if (isChainMethod(method))
|
|
5
|
+
return false;
|
|
6
|
+
const m = method.toLowerCase();
|
|
7
|
+
return m.includes('subscribe') && !m.includes('unsubscribe');
|
|
8
|
+
};
|
|
9
|
+
const isUnsubscribeMethod = (method) => !isChainMethod(method) && method.toLowerCase().includes('unsubscribe');
|
|
4
10
|
export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
5
11
|
// request id → request object (sent, awaiting server subscription ID)
|
|
6
12
|
const pendingSubscriptions = new Map();
|
|
@@ -8,11 +14,7 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
|
8
14
|
const activeSubscriptions = new Map();
|
|
9
15
|
const conn = provider(message => {
|
|
10
16
|
// Response with a string result means it's a subscription confirmation
|
|
11
|
-
if (
|
|
12
|
-
message.id &&
|
|
13
|
-
!('method' in message) &&
|
|
14
|
-
'result' in message &&
|
|
15
|
-
typeof message.result === 'string') {
|
|
17
|
+
if (message.id && isResponse(message) && 'result' in message && typeof message.result === 'string') {
|
|
16
18
|
const id = message.id;
|
|
17
19
|
const pending = pendingSubscriptions.get(id);
|
|
18
20
|
if (pending !== undefined) {
|
|
@@ -65,7 +65,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
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, '
|
|
68
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
69
69
|
// intentionally no simulateMessage — subscription not confirmed by server
|
|
70
70
|
mock.send.mockClear();
|
|
71
71
|
control.triggerReconnect();
|
|
@@ -75,7 +75,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
75
75
|
const mock = createMockProvider();
|
|
76
76
|
const control = createReconnectControl();
|
|
77
77
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
78
|
-
const subscribeMsg = req(1, '
|
|
78
|
+
const subscribeMsg = req(1, 'statement_subscribeStatement');
|
|
79
79
|
conn.send(subscribeMsg);
|
|
80
80
|
mock.simulateMessage(res(1, 'sub-id-1'));
|
|
81
81
|
mock.send.mockClear();
|
|
@@ -86,7 +86,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
86
86
|
const mock = createMockProvider();
|
|
87
87
|
const control = createReconnectControl();
|
|
88
88
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
89
|
-
const msg1 = req(1, '
|
|
89
|
+
const msg1 = req(1, 'statement_subscribeStatement');
|
|
90
90
|
const msg2 = req(2, 'state_subscribeStorage', [[]]);
|
|
91
91
|
conn.send(msg1);
|
|
92
92
|
mock.simulateMessage(res(1, 'sub-id-1'));
|
|
@@ -102,7 +102,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
102
102
|
const mock = createMockProvider();
|
|
103
103
|
const control = createReconnectControl();
|
|
104
104
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
105
|
-
const subscribeMsg = req(1, '
|
|
105
|
+
const subscribeMsg = req(1, 'statement_subscribeStatement');
|
|
106
106
|
conn.send(subscribeMsg);
|
|
107
107
|
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
108
108
|
// First reconnect — replays and moves to pending
|
|
@@ -110,7 +110,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
110
110
|
// Server assigns new subscription ID
|
|
111
111
|
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
112
112
|
// Unsubscribing with the old ID should have no effect (old ID is no longer tracked)
|
|
113
|
-
conn.send(req(2, '
|
|
113
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
|
|
114
114
|
mock.send.mockClear();
|
|
115
115
|
// Second reconnect — subscription is still active (new-sub-id was not removed)
|
|
116
116
|
control.triggerReconnect();
|
|
@@ -120,9 +120,9 @@ describe('withSubscriptionReplay', () => {
|
|
|
120
120
|
const mock = createMockProvider();
|
|
121
121
|
const control = createReconnectControl();
|
|
122
122
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
123
|
-
conn.send(req(1, '
|
|
123
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
124
124
|
mock.simulateMessage(res(1, 'sub-id-1'));
|
|
125
|
-
conn.send(req(2, '
|
|
125
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['sub-id-1']));
|
|
126
126
|
mock.send.mockClear();
|
|
127
127
|
control.triggerReconnect();
|
|
128
128
|
expect(mock.send).not.toHaveBeenCalled();
|
|
@@ -131,7 +131,7 @@ describe('withSubscriptionReplay', () => {
|
|
|
131
131
|
const mock = createMockProvider();
|
|
132
132
|
const control = createReconnectControl();
|
|
133
133
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
134
|
-
conn.send(req(1, '
|
|
134
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
135
135
|
mock.simulateMessage(res(1, 'sub-id-1'));
|
|
136
136
|
conn.disconnect();
|
|
137
137
|
expect(mock.disconnect).toHaveBeenCalledTimes(1);
|
|
@@ -141,48 +141,15 @@ describe('withSubscriptionReplay', () => {
|
|
|
141
141
|
control.triggerReconnect();
|
|
142
142
|
expect(mock.send).not.toHaveBeenCalled();
|
|
143
143
|
});
|
|
144
|
-
it('
|
|
145
|
-
const mock = createMockProvider();
|
|
146
|
-
const control = createReconnectControl();
|
|
147
|
-
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
148
|
-
conn.send(req(1, 'chainHead_v1_follow', [true]));
|
|
149
|
-
// intentionally no simulateMessage — subscription not confirmed by server
|
|
150
|
-
mock.send.mockClear();
|
|
151
|
-
control.triggerReconnect();
|
|
152
|
-
expect(mock.send).not.toHaveBeenCalled();
|
|
153
|
-
});
|
|
154
|
-
it('replays active chainHead_v1_follow subscription on reconnect', () => {
|
|
155
|
-
const mock = createMockProvider();
|
|
156
|
-
const control = createReconnectControl();
|
|
157
|
-
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
158
|
-
const followMsg = req(1, 'chainHead_v1_follow', [true]);
|
|
159
|
-
conn.send(followMsg);
|
|
160
|
-
mock.simulateMessage(res(1, 'follow-sub-id'));
|
|
161
|
-
mock.send.mockClear();
|
|
162
|
-
control.triggerReconnect();
|
|
163
|
-
expect(mock.send).toHaveBeenCalledWith(followMsg);
|
|
164
|
-
});
|
|
165
|
-
it('does not replay chainHead_v1_follow after chainHead_v1_unfollow', () => {
|
|
166
|
-
const mock = createMockProvider();
|
|
167
|
-
const control = createReconnectControl();
|
|
168
|
-
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
169
|
-
conn.send(req(1, 'chainHead_v1_follow', [true]));
|
|
170
|
-
mock.simulateMessage(res(1, 'follow-sub-id'));
|
|
171
|
-
conn.send(req(2, 'chainHead_v1_unfollow', ['follow-sub-id']));
|
|
172
|
-
mock.send.mockClear();
|
|
173
|
-
control.triggerReconnect();
|
|
174
|
-
expect(mock.send).not.toHaveBeenCalled();
|
|
175
|
-
});
|
|
176
|
-
it('keeps chainHead_v1_follow active when chainHead_v1_unfollow uses wrong sub-id', () => {
|
|
144
|
+
it('should ignore chain subscriptions', () => {
|
|
177
145
|
const mock = createMockProvider();
|
|
178
146
|
const control = createReconnectControl();
|
|
179
147
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
180
|
-
const
|
|
181
|
-
conn.send(
|
|
182
|
-
mock.simulateMessage(res(1, '
|
|
183
|
-
conn.send(req(2, 'chainHead_v1_unfollow', ['wrong-sub-id']));
|
|
148
|
+
const subscribeMsg = req(1, 'chain_subscribeNewHeads');
|
|
149
|
+
conn.send(subscribeMsg);
|
|
150
|
+
mock.simulateMessage(res(1, 'sub-id-1'));
|
|
184
151
|
mock.send.mockClear();
|
|
185
152
|
control.triggerReconnect();
|
|
186
|
-
expect(mock.send).toHaveBeenCalledWith(
|
|
153
|
+
expect(mock.send).not.toHaveBeenCalledWith(subscribeMsg);
|
|
187
154
|
});
|
|
188
155
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -4,9 +4,10 @@ export type ChainConfig = {
|
|
|
4
4
|
};
|
|
5
5
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
|
6
6
|
export type BranchedProvider = {
|
|
7
|
-
branch(
|
|
7
|
+
branch(onHalt?: VoidFunction): JsonRpcProvider;
|
|
8
8
|
};
|
|
9
9
|
export type PooledClient = {
|
|
10
10
|
client: PolkadotClient;
|
|
11
11
|
provider: BranchedProvider;
|
|
12
|
+
rootProvider: JsonRpcProvider;
|
|
12
13
|
};
|
package/dist/wsProvider.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import type { SocketLoggerFn } from '@polkadot-api/ws-provider';
|
|
1
2
|
import type { JsonRpcProvider } from 'polkadot-api';
|
|
2
3
|
import type { ConnectionStatus } from './types.js';
|
|
4
|
+
export type PausableJsonRpcProvider = JsonRpcProvider & {
|
|
5
|
+
pause(): void;
|
|
6
|
+
resume(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare const isPausable: (provider: JsonRpcProvider) => provider is PausableJsonRpcProvider;
|
|
3
9
|
export declare const createWsJsonRpcProvider: (options: {
|
|
4
10
|
endpoints: string[];
|
|
5
11
|
onStatusChanged?: (status: ConnectionStatus) => void;
|
|
6
|
-
|
|
12
|
+
websocketClass?: typeof WebSocket;
|
|
13
|
+
heartbeatTimeout?: number;
|
|
14
|
+
logger?: SocketLoggerFn;
|
|
15
|
+
}) => PausableJsonRpcProvider;
|
package/dist/wsProvider.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { WsEvent, getWsProvider } from 'polkadot-api/ws';
|
|
1
|
+
import { WsEvent, getWsProvider } from '@polkadot-api/ws-provider';
|
|
2
2
|
import { noop } from './helpers.js';
|
|
3
|
+
import { createPauseController } from './pauseController.js';
|
|
3
4
|
import { withSubscriptionReplay } from './subscriptionReplayProvider.js';
|
|
5
|
+
export const isPausable = (provider) => {
|
|
6
|
+
const maybe = provider;
|
|
7
|
+
return typeof maybe.pause === 'function' && typeof maybe.resume === 'function';
|
|
8
|
+
};
|
|
9
|
+
const STATUS_BY_WS_EVENT = {
|
|
10
|
+
[WsEvent.CONNECTING]: 'connecting',
|
|
11
|
+
[WsEvent.CONNECTED]: 'connected',
|
|
12
|
+
[WsEvent.ERROR]: 'disconnected',
|
|
13
|
+
[WsEvent.CLOSE]: 'disconnected',
|
|
14
|
+
};
|
|
4
15
|
export const createWsJsonRpcProvider = (options) => {
|
|
5
16
|
let notifyReconnect = noop;
|
|
6
17
|
const onReconnect = (cb) => {
|
|
@@ -9,27 +20,23 @@ export const createWsJsonRpcProvider = (options) => {
|
|
|
9
20
|
notifyReconnect = noop;
|
|
10
21
|
};
|
|
11
22
|
};
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
const pauseController = createPauseController();
|
|
24
|
+
const baseProvider = getWsProvider(options.endpoints, {
|
|
25
|
+
logger: options.logger,
|
|
26
|
+
heartbeatTimeout: options.heartbeatTimeout,
|
|
27
|
+
middleware: inner => pauseController.middleware(inner),
|
|
28
|
+
websocketClass: options.websocketClass,
|
|
14
29
|
onStatusChanged: event => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
30
|
+
const status = STATUS_BY_WS_EVENT[event.type];
|
|
31
|
+
if (status === 'connected') {
|
|
32
|
+
notifyReconnect();
|
|
31
33
|
}
|
|
32
34
|
options.onStatusChanged?.(status);
|
|
33
35
|
},
|
|
34
|
-
})
|
|
36
|
+
});
|
|
37
|
+
const replayProvider = withSubscriptionReplay(baseProvider, onReconnect);
|
|
38
|
+
return Object.assign(replayProvider, {
|
|
39
|
+
pause: () => pauseController.pause(),
|
|
40
|
+
resume: () => pauseController.resume(),
|
|
41
|
+
});
|
|
35
42
|
};
|
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.0",
|
|
4
|
+
"version": "0.7.1-0",
|
|
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,9 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/storage-adapter": "0.7.0",
|
|
28
|
+
"@novasamatech/storage-adapter": "0.7.1-0",
|
|
29
|
+
"@polkadot-api/ws-provider": "^0.9.0",
|
|
30
|
+
"@polkadot-api/json-rpc-provider": "^0.2.0",
|
|
29
31
|
"@polkadot-api/json-rpc-provider-proxy": "^0.4.0",
|
|
30
32
|
"nanoevents": "^9.1.0",
|
|
31
33
|
"polkadot-api": ">=2"
|