@prosopo/load-balancer 2.10.6 → 2.10.8
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/.turbo/turbo-build$colon$cjs.log +4 -4
- package/.turbo/turbo-build$colon$tsc.log +11 -11
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +23 -0
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/providers.cjs +31 -0
- package/dist/index.js +2 -1
- package/dist/providers.d.ts +1 -0
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +32 -1
- package/dist/providers.js.map +1 -1
- package/dist/tests/productionProviderList.unit.test.d.ts +2 -0
- package/dist/tests/productionProviderList.unit.test.d.ts.map +1 -0
- package/dist/tests/productionProviderList.unit.test.js +132 -0
- package/dist/tests/productionProviderList.unit.test.js.map +1 -0
- package/dist/tests/providers.unit.test.js +78 -1
- package/dist/tests/providers.unit.test.js.map +1 -1
- package/package.json +2 -2
- package/src/providers.ts +66 -0
- package/src/tests/productionProviderList.unit.test.ts +230 -0
- package/src/tests/providers.unit.test.ts +129 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// End-to-end coverage that drives the *real* balancer parsing against the shape
|
|
16
|
+
// served by https://provider-list.prosopo.io/ — dual-stack pronode entries at
|
|
17
|
+
// the top level alongside `ipv4` / `ipv6` single-stack sub-lists. Deliberately
|
|
18
|
+
// does NOT mock `../balancer.js` (unlike providers.unit.test.ts) so the raw JSON
|
|
19
|
+
// flows through convertHostedProvider → loadBalancer → getProviders →
|
|
20
|
+
// getRandomProviderFromList exactly as it does in production, with only
|
|
21
|
+
// `fetch` stubbed.
|
|
22
|
+
|
|
23
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
24
|
+
import {
|
|
25
|
+
convertHostedProvider,
|
|
26
|
+
getProviderHostname,
|
|
27
|
+
loadBalancer,
|
|
28
|
+
} from "../balancer.js";
|
|
29
|
+
import {
|
|
30
|
+
_resetPinCache,
|
|
31
|
+
_resetProviderListCache,
|
|
32
|
+
getRandomProviderFromList,
|
|
33
|
+
} from "../providers.js";
|
|
34
|
+
|
|
35
|
+
const DATASET_ID =
|
|
36
|
+
"0x2e52a1e70f0e0c808972bc04e6fbc8f264b1fbb29c94041fdf01a839b000a40e";
|
|
37
|
+
const ADDRESS = "5FnBurrfqWgSLJFMsojjEP74mLX1vZZ9ASyNXKfA5YXu8FR2";
|
|
38
|
+
|
|
39
|
+
type ProviderEntry = { url: string; datasetId: string; address: string };
|
|
40
|
+
|
|
41
|
+
const entry = (host: string): ProviderEntry => ({
|
|
42
|
+
url: `https://${host}.prosopo.io`,
|
|
43
|
+
datasetId: DATASET_ID,
|
|
44
|
+
address: ADDRESS,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A trimmed but faithful copy of the production provider-list JSON: three
|
|
48
|
+
// pronodes, each present in the ipv4 sub-list, the ipv6 sub-list, and the
|
|
49
|
+
// top-level dual-stack list. Note pronode16 sorts before pronode6 and pronode7
|
|
50
|
+
// under a plain string compare ('1' < '6' < '7').
|
|
51
|
+
const productionJson: Record<string, unknown> = {
|
|
52
|
+
ipv4: {
|
|
53
|
+
pronode6: entry("ipv4.pronode6"),
|
|
54
|
+
pronode7: entry("ipv4.pronode7"),
|
|
55
|
+
pronode16: entry("ipv4.pronode16"),
|
|
56
|
+
},
|
|
57
|
+
ipv6: {
|
|
58
|
+
pronode6: entry("ipv6.pronode6"),
|
|
59
|
+
pronode7: entry("ipv6.pronode7"),
|
|
60
|
+
pronode16: entry("ipv6.pronode16"),
|
|
61
|
+
},
|
|
62
|
+
pronode6: entry("pronode6"),
|
|
63
|
+
pronode7: entry("pronode7"),
|
|
64
|
+
pronode16: entry("pronode16"),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const originalFetch = globalThis.fetch;
|
|
68
|
+
|
|
69
|
+
const mockFetchReturning = (payload: unknown) => {
|
|
70
|
+
const mocked = vi.fn(
|
|
71
|
+
async () => new Response(JSON.stringify(payload), { status: 200 }),
|
|
72
|
+
);
|
|
73
|
+
globalThis.fetch = mocked as typeof fetch;
|
|
74
|
+
return mocked;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
_resetPinCache();
|
|
79
|
+
_resetProviderListCache();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
globalThis.fetch = originalFetch;
|
|
84
|
+
vi.restoreAllMocks();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("convertHostedProvider (production.json shape)", () => {
|
|
88
|
+
it("keeps only the dual-stack pronodes and drops the ipv4/ipv6 sub-lists", () => {
|
|
89
|
+
const providers = convertHostedProvider(productionJson);
|
|
90
|
+
expect(providers.map((p) => p.url)).toEqual([
|
|
91
|
+
// sorted by url.localeCompare — pronode16 first ('1' < '6')
|
|
92
|
+
"https://pronode16.prosopo.io",
|
|
93
|
+
"https://pronode6.prosopo.io",
|
|
94
|
+
"https://pronode7.prosopo.io",
|
|
95
|
+
]);
|
|
96
|
+
// Every entry defaults to weight 1 and carries the shared dataset/address.
|
|
97
|
+
for (const provider of providers) {
|
|
98
|
+
expect(provider.weight).toBe(1);
|
|
99
|
+
expect(provider.datasetId).toBe(DATASET_ID);
|
|
100
|
+
expect(provider.address).toBe(ADDRESS);
|
|
101
|
+
expect(provider.url).not.toMatch(/ipv[46]\./);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns the ipv4 single-stack sub-list when ipMode=ipv4", () => {
|
|
106
|
+
const providers = convertHostedProvider(productionJson, "ipv4");
|
|
107
|
+
expect(providers.map((p) => p.url)).toEqual([
|
|
108
|
+
"https://ipv4.pronode16.prosopo.io",
|
|
109
|
+
"https://ipv4.pronode6.prosopo.io",
|
|
110
|
+
"https://ipv4.pronode7.prosopo.io",
|
|
111
|
+
]);
|
|
112
|
+
// The label strips back to the bare pronode hostname for identity.
|
|
113
|
+
expect(providers.map((p) => getProviderHostname(p))).toEqual([
|
|
114
|
+
"pronode16.prosopo.io",
|
|
115
|
+
"pronode6.prosopo.io",
|
|
116
|
+
"pronode7.prosopo.io",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns the ipv6 single-stack sub-list when ipMode=ipv6", () => {
|
|
121
|
+
const providers = convertHostedProvider(productionJson, "ipv6");
|
|
122
|
+
expect(providers.map((p) => p.url)).toEqual([
|
|
123
|
+
"https://ipv6.pronode16.prosopo.io",
|
|
124
|
+
"https://ipv6.pronode6.prosopo.io",
|
|
125
|
+
"https://ipv6.pronode7.prosopo.io",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("loadBalancer (production.json shape)", () => {
|
|
131
|
+
it("fetches the production list and returns the dual-stack pronodes", async () => {
|
|
132
|
+
const mocked = mockFetchReturning(productionJson);
|
|
133
|
+
const providers = await loadBalancer("production");
|
|
134
|
+
expect(mocked).toHaveBeenCalledWith(
|
|
135
|
+
"https://provider-list.prosopo.io/",
|
|
136
|
+
expect.objectContaining({ method: "GET" }),
|
|
137
|
+
);
|
|
138
|
+
expect(providers.map((p) => p.url)).toEqual([
|
|
139
|
+
"https://pronode16.prosopo.io",
|
|
140
|
+
"https://pronode6.prosopo.io",
|
|
141
|
+
"https://pronode7.prosopo.io",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("getRandomProviderFromList (production.json end-to-end)", () => {
|
|
147
|
+
const dualStackUrls = [
|
|
148
|
+
"https://pronode16.prosopo.io",
|
|
149
|
+
"https://pronode6.prosopo.io",
|
|
150
|
+
"https://pronode7.prosopo.io",
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
it("picks a dual-stack pronode from the fetched production list", async () => {
|
|
154
|
+
mockFetchReturning(productionJson);
|
|
155
|
+
const result = await getRandomProviderFromList(
|
|
156
|
+
"production",
|
|
157
|
+
undefined,
|
|
158
|
+
undefined,
|
|
159
|
+
() => 0,
|
|
160
|
+
);
|
|
161
|
+
// random=0 selects the first entry after the url sort.
|
|
162
|
+
expect(result.provider.url).toBe("https://pronode16.prosopo.io");
|
|
163
|
+
expect(result.providerAccount).toBe(ADDRESS);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("labels the chosen pronode with ipv4 when ipMode=ipv4", async () => {
|
|
167
|
+
mockFetchReturning(productionJson);
|
|
168
|
+
const result = await getRandomProviderFromList(
|
|
169
|
+
"production",
|
|
170
|
+
"ipv4",
|
|
171
|
+
undefined,
|
|
172
|
+
() => 0,
|
|
173
|
+
);
|
|
174
|
+
// Matches the url found under the production `ipv4` sub-list.
|
|
175
|
+
expect(result.provider.url).toBe("https://ipv4.pronode16.prosopo.io");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("labels the chosen pronode with ipv6 when ipMode=ipv6", async () => {
|
|
179
|
+
mockFetchReturning(productionJson);
|
|
180
|
+
const result = await getRandomProviderFromList(
|
|
181
|
+
"production",
|
|
182
|
+
"ipv6",
|
|
183
|
+
undefined,
|
|
184
|
+
() => 0.99,
|
|
185
|
+
);
|
|
186
|
+
// random≈1 selects the last entry (pronode7) after the sort.
|
|
187
|
+
expect(result.provider.url).toBe("https://ipv6.pronode7.prosopo.io");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("excludes the pronode that just failed and picks a different one", async () => {
|
|
191
|
+
mockFetchReturning(productionJson);
|
|
192
|
+
const result = await getRandomProviderFromList(
|
|
193
|
+
"production",
|
|
194
|
+
undefined,
|
|
195
|
+
"https://pronode16.prosopo.io",
|
|
196
|
+
() => 0,
|
|
197
|
+
);
|
|
198
|
+
expect(result.provider.url).not.toBe("https://pronode16.prosopo.io");
|
|
199
|
+
expect(dualStackUrls).toContain(result.provider.url);
|
|
200
|
+
// With pronode16 removed the sorted pool starts at pronode6.
|
|
201
|
+
expect(result.provider.url).toBe("https://pronode6.prosopo.io");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("excludes by ipv4-labelled url, matching what the widget actually used", async () => {
|
|
205
|
+
mockFetchReturning(productionJson);
|
|
206
|
+
// The widget's previous attempt used the ipv4-labelled url; exclusion must
|
|
207
|
+
// still match the underlying pronode so it is dropped from the pool.
|
|
208
|
+
const result = await getRandomProviderFromList(
|
|
209
|
+
"production",
|
|
210
|
+
"ipv4",
|
|
211
|
+
"https://ipv4.pronode16.prosopo.io",
|
|
212
|
+
() => 0,
|
|
213
|
+
);
|
|
214
|
+
expect(result.provider.url).not.toBe("https://ipv4.pronode16.prosopo.io");
|
|
215
|
+
expect(result.provider.url).toBe("https://ipv4.pronode6.prosopo.io");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("serves the cached list across repeat selections (one fetch)", async () => {
|
|
219
|
+
const mocked = mockFetchReturning(productionJson);
|
|
220
|
+
await getRandomProviderFromList(
|
|
221
|
+
"production",
|
|
222
|
+
undefined,
|
|
223
|
+
undefined,
|
|
224
|
+
() => 0,
|
|
225
|
+
);
|
|
226
|
+
await getRandomProviderFromList("production", "ipv4", undefined, () => 0);
|
|
227
|
+
await getRandomProviderFromList("production", "ipv6", undefined, () => 0);
|
|
228
|
+
expect(mocked).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import type { HardcodedProvider } from "../balancer.js";
|
|
16
17
|
import {
|
|
17
18
|
_resetPinCache,
|
|
18
19
|
_resetProviderListCache,
|
|
19
20
|
getProviders,
|
|
20
21
|
getRandomActiveProvider,
|
|
22
|
+
getRandomProviderFromList,
|
|
21
23
|
} from "../providers.js";
|
|
22
24
|
|
|
23
25
|
// Mock the underlying load balancer so getProviders' caching/failure semantics
|
|
@@ -187,3 +189,130 @@ describe("getProviders", () => {
|
|
|
187
189
|
expect(loadBalancer).toHaveBeenCalledTimes(2);
|
|
188
190
|
});
|
|
189
191
|
});
|
|
192
|
+
|
|
193
|
+
describe("getRandomProviderFromList", () => {
|
|
194
|
+
const providerList: HardcodedProvider[] = [
|
|
195
|
+
{
|
|
196
|
+
address: "5A",
|
|
197
|
+
url: "https://provider-a.io",
|
|
198
|
+
datasetId: "0x0",
|
|
199
|
+
weight: 1,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
address: "5B",
|
|
203
|
+
url: "https://provider-b.io",
|
|
204
|
+
datasetId: "0x0",
|
|
205
|
+
weight: 1,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
address: "5C",
|
|
209
|
+
url: "https://provider-c.io",
|
|
210
|
+
datasetId: "0x0",
|
|
211
|
+
weight: 1,
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
_resetProviderListCache();
|
|
217
|
+
_resetPinCache();
|
|
218
|
+
loadBalancer.mockReset();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("picks a provider from the list (not the DNS-routed endpoint)", async () => {
|
|
222
|
+
loadBalancer.mockResolvedValue(providerList);
|
|
223
|
+
// random just above 1/3 lands in the second bucket (uniform weights).
|
|
224
|
+
const result = await getRandomProviderFromList(
|
|
225
|
+
"production",
|
|
226
|
+
undefined,
|
|
227
|
+
undefined,
|
|
228
|
+
() => 0.4,
|
|
229
|
+
);
|
|
230
|
+
expect(result.provider.url).toBe("https://provider-b.io");
|
|
231
|
+
expect(result.providerAccount).toBe("5B");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("excludes the provider that just failed when others remain", async () => {
|
|
235
|
+
loadBalancer.mockResolvedValue(providerList);
|
|
236
|
+
// random=0 would normally pick the first entry; excluding provider-a
|
|
237
|
+
// shifts the pool so index 0 is provider-b.
|
|
238
|
+
const result = await getRandomProviderFromList(
|
|
239
|
+
"production",
|
|
240
|
+
undefined,
|
|
241
|
+
"https://provider-a.io",
|
|
242
|
+
() => 0,
|
|
243
|
+
);
|
|
244
|
+
expect(result.provider.url).not.toBe("https://provider-a.io");
|
|
245
|
+
expect(result.provider.url).toBe("https://provider-b.io");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("ignores a trailing slash when matching the excluded url", async () => {
|
|
249
|
+
loadBalancer.mockResolvedValue(providerList);
|
|
250
|
+
const result = await getRandomProviderFromList(
|
|
251
|
+
"production",
|
|
252
|
+
undefined,
|
|
253
|
+
"https://provider-a.io/",
|
|
254
|
+
() => 0,
|
|
255
|
+
);
|
|
256
|
+
expect(result.provider.url).toBe("https://provider-b.io");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("still returns the only provider in development even if it is excluded", async () => {
|
|
260
|
+
const single: HardcodedProvider[] = [
|
|
261
|
+
{
|
|
262
|
+
address: "5Local",
|
|
263
|
+
url: "https://localhost:9229",
|
|
264
|
+
datasetId: "0x0",
|
|
265
|
+
weight: 1,
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
loadBalancer.mockResolvedValue(single);
|
|
269
|
+
const result = await getRandomProviderFromList(
|
|
270
|
+
"development",
|
|
271
|
+
undefined,
|
|
272
|
+
"https://localhost:9229",
|
|
273
|
+
() => 0,
|
|
274
|
+
);
|
|
275
|
+
expect(result.provider.url).toBe("https://localhost:9229");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("applies the ipv4 label to the chosen provider url", async () => {
|
|
279
|
+
loadBalancer.mockResolvedValue(providerList);
|
|
280
|
+
const result = await getRandomProviderFromList(
|
|
281
|
+
"production",
|
|
282
|
+
"ipv4",
|
|
283
|
+
undefined,
|
|
284
|
+
() => 0,
|
|
285
|
+
);
|
|
286
|
+
expect(result.provider.url).toBe("https://ipv4.provider-a.io");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("weights the pick by provider weight", async () => {
|
|
290
|
+
const weighted: HardcodedProvider[] = [
|
|
291
|
+
{ address: "5A", url: "https://a.io", datasetId: "0x0", weight: 1 },
|
|
292
|
+
{ address: "5B", url: "https://b.io", datasetId: "0x0", weight: 99 },
|
|
293
|
+
];
|
|
294
|
+
loadBalancer.mockResolvedValue(weighted);
|
|
295
|
+
// threshold = 0.5 * 100 = 50 → after subtracting weight 1 (still 49),
|
|
296
|
+
// lands on the heavy second provider.
|
|
297
|
+
const result = await getRandomProviderFromList(
|
|
298
|
+
"production",
|
|
299
|
+
undefined,
|
|
300
|
+
undefined,
|
|
301
|
+
() => 0.5,
|
|
302
|
+
);
|
|
303
|
+
expect(result.provider.url).toBe("https://b.io");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("falls back to the DNS-routed endpoint when the list is empty", async () => {
|
|
307
|
+
loadBalancer.mockResolvedValue([]);
|
|
308
|
+
const result = await getRandomProviderFromList(
|
|
309
|
+
"development",
|
|
310
|
+
undefined,
|
|
311
|
+
undefined,
|
|
312
|
+
() => 0,
|
|
313
|
+
);
|
|
314
|
+
// Development resolves the local URL without a healthz round-trip.
|
|
315
|
+
expect(result.provider.url).toBe("https://localhost:9229");
|
|
316
|
+
expect(result.providerAccount).toBe("dns-routed");
|
|
317
|
+
});
|
|
318
|
+
});
|