@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.
@@ -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 non-empty,
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 with `rank: true` for latency-based
63
- * load balancing and `retryCount: 2` per underlying transport.
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 {};
@@ -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 non-empty,
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
- return [...userUrls, ...curatedFallbacks, defaultUrl].filter((url) => Boolean(url));
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 with `rank: true` for latency-based
80
- * load balancing and `retryCount: 2` per underlying transport.
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
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/utils",
3
- "version": "1.9.2-rc.20260721051954.c2db778",
3
+ "version": "1.9.2-rc.20260722211737.69a2b06",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",