@prosopo/load-balancer 2.9.21 → 2.10.1

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/src/providers.ts CHANGED
@@ -13,119 +13,153 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import type { EnvironmentTypes, RandomProvider } from "@prosopo/types";
16
- import { type HardcodedProvider, loadBalancer } from "./index.js";
16
+ import {
17
+ type HardcodedProvider,
18
+ type IpMode,
19
+ loadBalancer,
20
+ } from "./balancer.js";
17
21
 
18
- // Keyed by env so a prefetch and a later call with a different env don't share.
19
- const providerPromiseCache: Map<
20
- EnvironmentTypes,
21
- Promise<HardcodedProvider[]>
22
- > = new Map();
22
+ // Base DNS endpoint per env the `pronode.prosopo.io` family is latency-routed
23
+ // (A/AAAA records across the pronode fleet). Clients hit this URL's `/healthz`
24
+ // once to discover which specific pronodeN the DNS layer picked, then pin
25
+ // subsequent captcha calls to that pronode so session creation and submission
26
+ // land on the same backend.
27
+ const DNS_ENDPOINT: Record<EnvironmentTypes, string> = {
28
+ development: "https://localhost:9229",
29
+ staging: "https://staging.pronode.prosopo.io",
30
+ production: "https://pronode.prosopo.io",
31
+ };
23
32
 
24
- /** Optional custom loader for server-side caching (e.g. cacheFile with ETag). */
25
- let customProviderLoader:
26
- | ((env: EnvironmentTypes) => Promise<HardcodedProvider[]>)
27
- | null = null;
33
+ // Apply the `ipv4.` / `ipv6.` DNS label to a hostname. The single-stack
34
+ // sub-zones only resolve to A or AAAA records respectively, so this pins the
35
+ // network path before TLS negotiation. Ansible provisions the matching certs
36
+ // for both `ipv4.{global}` and `ipv4.pronodeN.{global}`.
37
+ const withIpModeLabel = (hostname: string, ipMode?: IpMode): string =>
38
+ ipMode ? `${ipMode}.${hostname}` : hostname;
28
39
 
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
- }
40
+ const applyIpModeToUrl = (url: string, ipMode?: IpMode): string => {
41
+ if (!ipMode) return url;
42
+ try {
43
+ const parsed = new URL(url);
44
+ parsed.hostname = withIpModeLabel(parsed.hostname, ipMode);
45
+ return parsed.toString().replace(/\/$/, "");
46
+ } catch {
47
+ return url;
48
+ }
49
+ };
39
50
 
40
- export function _resetCache() {
41
- providerPromiseCache.clear();
42
- }
51
+ // Cached, in-flight pin per (env, ipMode). Keyed so dual-stack and single-stack
52
+ // callers maintain separate stickiness — they hit different /healthz endpoints
53
+ // and shouldn't share each other's resolution.
54
+ type CacheKey = `${EnvironmentTypes}|${IpMode | "dual"}`;
55
+ const cacheKey = (env: EnvironmentTypes, ipMode?: IpMode): CacheKey =>
56
+ `${env}|${ipMode ?? "dual"}`;
57
+ const pinPromiseCache: Map<CacheKey, Promise<string>> = new Map();
43
58
 
