@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
package/README.md
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# @novasamatech/host-substrate-chain-connection
|
|
2
|
+
|
|
3
|
+
Reference-counted connection pool for [polkadot-api](https://github.com/polkadot-api/polkadot-api). Connections are created on first use, shared across callers, and destroyed when the last caller releases them.
|
|
4
|
+
|
|
5
|
+
- **Shared connections** - one underlying WebSocket (or light client) per chain, multiplexed across consumers
|
|
6
|
+
- **Automatic lifecycle** - ref-counted; opens on first acquire, closes when the last caller releases
|
|
7
|
+
- **Flexible resolution** - transform the raw `PolkadotClient` into any app-specific type via `resolve`
|
|
8
|
+
- **Metadata caching** - optional persistent cache so `polkadot-api` skips re-fetching metadata on reconnect
|
|
9
|
+
- **Status tracking** - subscribe to per-chain connection status changes
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @novasamatech/host-substrate-chain-connection
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import {
|
|
21
|
+
createChainConnection,
|
|
22
|
+
createWsJsonRpcProvider,
|
|
23
|
+
type ChainConfig,
|
|
24
|
+
} from '@novasamatech/host-substrate-chain-connection';
|
|
25
|
+
import { dot } from '@polkadot-api/descriptors';
|
|
26
|
+
|
|
27
|
+
const polkadot: ChainConfig = {
|
|
28
|
+
chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
|
29
|
+
nodes: [{ url: 'wss://rpc.polkadot.io' }],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const chains = createChainConnection({
|
|
33
|
+
createProvider: (chain, onStatusChanged) =>
|
|
34
|
+
createWsJsonRpcProvider({
|
|
35
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
36
|
+
onStatusChanged,
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// One-shot query - connection is acquired and released automatically
|
|
41
|
+
const account = await chains.requestApi(polkadot, async (client) => {
|
|
42
|
+
const api = client.getTypedApi(dot);
|
|
43
|
+
return api.query.System.Account.getValue('5GrwvaEF...');
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Table of contents
|
|
48
|
+
|
|
49
|
+
- [API](#api)
|
|
50
|
+
- [`createChainConnection`](#createchainconnectionconfig)
|
|
51
|
+
- [`requestApi`](#requestapichain-callback)
|
|
52
|
+
- [`lockApi`](#lockapichain)
|
|
53
|
+
- [`getProvider`](#getproviderchain)
|
|
54
|
+
- [`status` / `onStatusChanged`](#statuschainid--onstatuschangedchainid-callback)
|
|
55
|
+
- [`createWsJsonRpcProvider`](#createwsjsonrpcprovideroptions)
|
|
56
|
+
- [`createMetadataCache`](#createmetadatacacheoptions)
|
|
57
|
+
- [Recipes](#recipes)
|
|
58
|
+
- [Custom resolve](#custom-resolve)
|
|
59
|
+
- [Metadata caching](#metadata-caching)
|
|
60
|
+
- [Smoldot light client](#smoldot-light-client)
|
|
61
|
+
- [Multiple chains](#multiple-chains)
|
|
62
|
+
- [How it works](#how-it-works)
|
|
63
|
+
- [Full example](#full-example)
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### `createChainConnection(config)`
|
|
68
|
+
|
|
69
|
+
Creates a connection pool. Generic over your chain config `C` and resolved API type `T`.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
function createChainConnection<C extends ChainConfig, T = PolkadotClient>(
|
|
73
|
+
config: ChainConnectionConfig<C, T>,
|
|
74
|
+
): ChainConnection<C, T>;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**`ChainConnectionConfig<C, T>`**
|
|
78
|
+
|
|
79
|
+
| Field | Type | Description |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `createProvider` | `(chain: C, onStatusChanged: (status: ConnectionStatus) => void) => JsonRpcProvider` | Factory for the underlying JSON-RPC transport. Called once per chain. Use `onStatusChanged` to feed connection status back into the pool. |
|
|
82
|
+
| `clientOptions` | `(chain: C) => ClientOptions` | Optional. Returns [polkadot-api client options](https://papi.how/) - typically metadata cache hooks (`getMetadata` / `setMetadata`). |
|
|
83
|
+
| `resolve` | `(chain: C, client: PolkadotClient) => Promise<T>` | Optional. Transforms the raw `PolkadotClient` into your app's API type. The result is cached per chain. If omitted, `T` defaults to `PolkadotClient`. |
|
|
84
|
+
|
|
85
|
+
**`ChainConfig`** - minimum shape your chain objects must satisfy:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
type ChainConfig = {
|
|
89
|
+
chainId: string;
|
|
90
|
+
nodes: ReadonlyArray<{ url: string }>;
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Returns a [`ChainConnection<C, T>`](#requestapichain-callback) with the methods below.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
### `requestApi(chain, callback)`
|
|
99
|
+
|
|
100
|
+
Acquires a connection, runs the callback, and releases automatically when it settles. Best for one-shot queries.
|
|
101
|
+
|
|
102
|
+
**Signature:**
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
requestApi<Return>(chain: C, callback: (api: T) => Return): Promise<Awaited<Return>>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Example:**
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const account = await chains.requestApi(polkadot, async (client) => {
|
|
112
|
+
const api = client.getTypedApi(dot);
|
|
113
|
+
return api.query.System.Account.getValue('5GrwvaEF...');
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `lockApi(chain)`
|
|
120
|
+
|
|
121
|
+
Acquires a connection and holds it until `unlock()` is called. Use for subscriptions or multi-step flows where you need the connection to stay alive.
|
|
122
|
+
|
|
123
|
+
**Signature:**
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
lockApi(chain: C): Promise<{ api: T; unlock: VoidFunction }>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Example:**
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const { api: client, unlock } = await chains.lockApi(polkadot);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const api = client.getTypedApi(dot);
|
|
136
|
+
|
|
137
|
+
const sub = api.query.System.Account.watchValue('5GrwvaEF...').subscribe({
|
|
138
|
+
next: (value) => console.info('Balance:', value.data.free),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// later...
|
|
142
|
+
sub.unsubscribe();
|
|
143
|
+
} finally {
|
|
144
|
+
unlock();
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### `getProvider(chain)`
|
|
151
|
+
|
|
152
|
+
Returns a `JsonRpcProvider` backed by the shared pooled connection. The provider holds a ref-counted branch - the underlying connection opens when the provider starts and closes when `disconnect()` is called.
|
|
153
|
+
|
|
154
|
+
Useful for passing a provider to an iframe, webview, or any library that expects a raw `JsonRpcProvider`.
|
|
155
|
+
|
|
156
|
+
**Signature:**
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
getProvider(chain: C): JsonRpcProvider
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Example:**
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const provider = chains.getProvider(polkadot);
|
|
166
|
+
// Connection is released when the consumer calls disconnect()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### `status(chainId)` / `onStatusChanged(chainId, callback)`
|
|
172
|
+
|
|
173
|
+
Read or subscribe to connection status. Both take a `chainId` string (the genesis hash). Returns `'disconnected'` for chains that have never been connected.
|
|
174
|
+
|
|
175
|
+
**Signature:**
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
status(chainId: string): ConnectionStatus
|
|
179
|
+
// ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
|
180
|
+
|
|
181
|
+
onStatusChanged(chainId: string, callback: (status: ConnectionStatus) => void): VoidFunction
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Example:**
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const currentStatus = chains.status(polkadot.chainId);
|
|
188
|
+
|
|
189
|
+
const unsubscribe = chains.onStatusChanged(polkadot.chainId, (status) => {
|
|
190
|
+
console.info('Polkadot:', status);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Stop listening:
|
|
194
|
+
unsubscribe();
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### `createWsJsonRpcProvider(options)`
|
|
200
|
+
|
|
201
|
+
WebSocket provider factory. Wraps polkadot-api's `getWsProvider` with `polkadot-sdk-compat` and translates WebSocket events to `ConnectionStatus`.
|
|
202
|
+
|
|
203
|
+
**Signature:**
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
function createWsJsonRpcProvider(options: {
|
|
207
|
+
endpoints: string[];
|
|
208
|
+
onStatusChanged?: (status: ConnectionStatus) => void;
|
|
209
|
+
}): JsonRpcProvider;
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Example:**
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const provider = createWsJsonRpcProvider({
|
|
216
|
+
endpoints: ['wss://rpc.polkadot.io', 'wss://polkadot-rpc.dwellir.com'],
|
|
217
|
+
onStatusChanged: (status) => console.info(status),
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
### `createMetadataCache(options)`
|
|
224
|
+
|
|
225
|
+
Caches chain metadata in memory, with optional persistence via a `StorageAdapter`. Wire it into `clientOptions` so polkadot-api skips re-fetching metadata on reconnect.
|
|
226
|
+
|
|
227
|
+
**Signature:**
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
function createMetadataCache(options?: { storage?: StorageAdapter }): MetadataCache;
|
|
231
|
+
|
|
232
|
+
type MetadataCache = {
|
|
233
|
+
forChain(chainId: string): ClientOptions;
|
|
234
|
+
};
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`forChain` returns an object with `getMetadata` / `setMetadata` methods that polkadot-api's `createClient` accepts as its second argument.
|
|
238
|
+
|
|
239
|
+
**Example:**
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { createMetadataCache } from '@novasamatech/host-substrate-chain-connection';
|
|
243
|
+
import { createLocalStorageAdapter } from '@novasamatech/storage-adapter';
|
|
244
|
+
|
|
245
|
+
// In-memory only
|
|
246
|
+
const cache = createMetadataCache();
|
|
247
|
+
|
|
248
|
+
// With localStorage persistence (survives page reloads)
|
|
249
|
+
const cache = createMetadataCache({
|
|
250
|
+
storage: createLocalStorageAdapter('chain-metadata'),
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Recipes
|
|
255
|
+
|
|
256
|
+
### Custom resolve
|
|
257
|
+
|
|
258
|
+
The `resolve` callback transforms the raw `PolkadotClient` into whatever your app needs. The result is cached per chain and shared across all callers.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { type PolkadotClient, type TypedApi } from 'polkadot-api';
|
|
262
|
+
import { dot, type DotDescriptor } from '@polkadot-api/descriptors';
|
|
263
|
+
|
|
264
|
+
type ResolvedApi = {
|
|
265
|
+
api: TypedApi<DotDescriptor>;
|
|
266
|
+
client: PolkadotClient;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const chains = createChainConnection<ChainConfig, ResolvedApi>({
|
|
270
|
+
createProvider: (chain, onStatusChanged) =>
|
|
271
|
+
createWsJsonRpcProvider({
|
|
272
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
273
|
+
onStatusChanged,
|
|
274
|
+
}),
|
|
275
|
+
|
|
276
|
+
resolve: async (_chain, client) => ({
|
|
277
|
+
api: client.getTypedApi(dot),
|
|
278
|
+
client,
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Now requestApi and lockApi return ResolvedApi instead of PolkadotClient
|
|
283
|
+
const account = await chains.requestApi(polkadot, async ({ api }) => {
|
|
284
|
+
return api.query.System.Account.getValue('5GrwvaEF...');
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
### Metadata caching
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import { createLocalStorageAdapter } from '@novasamatech/storage-adapter';
|
|
294
|
+
|
|
295
|
+
const metadataCache = createMetadataCache({
|
|
296
|
+
storage: createLocalStorageAdapter('chain-metadata'),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const chains = createChainConnection({
|
|
300
|
+
createProvider: (chain, onStatusChanged) =>
|
|
301
|
+
createWsJsonRpcProvider({
|
|
302
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
303
|
+
onStatusChanged,
|
|
304
|
+
}),
|
|
305
|
+
clientOptions: (chain) => metadataCache.forChain(chain.chainId),
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
### Smoldot light client
|
|
312
|
+
|
|
313
|
+
Smoldot syncs chain state directly in the browser without trusting a remote RPC node. It works for well-known relay chains (Polkadot, Kusama, Westend) - parachains fall back to WebSocket.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
import { type JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
|
|
317
|
+
import { getSmProvider } from 'polkadot-api/sm-provider';
|
|
318
|
+
import { type Client as SmoldotClient, start as startSmoldot } from 'polkadot-api/smoldot';
|
|
319
|
+
|
|
320
|
+
type MyChain = ChainConfig & {
|
|
321
|
+
name: string;
|
|
322
|
+
lightClient?: boolean;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Chain specs for each relay chain - polkadot-api ships these built-in.
|
|
326
|
+
const lightClientChainSpecs: Record<string, () => Promise<{ chainSpec: string }>> = {
|
|
327
|
+
'0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3': () => import('polkadot-api/chains/polkadot'),
|
|
328
|
+
'0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe': () => import('polkadot-api/chains/ksmcc3'),
|
|
329
|
+
'0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e': () => import('polkadot-api/chains/westend2'),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Smoldot instance is created lazily and shared across all chains.
|
|
333
|
+
let smoldot: SmoldotClient | null = null;
|
|
334
|
+
|
|
335
|
+
const createLightClientProvider = (chain: MyChain): JsonRpcProvider => {
|
|
336
|
+
const getChainSpec = lightClientChainSpecs[chain.chainId];
|
|
337
|
+
if (!getChainSpec) {
|
|
338
|
+
throw new Error(`Light client for chain "${chain.name}" is not supported`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const smoldotChain = getChainSpec().then(({ chainSpec }) => {
|
|
342
|
+
if (!smoldot) {
|
|
343
|
+
smoldot = startSmoldot();
|
|
344
|
+
}
|
|
345
|
+
return smoldot.addChain({ chainSpec });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
return getSmProvider(smoldotChain);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const chains = createChainConnection<MyChain>({
|
|
352
|
+
createProvider: (chain, onStatusChanged) => {
|
|
353
|
+
if (chain.lightClient && chain.chainId in lightClientChainSpecs) {
|
|
354
|
+
// Light clients report connected immediately - Smoldot handles syncing internally.
|
|
355
|
+
onStatusChanged('connected');
|
|
356
|
+
return createLightClientProvider(chain);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return createWsJsonRpcProvider({
|
|
360
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
361
|
+
onStatusChanged,
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
### Multiple chains
|
|
370
|
+
|
|
371
|
+
When your app connects to several chains with different descriptors, `resolve` receives the chain object so you can return the right typed API for each.
|
|
372
|
+
|
|
373
|
+
Common additions beyond `api` and `client`:
|
|
374
|
+
|
|
375
|
+
- **Pre-resolved `compatibilityToken`** - avoids repeated async lookups at every call site
|
|
376
|
+
- **Typed codecs** via `getTypedCodecs(descriptor)` - for encoding/decoding extrinsics without going through the API layer
|
|
377
|
+
- **Descriptor key** - a string discriminant so you can narrow the typed API at runtime
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
import { dot, ksm, type DotDescriptor, type KsmDescriptor } from '@polkadot-api/descriptors';
|
|
381
|
+
import { type ChainDefinition, type CompatibilityToken, type TypedApi, getTypedCodecs } from 'polkadot-api';
|
|
382
|
+
|
|
383
|
+
const descriptorMap: Record<string, ChainDefinition> = {
|
|
384
|
+
[polkadot.chainId]: dot,
|
|
385
|
+
[kusama.chainId]: ksm,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
type ResolvedApi = {
|
|
389
|
+
api: TypedApi<DotDescriptor> | TypedApi<KsmDescriptor>;
|
|
390
|
+
client: PolkadotClient;
|
|
391
|
+
codecs: Awaited<ReturnType<typeof getTypedCodecs>>;
|
|
392
|
+
compatibilityToken: CompatibilityToken;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const chains = createChainConnection<MyChain, ResolvedApi>({
|
|
396
|
+
createProvider: (chain, onStatusChanged) =>
|
|
397
|
+
createWsJsonRpcProvider({
|
|
398
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
399
|
+
onStatusChanged,
|
|
400
|
+
}),
|
|
401
|
+
|
|
402
|
+
resolve: async (chain, client) => {
|
|
403
|
+
const descriptor = descriptorMap[chain.chainId];
|
|
404
|
+
const api = client.getTypedApi(descriptor);
|
|
405
|
+
|
|
406
|
+
// Pre-resolve once - these require async metadata fetches
|
|
407
|
+
// that you don't want repeated at every call site.
|
|
408
|
+
const [compatibilityToken, codecs] = await Promise.all([
|
|
409
|
+
api.compatibilityToken,
|
|
410
|
+
getTypedCodecs(descriptor),
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
return { api, client, codecs, compatibilityToken };
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## How it works
|
|
419
|
+
|
|
420
|
+
```mermaid
|
|
421
|
+
graph LR
|
|
422
|
+
subgraph Products ["Products (iframe / webview)"]
|
|
423
|
+
P1["Product A"]
|
|
424
|
+
P2["Product B"]
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
subgraph Host App
|
|
428
|
+
HA["requestApi() / lockApi()"]
|
|
429
|
+
Container
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
P1 -- "remote_chain_*" --> Container
|
|
433
|
+
P2 -- "remote_chain_*" --> Container
|
|
434
|
+
Container -- "getProvider()" --> Pool
|
|
435
|
+
|
|
436
|
+
HA --> Pool
|
|
437
|
+
|
|
438
|
+
subgraph Pool ["Connection Pool"]
|
|
439
|
+
C1["Polkadot"]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
C1 --> W1["wss://rpc.polkadot.io"]
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Products embedded in iframes or webviews don't connect to RPC nodes directly. They send `remote_chain_*` requests to the **Container**, which obtains a provider from the **Connection Pool** via `getProvider(chain)`.
|
|
446
|
+
|
|
447
|
+
The host app's own code uses the same pool through `requestApi` and `lockApi`. Everyone shares the same underlying connections - one per chain. The pool opens a connection on first use and closes it when the last consumer releases.
|
|
448
|
+
|
|
449
|
+
## Full example
|
|
450
|
+
|
|
451
|
+
Production-grade setup matching the architecture of [polkadot-desktop](https://github.com/paritytech/polkadot-desktop). Includes Smoldot light client fallback, metadata caching, specName-based descriptor resolution, and a rich resolved API type.
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
import {
|
|
455
|
+
createChainConnection,
|
|
456
|
+
createMetadataCache,
|
|
457
|
+
createWsJsonRpcProvider,
|
|
458
|
+
type ChainConfig,
|
|
459
|
+
} from '@novasamatech/host-substrate-chain-connection';
|
|
460
|
+
import { createLocalStorageAdapter } from '@novasamatech/storage-adapter';
|
|
461
|
+
import { type JsonRpcProvider } from '@polkadot-api/json-rpc-provider';
|
|
462
|
+
import {
|
|
463
|
+
type ChainDefinition,
|
|
464
|
+
type CompatibilityToken,
|
|
465
|
+
type PolkadotClient,
|
|
466
|
+
type TypedApi,
|
|
467
|
+
getTypedCodecs,
|
|
468
|
+
} from 'polkadot-api';
|
|
469
|
+
import { getSmProvider } from 'polkadot-api/sm-provider';
|
|
470
|
+
import { type Client as SmoldotClient, start as startSmoldot } from 'polkadot-api/smoldot';
|
|
471
|
+
|
|
472
|
+
import { dot, dot_ah, dot_ppl, ksm, ksm_ah, wnd, wnd_ah } from '@polkadot-api/descriptors';
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// 1. Chain config
|
|
476
|
+
//
|
|
477
|
+
// Extend ChainConfig with app-specific fields. `specName` is used to
|
|
478
|
+
// select the right descriptor for each chain.
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
type Chain = ChainConfig & {
|
|
482
|
+
name: string;
|
|
483
|
+
specName: string;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
// 2. Descriptor resolution
|
|
488
|
+
//
|
|
489
|
+
// Maps specName → default descriptor, with chainId overrides for
|
|
490
|
+
// parachains that share a specName with their relay chain.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
type Descriptor = { type: string; def: ChainDefinition };
|
|
494
|
+
|
|
495
|
+
const parachainOverrides: Record<string, Descriptor> = {
|
|
496
|
+
// Polkadot Asset Hub
|
|
497
|
+
'0x68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f': { type: 'dot_ah', def: dot_ah },
|
|
498
|
+
// Polkadot People
|
|
499
|
+
'0x67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008': { type: 'dot_ppl', def: dot_ppl },
|
|
500
|
+
// Kusama Asset Hub
|
|
501
|
+
'0x48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a': { type: 'ksm_ah', def: ksm_ah },
|
|
502
|
+
// Westend Asset Hub
|
|
503
|
+
'0x67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9': { type: 'wnd_ah', def: wnd_ah },
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const specNameDefaults: Record<string, Descriptor> = {
|
|
507
|
+
polkadot: { type: 'dot', def: dot },
|
|
508
|
+
kusama: { type: 'ksm', def: ksm },
|
|
509
|
+
westend: { type: 'wnd', def: wnd },
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const getDescriptor = (chain: Chain): Descriptor => {
|
|
513
|
+
return parachainOverrides[chain.chainId]
|
|
514
|
+
?? specNameDefaults[chain.specName]
|
|
515
|
+
?? { type: 'dot', def: dot };
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// 3. Smoldot light client
|
|
520
|
+
//
|
|
521
|
+
// Used for relay chains; parachains fall back to WebSocket.
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
const lightClientChainSpecs: Record<string, () => Promise<{ chainSpec: string }>> = {
|
|
525
|
+
'0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3': () => import('polkadot-api/chains/polkadot'),
|
|
526
|
+
'0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe': () => import('polkadot-api/chains/ksmcc3'),
|
|
527
|
+
'0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e': () => import('polkadot-api/chains/westend2'),
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
let smoldot: SmoldotClient | null = null;
|
|
531
|
+
|
|
532
|
+
const createLightClientProvider = (chainId: string): JsonRpcProvider => {
|
|
533
|
+
const getChainSpec = lightClientChainSpecs[chainId]!;
|
|
534
|
+
|
|
535
|
+
const smoldotChain = getChainSpec().then(({ chainSpec }) => {
|
|
536
|
+
if (!smoldot) {
|
|
537
|
+
smoldot = startSmoldot();
|
|
538
|
+
}
|
|
539
|
+
return smoldot.addChain({ chainSpec });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return getSmProvider(smoldotChain);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// 4. Metadata cache
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
const metadataCache = createMetadataCache({
|
|
550
|
+
storage: createLocalStorageAdapter('chain-metadata'),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// 5. Resolved API type
|
|
555
|
+
//
|
|
556
|
+
// `resolve` pre-computes everything callers need so they don't have to
|
|
557
|
+
// repeat async lookups (compatibilityToken, codecs) at every call site.
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
type TypedClient = {
|
|
561
|
+
type: string;
|
|
562
|
+
api: TypedApi<ChainDefinition>;
|
|
563
|
+
codecs: Awaited<ReturnType<typeof getTypedCodecs>>;
|
|
564
|
+
compatibilityToken: CompatibilityToken;
|
|
565
|
+
client: PolkadotClient;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// 6. Create the connection pool
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
const chains = createChainConnection<Chain, TypedClient>({
|
|
573
|
+
createProvider: (chain, onStatusChanged) => {
|
|
574
|
+
if (chain.chainId in lightClientChainSpecs) {
|
|
575
|
+
onStatusChanged('connected');
|
|
576
|
+
return createLightClientProvider(chain.chainId);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return createWsJsonRpcProvider({
|
|
580
|
+
endpoints: chain.nodes.map((n) => n.url),
|
|
581
|
+
onStatusChanged,
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
clientOptions: (chain) => metadataCache.forChain(chain.chainId),
|
|
586
|
+
|
|
587
|
+
resolve: async (chain, client) => {
|
|
588
|
+
const { type, def } = getDescriptor(chain);
|
|
589
|
+
const api = client.getTypedApi(def);
|
|
590
|
+
|
|
591
|
+
const [compatibilityToken, codecs] = await Promise.all([
|
|
592
|
+
api.compatibilityToken,
|
|
593
|
+
getTypedCodecs(def),
|
|
594
|
+
]);
|
|
595
|
+
|
|
596
|
+
return { type, api, codecs, compatibilityToken, client };
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// 7. Usage
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
const polkadot: Chain = {
|
|
605
|
+
chainId: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
|
606
|
+
specName: 'polkadot',
|
|
607
|
+
name: 'Polkadot',
|
|
608
|
+
nodes: [{ url: 'wss://rpc.polkadot.io' }, { url: 'wss://polkadot-rpc.dwellir.com' }],
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// One-shot query
|
|
612
|
+
const account = await chains.requestApi(polkadot, async ({ api }) => {
|
|
613
|
+
return api.query.System.Account.getValue('5GrwvaEF...');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Long-lived subscription
|
|
617
|
+
const { api: resolved, unlock } = await chains.lockApi(polkadot);
|
|
618
|
+
|
|
619
|
+
const sub = resolved.api.query.System.Account.watchValue('5GrwvaEF...').subscribe({
|
|
620
|
+
next: (value) => console.info('Balance:', value.data.free),
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Cleanup
|
|
624
|
+
sub.unsubscribe();
|
|
625
|
+
unlock();
|
|
626
|
+
|
|
627
|
+
// Connection status
|
|
628
|
+
const unsubscribe = chains.onStatusChanged(polkadot.chainId, (status) => {
|
|
629
|
+
console.info(`${polkadot.name}:`, status);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Pass provider to an iframe or webview
|
|
633
|
+
const provider = chains.getProvider(polkadot);
|
|
634
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createNanoEvents } from 'nanoevents';
|
|
2
|
+
import { createRefCounter } from './refCounter.js';
|
|
3
|
+
export const createBranchedProvider = (provider) => {
|
|
4
|
+
const messages = createNanoEvents();
|
|
5
|
+
const refs = createRefCounter();
|
|
6
|
+
let connection = null;
|
|
7
|
+
return {
|
|
8
|
+
branch(onDisconnect) {
|
|
9
|
+
return onMessage => {
|
|
10
|
+
if (!connection) {
|
|
11
|
+
connection = provider(message => messages.emit('incoming', message));
|
|
12
|
+
}
|
|
13
|
+
const unsub = messages.on('incoming', onMessage);
|
|
14
|
+
refs.increment('connection');
|
|
15
|
+
return {
|
|
16
|
+
send(message) {
|
|
17
|
+
connection?.send(message);
|
|
18
|
+
},
|
|
19
|
+
disconnect() {
|
|
20
|
+
if (refs.decrement('connection') === 0) {
|
|
21
|
+
connection?.disconnect();
|
|
22
|
+
connection = null;
|
|
23
|
+
}
|
|
24
|
+
onDisconnect?.();
|
|
25
|
+
unsub();
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|