@prosopo/load-balancer 2.9.20 → 2.10.0
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 +5 -5
- package/.turbo/turbo-build$colon$tsc.log +11 -11
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +77 -0
- package/dist/balancer.d.ts +4 -1
- package/dist/balancer.d.ts.map +1 -1
- package/dist/balancer.js +10 -5
- package/dist/balancer.js.map +1 -1
- package/dist/cjs/balancer.cjs +9 -4
- package/dist/cjs/index.cjs +3 -4
- package/dist/cjs/providers.cjs +55 -54
- package/dist/index.js +5 -6
- package/dist/providers.d.ts +3 -6
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +56 -55
- package/dist/providers.js.map +1 -1
- package/dist/tests/providers.unit.test.js +73 -211
- package/dist/tests/providers.unit.test.js.map +1 -1
- package/package.json +3 -3
- package/src/balancer.ts +29 -3
- package/src/providers.ts +91 -96
- package/src/tests/providers.unit.test.ts +85 -281
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,224 +1,86 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
vi.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
url: "url3",
|
|
26
|
-
datasetId: "dataset3",
|
|
27
|
-
weight: 1,
|
|
28
|
-
},
|
|
29
|
-
];
|
|
30
|
-
expect(selectWeightedProvider(providers, 0).address).toBe("address1");
|
|
31
|
-
expect(selectWeightedProvider(providers, 1).address).toBe("address2");
|
|
32
|
-
expect(selectWeightedProvider(providers, 2).address).toBe("address2");
|
|
33
|
-
expect(selectWeightedProvider(providers, 3).address).toBe("address2");
|
|
34
|
-
expect(selectWeightedProvider(providers, 4).address).toBe("address3");
|
|
35
|
-
expect(selectWeightedProvider(providers, 5).address).toBe("address1");
|
|
36
|
-
expect(selectWeightedProvider(providers, 6).address).toBe("address2");
|
|
37
|
-
});
|
|
38
|
-
it("handles equal weights correctly", () => {
|
|
39
|
-
const providers = [
|
|
40
|
-
{
|
|
41
|
-
address: "address1",
|
|
42
|
-
url: "url1",
|
|
43
|
-
datasetId: "dataset1",
|
|
44
|
-
weight: 1,
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
address: "address2",
|
|
48
|
-
url: "url2",
|
|
49
|
-
datasetId: "dataset2",
|
|
50
|
-
weight: 1,
|
|
51
|
-
},
|
|
52
|
-
];
|
|
53
|
-
expect(selectWeightedProvider(providers, 0).address).toBe("address1");
|
|
54
|
-
expect(selectWeightedProvider(providers, 1).address).toBe("address2");
|
|
55
|
-
expect(selectWeightedProvider(providers, 2).address).toBe("address1");
|
|
56
|
-
expect(selectWeightedProvider(providers, 3).address).toBe("address2");
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { _resetPinCache, getRandomActiveProvider } from "../providers.js";
|
|
3
|
+
const originalFetch = globalThis.fetch;
|
|
4
|
+
const mockHealthzFetch = (host, ok = true, status = 200) => {
|
|
5
|
+
const mocked = vi.fn(async () => ({
|
|
6
|
+
ok,
|
|
7
|
+
status,
|
|
8
|
+
json: async () => ({ ok: true, host }),
|
|
9
|
+
}));
|
|
10
|
+
globalThis.fetch = mocked;
|
|
11
|
+
return mocked;
|
|
12
|
+
};
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
_resetPinCache();
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
globalThis.fetch = originalFetch;
|
|
18
|
+
});
|
|
19
|
+
describe("getRandomActiveProvider (dual stack)", () => {
|
|
20
|
+
it("uses the local URL directly in development (no healthz round-trip)", async () => {
|
|
21
|
+
const mocked = mockHealthzFetch("ignored");
|
|
22
|
+
const result = await getRandomActiveProvider("development");
|
|
23
|
+
expect(result.provider.url).toBe("https://localhost:9229");
|
|
24
|
+
expect(mocked).not.toHaveBeenCalled();
|
|
57
25
|
});
|
|
58
|
-
it("
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
url: "url1",
|
|
63
|
-
datasetId: "dataset1",
|
|
64
|
-
weight: 10,
|
|
65
|
-
},
|
|
66
|
-
];
|
|
67
|
-
expect(selectWeightedProvider(providers, 0).address).toBe("address1");
|
|
68
|
-
expect(selectWeightedProvider(providers, 5).address).toBe("address1");
|
|
69
|
-
expect(selectWeightedProvider(providers, 100).address).toBe("address1");
|
|
26
|
+
it("pins to the host returned by /healthz in production", async () => {
|
|
27
|
+
mockHealthzFetch("pronode7.prosopo.io");
|
|
28
|
+
const result = await getRandomActiveProvider("production");
|
|
29
|
+
expect(result.provider.url).toBe("https://pronode7.prosopo.io");
|
|
70
30
|
});
|
|
71
|
-
it("
|
|
72
|
-
|
|
31
|
+
it("pins to the host returned by /healthz in staging", async () => {
|
|
32
|
+
mockHealthzFetch("staging-pronode2.prosopo.io");
|
|
33
|
+
const result = await getRandomActiveProvider("staging");
|
|
34
|
+
expect(result.provider.url).toBe("https://staging-pronode2.prosopo.io");
|
|
73
35
|
});
|
|
74
|
-
it("
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
weight: 1,
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
address: "address2",
|
|
84
|
-
url: "url2",
|
|
85
|
-
datasetId: "dataset2",
|
|
86
|
-
weight: 99,
|
|
87
|
-
},
|
|
88
|
-
];
|
|
89
|
-
const selections = { address1: 0, address2: 0 };
|
|
90
|
-
for (let i = 0; i < 100; i++) {
|
|
91
|
-
const selected = selectWeightedProvider(providers, i);
|
|
92
|
-
if (selected.address === "address1") {
|
|
93
|
-
selections.address1++;
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
selections.address2++;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
expect(selections.address1).toBe(1);
|
|
100
|
-
expect(selections.address2).toBe(99);
|
|
36
|
+
it("caches the pinned URL for repeat callers in the same env + ipMode", async () => {
|
|
37
|
+
const mocked = mockHealthzFetch("pronode3.prosopo.io");
|
|
38
|
+
await getRandomActiveProvider("production");
|
|
39
|
+
await getRandomActiveProvider("production");
|
|
40
|
+
await getRandomActiveProvider("production");
|
|
41
|
+
expect(mocked).toHaveBeenCalledTimes(1);
|
|
101
42
|
});
|
|
102
|
-
it("
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
url: "url1",
|
|
107
|
-
datasetId: "dataset1",
|
|
108
|
-
weight: 100,
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
address: "address2",
|
|
112
|
-
url: "url2",
|
|
113
|
-
datasetId: "dataset2",
|
|
114
|
-
weight: 100,
|
|
115
|
-
},
|
|
116
|
-
];
|
|
117
|
-
const selections = { address1: 0, address2: 0 };
|
|
118
|
-
for (let i = 0; i < 200; i++) {
|
|
119
|
-
const selected = selectWeightedProvider(providers, i);
|
|
120
|
-
if (selected.address === "address1") {
|
|
121
|
-
selections.address1++;
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
selections.address2++;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
expect(selections.address1).toBe(100);
|
|
128
|
-
expect(selections.address2).toBe(100);
|
|
43
|
+
it("falls back to the dual-stack base URL when /healthz responds non-OK", async () => {
|
|
44
|
+
mockHealthzFetch("ignored", false, 503);
|
|
45
|
+
const result = await getRandomActiveProvider("production");
|
|
46
|
+
expect(result.provider.url).toBe("https://pronode.prosopo.io");
|
|
129
47
|
});
|
|
130
|
-
it("
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
url: "url2",
|
|
140
|
-
datasetId: "dataset2",
|
|
141
|
-
weight: 3,
|
|
142
|
-
},
|
|
143
|
-
];
|
|
144
|
-
const providersWithDefaults = [
|
|
145
|
-
{ ...providers[0], weight: 1 },
|
|
146
|
-
providers[1],
|
|
147
|
-
];
|
|
148
|
-
const selections = { address1: 0, address2: 0 };
|
|
149
|
-
for (let i = 0; i < 100; i++) {
|
|
150
|
-
const selected = selectWeightedProvider(providersWithDefaults, i);
|
|
151
|
-
if (selected.address === "address1") {
|
|
152
|
-
selections.address1++;
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
selections.address2++;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
expect(selections.address1).toBe(25);
|
|
159
|
-
expect(selections.address2).toBe(75);
|
|
48
|
+
it("falls back to the dual-stack base URL when /healthz body is malformed", async () => {
|
|
49
|
+
const mocked = vi.fn(async () => ({
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
json: async () => ({ ok: true }),
|
|
53
|
+
}));
|
|
54
|
+
globalThis.fetch = mocked;
|
|
55
|
+
const result = await getRandomActiveProvider("production");
|
|
56
|
+
expect(result.provider.url).toBe("https://pronode.prosopo.io");
|
|
160
57
|
});
|
|
161
58
|
});
|
|
162
|
-
describe("getRandomActiveProvider", () => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
it("returns a random provider when providers list is populated", async () => {
|
|
170
|
-
const mockProviders = [
|
|
171
|
-
{ address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
|
|
172
|
-
{ address: "address2", url: "url2", datasetId: "dataset2", weight: 1 },
|
|
173
|
-
];
|
|
174
|
-
loadBalancer.mockResolvedValue(mockProviders);
|
|
175
|
-
const result = await getRandomActiveProvider("development", 1);
|
|
176
|
-
expect(result.providerAccount).toBe("address2");
|
|
177
|
-
expect(result.provider.url).toBe("url2");
|
|
178
|
-
expect(result.provider.datasetId).toBe("dataset2");
|
|
179
|
-
});
|
|
180
|
-
it("loads providers only once when called multiple times", async () => {
|
|
181
|
-
const mockProviders = [
|
|
182
|
-
{ address: "address1", url: "url1", datasetId: "dataset1", weight: 1 },
|
|
183
|
-
];
|
|
184
|
-
loadBalancer.mockResolvedValue(mockProviders);
|
|
185
|
-
await getRandomActiveProvider("development", 123);
|
|
186
|
-
await getRandomActiveProvider("development", 456);
|
|
187
|
-
expect(loadBalancer).toHaveBeenCalledTimes(1);
|
|
59
|
+
describe("getRandomActiveProvider (single stack ipMode)", () => {
|
|
60
|
+
it("hits ipv4.pronode.prosopo.io/healthz and pins to ipv4.pronodeN", async () => {
|
|
61
|
+
const mocked = mockHealthzFetch("pronode4.prosopo.io");
|
|
62
|
+
const result = await getRandomActiveProvider("production", "ipv4");
|
|
63
|
+
expect(result.provider.url).toBe("https://ipv4.pronode4.prosopo.io");
|
|
64
|
+
expect(mocked).toHaveBeenCalledWith("https://ipv4.pronode.prosopo.io/healthz", expect.any(Object));
|
|
188
65
|
});
|
|
189
|
-
it("
|
|
190
|
-
|
|
191
|
-
await
|
|
66
|
+
it("hits ipv6 staging /healthz and pins to ipv6.staging-pronodeN", async () => {
|
|
67
|
+
const mocked = mockHealthzFetch("staging-pronode2.prosopo.io");
|
|
68
|
+
const result = await getRandomActiveProvider("staging", "ipv6");
|
|
69
|
+
expect(result.provider.url).toBe("https://ipv6.staging-pronode2.prosopo.io");
|
|
70
|
+
expect(mocked).toHaveBeenCalledWith("https://ipv6.staging.pronode.prosopo.io/healthz", expect.any(Object));
|
|
192
71
|
});
|
|
193
|
-
it("
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
];
|
|
198
|
-
loadBalancer.mockResolvedValue(mockProviders);
|
|
199
|
-
const result0 = await getRandomActiveProvider("development", 0);
|
|
200
|
-
expect(result0.providerAccount).toBe("address1");
|
|
201
|
-
_resetCache();
|
|
202
|
-
const result1 = await getRandomActiveProvider("development", 1);
|
|
203
|
-
expect(result1.providerAccount).toBe("address2");
|
|
204
|
-
_resetCache();
|
|
205
|
-
const result2 = await getRandomActiveProvider("development", 2);
|
|
206
|
-
expect(result2.providerAccount).toBe("address2");
|
|
207
|
-
_resetCache();
|
|
208
|
-
const result3 = await getRandomActiveProvider("development", 3);
|
|
209
|
-
expect(result3.providerAccount).toBe("address2");
|
|
72
|
+
it("falls back to the ipv4-labelled global URL when ipv4 /healthz fails", async () => {
|
|
73
|
+
mockHealthzFetch("ignored", false, 500);
|
|
74
|
+
const result = await getRandomActiveProvider("production", "ipv4");
|
|
75
|
+
expect(result.provider.url).toBe("https://ipv4.pronode.prosopo.io");
|
|
210
76
|
});
|
|
211
|
-
it("
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
expect(result0.providerAccount).toBe("address1");
|
|
219
|
-
_resetCache();
|
|
220
|
-
const result1 = await getRandomActiveProvider("development", 1);
|
|
221
|
-
expect(result1.providerAccount).toBe("address2");
|
|
77
|
+
it("keeps the dual-stack cache and the ipv4 cache separate", async () => {
|
|
78
|
+
const mocked = mockHealthzFetch("pronode9.prosopo.io");
|
|
79
|
+
await getRandomActiveProvider("production");
|
|
80
|
+
await getRandomActiveProvider("production", "ipv4");
|
|
81
|
+
await getRandomActiveProvider("production");
|
|
82
|
+
await getRandomActiveProvider("production", "ipv4");
|
|
83
|
+
expect(mocked).toHaveBeenCalledTimes(2);
|
|
222
84
|
});
|
|
223
85
|
});
|
|
224
86
|
//# sourceMappingURL=providers.unit.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"providers.unit.test.js","sourceRoot":"","sources":["../../src/tests/providers.unit.test.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"providers.unit.test.js","sourceRoot":"","sources":["../../src/tests/providers.unit.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prosopo/load-balancer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Provider load balancer",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
"homepage": "https://github.com/prosopo/captcha#readme",
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@prosopo/common": "3.1.40",
|
|
41
|
-
"@prosopo/types": "4.
|
|
41
|
+
"@prosopo/types": "4.8.0",
|
|
42
42
|
"zod": "3.23.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@prosopo/config": "3.3.
|
|
45
|
+
"@prosopo/config": "3.3.3",
|
|
46
46
|
"@types/node": "22.10.2",
|
|
47
47
|
"@vitest/coverage-v8": "3.2.4",
|
|
48
48
|
"concurrently": "9.0.1",
|
package/src/balancer.ts
CHANGED
|
@@ -32,17 +32,43 @@ const HardcodedProviderSchema = z.object({
|
|
|
32
32
|
|
|
33
33
|
export type HardcodedProvider = z.infer<typeof HardcodedProviderSchema>;
|
|
34
34
|
|
|
35
|
+
export type IpMode = "ipv4" | "ipv6";
|
|
36
|
+
|
|
37
|
+
// Top-level keys reserved by providerListJson for the ipv4-only / ipv6-only
|
|
38
|
+
// sub-lists. They appear alongside the dual-stack provider entries in the
|
|
39
|
+
// fetched JSON; the converter skips them so existing dual-stack consumers
|
|
40
|
+
// keep working.
|
|
41
|
+
const IP_MODE_KEYS: ReadonlyArray<IpMode> = ["ipv4", "ipv6"];
|
|
42
|
+
|
|
35
43
|
type hostedProviders = Record<string, unknown>;
|
|
36
44
|
|
|
45
|
+
const isProviderRecord = (value: unknown): value is hostedProviders =>
|
|
46
|
+
typeof value === "object" && value !== null;
|
|
47
|
+
|
|
37
48
|
export const convertHostedProvider = (
|
|
38
49
|
provider: hostedProviders,
|
|
50
|
+
ipMode?: IpMode,
|
|
39
51
|
): HardcodedProvider[] => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
const source =
|
|
53
|
+
ipMode && isProviderRecord(provider[ipMode])
|
|
54
|
+
? (provider[ipMode] as hostedProviders)
|
|
55
|
+
: provider;
|
|
56
|
+
|
|
57
|
+
const providers = Object.entries(source)
|
|
58
|
+
.filter(([key]) => !IP_MODE_KEYS.includes(key as IpMode))
|
|
59
|
+
.map(([, value]) => HardcodedProviderSchema.parse(value));
|
|
43
60
|
return providers.sort((a, b) => a.url.localeCompare(b.url));
|
|
44
61
|
};
|
|
45
62
|
|
|
63
|
+
// Strip the leading `ipv4.` / `ipv6.` label that providerListJson prepends for
|
|
64
|
+
// the single-stack sub-lists so callers can derive a stable identity from the
|
|
65
|
+
// provider URL regardless of which ipMode was requested.
|
|
66
|
+
export const stripIpModeLabel = (hostname: string): string =>
|
|
67
|
+
hostname.replace(/^ipv[46]\./, "");
|
|
68
|
+
|
|
69
|
+
export const getProviderHostname = (provider: HardcodedProvider): string =>
|
|
70
|
+
stripIpModeLabel(new URL(provider.url).hostname);
|
|
71
|
+
|
|
46
72
|
export const getLoadBalancerUrl = (environment: EnvironmentTypes): string => {
|
|
47
73
|
if (environment === "production") {
|
|
48
74
|
return "https://provider-list.prosopo.io/";
|
package/src/providers.ts
CHANGED
|
@@ -13,119 +13,114 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
15
|
import type { EnvironmentTypes, RandomProvider } from "@prosopo/types";
|
|
16
|
-
import
|
|
16
|
+
import type { IpMode } from "./balancer.js";
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Set a custom provider loader that replaces the default HTTP fetch.
|
|
31
|
-
* Use this on the server side to inject cacheFile-based loading with
|
|
32
|
-
* ETag/Last-Modified support for disk persistence across restarts.
|
|
33
|
-
*/
|
|
34
|
-
export function setProviderLoader(
|
|
35
|
-
loader: (env: EnvironmentTypes) => Promise<HardcodedProvider[]>,
|
|
36
|
-
): void {
|
|
37
|
-
customProviderLoader = loader;
|
|
38
|
-
}
|
|
18
|
+
// Base DNS endpoint per env — the `pronode.prosopo.io` family is latency-routed
|
|
19
|
+
// (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
|
|
20
|
+
// once to discover which specific pronodeN the DNS layer picked, then pin
|
|
21
|
+
// subsequent captcha calls to that pronode so session creation and submission
|
|
22
|
+
// land on the same backend.
|
|
23
|
+
const DNS_ENDPOINT: Record<EnvironmentTypes, string> = {
|
|
24
|
+
development: "https://localhost:9229",
|
|
25
|
+
staging: "https://staging.pronode.prosopo.io",
|
|
26
|
+
production: "https://pronode.prosopo.io",
|
|
27
|
+
};
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
// Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack
|
|
30
|
+
// sub-zones only resolve to A or AAAA records respectively, so this pins the
|
|
31
|
+
// network path before TLS negotiation. Ansible provisions the matching certs
|
|
32
|
+
// for both `ipv4.{global}` and `ipv4.pronodeN.{global}`.
|
|
33
|
+
const withIpModeLabel = (hostname: string, ipMode?: IpMode): string =>
|
|
34
|
+
ipMode ? `${ipMode}.${hostname}` : hostname;
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export function selectWeightedProvider(
|
|
53
|
-
providers: HardcodedProvider[],
|
|
54
|
-
entropy: number,
|
|
55
|
-
): HardcodedProvider {
|
|
56
|
-
if (providers.length === 0) {
|
|
57
|
-
throw new Error("No providers available");
|
|
36
|
+
const applyIpModeToUrl = (url: string, ipMode?: IpMode): string => {
|
|
37
|
+
if (!ipMode) return url;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
parsed.hostname = withIpModeLabel(parsed.hostname, ipMode);
|
|
41
|
+
return parsed.toString().replace(/\/$/, "");
|
|
42
|
+
} catch {
|
|
43
|
+
return url;
|
|
58
44
|
}
|
|
45
|
+
};
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
for (const provider of providers) {
|
|
68
|
-
cumulativeWeight += provider.weight;
|
|
69
|
-
if (randomValue < cumulativeWeight) {
|
|
70
|
-
return provider;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
47
|
+
// Cached, in-flight pin per (env, ipMode). Keyed so dual-stack and single-stack
|
|
48
|
+
// callers maintain separate stickiness — they hit different /healthz endpoints
|
|
49
|
+
// and shouldn't share each other's resolution.
|
|
50
|
+
type CacheKey = `${EnvironmentTypes}|${IpMode | "dual"}`;
|
|
51
|
+
const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
|
|
52
|
+
`${env}|${ipMode ?? "dual"}`;
|
|
53
|
+
const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
|
|
73
54
|
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
|
|
56
|
+
const res = await fetch(`${baseUrl}/healthz`, {
|
|
57
|
+
method: "GET",
|
|
58
|
+
cache: "no-store",
|
|
59
|
+
credentials: "omit",
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
throw new Error(`healthz responded with ${res.status}`);
|
|
78
63
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/** Load providers using the custom loader if set, otherwise the default fetch. */
|
|
83
|
-
const loadProviders = async (
|
|
84
|
-
env: EnvironmentTypes,
|
|
85
|
-
): Promise<HardcodedProvider[]> => {
|
|
86
|
-
if (customProviderLoader) {
|
|
87
|
-
return customProviderLoader(env);
|
|
64
|
+
const body = (await res.json()) as { host?: unknown };
|
|
65
|
+
if (typeof body.host !== "string" || body.host.length === 0) {
|
|
66
|
+
throw new Error("healthz response missing host field");
|
|
88
67
|
}
|
|
89
|
-
return
|
|
68
|
+
return body.host;
|
|
90
69
|
};
|
|
91
70
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const getProvidersPromise = (
|
|
95
|
-
env: EnvironmentTypes,
|
|
96
|
-
): Promise<HardcodedProvider[]> => {
|
|
97
|
-
const existing = providerPromiseCache.get(env);
|
|
98
|
-
if (existing) return existing;
|
|
99
|
-
const promise = loadProviders(env).catch((err) => {
|
|
100
|
-
providerPromiseCache.delete(env);
|
|
101
|
-
throw err;
|
|
102
|
-
});
|
|
103
|
-
providerPromiseCache.set(env, promise);
|
|
104
|
-
return promise;
|
|
105
|
-
};
|
|
71
|
+
const resolveBaseUrl = (env: EnvironmentTypes): string =>
|
|
72
|
+
DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development;
|
|
106
73
|
|
|
107
|
-
|
|
108
|
-
* Pre-warms the provider cache for a given environment without requiring entropy.
|
|
109
|
-
* Call this as early as possible to avoid a cold-cache delay when getRandomActiveProvider is first used.
|
|
110
|
-
*/
|
|
111
|
-
export const prefetchProviders = async (
|
|
74
|
+
const resolvePinnedUrl = async (
|
|
112
75
|
env: EnvironmentTypes,
|
|
113
|
-
|
|
114
|
-
|
|
76
|
+
ipMode?: IpMode,
|
|
77
|
+
): Promise<string> => {
|
|
78
|
+
// The base for /healthz already carries the ipv4./ipv6. label when one is
|
|
79
|
+
// requested, so DNS keeps the discovery request on the same single-stack
|
|
80
|
+
// path as the captcha calls that follow.
|
|
81
|
+
const base = applyIpModeToUrl(resolveBaseUrl(env), ipMode);
|
|
82
|
+
// Development has no global hostname to /healthz against; use the
|
|
83
|
+
// hardcoded local URL.
|
|
84
|
+
if (env === "development") return base;
|
|
85
|
+
|
|
86
|
+
const key = cacheKey(env, ipMode);
|
|
87
|
+
const cached = pinPromiseCache.get(key);
|
|
88
|
+
if (cached) return cached;
|
|
89
|
+
|
|
90
|
+
const promise = (async () => {
|
|
91
|
+
try {
|
|
92
|
+
const host = await fetchPinnedHost(base);
|
|
93
|
+
const parsed = new URL(base);
|
|
94
|
+
// /healthz returns the bare pronodeN.prosopo.io (env.config.host).
|
|
95
|
+
// Re-apply the ipMode label so the per-pronode URL stays on the same
|
|
96
|
+
// single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
|
|
97
|
+
parsed.hostname = withIpModeLabel(host, ipMode);
|
|
98
|
+
return parsed.toString().replace(/\/$/, "");
|
|
99
|
+
} catch {
|
|
100
|
+
// Healthz unreachable / malformed — fall back to the load-balanced
|
|
101
|
+
// hostname (with the ipMode label still applied). Clients still
|
|
102
|
+
// work, they just lose per-pronode stickiness.
|
|
103
|
+
return base;
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
pinPromiseCache.set(key, promise);
|
|
108
|
+
return promise;
|
|
115
109
|
};
|
|
116
110
|
|
|
117
111
|
export const getRandomActiveProvider = async (
|
|
118
112
|
env: EnvironmentTypes,
|
|
119
|
-
|
|
113
|
+
ipMode?: IpMode,
|
|
120
114
|
): Promise<RandomProvider> => {
|
|
121
|
-
const
|
|
122
|
-
const randomProviderObj = selectWeightedProvider(providers, entropy);
|
|
123
|
-
|
|
115
|
+
const url = await resolvePinnedUrl(env, ipMode);
|
|
124
116
|
return {
|
|
125
|
-
providerAccount:
|
|
126
|
-
provider: {
|
|
127
|
-
url: randomProviderObj.url,
|
|
128
|
-
datasetId: randomProviderObj.datasetId,
|
|
129
|
-
},
|
|
117
|
+
providerAccount: "dns-routed",
|
|
118
|
+
provider: { url },
|
|
130
119
|
};
|
|
131
120
|
};
|
|
121
|
+
|
|
122
|
+
// Test-only escape hatch so tests can isolate the healthz cache between
|
|
123
|
+
// cases. Not exported from the package index — internal use only.
|
|
124
|
+
export const _resetPinCache = () => {
|
|
125
|
+
pinPromiseCache.clear();
|
|
126
|
+
};
|