@novasamatech/host-substrate-chain-connection 0.7.4 → 0.7.5-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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isResponse } from '@polkadot-api/json-rpc-provider';
|
|
1
|
+
import { isRequest, isResponse } from '@polkadot-api/json-rpc-provider';
|
|
2
2
|
const isChainMethod = (method) => method.startsWith('chain_');
|
|
3
3
|
const isSubscribeMethod = (method) => {
|
|
4
4
|
if (isChainMethod(method))
|
|
@@ -8,47 +8,88 @@ const isSubscribeMethod = (method) => {
|
|
|
8
8
|
};
|
|
9
9
|
const isUnsubscribeMethod = (method) => !isChainMethod(method) && method.toLowerCase().includes('unsubscribe');
|
|
10
10
|
export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
11
|
-
// request id → request object (sent, awaiting server subscription ID)
|
|
12
11
|
const pendingSubscriptions = new Map();
|
|
13
|
-
//
|
|
12
|
+
// Confirmed subscriptions keyed by the FIRST subId the consumer received.
|
|
13
|
+
// That key stays stable across reconnects so the consumer's subscription
|
|
14
|
+
// manager (which only ever sees the first subId) keeps routing notifications.
|
|
14
15
|
const activeSubscriptions = new Map();
|
|
16
|
+
// Reverse index for inbound notification translation.
|
|
17
|
+
const currentToConsumer = new Map();
|
|
18
|
+
const removeSubscription = (consumerSubId) => {
|
|
19
|
+
const sub = activeSubscriptions.get(consumerSubId);
|
|
20
|
+
if (sub === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
activeSubscriptions.delete(consumerSubId);
|
|
23
|
+
currentToConsumer.delete(sub.currentSubId);
|
|
24
|
+
// If a reconnect re-send for this entry is mid-flight, drop the pending
|
|
25
|
+
// record so the eventual response doesn't try to update a removed entry.
|
|
26
|
+
const pending = pendingSubscriptions.get(sub.id);
|
|
27
|
+
if (pending?.reconnectFor === consumerSubId)
|
|
28
|
+
pendingSubscriptions.delete(sub.id);
|
|
29
|
+
return sub;
|
|
30
|
+
};
|
|
15
31
|
const conn = provider(message => {
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const pending = pendingSubscriptions.get(id);
|
|
32
|
+
// Subscribe response (string result) — register fresh, or update an
|
|
33
|
+
// existing entry's currentSubId on a re-confirmation after reconnect.
|
|
34
|
+
if (isResponse(message) && message.id != null && 'result' in message && typeof message.result === 'string') {
|
|
35
|
+
const pending = pendingSubscriptions.get(message.id);
|
|
20
36
|
if (pending !== undefined) {
|
|
21
|
-
pendingSubscriptions.delete(id);
|
|
22
|
-
|
|
37
|
+
pendingSubscriptions.delete(message.id);
|
|
38
|
+
const newSubId = message.result;
|
|
39
|
+
if (pending.reconnectFor !== null) {
|
|
40
|
+
const sub = activeSubscriptions.get(pending.reconnectFor);
|
|
41
|
+
if (sub !== undefined) {
|
|
42
|
+
currentToConsumer.delete(sub.currentSubId);
|
|
43
|
+
sub.currentSubId = newSubId;
|
|
44
|
+
currentToConsumer.set(newSubId, pending.reconnectFor);
|
|
45
|
+
}
|
|
46
|
+
// Suppress: the consumer already saw the original response and
|
|
47
|
+
// registered its subscriber under the original subId.
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
activeSubscriptions.set(newSubId, { id: message.id, payload: pending.payload, currentSubId: newSubId });
|
|
51
|
+
currentToConsumer.set(newSubId, newSubId);
|
|
52
|
+
}
|
|
53
|
+
onMessage(message);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Notification: rewrite the server's current subId back to the consumer's
|
|
57
|
+
// stable subId so raw-client's subscription manager routes the event.
|
|
58
|
+
if (isRequest(message)) {
|
|
59
|
+
const params = message.params;
|
|
60
|
+
const incoming = params?.subscription;
|
|
61
|
+
if (typeof incoming === 'string') {
|
|
62
|
+
const consumerSubId = currentToConsumer.get(incoming);
|
|
63
|
+
if (consumerSubId !== undefined && consumerSubId !== incoming) {
|
|
64
|
+
onMessage({ ...message, params: { ...params, subscription: consumerSubId } });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
23
67
|
}
|
|
24
68
|
}
|
|
25
69
|
onMessage(message);
|
|
26
70
|
});
|
|
27
71
|
const unsubReconnect = onReconnect(() => {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
pendingSubscriptions.set(id, payload);
|
|
32
|
-
conn.send(payload);
|
|
72
|
+
for (const [consumerSubId, sub] of activeSubscriptions) {
|
|
73
|
+
pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
|
|
74
|
+
conn.send(sub.payload);
|
|
33
75
|
}
|
|
34
76
|
});
|
|
35
77
|
return {
|
|
36
78
|
send(message) {
|
|
37
|
-
|
|
38
|
-
|
|
79
|
+
if (isRequest(message)) {
|
|
80
|
+
const { method, id, params } = message;
|
|
39
81
|
if (isSubscribeMethod(method)) {
|
|
40
|
-
if (id
|
|
41
|
-
pendingSubscriptions.set(id, message);
|
|
82
|
+
if (id != null)
|
|
83
|
+
pendingSubscriptions.set(id, { payload: message, reconnectFor: null });
|
|
42
84
|
}
|
|
43
85
|
else if (isUnsubscribeMethod(method)) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
activeSubscriptions.delete(subId);
|
|
86
|
+
const consumerSubId = params?.[0];
|
|
87
|
+
const sub = consumerSubId !== undefined ? removeSubscription(consumerSubId) : undefined;
|
|
88
|
+
if (sub !== undefined && sub.currentSubId !== consumerSubId) {
|
|
89
|
+
const rest = (params ?? []).slice(1);
|
|
90
|
+
conn.send({ ...message, params: [sub.currentSubId, ...rest] });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
52
93
|
}
|
|
53
94
|
}
|
|
54
95
|
conn.send(message);
|
|
@@ -56,6 +97,7 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
|
|
|
56
97
|
disconnect() {
|
|
57
98
|
pendingSubscriptions.clear();
|
|
58
99
|
activeSubscriptions.clear();
|
|
100
|
+
currentToConsumer.clear();
|
|
59
101
|
unsubReconnect();
|
|
60
102
|
conn.disconnect();
|
|
61
103
|
},
|
|
@@ -98,23 +98,63 @@ describe('withSubscriptionReplay', () => {
|
|
|
98
98
|
expect(mock.send).toHaveBeenCalledWith(msg1);
|
|
99
99
|
expect(mock.send).toHaveBeenCalledWith(msg2);
|
|
100
100
|
});
|
|
101
|
-
it('
|
|
101
|
+
it('translates inbound notifications from the new server subId back to the consumer-facing subId after reconnect', () => {
|
|
102
|
+
const mock = createMockProvider();
|
|
103
|
+
const control = createReconnectControl();
|
|
104
|
+
const onMessage = vi.fn();
|
|
105
|
+
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(onMessage);
|
|
106
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
107
|
+
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
108
|
+
control.triggerReconnect();
|
|
109
|
+
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
110
|
+
// The post-reconnect re-confirmation must NOT reach the consumer; it would be
|
|
111
|
+
// a duplicate response for a request whose callback was consumed on first connect.
|
|
112
|
+
expect(onMessage).not.toHaveBeenCalledWith(res(1, 'new-sub-id'));
|
|
113
|
+
// Notifications from the server now arrive under the new server subId. The
|
|
114
|
+
// middleware must rewrite them to the consumer's stable subId, otherwise
|
|
115
|
+
// the consumer's subscription manager (which only ever saw 'old-sub-id')
|
|
116
|
+
// would route them nowhere.
|
|
117
|
+
onMessage.mockClear();
|
|
118
|
+
mock.simulateMessage({
|
|
119
|
+
jsonrpc: '2.0',
|
|
120
|
+
method: 'state_storage',
|
|
121
|
+
params: { subscription: 'new-sub-id', result: { changes: [] } },
|
|
122
|
+
});
|
|
123
|
+
expect(onMessage).toHaveBeenCalledWith({
|
|
124
|
+
jsonrpc: '2.0',
|
|
125
|
+
method: 'state_storage',
|
|
126
|
+
params: { subscription: 'old-sub-id', result: { changes: [] } },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
it('translates outbound unsubscribe requests from the consumer-facing subId to the current server subId', () => {
|
|
130
|
+
const mock = createMockProvider();
|
|
131
|
+
const control = createReconnectControl();
|
|
132
|
+
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
133
|
+
conn.send(req(1, 'statement_subscribeStatement'));
|
|
134
|
+
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
135
|
+
control.triggerReconnect();
|
|
136
|
+
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
137
|
+
mock.send.mockClear();
|
|
138
|
+
// Consumer only knows 'old-sub-id' (the first response it ever received).
|
|
139
|
+
// Middleware must rewrite the unsubscribe params to use the current server subId.
|
|
140
|
+
conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
|
|
141
|
+
expect(mock.send).toHaveBeenCalledWith(expect.objectContaining({ method: 'statement_unsubscribeStatement', params: ['new-sub-id'] }));
|
|
142
|
+
});
|
|
143
|
+
it('drops a subscription on consumer-side unsubscribe so it is not replayed on the next reconnect', () => {
|
|
102
144
|
const mock = createMockProvider();
|
|
103
145
|
const control = createReconnectControl();
|
|
104
146
|
const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
|
|
105
147
|
const subscribeMsg = req(1, 'statement_subscribeStatement');
|
|
106
148
|
conn.send(subscribeMsg);
|
|
107
149
|
mock.simulateMessage(res(1, 'old-sub-id'));
|
|
108
|
-
// First reconnect — replays and moves to pending
|
|
109
150
|
control.triggerReconnect();
|
|
110
|
-
// Server assigns new subscription ID
|
|
111
151
|
mock.simulateMessage(res(1, 'new-sub-id'));
|
|
112
|
-
//
|
|
152
|
+
// Consumer unsubscribes using the only subId it knows about.
|
|
113
153
|
conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
|
|
114
154
|
mock.send.mockClear();
|
|
115
|
-
// Second reconnect — subscription is still active (new-sub-id was not removed)
|
|
116
155
|
control.triggerReconnect();
|
|
117
|
-
|
|
156
|
+
// Subscription was unsubscribed; nothing should be re-sent.
|
|
157
|
+
expect(mock.send).not.toHaveBeenCalled();
|
|
118
158
|
});
|
|
119
159
|
it('does not replay unsubscribed subscriptions on reconnect', () => {
|
|
120
160
|
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.
|
|
4
|
+
"version": "0.7.5-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,7 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/storage-adapter": "0.7.
|
|
28
|
+
"@novasamatech/storage-adapter": "0.7.5-0",
|
|
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",
|