@provex/utils 1.9.2-rc.20260721051954.c2db778 → 1.9.2-rc.20260722211737.69a2b06
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/transports.d.ts +59 -5
- package/dist/transports.js +76 -6
- package/dist/transports.test.js +73 -1
- package/package.json +1 -1
package/dist/transports.d.ts
CHANGED
|
@@ -27,6 +27,40 @@ import { type ChainId } from './chain.js';
|
|
|
27
27
|
* and g4mm4 does not operate a Base RPC.
|
|
28
28
|
*/
|
|
29
29
|
export declare const FALLBACK_RPC_URLS: Partial<Record<ChainId, readonly string[]>>;
|
|
30
|
+
/**
|
|
31
|
+
* Parse a comma-separated RPC URL list into an ordered array of valid URLs.
|
|
32
|
+
*
|
|
33
|
+
* This is the single parser for every comma-separated RPC env in the repo —
|
|
34
|
+
* the browser's `VITE_RPC_URLS_<chainId>` and the server's `BASE_RPC_URL` /
|
|
35
|
+
* `PULSECHAIN_RPC_URL` all share this shape, matching the `PONDER_RPC_URL_*`
|
|
36
|
+
* convention the indexer already uses.
|
|
37
|
+
*
|
|
38
|
+
* @param raw - Raw env value. Missing, blank, and all-invalid inputs yield `[]`
|
|
39
|
+
* so callers can treat the result as a priority-ordered prefix without
|
|
40
|
+
* null-checking.
|
|
41
|
+
* @returns Ordered array of trimmed, validated URL strings.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* parseRpcUrlList('https://a.example, https://b.example')
|
|
46
|
+
* // ['https://a.example', 'https://b.example']
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare const parseRpcUrlList: (raw: string | undefined | null) => string[];
|
|
50
|
+
/**
|
|
51
|
+
* How {@link createChainTransport} distributes traffic across the resolved
|
|
52
|
+
* URL list.
|
|
53
|
+
*
|
|
54
|
+
* - `ranked` — viem's latency-based load balancing. The fastest healthy
|
|
55
|
+
* endpoint serves traffic. Correct for browsers, where the goal is the
|
|
56
|
+
* snappiest response and every endpoint is equally cheap.
|
|
57
|
+
* - `ordered` — strict priority. Entry #1 serves every request and later
|
|
58
|
+
* entries are touched only when the ones before them error. Correct when
|
|
59
|
+
* later entries are metered (a paid provider held in reserve), because
|
|
60
|
+
* ranking would otherwise route traffic *to* the fast paid endpoint and
|
|
61
|
+
* defeat the reason it was placed second.
|
|
62
|
+
*/
|
|
63
|
+
export type ChainTransportStrategy = 'ranked' | 'ordered';
|
|
30
64
|
/**
|
|
31
65
|
* Options accepted by {@link createChainTransport}.
|
|
32
66
|
*/
|
|
@@ -36,6 +70,11 @@ interface CreateChainTransportOptions {
|
|
|
36
70
|
* and the chain default. Typically parsed from a `VITE_RPC_URLS_*` env.
|
|
37
71
|
*/
|
|
38
72
|
userUrls?: readonly string[];
|
|
73
|
+
/**
|
|
74
|
+
* Traffic-distribution strategy. Defaults to `ranked`, preserving the
|
|
75
|
+
* latency-optimised behaviour every browser call-site relies on.
|
|
76
|
+
*/
|
|
77
|
+
strategy?: ChainTransportStrategy;
|
|
39
78
|
}
|
|
40
79
|
/**
|
|
41
80
|
* Resolve the ordered RPC URL list for a chain: caller overrides, then the
|
|
@@ -45,11 +84,17 @@ interface CreateChainTransportOptions {
|
|
|
45
84
|
* @param chainId - The chain ID to resolve URLs for. Must resolve via
|
|
46
85
|
* {@link chainIdToChain}.
|
|
47
86
|
* @param options - Optional caller-supplied overrides.
|
|
48
|
-
* @returns Priority-ordered list of valid RPC URLs (always
|
|
49
|
-
* because the chain default is always present).
|
|
87
|
+
* @returns Priority-ordered, de-duplicated list of valid RPC URLs (always
|
|
88
|
+
* non-empty, because the chain default is always present).
|
|
50
89
|
*
|
|
51
90
|
* @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
|
|
52
91
|
* the boundary rather than silently building an empty list.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* Duplicates are collapsed keeping first position. A caller that names the
|
|
95
|
+
* chain default explicitly — e.g. `BASE_RPC_URL="https://mainnet.base.org,…"`
|
|
96
|
+
* to pin it as primary — would otherwise get that URL twice, once from the
|
|
97
|
+
* override list and once from the always-appended chain default.
|
|
53
98
|
*/
|
|
54
99
|
export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateChainTransportOptions) => string[];
|
|
55
100
|
/**
|
|
@@ -58,9 +103,10 @@ export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateCha
|
|
|
58
103
|
*
|
|
59
104
|
* @param chainId - The chain ID to build a transport for. Must resolve via
|
|
60
105
|
* {@link chainIdToChain}.
|
|
61
|
-
* @param options - Optional caller-supplied overrides.
|
|
62
|
-
* @returns A viem `fallback` transport
|
|
63
|
-
*
|
|
106
|
+
* @param options - Optional caller-supplied overrides and strategy.
|
|
107
|
+
* @returns A viem `fallback` transport — rank-enabled under the default
|
|
108
|
+
* `ranked` strategy, strict-priority under `ordered` — with `retryCount: 2`
|
|
109
|
+
* per underlying transport.
|
|
64
110
|
*
|
|
65
111
|
* @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
|
|
66
112
|
* boundary rather than silently falling through to an empty transport list.
|
|
@@ -70,6 +116,14 @@ export declare const resolveChainRpcUrls: (chainId: ChainId, options?: CreateCha
|
|
|
70
116
|
* const transport = createChainTransport(369, { userUrls: ['https://my-rpc'] })
|
|
71
117
|
* const client = createPublicClient({ chain: pulsechain, transport })
|
|
72
118
|
* ```
|
|
119
|
+
*
|
|
120
|
+
* @example Hold a metered provider in reserve behind a free primary.
|
|
121
|
+
* ```ts
|
|
122
|
+
* const transport = createChainTransport(8453, {
|
|
123
|
+
* userUrls: parseRpcUrlList(process.env.BASE_RPC_URL),
|
|
124
|
+
* strategy: 'ordered',
|
|
125
|
+
* })
|
|
126
|
+
* ```
|
|
73
127
|
*/
|
|
74
128
|
export declare const createChainTransport: (chainId: ChainId, options?: CreateChainTransportOptions) => Transport;
|
|
75
129
|
export {};
|
package/dist/transports.js
CHANGED
|
@@ -45,6 +45,51 @@ export const FALLBACK_RPC_URLS = {
|
|
|
45
45
|
369: ['https://rpc-pulsechain.g4mm4.io'],
|
|
46
46
|
8453: [],
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* Validate a candidate URL string with the `URL` constructor.
|
|
50
|
+
*
|
|
51
|
+
* @param candidate - Trimmed URL string to validate.
|
|
52
|
+
* @returns True if `new URL(candidate)` succeeds.
|
|
53
|
+
*/
|
|
54
|
+
const isValidUrl = (candidate) => {
|
|
55
|
+
if (!candidate)
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
// eslint-disable-next-line no-new
|
|
59
|
+
new URL(candidate);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Parse a comma-separated RPC URL list into an ordered array of valid URLs.
|
|
68
|
+
*
|
|
69
|
+
* This is the single parser for every comma-separated RPC env in the repo —
|
|
70
|
+
* the browser's `VITE_RPC_URLS_<chainId>` and the server's `BASE_RPC_URL` /
|
|
71
|
+
* `PULSECHAIN_RPC_URL` all share this shape, matching the `PONDER_RPC_URL_*`
|
|
72
|
+
* convention the indexer already uses.
|
|
73
|
+
*
|
|
74
|
+
* @param raw - Raw env value. Missing, blank, and all-invalid inputs yield `[]`
|
|
75
|
+
* so callers can treat the result as a priority-ordered prefix without
|
|
76
|
+
* null-checking.
|
|
77
|
+
* @returns Ordered array of trimmed, validated URL strings.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* parseRpcUrlList('https://a.example, https://b.example')
|
|
82
|
+
* // ['https://a.example', 'https://b.example']
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export const parseRpcUrlList = (raw) => {
|
|
86
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
87
|
+
return [];
|
|
88
|
+
return raw
|
|
89
|
+
.split(',')
|
|
90
|
+
.map((entry) => entry.trim())
|
|
91
|
+
.filter(isValidUrl);
|
|
92
|
+
};
|
|
48
93
|
/**
|
|
49
94
|
* Resolve the ordered RPC URL list for a chain: caller overrides, then the
|
|
50
95
|
* curated fallback list, then the viem chain default. Exposed for testing
|
|
@@ -53,11 +98,17 @@ export const FALLBACK_RPC_URLS = {
|
|
|
53
98
|
* @param chainId - The chain ID to resolve URLs for. Must resolve via
|
|
54
99
|
* {@link chainIdToChain}.
|
|
55
100
|
* @param options - Optional caller-supplied overrides.
|
|
56
|
-
* @returns Priority-ordered list of valid RPC URLs (always
|
|
57
|
-
* because the chain default is always present).
|
|
101
|
+
* @returns Priority-ordered, de-duplicated list of valid RPC URLs (always
|
|
102
|
+
* non-empty, because the chain default is always present).
|
|
58
103
|
*
|
|
59
104
|
* @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
|
|
60
105
|
* the boundary rather than silently building an empty list.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Duplicates are collapsed keeping first position. A caller that names the
|
|
109
|
+
* chain default explicitly — e.g. `BASE_RPC_URL="https://mainnet.base.org,…"`
|
|
110
|
+
* to pin it as primary — would otherwise get that URL twice, once from the
|
|
111
|
+
* override list and once from the always-appended chain default.
|
|
61
112
|
*/
|
|
62
113
|
export const resolveChainRpcUrls = (chainId, options = {}) => {
|
|
63
114
|
const chain = chainIdToChain.get(chainId);
|
|
@@ -67,7 +118,8 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
|
|
|
67
118
|
const userUrls = options.userUrls ?? [];
|
|
68
119
|
const curatedFallbacks = FALLBACK_RPC_URLS[chainId] ?? [];
|
|
69
120
|
const defaultUrl = chain.rpcUrls.default.http[0];
|
|
70
|
-
|
|
121
|
+
const ordered = [...userUrls, ...curatedFallbacks, defaultUrl].filter((url) => Boolean(url));
|
|
122
|
+
return [...new Set(ordered)];
|
|
71
123
|
};
|
|
72
124
|
/**
|
|
73
125
|
* Build a viem transport for the given chain, layering caller overrides on top
|
|
@@ -75,9 +127,10 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
|
|
|
75
127
|
*
|
|
76
128
|
* @param chainId - The chain ID to build a transport for. Must resolve via
|
|
77
129
|
* {@link chainIdToChain}.
|
|
78
|
-
* @param options - Optional caller-supplied overrides.
|
|
79
|
-
* @returns A viem `fallback` transport
|
|
80
|
-
*
|
|
130
|
+
* @param options - Optional caller-supplied overrides and strategy.
|
|
131
|
+
* @returns A viem `fallback` transport — rank-enabled under the default
|
|
132
|
+
* `ranked` strategy, strict-priority under `ordered` — with `retryCount: 2`
|
|
133
|
+
* per underlying transport.
|
|
81
134
|
*
|
|
82
135
|
* @throws Error if `chainId` has no entry in `chainIdToChain` — fail fast at
|
|
83
136
|
* boundary rather than silently falling through to an empty transport list.
|
|
@@ -87,6 +140,14 @@ export const resolveChainRpcUrls = (chainId, options = {}) => {
|
|
|
87
140
|
* const transport = createChainTransport(369, { userUrls: ['https://my-rpc'] })
|
|
88
141
|
* const client = createPublicClient({ chain: pulsechain, transport })
|
|
89
142
|
* ```
|
|
143
|
+
*
|
|
144
|
+
* @example Hold a metered provider in reserve behind a free primary.
|
|
145
|
+
* ```ts
|
|
146
|
+
* const transport = createChainTransport(8453, {
|
|
147
|
+
* userUrls: parseRpcUrlList(process.env.BASE_RPC_URL),
|
|
148
|
+
* strategy: 'ordered',
|
|
149
|
+
* })
|
|
150
|
+
* ```
|
|
90
151
|
*/
|
|
91
152
|
export const createChainTransport = (chainId, options = {}) => {
|
|
92
153
|
const urls = resolveChainRpcUrls(chainId, options);
|
|
@@ -102,6 +163,15 @@ export const createChainTransport = (chainId, options = {}) => {
|
|
|
102
163
|
if (transports.length <= 1) {
|
|
103
164
|
return transports[0];
|
|
104
165
|
}
|
|
166
|
+
// Ordered strategy: strict priority, no ranking. Entry #1 absorbs every
|
|
167
|
+
// request and later entries are reached only when the ones before them
|
|
168
|
+
// error. Ranking is not merely unnecessary here — it is actively wrong when
|
|
169
|
+
// a later entry is metered, because ranking optimises for latency and a paid
|
|
170
|
+
// endpoint is usually the fastest one in the list, so it would steer traffic
|
|
171
|
+
// onto exactly the endpoint the ordering was written to protect.
|
|
172
|
+
if ((options.strategy ?? 'ranked') === 'ordered') {
|
|
173
|
+
return fallback(transports);
|
|
174
|
+
}
|
|
105
175
|
// Multi-URL chains: rank-enabled fallback so the fastest healthy endpoint
|
|
106
176
|
// serves traffic. Custom ping uses eth_blockNumber instead of viem's default
|
|
107
177
|
// net_listening — measures actual query latency (not just TCP round-trip) and
|
package/dist/transports.test.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { base, pulsechain } from 'viem/chains';
|
|
4
|
-
import { createChainTransport, resolveChainRpcUrls, FALLBACK_RPC_URLS, } from './transports.js';
|
|
4
|
+
import { createChainTransport, parseRpcUrlList, resolveChainRpcUrls, FALLBACK_RPC_URLS, } from './transports.js';
|
|
5
|
+
/**
|
|
6
|
+
* Read the ordered list of RPC URLs a built transport will actually dial.
|
|
7
|
+
*
|
|
8
|
+
* Only safe for the `ordered` strategy: instantiating a ranked transport
|
|
9
|
+
* starts viem's background ranking poll loop, which would leave a live timer
|
|
10
|
+
* behind for the duration of the test run.
|
|
11
|
+
*/
|
|
12
|
+
const dialledUrls = (transport) => {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
const instance = transport({});
|
|
15
|
+
if (instance.config.type === 'http')
|
|
16
|
+
return [instance.value.url];
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
return instance.value.transports.map((t) => t.value?.url);
|
|
19
|
+
};
|
|
5
20
|
describe('transports', () => {
|
|
6
21
|
describe('FALLBACK_RPC_URLS', () => {
|
|
7
22
|
it('lists g4mm4 for PulseChain (369)', () => {
|
|
@@ -11,6 +26,26 @@ describe('transports', () => {
|
|
|
11
26
|
assert.deepEqual(FALLBACK_RPC_URLS[8453], []);
|
|
12
27
|
});
|
|
13
28
|
});
|
|
29
|
+
describe('parseRpcUrlList', () => {
|
|
30
|
+
it('returns an empty list for unset, blank, and whitespace-only input', () => {
|
|
31
|
+
assert.deepEqual(parseRpcUrlList(undefined), []);
|
|
32
|
+
assert.deepEqual(parseRpcUrlList(null), []);
|
|
33
|
+
assert.deepEqual(parseRpcUrlList(''), []);
|
|
34
|
+
assert.deepEqual(parseRpcUrlList(' '), []);
|
|
35
|
+
});
|
|
36
|
+
it('preserves author-supplied order — position encodes priority', () => {
|
|
37
|
+
assert.deepEqual(parseRpcUrlList('https://first.example,https://second.example'), ['https://first.example', 'https://second.example']);
|
|
38
|
+
});
|
|
39
|
+
it('trims padding so a human-formatted env list is not silently rejected', () => {
|
|
40
|
+
assert.deepEqual(parseRpcUrlList(' https://a.example , https://b.example '), ['https://a.example', 'https://b.example']);
|
|
41
|
+
});
|
|
42
|
+
it('drops malformed entries instead of handing them to the transport layer', () => {
|
|
43
|
+
assert.deepEqual(parseRpcUrlList('not-a-url, https://good.example, also bad'), ['https://good.example']);
|
|
44
|
+
});
|
|
45
|
+
it('returns an empty list when every entry is malformed, so callers fall through to defaults', () => {
|
|
46
|
+
assert.deepEqual(parseRpcUrlList('nope, also-nope'), []);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
14
49
|
describe('resolveChainRpcUrls', () => {
|
|
15
50
|
it('returns g4mm4 then chain default for PulseChain', () => {
|
|
16
51
|
const urls = resolveChainRpcUrls(369);
|
|
@@ -33,6 +68,14 @@ describe('transports', () => {
|
|
|
33
68
|
pulsechain.rpcUrls.default.http[0],
|
|
34
69
|
]);
|
|
35
70
|
});
|
|
71
|
+
it('collapses a duplicate when the caller names the chain default explicitly', () => {
|
|
72
|
+
// Pinning the free public endpoint as primary is the whole point of the
|
|
73
|
+
// ordered strategy; it must not cost a second, redundant transport.
|
|
74
|
+
const urls = resolveChainRpcUrls(8453, {
|
|
75
|
+
userUrls: [base.rpcUrls.default.http[0], 'https://metered.example'],
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(urls, [base.rpcUrls.default.http[0], 'https://metered.example']);
|
|
78
|
+
});
|
|
36
79
|
it('throws for unknown chain ID rather than silently returning a single URL', () => {
|
|
37
80
|
assert.throws(() => resolveChainRpcUrls(99999), /no chain definition for chainId 99999/);
|
|
38
81
|
});
|
|
@@ -49,5 +92,34 @@ describe('transports', () => {
|
|
|
49
92
|
it('throws for unknown chain ID via resolveChainRpcUrls', () => {
|
|
50
93
|
assert.throws(() => createChainTransport(99999), /no chain definition for chainId 99999/);
|
|
51
94
|
});
|
|
95
|
+
it('keeps a metered reserve in second position under the ordered strategy', () => {
|
|
96
|
+
// Regression guard for the cost bug this strategy exists to prevent:
|
|
97
|
+
// ranking picks the *fastest* endpoint, and a paid provider is usually
|
|
98
|
+
// the fastest in the list, so a ranked transport would steer traffic
|
|
99
|
+
// onto exactly the endpoint the ordering was written to protect.
|
|
100
|
+
const transport = createChainTransport(8453, {
|
|
101
|
+
userUrls: [base.rpcUrls.default.http[0], 'https://metered.example'],
|
|
102
|
+
strategy: 'ordered',
|
|
103
|
+
});
|
|
104
|
+
assert.deepEqual(dialledUrls(transport), [
|
|
105
|
+
base.rpcUrls.default.http[0],
|
|
106
|
+
'https://metered.example',
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
it('skips the fallback wrapper when only one endpoint survives resolution', () => {
|
|
110
|
+
const transport = createChainTransport(8453, {
|
|
111
|
+
userUrls: [base.rpcUrls.default.http[0]],
|
|
112
|
+
strategy: 'ordered',
|
|
113
|
+
});
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
assert.equal(transport({}).config.type, 'http');
|
|
116
|
+
});
|
|
117
|
+
it('ignores malformed overrides end-to-end rather than dialling them', () => {
|
|
118
|
+
const transport = createChainTransport(8453, {
|
|
119
|
+
userUrls: parseRpcUrlList('garbage, https://metered.example'),
|
|
120
|
+
strategy: 'ordered',
|
|
121
|
+
});
|
|
122
|
+
assert.ok(!dialledUrls(transport).includes('garbage'));
|
|
123
|
+
});
|
|
52
124
|
});
|
|
53
125
|
});
|