@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/.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 +82 -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 +5 -4
- package/dist/cjs/providers.cjs +69 -51
- package/dist/index.js +7 -6
- package/dist/providers.d.ts +5 -6
- package/dist/providers.d.ts.map +1 -1
- package/dist/providers.js +70 -52
- package/dist/providers.js.map +1 -1
- package/dist/tests/providers.unit.test.js +112 -204
- package/dist/tests/providers.unit.test.js.map +1 -1
- package/package.json +4 -4
- package/src/balancer.ts +29 -3
- package/src/providers.ts +124 -90
- package/src/tests/providers.unit.test.ts +131 -262
- package/tsconfig.tsbuildinfo +1 -1
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 {
|
|
16
|
+
import {
|
|
17
|
+
type HardcodedProvider,
|
|
18
|
+
type IpMode,
|
|
19
|
+
loadBalancer,
|
|
20
|
+
} from "./balancer.js";
|
|
17
21
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
75
|
+
const resolveBaseUrl = (env: EnvironmentTypes): string =>
|
|
76
|
+
DNS_ENDPOINT[env] ?? DNS_ENDPOINT.development;
|
|
61
77
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
ipMode?: IpMode,
|
|
120
147
|
): Promise<RandomProvider> => {
|
|
121
|
-
const
|
|
122
|
-
const randomProviderObj = selectWeightedProvider(providers, entropy);
|
|
123
|
-
|
|
148
|
+
const url = await resolvePinnedUrl(env, ipMode);
|
|
124
149
|
return {
|
|
125
|
-
providerAccount:
|
|
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
|
+
};
|