@prosopo/load-balancer 2.11.4 → 2.11.6

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.
@@ -1,55 +0,0 @@
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
- /**
16
- * Where a development widget looks for its provider.
17
- *
18
- * `localhost` is right when the page and the provider share a machine, and
19
- * wrong the moment they do not: a phone or a simulator loading the demo over
20
- * the LAN resolves `localhost` to itself, so the widget never reaches the
21
- * provider and reports the site key as unregistered. The override exists so
22
- * that case can be pointed at the host running the provider.
23
- *
24
- * Development only — staging and production resolve through their DNS-routed
25
- * endpoints and ignore this entirely.
26
- */
27
- export const DEFAULT_DEVELOPMENT_PROVIDER_URL = "https://localhost:9229";
28
-
29
- /**
30
- * Read the override without assuming a runtime.
31
- *
32
- * Written as a static `process.env.X` access on purpose: the frontend build
33
- * substitutes that exact expression for a literal, which a computed lookup
34
- * (`process.env[key]`) would not match. For the same reason there is no
35
- * `typeof process` guard — in a browser bundle the whole expression is already
36
- * a string by the time it runs, and guarding on a `process` that does not
37
- * exist there would discard it. Under plain node with the variable unset it is
38
- * simply undefined; where neither applies the access throws and is caught.
39
- */
40
- export const readDevelopmentProviderUrlOverride = (): string | undefined => {
41
- try {
42
- return process.env.PROSOPO_PROVIDER_URL_DEVELOPMENT;
43
- } catch {
44
- return undefined;
45
- }
46
- };
47
-
48
- export const getDevelopmentProviderUrl = (
49
- override: string | undefined = readDevelopmentProviderUrlOverride(),
50
- ): string => {
51
- const trimmed = override?.trim();
52
- return trimmed
53
- ? trimmed.replace(/\/$/, "")
54
- : DEFAULT_DEVELOPMENT_PROVIDER_URL;
55
- };
package/src/index.ts DELETED
@@ -1,17 +0,0 @@
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
- export * from "./providers.js";
15
- export * from "./balancer.js";
16
- export * from "./developmentProviderUrl.js";
17
- export * from "./providerUrlOverride.js";
@@ -1,44 +0,0 @@
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
- /**
16
- * Supplies the provider list directly instead of fetching Prosopo's.
17
- *
18
- * A self-hosted deployment has its own providers and no hosted list to
19
- * discover them from. Setting this replaces both halves of discovery — the
20
- * endpoint the widget calls and the list a verifier matches a token against —
21
- * with whatever it names. See `getProviderListOverride` in `balancer.ts` for
22
- * the accepted forms.
23
- *
24
- * Distinct from `PROSOPO_PROVIDER_URL_DEVELOPMENT`, which only applies when
25
- * the environment is `development`. That coupling forced self-hosters to ship
26
- * a development build of the widget purely to redirect it; this override is
27
- * environment-independent, so `production` stays correct everywhere.
28
- *
29
- * Kept in its own module, holding nothing but the read, so that parsing can
30
- * live next to `convertHostedProvider` in `balancer.ts` without the two
31
- * modules importing each other. A cycle here would be bundled into the widget
32
- * and this package has already lost a release to one.
33
- *
34
- * Written as a static `process.env.X` access for the same reason as the
35
- * development override: frontend bundlers substitute that exact expression
36
- * for a literal, which a computed lookup would not match.
37
- */
38
- export const readProviderListOverride = (): string | undefined => {
39
- try {
40
- return process.env.PROSOPO_PROVIDER_LIST;
41
- } catch {
42
- return undefined;
43
- }
44
- };
package/src/providers.ts DELETED
@@ -1,352 +0,0 @@
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
- import type { EnvironmentTypes, RandomProvider } from "@prosopo/types";
16
- import {
17
- type HardcodedProvider,
18
- type IpMode,
19
- getProviderListOverride,
20
- loadBalancer,
21
- } from "./balancer.js";
22
- import { getDevelopmentProviderUrl } from "./developmentProviderUrl.js";
23
- import { retryWithBackoff } from "./retry.js";
24
-
25
- // Base DNS endpoint per env — the `pronode.prosopo.io` family is latency-routed
26
- // (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
27
- // once to discover which specific pronodeN the DNS layer picked, then pin
28
- // subsequent captcha calls to that pronode so session creation and submission
29
- // land on the same backend.
30
- // Resolved per call rather than once at module load, so a development override
31
- // set after this module is imported is still picked up.
32
- const dnsEndpoints = (): Record<EnvironmentTypes, string> => ({
33
- development: getDevelopmentProviderUrl(),
34
- staging: "https://staging.pronode.prosopo.io",
35
- production: "https://pronode.prosopo.io",
36
- });
37
-
38
- // Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack
39
- // sub-zones only resolve to A or AAAA records respectively, so this pins the
40
- // network path before TLS negotiation. Ansible provisions the matching certs
41
- // for both `ipv4.{global}` and `ipv4.pronodeN.{global}`.
42
- const withIpModeLabel = (hostname: string, ipMode?: IpMode): string =>
43
- ipMode ? `${ipMode}.${hostname}` : hostname;
44
-
45
- const applyIpModeToUrl = (url: string, ipMode?: IpMode): string => {
46
- if (!ipMode) return url;
47
- try {
48
- const parsed = new URL(url);
49
- parsed.hostname = withIpModeLabel(parsed.hostname, ipMode);
50
- return parsed.toString().replace(/\/$/, "");
51
- } catch {
52
- return url;
53
- }
54
- };
55
-
56
- // Cached, in-flight pin per (env, ipMode). Keyed so dual-stack and single-stack
57
- // callers maintain separate stickiness — they hit different /healthz endpoints
58
- // and shouldn't share each other's resolution.
59
- type CacheKey = `${EnvironmentTypes}|${IpMode | "dual"}`;
60
- const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
61
- `${env}|${ipMode ?? "dual"}`;
62
- const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
63
-
64
- // Healthz retry policy. Healthz is a prerequisite for pinning a session to a
65
- // specific pronode — without a pinned host the token embeds the load-balanced
66
- // hostname, which isn't a registered on-chain provider and gets rejected at
67
- // verify time. A transient blip therefore mustn't fail immediately: retry
68
- // with exponential-backoff + full jitter before surfacing the error.
69
- const HEALTHZ_MAX_ATTEMPTS = 3;
70
- const HEALTHZ_RETRY_BASE_DELAY_MS = 250;
71
- const HEALTHZ_RETRY_MAX_DELAY_MS = 2_000;
72
-
73
- let healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
74
- let healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
75
- let healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
76
-
77
- const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
78
- const res = await fetch(`${baseUrl}/healthz`, {
79
- method: "GET",
80
- cache: "no-store",
81
- credentials: "omit",
82
- });
83
- if (!res.ok) {
84
- throw new Error(`healthz responded with ${res.status}`);
85
- }
86
- const body = (await res.json()) as { host?: unknown };
87
- if (typeof body.host !== "string" || body.host.length === 0) {
88
- throw new Error("healthz response missing host field");
89
- }
90
- return body.host;
91
- };
92
-
93
- const fetchPinnedHostWithRetry = (baseUrl: string): Promise<string> =>
94
- retryWithBackoff(() => fetchPinnedHost(baseUrl), {
95
- maxAttempts: healthzMaxAttempts,
96
- baseDelayMs: healthzRetryBaseDelayMs,
97
- maxDelayMs: healthzRetryMaxDelayMs,
98
- });
99
-
100
- const resolveBaseUrl = (env: EnvironmentTypes): string => {
101
- const endpoints = dnsEndpoints();
102
- return endpoints[env] ?? endpoints.development;
103
- };
104
-
105
- const resolvePinnedUrl = async (
106
- env: EnvironmentTypes,
107
- ipMode?: IpMode,
108
- ): Promise<string> => {
109
- // An overridden list needs no discovery: healthz exists to report which
110
- // pronode the DNS layer picked, and an override already names the nodes.
111
- // Returning early also keeps the widget off Prosopo's hostnames entirely.
112
- // The pick is weighted and per-call rather than cached, so traffic spreads
113
- // across a multi-provider override instead of every page in a browser
114
- // session pinning to whichever node was drawn first.
115
- const override = getProviderListOverride(undefined, ipMode);
116
- if (override.length > 0) {
117
- const chosen = pickWeightedProvider(override, Math.random);
118
- if (chosen) return chosen.url;
119
- }
120
-
121
- // The base for /healthz already carries the ipv4./ipv6. label when one is
122
- // requested, so DNS keeps the discovery request on the same single-stack
123
- // path as the captcha calls that follow.
124
- const base = applyIpModeToUrl(resolveBaseUrl(env), ipMode);
125
- // Development has no global hostname to /healthz against; use the
126
- // hardcoded local URL.
127
- if (env === "development") return base;
128
-
129
- const key = cacheKey(env, ipMode);
130
- const cached = pinPromiseCache.get(key);
131
- if (cached) return cached;
132
-
133
- const promise = (async () => {
134
- try {
135
- const host = await fetchPinnedHostWithRetry(base);
136
- const parsed = new URL(base);
137
- // /healthz returns the bare pronodeN.prosopo.io (env.config.host).
138
- // Re-apply the ipMode label so the per-pronode URL stays on the same
139
- // single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
140
- parsed.hostname = withIpModeLabel(host, ipMode);
141
- return parsed.toString().replace(/\/$/, "");
142
- } catch (err) {
143
- // Healthz is a prerequisite for the rest of the captcha flow — the
144
- // token must embed a specific pronodeN URL for verify to accept it.
145
- // Evict the cached rejection so a subsequent captcha attempt gets a
146
- // fresh chance, and surface the error so the caller's own retry
147
- // (`providerRetry` in @prosopo/procaptcha-common) can fall through
148
- // to `getRandomProviderFromList`, which bypasses healthz entirely.
149
- pinPromiseCache.delete(key);
150
- throw err;
151
- }
152
- })();
153
-
154
- pinPromiseCache.set(key, promise);
155
- return promise;
156
- };
157
-
158
- // How long a loaded list is served before the next caller reloads it. The
159
- // hosted list advertises `cache-control: max-age=300`, so this matches what it
160
- // asks for. `findProvider` covers the window where a pronode is added mid-TTL.
161
- export const PROVIDER_LIST_TTL_MS = 5 * 60 * 1000;
162
-
163
- // Floor on how often a lookup miss may force an early reload. `providerUrl` is
164
- // read from the token, which a caller controls, so an unknown url must not be
165
- // able to turn every request into a fresh fetch of the list.
166
- export const PROVIDER_LIST_MISS_RELOAD_MS = 10 * 1000;
167
-
168
- interface ProviderListEntry {
169
- providers: Promise<HardcodedProvider[]>;
170
- loadedAt: number;
171
- }
172
-
173
- // Cached, in-flight provider-list load per env + ipMode. The list rarely
174
- // changes, so a single fetch is shared across callers rather than re-fetching
175
- // the provider-list JSON on every verify decision. Keyed the same way as the
176
- // pin cache: the `ipv4`/`ipv6` sections carry different urls to the dual-stack
177
- // default, so they cannot share an entry.
178
- const providerListCache: Map<CacheKey, ProviderListEntry> = new Map();
179
-
180
- /**
181
- * Returns the full (cached) list of active providers for an environment.
182
- * Used to look up a provider by url/address, e.g. to find the provider that
183
- * issued a token before forwarding a verification request to it.
184
- */
185
- export const getProviders = async (
186
- env: EnvironmentTypes,
187
- ipMode?: IpMode,
188
- ): Promise<HardcodedProvider[]> => {
189
- const key = cacheKey(env, ipMode);
190
- const cached = providerListCache.get(key);
191
- if (cached && Date.now() - cached.loadedAt < PROVIDER_LIST_TTL_MS) {
192
- return cached.providers;
193
- }
194
-
195
- const providers = loadBalancer(env, ipMode).catch((err) => {
196
- // Don't cache failures — a transient fetch error shouldn't poison the
197
- // cache for the lifetime of the process. Only evict this entry: a
198
- // later load may already have replaced it.
199
- if (providerListCache.get(key)?.providers === providers) {
200
- providerListCache.delete(key);
201
- }
202
- throw err;
203
- });
204
- providerListCache.set(key, { providers, loadedAt: Date.now() });
205
- return providers;
206
- };
207
-
208
- /**
209
- * Finds the provider that minted a token, by exact url match.
210
- *
211
- * Exact-string rather than normalised: the single-stack sections of the list
212
- * carry sub-zone urls (`https://ipv4.pronode15.prosopo.io`), so the caller
213
- * passes the `ipMode` it derived from the token's own hostname and both sides
214
- * of the comparison stay in the same section.
215
- *
216
- * A miss reloads the list once before giving up, so a pronode added part-way
217
- * through the TTL still verifies the tokens it has already minted. That reload
218
- * is rate-limited, because a miss is also what an unknown url produces.
219
- */
220
- export const findProvider = async (
221
- env: EnvironmentTypes,
222
- providerUrl: string | undefined,
223
- ipMode?: IpMode,
224
- ): Promise<HardcodedProvider | undefined> => {
225
- if (!providerUrl) return undefined;
226
-
227
- const providers = await getProviders(env, ipMode);
228
- const provider = providers.find((p) => p.url === providerUrl);
229
- if (provider) return provider;
230
-
231
- const key = cacheKey(env, ipMode);
232
- const cached = providerListCache.get(key);
233
- if (cached && Date.now() - cached.loadedAt < PROVIDER_LIST_MISS_RELOAD_MS) {
234
- return undefined;
235
- }
236
- providerListCache.delete(key);
237
-
238
- const reloaded = await getProviders(env, ipMode);
239
- return reloaded.find((p) => p.url === providerUrl);
240
- };
241
-
242
- export const getRandomActiveProvider = async (
243
- env: EnvironmentTypes,
244
- ipMode?: IpMode,
245
- ): Promise<RandomProvider> => {
246
- const url = await resolvePinnedUrl(env, ipMode);
247
- return {
248
- providerAccount: "dns-routed",
249
- provider: { url },
250
- };
251
- };
252
-
253
- // Compare two provider URLs ignoring a trailing slash so a stored "failed" URL
254
- // still matches its list entry regardless of formatting.
255
- const sameProviderUrl = (a: string, b: string): boolean =>
256
- a.replace(/\/$/, "") === b.replace(/\/$/, "");
257
-
258
- // Weighted random pick — providers advertise a capacity weight (1-100), so
259
- // fallback traffic is spread in proportion to capacity rather than uniformly.
260
- // Returns undefined only for an empty list (callers guard against that).
261
- const pickWeightedProvider = (
262
- providers: HardcodedProvider[],
263
- random: () => number,
264
- ): HardcodedProvider | undefined => {
265
- const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0);
266
- let threshold = random() * totalWeight;
267
- let chosen: HardcodedProvider | undefined;
268
- for (const provider of providers) {
269
- chosen = provider;
270
- threshold -= provider.weight;
271
- if (threshold < 0) break;
272
- }
273
- return chosen;
274
- };
275
-
276
- /**
277
- * Pick a random provider directly from the provider list, bypassing the
278
- * DNS-routed endpoint. This is the error-fallback path: once a provider has
279
- * errored, retrying it re-hits the same (possibly-down) endpoint, and a fleet
280
- * of widgets doing that in a tight loop can accidentally DDoS the provider — so
281
- * instead we spread the retry across the fleet by choosing a random provider
282
- * from the list. `excludeUrl` (the provider that just failed) is dropped from
283
- * the pool when other providers remain. In development the list holds only the
284
- * single local provider, so this naturally degrades to retrying that provider.
285
- * `random` is injectable so tests can make the pick deterministic.
286
- */
287
- export const getRandomProviderFromList = async (
288
- env: EnvironmentTypes,
289
- ipMode?: IpMode,
290
- excludeUrl?: string,
291
- random: () => number = Math.random,
292
- ): Promise<RandomProvider> => {
293
- const providers = await getProviders(env);
294
- if (providers.length === 0) {
295
- // Nothing to choose from — fall back to the DNS-routed endpoint.
296
- return getRandomActiveProvider(env, ipMode);
297
- }
298
-
299
- // Only exclude the failed provider when at least one other remains, so we
300
- // never end up with an empty pool (e.g. a single-provider environment).
301
- const eligible =
302
- excludeUrl && providers.length > 1
303
- ? providers.filter(
304
- (p) => !sameProviderUrl(applyIpModeToUrl(p.url, ipMode), excludeUrl),
305
- )
306
- : providers;
307
- const pool = eligible.length > 0 ? eligible : providers;
308
-
309
- const chosen = pickWeightedProvider(pool, random);
310
- if (!chosen) {
311
- return getRandomActiveProvider(env, ipMode);
312
- }
313
- return {
314
- providerAccount: chosen.address,
315
- provider: { url: applyIpModeToUrl(chosen.url, ipMode) },
316
- };
317
- };
318
-
319
- // Test-only escape hatch so tests can isolate the healthz cache between
320
- // cases. Not exported from the package index — internal use only.
321
- export const _resetPinCache = () => {
322
- pinPromiseCache.clear();
323
- };
324
-
325
- // Test-only escape hatch to isolate the provider-list cache between cases.
326
- // Not exported from the package index — internal use only.
327
- export const _resetProviderListCache = () => {
328
- providerListCache.clear();
329
- };
330
-
331
- // Test-only override for the healthz retry policy — tests use it to disable
332
- // backoff delays (baseDelayMs: 0) and to shorten/lengthen the attempt count.
333
- // Not exported from the package index — internal use only.
334
- export const _setHealthzRetryPolicy = (opts: {
335
- maxAttempts?: number;
336
- baseDelayMs?: number;
337
- maxDelayMs?: number;
338
- }) => {
339
- if (opts.maxAttempts !== undefined) healthzMaxAttempts = opts.maxAttempts;
340
- if (opts.baseDelayMs !== undefined)
341
- healthzRetryBaseDelayMs = opts.baseDelayMs;
342
- if (opts.maxDelayMs !== undefined) healthzRetryMaxDelayMs = opts.maxDelayMs;
343
- };
344
-
345
- // Test-only reset for the healthz retry policy — restores the defaults so an
346
- // override in one test doesn't leak into the next. Not exported from the
347
- // package index — internal use only.
348
- export const _resetHealthzRetryPolicy = () => {
349
- healthzMaxAttempts = HEALTHZ_MAX_ATTEMPTS;
350
- healthzRetryBaseDelayMs = HEALTHZ_RETRY_BASE_DELAY_MS;
351
- healthzRetryMaxDelayMs = HEALTHZ_RETRY_MAX_DELAY_MS;
352
- };
package/src/retry.ts DELETED
@@ -1,82 +0,0 @@
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
- export type RetryOptions = {
16
- // Total attempts including the first. `1` disables retries.
17
- maxAttempts: number;
18
- // Base delay for the first retry — attempt N waits in [0, base * 2^N].
19
- baseDelayMs: number;
20
- // Upper bound on the jitter window so backoff can't grow unboundedly.
21
- maxDelayMs: number;
22
- // Injectable for deterministic tests. Defaults to Math.random.
23
- random?: () => number;
24
- // Injectable for deterministic tests. Defaults to setTimeout-based sleep.
25
- sleep?: (ms: number) => Promise<void>;
26
- };
27
-
28
- const defaultSleep = (ms: number): Promise<void> =>
29
- ms > 0
30
- ? new Promise((resolve) => setTimeout(resolve, ms))
31
- : Promise.resolve();
32
-
33
- // Full-jitter exponential backoff — attempt N picks a uniform delay in the
34
- // range [0, min(maxDelayMs, baseDelayMs * 2^N)]. Full jitter (rather than
35
- // equal jitter) desynchronises clients that all failed at the same moment,
36
- // so their retries don't reconverge into a thundering herd against whatever
37
- // they're calling. Matches the algorithm in @prosopo/procaptcha-common's
38
- // getRetryDelayMs so both retry paths behave the same way.
39
- export const getBackoffDelayMs = (
40
- attempt: number,
41
- baseDelayMs: number,
42
- maxDelayMs: number,
43
- random: () => number = Math.random,
44
- ): number => {
45
- const safeAttempt = Math.max(0, Math.floor(attempt));
46
- const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** safeAttempt);
47
- return Math.round(random() * cap);
48
- };
49
-
50
- /**
51
- * Run `fn` up to `maxAttempts` times, sleeping with full-jitter exponential
52
- * backoff between attempts. Returns the first successful value. If every
53
- * attempt throws, throws the last error (Error-normalised).
54
- */
55
- export const retryWithBackoff = async <T>(
56
- fn: () => Promise<T>,
57
- opts: RetryOptions,
58
- ): Promise<T> => {
59
- const {
60
- maxAttempts,
61
- baseDelayMs,
62
- maxDelayMs,
63
- random = Math.random,
64
- sleep = defaultSleep,
65
- } = opts;
66
- if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
67
- throw new RangeError(
68
- `maxAttempts must be a positive integer, got ${maxAttempts}`,
69
- );
70
- }
71
- let lastErr: unknown;
72
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
73
- try {
74
- return await fn();
75
- } catch (err) {
76
- lastErr = err;
77
- if (attempt >= maxAttempts - 1) break;
78
- await sleep(getBackoffDelayMs(attempt, baseDelayMs, maxDelayMs, random));
79
- }
80
- }
81
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
82
- };
@@ -1,76 +0,0 @@
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
- import { afterEach, describe, expect, it } from "vitest";
16
- import {
17
- DEFAULT_DEVELOPMENT_PROVIDER_URL,
18
- getDevelopmentProviderUrl,
19
- readDevelopmentProviderUrlOverride,
20
- } from "../developmentProviderUrl.js";
21
-
22
- const ENV_KEY = "PROSOPO_PROVIDER_URL_DEVELOPMENT";
23
- const original: string | undefined = process.env[ENV_KEY];
24
-
25
- afterEach(() => {
26
- if (original === undefined) delete process.env[ENV_KEY];
27
- else process.env[ENV_KEY] = original;
28
- });
29
-
30
- describe("the development provider url", () => {
31
- it("is localhost when nothing overrides it", () => {
32
- expect(getDevelopmentProviderUrl(undefined)).toBe("https://localhost:9229");
33
- expect(DEFAULT_DEVELOPMENT_PROVIDER_URL).toBe("https://localhost:9229");
34
- });
35
-
36
- it("is the override when one is set", () => {
37
- // The case this exists for: a phone loading the demo over the LAN cannot
38
- // reach the provider via `localhost`, which resolves to the phone.
39
- expect(getDevelopmentProviderUrl("https://192.0.2.10:9229")).toBe(
40
- "https://192.0.2.10:9229",
41
- );
42
- });
43
-
44
- it("drops a trailing slash so the url composes with /healthz", () => {
45
- expect(getDevelopmentProviderUrl("https://192.0.2.10:9229/")).toBe(
46
- "https://192.0.2.10:9229",
47
- );
48
- });
49
-
50
- it("falls back rather than yielding an empty url", () => {
51
- for (const blank of ["", " ", undefined]) {
52
- expect(getDevelopmentProviderUrl(blank)).toBe(
53
- DEFAULT_DEVELOPMENT_PROVIDER_URL,
54
- );
55
- }
56
- });
57
- });
58
-
59
- describe("reading the override", () => {
60
- it("reads the environment variable", () => {
61
- process.env[ENV_KEY] = "https://198.51.100.7:9229";
62
- expect(readDevelopmentProviderUrlOverride()).toBe(
63
- "https://198.51.100.7:9229",
64
- );
65
- });
66
-
67
- it("is undefined when the variable is unset", () => {
68
- delete process.env[ENV_KEY];
69
- expect(readDevelopmentProviderUrlOverride()).toBeUndefined();
70
- });
71
-
72
- it("defaults when the variable is unset", () => {
73
- delete process.env[ENV_KEY];
74
- expect(getDevelopmentProviderUrl()).toBe(DEFAULT_DEVELOPMENT_PROVIDER_URL);
75
- });
76
- });