@novasamatech/host-substrate-chain-connection 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +634 -0
- package/dist/branchedProvider.d.ts +3 -0
- package/dist/branchedProvider.js +31 -0
- package/dist/branchedProvider.spec.d.ts +1 -0
- package/dist/branchedProvider.spec.js +102 -0
- package/dist/connectionManager.d.ts +6 -0
- package/dist/connectionManager.js +24 -0
- package/dist/connectionManager.spec.d.ts +1 -0
- package/dist/connectionManager.spec.js +47 -0
- package/dist/connectionPool.d.ts +20 -0
- package/dist/connectionPool.js +109 -0
- package/dist/connectionPool.spec.d.ts +1 -0
- package/dist/connectionPool.spec.js +192 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/metadataCache.d.ts +10 -0
- package/dist/metadataCache.js +47 -0
- package/dist/metadataCache.spec.d.ts +1 -0
- package/dist/metadataCache.spec.js +95 -0
- package/dist/providers.d.ts +6 -0
- package/dist/providers.js +26 -0
- package/dist/refCounter.d.ts +5 -0
- package/dist/refCounter.js +21 -0
- package/dist/refCounter.spec.d.ts +1 -0
- package/dist/refCounter.spec.js +33 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createBranchedProvider } from './branchedProvider.js';
|
|
3
|
+
const createMockProvider = () => {
|
|
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
|
+
return {
|
|
12
|
+
provider,
|
|
13
|
+
send,
|
|
14
|
+
disconnect,
|
|
15
|
+
simulateMessage: (msg) => onMessage?.(msg),
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
describe('createBranchedProvider', () => {
|
|
19
|
+
it('creates underlying connection on first branch', () => {
|
|
20
|
+
const mock = createMockProvider();
|
|
21
|
+
const branched = createBranchedProvider(mock.provider);
|
|
22
|
+
const onMessage = vi.fn();
|
|
23
|
+
branched.branch()(onMessage);
|
|
24
|
+
mock.simulateMessage('{"id":1}');
|
|
25
|
+
expect(onMessage).toHaveBeenCalledWith('{"id":1}');
|
|
26
|
+
});
|
|
27
|
+
it('reuses connection for multiple branches', () => {
|
|
28
|
+
const mock = createMockProvider();
|
|
29
|
+
const providerSpy = vi.fn(mock.provider);
|
|
30
|
+
const branched = createBranchedProvider(providerSpy);
|
|
31
|
+
branched.branch()(vi.fn());
|
|
32
|
+
branched.branch()(vi.fn());
|
|
33
|
+
expect(providerSpy).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
it('broadcasts messages to all active branches', () => {
|
|
36
|
+
const mock = createMockProvider();
|
|
37
|
+
const branched = createBranchedProvider(mock.provider);
|
|
38
|
+
const cb1 = vi.fn();
|
|
39
|
+
const cb2 = vi.fn();
|
|
40
|
+
branched.branch()(cb1);
|
|
41
|
+
branched.branch()(cb2);
|
|
42
|
+
mock.simulateMessage('{"id":1}');
|
|
43
|
+
expect(cb1).toHaveBeenCalledWith('{"id":1}');
|
|
44
|
+
expect(cb2).toHaveBeenCalledWith('{"id":1}');
|
|
45
|
+
});
|
|
46
|
+
it('disconnected branch stops receiving messages', () => {
|
|
47
|
+
const mock = createMockProvider();
|
|
48
|
+
const branched = createBranchedProvider(mock.provider);
|
|
49
|
+
const cb1 = vi.fn();
|
|
50
|
+
const conn1 = branched.branch()(cb1);
|
|
51
|
+
branched.branch()(vi.fn()); // keep connection alive
|
|
52
|
+
conn1.disconnect();
|
|
53
|
+
mock.simulateMessage('{"id":1}');
|
|
54
|
+
expect(cb1).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('disconnecting one branch does not affect others', () => {
|
|
57
|
+
const mock = createMockProvider();
|
|
58
|
+
const branched = createBranchedProvider(mock.provider);
|
|
59
|
+
const conn1 = branched.branch()(vi.fn());
|
|
60
|
+
const cb2 = vi.fn();
|
|
61
|
+
branched.branch()(cb2);
|
|
62
|
+
conn1.disconnect();
|
|
63
|
+
mock.simulateMessage('{"id":1}');
|
|
64
|
+
expect(cb2).toHaveBeenCalledWith('{"id":1}');
|
|
65
|
+
expect(mock.disconnect).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
it('disconnecting last branch tears down underlying connection', () => {
|
|
68
|
+
const mock = createMockProvider();
|
|
69
|
+
const branched = createBranchedProvider(mock.provider);
|
|
70
|
+
const conn1 = branched.branch()(vi.fn());
|
|
71
|
+
const conn2 = branched.branch()(vi.fn());
|
|
72
|
+
conn1.disconnect();
|
|
73
|
+
expect(mock.disconnect).not.toHaveBeenCalled();
|
|
74
|
+
conn2.disconnect();
|
|
75
|
+
expect(mock.disconnect).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
it('calls onDisconnect callback on branch disconnect', () => {
|
|
78
|
+
const mock = createMockProvider();
|
|
79
|
+
const branched = createBranchedProvider(mock.provider);
|
|
80
|
+
const onDisconnect = vi.fn();
|
|
81
|
+
const conn = branched.branch(onDisconnect)(vi.fn());
|
|
82
|
+
conn.disconnect();
|
|
83
|
+
expect(onDisconnect).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
it('new branch after full teardown creates fresh connection', () => {
|
|
86
|
+
const mock = createMockProvider();
|
|
87
|
+
const providerSpy = vi.fn(mock.provider);
|
|
88
|
+
const branched = createBranchedProvider(providerSpy);
|
|
89
|
+
const conn = branched.branch()(vi.fn());
|
|
90
|
+
conn.disconnect();
|
|
91
|
+
expect(providerSpy).toHaveBeenCalledTimes(1);
|
|
92
|
+
branched.branch()(vi.fn());
|
|
93
|
+
expect(providerSpy).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
it('branch.send delegates to underlying connection', () => {
|
|
96
|
+
const mock = createMockProvider();
|
|
97
|
+
const branched = createBranchedProvider(mock.provider);
|
|
98
|
+
const conn = branched.branch()(vi.fn());
|
|
99
|
+
conn.send('{"method":"test"}');
|
|
100
|
+
expect(mock.send).toHaveBeenCalledWith('{"method":"test"}');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ConnectionStatus } from './types.js';
|
|
2
|
+
export declare const createConnectionManager: () => {
|
|
3
|
+
getConnectionStatus(chainId: string): ConnectionStatus;
|
|
4
|
+
update(chainId: string, status: ConnectionStatus): void;
|
|
5
|
+
onStatusChange(chainId: string, callback: (status: ConnectionStatus) => void): import("nanoevents").Unsubscribe;
|
|
6
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createNanoEvents } from 'nanoevents';
|
|
2
|
+
export const createConnectionManager = () => {
|
|
3
|
+
const events = createNanoEvents();
|
|
4
|
+
const connectionStatuses = new Map();
|
|
5
|
+
events.on('status', ({ chainId, status }) => {
|
|
6
|
+
connectionStatuses.set(chainId, status);
|
|
7
|
+
});
|
|
8
|
+
return {
|
|
9
|
+
getConnectionStatus(chainId) {
|
|
10
|
+
return connectionStatuses.get(chainId) ?? 'disconnected';
|
|
11
|
+
},
|
|
12
|
+
update(chainId, status) {
|
|
13
|
+
events.emit('status', { chainId, status });
|
|
14
|
+
},
|
|
15
|
+
onStatusChange(chainId, callback) {
|
|
16
|
+
const handler = (event) => {
|
|
17
|
+
if (event.chainId === chainId) {
|
|
18
|
+
callback(event.status);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return events.on('status', handler);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createConnectionManager } from './connectionManager.js';
|
|
3
|
+
describe('createConnectionManager', () => {
|
|
4
|
+
it('returns disconnected for unknown chainId', () => {
|
|
5
|
+
const manager = createConnectionManager();
|
|
6
|
+
expect(manager.getConnectionStatus('unknown')).toBe('disconnected');
|
|
7
|
+
});
|
|
8
|
+
it('returns current status after update', () => {
|
|
9
|
+
const manager = createConnectionManager();
|
|
10
|
+
manager.update('chain-a', 'connecting');
|
|
11
|
+
expect(manager.getConnectionStatus('chain-a')).toBe('connecting');
|
|
12
|
+
manager.update('chain-a', 'connected');
|
|
13
|
+
expect(manager.getConnectionStatus('chain-a')).toBe('connected');
|
|
14
|
+
});
|
|
15
|
+
it('fires onStatusChange callbacks', () => {
|
|
16
|
+
const manager = createConnectionManager();
|
|
17
|
+
const callback = vi.fn();
|
|
18
|
+
manager.onStatusChange('chain-a', callback);
|
|
19
|
+
manager.update('chain-a', 'connected');
|
|
20
|
+
expect(callback).toHaveBeenCalledWith('connected');
|
|
21
|
+
});
|
|
22
|
+
it('only fires for matching chainId', () => {
|
|
23
|
+
const manager = createConnectionManager();
|
|
24
|
+
const callback = vi.fn();
|
|
25
|
+
manager.onStatusChange('chain-a', callback);
|
|
26
|
+
manager.update('chain-b', 'connected');
|
|
27
|
+
expect(callback).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
it('supports multiple subscribers', () => {
|
|
30
|
+
const manager = createConnectionManager();
|
|
31
|
+
const cb1 = vi.fn();
|
|
32
|
+
const cb2 = vi.fn();
|
|
33
|
+
manager.onStatusChange('chain-a', cb1);
|
|
34
|
+
manager.onStatusChange('chain-a', cb2);
|
|
35
|
+
manager.update('chain-a', 'connected');
|
|
36
|
+
expect(cb1).toHaveBeenCalledWith('connected');
|
|
37
|
+
expect(cb2).toHaveBeenCalledWith('connected');
|
|
38
|
+
});
|
|
39
|
+
it('unsubscribe stops notifications', () => {
|
|
40
|
+
const manager = createConnectionManager();
|
|
41
|
+
const callback = vi.fn();
|
|
42
|
+
const unsub = manager.onStatusChange('chain-a', callback);
|
|
43
|
+
unsub();
|
|
44
|
+
manager.update('chain-a', 'connected');
|
|
45
|
+
expect(callback).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
|
|
2
|
+
import type { PolkadotClient } from 'polkadot-api';
|
|
3
|
+
import { createClient } from 'polkadot-api';
|
|
4
|
+
import type { ChainConfig, ConnectionStatus } from './types.js';
|
|
5
|
+
export type ChainConnectionConfig<C extends ChainConfig, T = PolkadotClient> = {
|
|
6
|
+
createProvider: (chain: C, onStatusChanged: (status: ConnectionStatus) => void) => JsonRpcProvider;
|
|
7
|
+
clientOptions?: (chain: C) => Parameters<typeof createClient>[1];
|
|
8
|
+
resolve?: (chain: C, client: PolkadotClient) => Promise<T>;
|
|
9
|
+
};
|
|
10
|
+
export type ChainConnection<C extends ChainConfig, T = PolkadotClient> = {
|
|
11
|
+
lockApi(chain: C): Promise<{
|
|
12
|
+
api: T;
|
|
13
|
+
unlock: VoidFunction;
|
|
14
|
+
}>;
|
|
15
|
+
requestApi<Return>(chain: C, callback: (api: T) => Return): Promise<Awaited<Return>>;
|
|
16
|
+
getProvider(chain: C): JsonRpcProvider;
|
|
17
|
+
status(chainId: string): ConnectionStatus;
|
|
18
|
+
onStatusChanged(chainId: string, callback: (status: ConnectionStatus) => void): VoidFunction;
|
|
19
|
+
};
|
|
20
|
+
export declare const createChainConnection: <C extends ChainConfig, T = PolkadotClient>(config: ChainConnectionConfig<C, T>) => ChainConnection<C, T>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { getSyncProvider } from '@polkadot-api/json-rpc-provider-proxy';
|
|
2
|
+
import { createClient } from 'polkadot-api';
|
|
3
|
+
import { createBranchedProvider } from './branchedProvider.js';
|
|
4
|
+
import { createConnectionManager } from './connectionManager.js';
|
|
5
|
+
import { createRefCounter } from './refCounter.js';
|
|
6
|
+
export const createChainConnection = (config) => {
|
|
7
|
+
const connections = createConnectionManager();
|
|
8
|
+
const refCounter = createRefCounter();
|
|
9
|
+
const existingClients = new Map();
|
|
10
|
+
// Resolve cache (when config.resolve is provided)
|
|
11
|
+
const resolvedApis = new Map();
|
|
12
|
+
const pendingResolutions = new Map();
|
|
13
|
+
const getOrCreateClient = (chain) => {
|
|
14
|
+
const existing = existingClients.get(chain.chainId);
|
|
15
|
+
if (existing)
|
|
16
|
+
return existing;
|
|
17
|
+
const provider = config.createProvider(chain, status => connections.update(chain.chainId, status));
|
|
18
|
+
const branchedProvider = createBranchedProvider(provider);
|
|
19
|
+
const client = createClient(branchedProvider.branch(), config.clientOptions?.(chain));
|
|
20
|
+
const pooled = { client, provider: branchedProvider };
|
|
21
|
+
existingClients.set(chain.chainId, pooled);
|
|
22
|
+
return pooled;
|
|
23
|
+
};
|
|
24
|
+
const destroyClient = (chainId) => {
|
|
25
|
+
const pooled = existingClients.get(chainId);
|
|
26
|
+
if (pooled) {
|
|
27
|
+
existingClients.delete(chainId);
|
|
28
|
+
connections.update(chainId, 'disconnected');
|
|
29
|
+
pooled.client.destroy();
|
|
30
|
+
}
|
|
31
|
+
resolvedApis.delete(chainId);
|
|
32
|
+
pendingResolutions.delete(chainId);
|
|
33
|
+
};
|
|
34
|
+
const rawAcquire = async (chain) => {
|
|
35
|
+
try {
|
|
36
|
+
refCounter.increment(chain.chainId);
|
|
37
|
+
const pooled = getOrCreateClient(chain);
|
|
38
|
+
await pooled.client.getBestBlocks();
|
|
39
|
+
return {
|
|
40
|
+
pooled,
|
|
41
|
+
unlock() {
|
|
42
|
+
refCounter.decrement(chain.chainId);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (refCounter.decrement(chain.chainId) === 0) {
|
|
48
|
+
destroyClient(chain.chainId);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const resolveApi = async (chain, polkadotClient) => {
|
|
54
|
+
if (!config.resolve)
|
|
55
|
+
return polkadotClient;
|
|
56
|
+
const existing = resolvedApis.get(chain.chainId);
|
|
57
|
+
if (existing && existing.polkadotClient === polkadotClient)
|
|
58
|
+
return existing.resolved;
|
|
59
|
+
const pending = pendingResolutions.get(chain.chainId);
|
|
60
|
+
if (pending && pending.polkadotClient === polkadotClient)
|
|
61
|
+
return pending.promise;
|
|
62
|
+
const promise = (async () => {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by early return above
|
|
64
|
+
const resolved = await config.resolve(chain, polkadotClient);
|
|
65
|
+
resolvedApis.set(chain.chainId, { resolved, polkadotClient });
|
|
66
|
+
return resolved;
|
|
67
|
+
})();
|
|
68
|
+
pendingResolutions.set(chain.chainId, { promise, polkadotClient });
|
|
69
|
+
promise.finally(() => pendingResolutions.delete(chain.chainId));
|
|
70
|
+
return promise;
|
|
71
|
+
};
|
|
72
|
+
const connection = {
|
|
73
|
+
async lockApi(chain) {
|
|
74
|
+
const { pooled, unlock } = await rawAcquire(chain);
|
|
75
|
+
try {
|
|
76
|
+
const api = await resolveApi(chain, pooled.client);
|
|
77
|
+
return { api, unlock };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
unlock();
|
|
81
|
+
resolvedApis.delete(chain.chainId);
|
|
82
|
+
pendingResolutions.delete(chain.chainId);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
requestApi: (async (chain, callback) => {
|
|
87
|
+
const { api, unlock } = await connection.lockApi(chain);
|
|
88
|
+
try {
|
|
89
|
+
return await callback(api);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
unlock();
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
getProvider(chain) {
|
|
96
|
+
return getSyncProvider(async () => {
|
|
97
|
+
const { pooled, unlock } = await rawAcquire(chain);
|
|
98
|
+
return pooled.provider.branch(unlock);
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
status(chainId) {
|
|
102
|
+
return connections.getConnectionStatus(chainId);
|
|
103
|
+
},
|
|
104
|
+
onStatusChanged(chainId, callback) {
|
|
105
|
+
return connections.onStatusChange(chainId, callback);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return connection;
|
|
109
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createChainConnection } from './connectionPool.js';
|
|
3
|
+
vi.mock('polkadot-api', () => ({
|
|
4
|
+
createClient: vi.fn((_provider, _options) => createMockClient()),
|
|
5
|
+
}));
|
|
6
|
+
const createMockClient = () => ({
|
|
7
|
+
getBestBlocks: vi.fn().mockResolvedValue([]),
|
|
8
|
+
destroy: vi.fn(),
|
|
9
|
+
});
|
|
10
|
+
const createMockProvider = () => {
|
|
11
|
+
const send = vi.fn();
|
|
12
|
+
const disconnect = vi.fn();
|
|
13
|
+
let onMessage = null;
|
|
14
|
+
const provider = cb => {
|
|
15
|
+
onMessage = cb;
|
|
16
|
+
return { send, disconnect };
|
|
17
|
+
};
|
|
18
|
+
return { provider, send, disconnect, simulateMessage: (msg) => onMessage?.(msg) };
|
|
19
|
+
};
|
|
20
|
+
const testChain = (id) => ({ chainId: id, nodes: [{ url: 'wss://test' }] });
|
|
21
|
+
const createTestConnection = (overrides) => {
|
|
22
|
+
const mockProvider = createMockProvider();
|
|
23
|
+
const connection = createChainConnection({
|
|
24
|
+
createProvider: () => mockProvider.provider,
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
return { connection, mockProvider };
|
|
28
|
+
};
|
|
29
|
+
describe('createChainConnection', () => {
|
|
30
|
+
describe('lockApi', () => {
|
|
31
|
+
it('returns api and unlock function', async () => {
|
|
32
|
+
const { connection } = createTestConnection();
|
|
33
|
+
const { api, unlock } = await connection.lockApi(testChain('a'));
|
|
34
|
+
expect(api).toBeDefined();
|
|
35
|
+
expect(typeof unlock).toBe('function');
|
|
36
|
+
unlock();
|
|
37
|
+
});
|
|
38
|
+
it('reuses client for same chainId', async () => {
|
|
39
|
+
const { connection } = createTestConnection();
|
|
40
|
+
const chain = testChain('a');
|
|
41
|
+
const { api: api1, unlock: u1 } = await connection.lockApi(chain);
|
|
42
|
+
const { api: api2, unlock: u2 } = await connection.lockApi(chain);
|
|
43
|
+
expect(api1).toBe(api2);
|
|
44
|
+
u1();
|
|
45
|
+
u2();
|
|
46
|
+
});
|
|
47
|
+
it('creates separate clients for different chains', async () => {
|
|
48
|
+
const { connection } = createTestConnection();
|
|
49
|
+
const { api: api1, unlock: u1 } = await connection.lockApi(testChain('a'));
|
|
50
|
+
const { api: api2, unlock: u2 } = await connection.lockApi(testChain('b'));
|
|
51
|
+
expect(api1).not.toBe(api2);
|
|
52
|
+
u1();
|
|
53
|
+
u2();
|
|
54
|
+
});
|
|
55
|
+
it('calls getBestBlocks to verify connectivity', async () => {
|
|
56
|
+
const { connection } = createTestConnection();
|
|
57
|
+
const { api, unlock } = await connection.lockApi(testChain('a'));
|
|
58
|
+
expect(api.getBestBlocks).toHaveBeenCalled();
|
|
59
|
+
unlock();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('lockApi — with resolve', () => {
|
|
63
|
+
it('calls resolve with chain and polkadotClient', async () => {
|
|
64
|
+
const resolve = vi.fn().mockResolvedValue('resolved-api');
|
|
65
|
+
const { connection } = createTestConnection({ resolve });
|
|
66
|
+
const chain = testChain('a');
|
|
67
|
+
const { api, unlock } = await connection.lockApi(chain);
|
|
68
|
+
expect(api).toBe('resolved-api');
|
|
69
|
+
expect(resolve).toHaveBeenCalledWith(chain, expect.anything());
|
|
70
|
+
unlock();
|
|
71
|
+
});
|
|
72
|
+
it('caches resolved api for subsequent calls', async () => {
|
|
73
|
+
const resolve = vi.fn().mockResolvedValue('resolved-api');
|
|
74
|
+
const { connection } = createTestConnection({ resolve });
|
|
75
|
+
const chain = testChain('a');
|
|
76
|
+
const { unlock: u1 } = await connection.lockApi(chain);
|
|
77
|
+
const { unlock: u2 } = await connection.lockApi(chain);
|
|
78
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
79
|
+
u1();
|
|
80
|
+
u2();
|
|
81
|
+
});
|
|
82
|
+
it('deduplicates concurrent resolutions', async () => {
|
|
83
|
+
const resolve = vi.fn().mockImplementation(() => new Promise(r => setTimeout(() => r('resolved-api'), 10)));
|
|
84
|
+
const { connection } = createTestConnection({ resolve });
|
|
85
|
+
const chain = testChain('a');
|
|
86
|
+
const [r1, r2] = await Promise.all([connection.lockApi(chain), connection.lockApi(chain)]);
|
|
87
|
+
expect(resolve).toHaveBeenCalledTimes(1);
|
|
88
|
+
r1.unlock();
|
|
89
|
+
r2.unlock();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('lockApi — error handling', () => {
|
|
93
|
+
it('throws when getBestBlocks rejects', async () => {
|
|
94
|
+
vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
|
|
95
|
+
getBestBlocks: vi.fn().mockRejectedValue(new Error('connection failed')),
|
|
96
|
+
destroy: vi.fn(),
|
|
97
|
+
});
|
|
98
|
+
const { connection } = createTestConnection();
|
|
99
|
+
await expect(connection.lockApi(testChain('a'))).rejects.toThrow('connection failed');
|
|
100
|
+
});
|
|
101
|
+
it('destroys client on error when ref count reaches 0', async () => {
|
|
102
|
+
const destroyFn = vi.fn();
|
|
103
|
+
vi.mocked(await import('polkadot-api')).createClient.mockReturnValueOnce({
|
|
104
|
+
getBestBlocks: vi.fn().mockRejectedValue(new Error('fail')),
|
|
105
|
+
destroy: destroyFn,
|
|
106
|
+
});
|
|
107
|
+
const { connection } = createTestConnection();
|
|
108
|
+
await expect(connection.lockApi(testChain('a'))).rejects.toThrow();
|
|
109
|
+
expect(destroyFn).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
it('throws when resolve rejects and calls unlock', async () => {
|
|
112
|
+
const resolve = vi.fn().mockRejectedValue(new Error('resolve failed'));
|
|
113
|
+
const { connection } = createTestConnection({ resolve });
|
|
114
|
+
// Suppress the unhandled rejection from the detached .finally() promise chain
|
|
115
|
+
// in connectionPool.ts (pendingResolutions cleanup).
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
117
|
+
const suppress = () => { };
|
|
118
|
+
process.on('unhandledRejection', suppress);
|
|
119
|
+
await expect(connection.lockApi(testChain('a'))).rejects.toThrow('resolve failed');
|
|
120
|
+
// Let microtask queue flush so the .finally() settles
|
|
121
|
+
await new Promise(r => setTimeout(r, 0));
|
|
122
|
+
process.off('unhandledRejection', suppress);
|
|
123
|
+
// After resolve failure with last ref released, new lock should create fresh client
|
|
124
|
+
resolve.mockResolvedValue('recovered');
|
|
125
|
+
const { api, unlock } = await connection.lockApi(testChain('a'));
|
|
126
|
+
expect(api).toBe('recovered');
|
|
127
|
+
unlock();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('requestApi', () => {
|
|
131
|
+
it('calls callback with api and returns result', async () => {
|
|
132
|
+
const { connection } = createTestConnection();
|
|
133
|
+
const result = await connection.requestApi(testChain('a'), api => {
|
|
134
|
+
expect(api).toBeDefined();
|
|
135
|
+
return 42;
|
|
136
|
+
});
|
|
137
|
+
expect(result).toBe(42);
|
|
138
|
+
});
|
|
139
|
+
it('unlocks after callback completes', async () => {
|
|
140
|
+
const { connection } = createTestConnection();
|
|
141
|
+
await connection.requestApi(testChain('a'), () => 'done');
|
|
142
|
+
// Subsequent request should work without issue
|
|
143
|
+
const result = await connection.requestApi(testChain('a'), () => 'again');
|
|
144
|
+
expect(result).toBe('again');
|
|
145
|
+
});
|
|
146
|
+
it('unlocks when callback throws', async () => {
|
|
147
|
+
const { connection } = createTestConnection();
|
|
148
|
+
await expect(connection.requestApi(testChain('a'), () => {
|
|
149
|
+
throw new Error('callback error');
|
|
150
|
+
})).rejects.toThrow('callback error');
|
|
151
|
+
// Should still work after error
|
|
152
|
+
const result = await connection.requestApi(testChain('a'), () => 'ok');
|
|
153
|
+
expect(result).toBe('ok');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('status / onStatusChanged', () => {
|
|
157
|
+
it('returns disconnected for unknown chain', () => {
|
|
158
|
+
const { connection } = createTestConnection();
|
|
159
|
+
expect(connection.status('unknown')).toBe('disconnected');
|
|
160
|
+
});
|
|
161
|
+
it('reflects status from createProvider callback', async () => {
|
|
162
|
+
let statusCb;
|
|
163
|
+
const { connection } = createTestConnection({
|
|
164
|
+
createProvider: (_chain, onStatusChanged) => {
|
|
165
|
+
statusCb = onStatusChanged;
|
|
166
|
+
return createMockProvider().provider;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await connection.lockApi(testChain('a')).then(({ unlock }) => unlock());
|
|
170
|
+
statusCb('connected');
|
|
171
|
+
expect(connection.status('a')).toBe('connected');
|
|
172
|
+
});
|
|
173
|
+
it('onStatusChanged returns unsubscribe function', async () => {
|
|
174
|
+
let statusCb;
|
|
175
|
+
const { connection } = createTestConnection({
|
|
176
|
+
createProvider: (_chain, onStatusChanged) => {
|
|
177
|
+
statusCb = onStatusChanged;
|
|
178
|
+
return createMockProvider().provider;
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
await connection.lockApi(testChain('a')).then(({ unlock }) => unlock());
|
|
182
|
+
const callback = vi.fn();
|
|
183
|
+
const unsub = connection.onStatusChanged('a', callback);
|
|
184
|
+
statusCb('connected');
|
|
185
|
+
expect(callback).toHaveBeenCalledWith('connected');
|
|
186
|
+
unsub();
|
|
187
|
+
callback.mockClear();
|
|
188
|
+
statusCb('disconnected');
|
|
189
|
+
expect(callback).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type ChainConnection, type ChainConnectionConfig, createChainConnection } from './connectionPool.js';
|
|
2
|
+
export { type MetadataCache, createMetadataCache } from './metadataCache.js';
|
|
3
|
+
export { createWsJsonRpcProvider } from './providers.js';
|
|
4
|
+
export { type ChainConfig, type ConnectionStatus } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { StorageAdapter } from '@novasamatech/storage-adapter';
|
|
2
|
+
import { createClient } from 'polkadot-api';
|
|
3
|
+
type ClientOptions = NonNullable<Parameters<typeof createClient>[1]>;
|
|
4
|
+
export type MetadataCache = {
|
|
5
|
+
forChain(chainId: string): ClientOptions;
|
|
6
|
+
};
|
|
7
|
+
export declare const createMetadataCache: (options?: {
|
|
8
|
+
storage?: StorageAdapter;
|
|
9
|
+
}) => MetadataCache;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createClient } from 'polkadot-api';
|
|
2
|
+
const bytesToBase64 = (bytes) => {
|
|
3
|
+
let binary = '';
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
binary += String.fromCharCode(byte);
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary);
|
|
8
|
+
};
|
|
9
|
+
const base64ToBytes = (base64) => {
|
|
10
|
+
const binary = atob(base64);
|
|
11
|
+
const bytes = new Uint8Array(binary.length);
|
|
12
|
+
for (let i = 0; i < binary.length; i++) {
|
|
13
|
+
bytes[i] = binary.charCodeAt(i);
|
|
14
|
+
}
|
|
15
|
+
return bytes;
|
|
16
|
+
};
|
|
17
|
+
export const createMetadataCache = (options) => {
|
|
18
|
+
const memory = new Map();
|
|
19
|
+
const storage = options?.storage;
|
|
20
|
+
const cacheKey = (chainId, key) => `${chainId}:${key}`;
|
|
21
|
+
return {
|
|
22
|
+
forChain(chainId) {
|
|
23
|
+
return {
|
|
24
|
+
async getMetadata(key) {
|
|
25
|
+
const k = cacheKey(chainId, key);
|
|
26
|
+
const cached = memory.get(k);
|
|
27
|
+
if (cached)
|
|
28
|
+
return cached;
|
|
29
|
+
if (storage) {
|
|
30
|
+
const result = await storage.read(k);
|
|
31
|
+
if (result.isOk() && result.value) {
|
|
32
|
+
const bytes = base64ToBytes(result.value);
|
|
33
|
+
memory.set(k, bytes);
|
|
34
|
+
return bytes;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
},
|
|
39
|
+
setMetadata(key, metadata) {
|
|
40
|
+
const k = cacheKey(chainId, key);
|
|
41
|
+
memory.set(k, metadata);
|
|
42
|
+
storage?.write(k, bytesToBase64(metadata));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|