44
- /**
45
- * Selects a weighted random provider using the entropy value.
46
- * Providers with higher weights are more likely to be selected.
47
- *
48
- * @param providers - Array of providers with weights
49
- * @param entropy - Random seed value for deterministic selection
50
- * @returns Selected provider
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");
59
+ const fetchPinnedHost = async (baseUrl: string): Promise<string> => {
60
+ const res = await fetch(`${baseUrl}/healthz`, {
61
+ method: "GET",
62
+ cache: "no-store",
63
+ credentials: "omit",
64
+ });
65
+ if (!res.ok) {
66
+ throw new Error(`healthz responded with ${res.status}`);
67
+ }
68
+ const body = (await res.json()) as { host?: unknown };
69
+ if (typeof body.host !== "string" || body.host.length === 0) {
70
+ throw new Error("healthz response missing host field");
58
71
  }
72
+ return body.host;
73
+ };
59
74
 
60
- const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0);
75
+ const resolveBaseUrl = (env: EnvironmentTypes): string =>
76
+ DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development;
61
77
 
62
- // Use entropy to generate a value between 0 and totalWeight-1
63
- const randomValue = entropy % totalWeight;
78
+ const resolvePinnedUrl = async (
79
+ env: EnvironmentTypes,
80
+ ipMode?: IpMode,
81
+ ): Promise<string> => {
82
+ // The base for /healthz already carries the ipv4./ipv6. label when one is
83
+ // requested, so DNS keeps the discovery request on the same single-stack
84
+ // path as the captcha calls that follow.
85
+ const base = applyIpModeToUrl(resolveBaseUrl(env), ipMode);
86
+ // Development has no global hostname to /healthz against; use the
87
+ // hardcoded local URL.
88
+ if (env === "development") return base;
64
89
 
65
- // Select provider based on cumulative weight
66
- let cumulativeWeight = 0;
67
- for (const provider of providers) {
68
- cumulativeWeight += provider.weight;
69
- if (randomValue < cumulativeWeight) {
70
- return provider;
71
- }
72
- }
90
+ const key = cacheKey(env, ipMode);
91
+ const cached = pinPromiseCache.get(key);
92
+ if (cached) return cached;
73
93
 
74
- // Fallback (should never reach here)
75
- const selectedProvider = providers[providers.length - 1];
76
- if (!selectedProvider) {
77
- throw new Error("No providers available");
78
- }
79
- return selectedProvider;
80
- }
94
+ const promise = (async () => {
95
+ try {
96
+ const host = await fetchPinnedHost(base);
97
+ const parsed = new URL(base);
98
+ // /healthz returns the bare pronodeN.prosopo.io (env.config.host).
99
+ // Re-apply the ipMode label so the per-pronode URL stays on the same
100
+ // single-stack sub-zone (`ipv4.pronode4.prosopo.io`).
101
+ parsed.hostname = withIpModeLabel(host, ipMode);
102
+ return parsed.toString().replace(/\/$/, "");
103
+ } catch {
104
+ // Healthz unreachable / malformed — fall back to the load-balanced
105
+ // hostname (with the ipMode label still applied). Clients still
106
+ // work, they just lose per-pronode stickiness.
107
+ return base;
108
+ }
109
+ })();
81
110
 
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);
88
- }
89
- return loadBalancer(env);
111
+ pinPromiseCache.set(key, promise);
112
+ return promise;
90
113
  };
91
114
 
92
- // Caches the in-flight Promise (not the resolved array) so concurrent callers
93
- // share a single network request rather than racing.
94
- const getProvidersPromise = (
115
+ // Cached, in-flight provider-list load per env. The list rarely changes, so a
116
+ // single fetch is shared across callers rather than re-fetching the
117
+ // provider-list JSON on every verify-forward decision.
118
+ const providerListPromiseCache: Map<
119
+ EnvironmentTypes,
120
+ Promise<HardcodedProvider[]>
121
+ > = new Map();
122
+
123
+ /**
124
+ * Returns the full (cached) list of active providers for an environment.
125
+ * Used to look up a provider by url/address, e.g. to find the provider that
126
+ * issued a token before forwarding a verification request to it.
127
+ */
128
+ export const getProviders = async (
95
129
  env: EnvironmentTypes,
96
130
  ): Promise<HardcodedProvider[]> => {
97
- const existing = providerPromiseCache.get(env);
98
- if (existing) return existing;
99
- const promise = loadProviders(env).catch((err) => {
100
- providerPromiseCache.delete(env);
131
+ const cached = providerListPromiseCache.get(env);
132
+ if (cached) return cached;
133
+
134
+ const promise = loadBalancer(env).catch((err) => {
135
+ // Don't cache failures — a transient fetch error shouldn't poison the
136
+ // cache for the lifetime of the process.
137
+ providerListPromiseCache.delete(env);
101
138
  throw err;
102
139
  });
103
- providerPromiseCache.set(env, promise);
140
+ providerListPromiseCache.set(env, promise);
104
141
  return promise;
105
142
  };
106
143
 
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 (
112
- env: EnvironmentTypes,
113
- ): Promise<void> => {
114
- await getProvidersPromise(env);
115
- };
116
-
117
144
  export const getRandomActiveProvider = async (
118
145
  env: EnvironmentTypes,
119
- entropy: number,
146
+ ipMode?: IpMode,
120
147
  ): Promise<RandomProvider> => {
121
- const providers = await getProvidersPromise(env);
122
- const randomProviderObj = selectWeightedProvider(providers, entropy);
123
-
148
+ const url = await resolvePinnedUrl(env, ipMode);
124
149
  return {
125
- providerAccount: randomProviderObj.address,
126
- provider: {
127
- url: randomProviderObj.url,
128
- datasetId: randomProviderObj.datasetId,
129
- },
150
+ providerAccount: "dns-routed",
151
+ provider: { url },
130
152
  };
131
153
  };
154
+
155
+ // Test-only escape hatch so tests can isolate the healthz cache between
156
+ // cases. Not exported from the package index — internal use only.
157
+ export const _resetPinCache = () => {
158
+ pinPromiseCache.clear();
159
+ };
160
+
161
+ // Test-only escape hatch to isolate the provider-list cache between cases.
162
+ // Not exported from the package index — internal use only.
163
+ export const _resetProviderListCache = () => {
164
+ providerListPromiseCache.clear();
165
+ };