@mochi.js/core 0.2.2 → 0.3.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/package.json +4 -4
- package/src/__tests__/geo-consistency.test.ts +277 -0
- package/src/__tests__/geo-probe.test.ts +415 -0
- package/src/__tests__/inject.test.ts +2 -0
- package/src/__tests__/integration.e2e.test.ts +24 -0
- package/src/geo-consistency.ts +343 -0
- package/src/geo-probe.ts +603 -0
- package/src/index.ts +10 -0
- package/src/launch.ts +78 -7
- package/src/page.ts +10 -1
- package/src/session.ts +228 -10
package/src/geo-probe.ts
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exit-IP geo-probe — closes the cross-layer leak where
|
|
3
|
+
* `(matrix.timezone, matrix.locale)` and the apparent **exit IP** disagree.
|
|
4
|
+
*
|
|
5
|
+
* A fingerprinter computing `Date.getTimezoneOffset()` and cross-referencing
|
|
6
|
+
* against the IP's geolocation sees a mismatch when, e.g., the user runs a
|
|
7
|
+
* US-West profile through an EU-egressing residential proxy. mochi takes
|
|
8
|
+
* matrix values as canonical regardless of proxy egress; this module is the
|
|
9
|
+
* first half of the fix (the second half is {@link reconcileGeoConsistency}
|
|
10
|
+
* in `launch.ts`).
|
|
11
|
+
*
|
|
12
|
+
* The probe issues a single GET through the same `wreq` preset the session
|
|
13
|
+
* would use for user traffic, so the geolocation service sees the **same
|
|
14
|
+
* JA4 / headers** as the actual page — the probe doesn't itself become
|
|
15
|
+
* detectable. The probe respects the `proxy` option; if unset, the probe
|
|
16
|
+
* goes direct (which is fine — the user's exit IP is the host's IP).
|
|
17
|
+
*
|
|
18
|
+
* ### Endpoint registry (verified working 2026-05-09)
|
|
19
|
+
*
|
|
20
|
+
* | Endpoint | Schema | Notes |
|
|
21
|
+
* |---|---|---|
|
|
22
|
+
* | `https://ip.decodo.com/json` | `{proxy, country, city}` | rich shape |
|
|
23
|
+
* | `https://ipinfo.io/json` | `{country, timezone, loc}` | flat |
|
|
24
|
+
* | `https://ipwho.is/` | `{country_code, timezone.id}` | rich |
|
|
25
|
+
* | `https://api.ip.sb/geoip` | `{country_code, timezone}` | secondary |
|
|
26
|
+
* | `https://ifconfig.co/json` | `{country_iso, time_zone}` | secondary |
|
|
27
|
+
* | `https://api.iplocation.net/` | country-only | last resort |
|
|
28
|
+
* | `https://ipapi.co/json/` | rate-limited | KEEP — expect failures |
|
|
29
|
+
*
|
|
30
|
+
* ### Strategy
|
|
31
|
+
*
|
|
32
|
+
* Shuffled-sequential. 2s per-endpoint timeout, 4-attempt cap. All 4 fail
|
|
33
|
+
* → return `null` and let the caller fall through to its `geoConsistency`
|
|
34
|
+
* mode (default `privacy-fallback`).
|
|
35
|
+
*
|
|
36
|
+
* **No cross-session caching** — proxy IPs rotate; stale cache is worse
|
|
37
|
+
* than no cache. (`docs/limits.md` — task 0262.)
|
|
38
|
+
*
|
|
39
|
+
* @see PLAN.md §9 (relational consistency — IP/TZ/Locale axis)
|
|
40
|
+
* @see tasks/0262-ip-tz-locale-exit-consistency.md
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { MatrixV1 } from "@mochi.js/consistency";
|
|
44
|
+
import { fetch as netFetch } from "@mochi.js/net";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalised geolocation derived from one of the probe endpoints. The
|
|
48
|
+
* probe layer never reads the proxy's raw response shape — every adapter
|
|
49
|
+
* normalises into this single record.
|
|
50
|
+
*
|
|
51
|
+
* `country` is required (it's the load-bearing field — locale-region
|
|
52
|
+
* compares against it). All other location fields are best-effort; an
|
|
53
|
+
* adapter MUST return `null` if it can't resolve at least
|
|
54
|
+
* `{ip, country, timezone}`.
|
|
55
|
+
*/
|
|
56
|
+
export interface ExitGeo {
|
|
57
|
+
/** The egressing IP as observed by the geolocation service. */
|
|
58
|
+
readonly ip: string;
|
|
59
|
+
/** ISO-3166-1 alpha-2 country code, e.g. `"TH"`. Uppercase. */
|
|
60
|
+
readonly country: string;
|
|
61
|
+
/** Best-effort administrative region (state/province). */
|
|
62
|
+
readonly region?: string;
|
|
63
|
+
/** Best-effort city name. */
|
|
64
|
+
readonly city?: string;
|
|
65
|
+
/** IANA timezone identifier, e.g. `"Asia/Bangkok"`. */
|
|
66
|
+
readonly timezone: string;
|
|
67
|
+
/** Postal / ZIP code, when available. */
|
|
68
|
+
readonly postalCode?: string;
|
|
69
|
+
/** Latitude (decimal degrees). */
|
|
70
|
+
readonly lat?: number;
|
|
71
|
+
/** Longitude (decimal degrees). */
|
|
72
|
+
readonly lng?: number;
|
|
73
|
+
/** Which endpoint answered — for diagnostics + the `_internalProbe` log. */
|
|
74
|
+
readonly source: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Per-endpoint adapter: a URL + a parser that returns `null` on schema mismatch. */
|
|
78
|
+
interface Adapter {
|
|
79
|
+
readonly url: string;
|
|
80
|
+
readonly parse: (json: unknown) => ExitGeo | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Coerce an arbitrary JSON value to a non-empty string, or undefined.
|
|
85
|
+
* Adapters use this to be defensive against schema drift.
|
|
86
|
+
*/
|
|
87
|
+
function s(v: unknown): string | undefined {
|
|
88
|
+
if (typeof v !== "string") return undefined;
|
|
89
|
+
const t = v.trim();
|
|
90
|
+
return t.length === 0 ? undefined : t;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Coerce to a finite number, or undefined. */
|
|
94
|
+
function n(v: unknown): number | undefined {
|
|
95
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
96
|
+
if (typeof v === "string") {
|
|
97
|
+
const f = Number.parseFloat(v);
|
|
98
|
+
return Number.isFinite(f) ? f : undefined;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build an `ExitGeo` from the per-adapter scratch fields, validating the
|
|
105
|
+
* minimum required set (`ip`, `country`, `timezone`). Returns `null` if any
|
|
106
|
+
* are missing — this is the schema-mismatch signal that drives the caller
|
|
107
|
+
* to the next endpoint.
|
|
108
|
+
*/
|
|
109
|
+
function build(
|
|
110
|
+
scratch: {
|
|
111
|
+
ip?: string;
|
|
112
|
+
country?: string;
|
|
113
|
+
region?: string;
|
|
114
|
+
city?: string;
|
|
115
|
+
timezone?: string;
|
|
116
|
+
postalCode?: string;
|
|
117
|
+
lat?: number;
|
|
118
|
+
lng?: number;
|
|
119
|
+
},
|
|
120
|
+
source: string,
|
|
121
|
+
): ExitGeo | null {
|
|
122
|
+
const ip = scratch.ip;
|
|
123
|
+
const country = scratch.country;
|
|
124
|
+
const timezone = scratch.timezone;
|
|
125
|
+
if (ip === undefined || country === undefined || timezone === undefined) return null;
|
|
126
|
+
const out: {
|
|
127
|
+
ip: string;
|
|
128
|
+
country: string;
|
|
129
|
+
region?: string;
|
|
130
|
+
city?: string;
|
|
131
|
+
timezone: string;
|
|
132
|
+
postalCode?: string;
|
|
133
|
+
lat?: number;
|
|
134
|
+
lng?: number;
|
|
135
|
+
source: string;
|
|
136
|
+
} = { ip, country: country.toUpperCase(), timezone, source };
|
|
137
|
+
if (scratch.region !== undefined) out.region = scratch.region;
|
|
138
|
+
if (scratch.city !== undefined) out.city = scratch.city;
|
|
139
|
+
if (scratch.postalCode !== undefined) out.postalCode = scratch.postalCode;
|
|
140
|
+
if (scratch.lat !== undefined) out.lat = scratch.lat;
|
|
141
|
+
if (scratch.lng !== undefined) out.lng = scratch.lng;
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The endpoint registry. Per-adapter `parse` MUST return `null` on schema
|
|
147
|
+
* mismatch, never throw — schemas drift over time and the caller falls
|
|
148
|
+
* through to the next endpoint.
|
|
149
|
+
*
|
|
150
|
+
* Order at definition time is irrelevant; the probe shuffles per-call.
|
|
151
|
+
*/
|
|
152
|
+
export const ADAPTERS: readonly Adapter[] = [
|
|
153
|
+
{
|
|
154
|
+
url: "https://ip.decodo.com/json",
|
|
155
|
+
parse(json) {
|
|
156
|
+
const j = json as {
|
|
157
|
+
proxy?: { ip?: unknown };
|
|
158
|
+
country?: { code?: unknown };
|
|
159
|
+
city?: {
|
|
160
|
+
name?: unknown;
|
|
161
|
+
state?: unknown;
|
|
162
|
+
time_zone?: unknown;
|
|
163
|
+
zip_code?: unknown;
|
|
164
|
+
latitude?: unknown;
|
|
165
|
+
longitude?: unknown;
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
return build(
|
|
169
|
+
{
|
|
170
|
+
...(s(j.proxy?.ip) !== undefined ? { ip: s(j.proxy?.ip) } : {}),
|
|
171
|
+
...(s(j.country?.code) !== undefined ? { country: s(j.country?.code) } : {}),
|
|
172
|
+
...(s(j.city?.name) !== undefined ? { city: s(j.city?.name) } : {}),
|
|
173
|
+
...(s(j.city?.state) !== undefined ? { region: s(j.city?.state) } : {}),
|
|
174
|
+
...(s(j.city?.time_zone) !== undefined ? { timezone: s(j.city?.time_zone) } : {}),
|
|
175
|
+
...(s(j.city?.zip_code) !== undefined ? { postalCode: s(j.city?.zip_code) } : {}),
|
|
176
|
+
...(n(j.city?.latitude) !== undefined ? { lat: n(j.city?.latitude) } : {}),
|
|
177
|
+
...(n(j.city?.longitude) !== undefined ? { lng: n(j.city?.longitude) } : {}),
|
|
178
|
+
},
|
|
179
|
+
"decodo",
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
url: "https://ipinfo.io/json",
|
|
185
|
+
parse(json) {
|
|
186
|
+
const j = json as {
|
|
187
|
+
ip?: unknown;
|
|
188
|
+
country?: unknown;
|
|
189
|
+
city?: unknown;
|
|
190
|
+
region?: unknown;
|
|
191
|
+
timezone?: unknown;
|
|
192
|
+
postal?: unknown;
|
|
193
|
+
loc?: unknown;
|
|
194
|
+
};
|
|
195
|
+
const scratch: {
|
|
196
|
+
ip?: string;
|
|
197
|
+
country?: string;
|
|
198
|
+
region?: string;
|
|
199
|
+
city?: string;
|
|
200
|
+
timezone?: string;
|
|
201
|
+
postalCode?: string;
|
|
202
|
+
lat?: number;
|
|
203
|
+
lng?: number;
|
|
204
|
+
} = {};
|
|
205
|
+
const ip = s(j.ip);
|
|
206
|
+
const country = s(j.country);
|
|
207
|
+
const tz = s(j.timezone);
|
|
208
|
+
const region = s(j.region);
|
|
209
|
+
const city = s(j.city);
|
|
210
|
+
const postal = s(j.postal);
|
|
211
|
+
if (ip !== undefined) scratch.ip = ip;
|
|
212
|
+
if (country !== undefined) scratch.country = country;
|
|
213
|
+
if (tz !== undefined) scratch.timezone = tz;
|
|
214
|
+
if (region !== undefined) scratch.region = region;
|
|
215
|
+
if (city !== undefined) scratch.city = city;
|
|
216
|
+
if (postal !== undefined) scratch.postalCode = postal;
|
|
217
|
+
const loc = s(j.loc);
|
|
218
|
+
if (loc !== undefined) {
|
|
219
|
+
const parts = loc.split(",");
|
|
220
|
+
if (parts.length === 2) {
|
|
221
|
+
const lat = n(parts[0]);
|
|
222
|
+
const lng = n(parts[1]);
|
|
223
|
+
if (lat !== undefined) scratch.lat = lat;
|
|
224
|
+
if (lng !== undefined) scratch.lng = lng;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return build(scratch, "ipinfo");
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
url: "https://ipwho.is/",
|
|
232
|
+
parse(json) {
|
|
233
|
+
const j = json as {
|
|
234
|
+
success?: unknown;
|
|
235
|
+
ip?: unknown;
|
|
236
|
+
country_code?: unknown;
|
|
237
|
+
city?: unknown;
|
|
238
|
+
region?: unknown;
|
|
239
|
+
timezone?: { id?: unknown };
|
|
240
|
+
postal?: unknown;
|
|
241
|
+
latitude?: unknown;
|
|
242
|
+
longitude?: unknown;
|
|
243
|
+
};
|
|
244
|
+
// ipwho.is signals "couldn't locate" via {success: false}; treat as
|
|
245
|
+
// schema mismatch so we fall through.
|
|
246
|
+
if (j.success === false) return null;
|
|
247
|
+
const scratch: {
|
|
248
|
+
ip?: string;
|
|
249
|
+
country?: string;
|
|
250
|
+
region?: string;
|
|
251
|
+
city?: string;
|
|
252
|
+
timezone?: string;
|
|
253
|
+
postalCode?: string;
|
|
254
|
+
lat?: number;
|
|
255
|
+
lng?: number;
|
|
256
|
+
} = {};
|
|
257
|
+
const ip = s(j.ip);
|
|
258
|
+
const country = s(j.country_code);
|
|
259
|
+
const region = s(j.region);
|
|
260
|
+
const city = s(j.city);
|
|
261
|
+
const tz = s(j.timezone?.id);
|
|
262
|
+
const postal = s(j.postal);
|
|
263
|
+
const lat = n(j.latitude);
|
|
264
|
+
const lng = n(j.longitude);
|
|
265
|
+
if (ip !== undefined) scratch.ip = ip;
|
|
266
|
+
if (country !== undefined) scratch.country = country;
|
|
267
|
+
if (tz !== undefined) scratch.timezone = tz;
|
|
268
|
+
if (region !== undefined) scratch.region = region;
|
|
269
|
+
if (city !== undefined) scratch.city = city;
|
|
270
|
+
if (postal !== undefined) scratch.postalCode = postal;
|
|
271
|
+
if (lat !== undefined) scratch.lat = lat;
|
|
272
|
+
if (lng !== undefined) scratch.lng = lng;
|
|
273
|
+
return build(scratch, "ipwhois");
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
url: "https://api.ip.sb/geoip",
|
|
278
|
+
parse(json) {
|
|
279
|
+
const j = json as {
|
|
280
|
+
ip?: unknown;
|
|
281
|
+
country_code?: unknown;
|
|
282
|
+
country?: unknown;
|
|
283
|
+
city?: unknown;
|
|
284
|
+
region?: unknown;
|
|
285
|
+
timezone?: unknown;
|
|
286
|
+
latitude?: unknown;
|
|
287
|
+
longitude?: unknown;
|
|
288
|
+
};
|
|
289
|
+
const scratch: {
|
|
290
|
+
ip?: string;
|
|
291
|
+
country?: string;
|
|
292
|
+
region?: string;
|
|
293
|
+
city?: string;
|
|
294
|
+
timezone?: string;
|
|
295
|
+
lat?: number;
|
|
296
|
+
lng?: number;
|
|
297
|
+
} = {};
|
|
298
|
+
const ip = s(j.ip);
|
|
299
|
+
// ip.sb uses `country_code`, not `country` (which is the full name).
|
|
300
|
+
const country = s(j.country_code);
|
|
301
|
+
const region = s(j.region);
|
|
302
|
+
const city = s(j.city);
|
|
303
|
+
const tz = s(j.timezone);
|
|
304
|
+
const lat = n(j.latitude);
|
|
305
|
+
const lng = n(j.longitude);
|
|
306
|
+
if (ip !== undefined) scratch.ip = ip;
|
|
307
|
+
if (country !== undefined) scratch.country = country;
|
|
308
|
+
if (tz !== undefined) scratch.timezone = tz;
|
|
309
|
+
if (region !== undefined) scratch.region = region;
|
|
310
|
+
if (city !== undefined) scratch.city = city;
|
|
311
|
+
if (lat !== undefined) scratch.lat = lat;
|
|
312
|
+
if (lng !== undefined) scratch.lng = lng;
|
|
313
|
+
return build(scratch, "ipsb");
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
url: "https://ifconfig.co/json",
|
|
318
|
+
parse(json) {
|
|
319
|
+
const j = json as {
|
|
320
|
+
ip?: unknown;
|
|
321
|
+
country_iso?: unknown;
|
|
322
|
+
country?: unknown;
|
|
323
|
+
city?: unknown;
|
|
324
|
+
region_name?: unknown;
|
|
325
|
+
time_zone?: unknown;
|
|
326
|
+
zip_code?: unknown;
|
|
327
|
+
latitude?: unknown;
|
|
328
|
+
longitude?: unknown;
|
|
329
|
+
};
|
|
330
|
+
const scratch: {
|
|
331
|
+
ip?: string;
|
|
332
|
+
country?: string;
|
|
333
|
+
region?: string;
|
|
334
|
+
city?: string;
|
|
335
|
+
timezone?: string;
|
|
336
|
+
postalCode?: string;
|
|
337
|
+
lat?: number;
|
|
338
|
+
lng?: number;
|
|
339
|
+
} = {};
|
|
340
|
+
const ip = s(j.ip);
|
|
341
|
+
// ifconfig.co exposes the alpha-2 as `country_iso`.
|
|
342
|
+
const country = s(j.country_iso);
|
|
343
|
+
const region = s(j.region_name);
|
|
344
|
+
const city = s(j.city);
|
|
345
|
+
const tz = s(j.time_zone);
|
|
346
|
+
const postal = s(j.zip_code);
|
|
347
|
+
const lat = n(j.latitude);
|
|
348
|
+
const lng = n(j.longitude);
|
|
349
|
+
if (ip !== undefined) scratch.ip = ip;
|
|
350
|
+
if (country !== undefined) scratch.country = country;
|
|
351
|
+
if (tz !== undefined) scratch.timezone = tz;
|
|
352
|
+
if (region !== undefined) scratch.region = region;
|
|
353
|
+
if (city !== undefined) scratch.city = city;
|
|
354
|
+
if (postal !== undefined) scratch.postalCode = postal;
|
|
355
|
+
if (lat !== undefined) scratch.lat = lat;
|
|
356
|
+
if (lng !== undefined) scratch.lng = lng;
|
|
357
|
+
return build(scratch, "ifconfig");
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
url: "https://api.iplocation.net/",
|
|
362
|
+
parse(json) {
|
|
363
|
+
// api.iplocation.net is country-only — no timezone — so it cannot
|
|
364
|
+
// satisfy build()'s minimum set. We keep it in the registry as a
|
|
365
|
+
// last-resort sanity check (the brief calls it out explicitly): the
|
|
366
|
+
// adapter ALWAYS returns `null`, which forces the caller to the next
|
|
367
|
+
// endpoint while still counting toward the 4-attempt cap. If a future
|
|
368
|
+
// schema gains a timezone field, lift it here.
|
|
369
|
+
void json;
|
|
370
|
+
return null;
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
url: "https://ipapi.co/json/",
|
|
375
|
+
parse(json) {
|
|
376
|
+
const j = json as {
|
|
377
|
+
ip?: unknown;
|
|
378
|
+
country_code?: unknown;
|
|
379
|
+
country?: unknown;
|
|
380
|
+
city?: unknown;
|
|
381
|
+
region?: unknown;
|
|
382
|
+
timezone?: unknown;
|
|
383
|
+
postal?: unknown;
|
|
384
|
+
latitude?: unknown;
|
|
385
|
+
longitude?: unknown;
|
|
386
|
+
error?: unknown;
|
|
387
|
+
reason?: unknown;
|
|
388
|
+
};
|
|
389
|
+
// ipapi.co's rate-limit response is `{error: true, reason: "RateLimited"}`.
|
|
390
|
+
if (j.error === true) return null;
|
|
391
|
+
const scratch: {
|
|
392
|
+
ip?: string;
|
|
393
|
+
country?: string;
|
|
394
|
+
region?: string;
|
|
395
|
+
city?: string;
|
|
396
|
+
timezone?: string;
|
|
397
|
+
postalCode?: string;
|
|
398
|
+
lat?: number;
|
|
399
|
+
lng?: number;
|
|
400
|
+
} = {};
|
|
401
|
+
const ip = s(j.ip);
|
|
402
|
+
const country = s(j.country_code) ?? s(j.country);
|
|
403
|
+
const region = s(j.region);
|
|
404
|
+
const city = s(j.city);
|
|
405
|
+
const tz = s(j.timezone);
|
|
406
|
+
const postal = s(j.postal);
|
|
407
|
+
const lat = n(j.latitude);
|
|
408
|
+
const lng = n(j.longitude);
|
|
409
|
+
if (ip !== undefined) scratch.ip = ip;
|
|
410
|
+
if (country !== undefined) scratch.country = country;
|
|
411
|
+
if (tz !== undefined) scratch.timezone = tz;
|
|
412
|
+
if (region !== undefined) scratch.region = region;
|
|
413
|
+
if (city !== undefined) scratch.city = city;
|
|
414
|
+
if (postal !== undefined) scratch.postalCode = postal;
|
|
415
|
+
if (lat !== undefined) scratch.lat = lat;
|
|
416
|
+
if (lng !== undefined) scratch.lng = lng;
|
|
417
|
+
return build(scratch, "ipapi");
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Total attempt cap across the (shuffled) registry. 4 × 2s = 8s wall-time
|
|
424
|
+
* worst case. Tunable in tests via {@link ProbeOptions.maxAttempts}.
|
|
425
|
+
*/
|
|
426
|
+
const DEFAULT_MAX_ATTEMPTS = 4;
|
|
427
|
+
/** Per-endpoint timeout (ms). */
|
|
428
|
+
const DEFAULT_PER_ENDPOINT_TIMEOUT_MS = 2000;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Injection seam for the underlying HTTP transport. Production uses
|
|
432
|
+
* `@mochi.js/net`'s `fetch` (so the probe carries the same JA4/headers as
|
|
433
|
+
* user traffic). Tests inject a stub.
|
|
434
|
+
*
|
|
435
|
+
* @internal
|
|
436
|
+
*/
|
|
437
|
+
export type ProbeFetch = (
|
|
438
|
+
url: string,
|
|
439
|
+
init: { preset: string; proxy?: string; timeoutMs: number },
|
|
440
|
+
) => Promise<Response>;
|
|
441
|
+
|
|
442
|
+
/** Options for {@link probeExitGeo}. */
|
|
443
|
+
export interface ProbeOptions {
|
|
444
|
+
/** Optional outbound proxy URL — `user:pass@host:port` form is fine. */
|
|
445
|
+
readonly proxy?: string;
|
|
446
|
+
/** The matrix whose `wreqPreset` drives the TLS fingerprint of the probe. */
|
|
447
|
+
readonly matrix: Pick<MatrixV1, "wreqPreset">;
|
|
448
|
+
/**
|
|
449
|
+
* Override the default 4-attempt cap. Tests use 2 to keep wall-time low;
|
|
450
|
+
* production sticks with 4.
|
|
451
|
+
*/
|
|
452
|
+
readonly maxAttempts?: number;
|
|
453
|
+
/** Override the per-endpoint timeout. Tests use 50ms. */
|
|
454
|
+
readonly perEndpointTimeoutMs?: number;
|
|
455
|
+
/**
|
|
456
|
+
* Inject a custom `fetch` transport (for tests). Defaults to
|
|
457
|
+
* `@mochi.js/net`'s `fetch` so the probe shares the session's TLS preset.
|
|
458
|
+
* @internal
|
|
459
|
+
*/
|
|
460
|
+
readonly fetch?: ProbeFetch;
|
|
461
|
+
/**
|
|
462
|
+
* Deterministic shuffle hook — tests pass an identity function to keep
|
|
463
|
+
* the registry order stable. Defaults to a Fisher-Yates with `Math.random`.
|
|
464
|
+
* @internal
|
|
465
|
+
*/
|
|
466
|
+
readonly shuffle?: (xs: readonly Adapter[]) => readonly Adapter[];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Default `ProbeFetch` — issues the request through `@mochi.js/net`'s
|
|
471
|
+
* one-shot `fetch` so the geo service sees the same JA4 as user traffic.
|
|
472
|
+
*
|
|
473
|
+
* The per-call timeout is mapped onto the wreq `timeoutMs` field; we do
|
|
474
|
+
* NOT also wrap with `AbortController` because Bun's `Response` from the
|
|
475
|
+
* FFI path is synchronous and we want the wreq layer to own the timeout
|
|
476
|
+
* (a layered `AbortController` would race with the Rust handle drop).
|
|
477
|
+
*/
|
|
478
|
+
const defaultFetch: ProbeFetch = (url, init) => {
|
|
479
|
+
return Promise.resolve(
|
|
480
|
+
netFetch(url, {
|
|
481
|
+
preset: init.preset,
|
|
482
|
+
...(init.proxy !== undefined ? { proxy: init.proxy } : {}),
|
|
483
|
+
method: "GET",
|
|
484
|
+
headers: { Accept: "application/json" },
|
|
485
|
+
timeoutMs: init.timeoutMs,
|
|
486
|
+
// Connect timeout matches the per-endpoint cap — a stuck SYN is the
|
|
487
|
+
// dominant failure mode against rate-limited endpoints.
|
|
488
|
+
connectTimeoutMs: init.timeoutMs,
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/** Fisher-Yates shuffle — non-deterministic, Math.random-backed. */
|
|
494
|
+
function defaultShuffle<T>(xs: readonly T[]): readonly T[] {
|
|
495
|
+
const out = [...xs];
|
|
496
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
497
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
498
|
+
const tmp = out[i] as T;
|
|
499
|
+
out[i] = out[j] as T;
|
|
500
|
+
out[j] = tmp;
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Race a promise against a timeout. Resolves with `null` on timeout (we
|
|
507
|
+
* use `null` as the universal "give up, try next" signal).
|
|
508
|
+
*/
|
|
509
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
|
|
510
|
+
return new Promise((resolve) => {
|
|
511
|
+
let settled = false;
|
|
512
|
+
const t = setTimeout(() => {
|
|
513
|
+
if (settled) return;
|
|
514
|
+
settled = true;
|
|
515
|
+
resolve(null);
|
|
516
|
+
}, ms);
|
|
517
|
+
p.then(
|
|
518
|
+
(v) => {
|
|
519
|
+
if (settled) return;
|
|
520
|
+
settled = true;
|
|
521
|
+
clearTimeout(t);
|
|
522
|
+
resolve(v);
|
|
523
|
+
},
|
|
524
|
+
() => {
|
|
525
|
+
if (settled) return;
|
|
526
|
+
settled = true;
|
|
527
|
+
clearTimeout(t);
|
|
528
|
+
resolve(null);
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Probe the exit IP for geolocation. Issues a single GET (through the
|
|
536
|
+
* session's wreq preset, optionally via proxy) against a shuffled registry
|
|
537
|
+
* of geo endpoints, returning the first valid response normalised to
|
|
538
|
+
* {@link ExitGeo}. Returns `null` when all attempts (up to 4 by default)
|
|
539
|
+
* fail.
|
|
540
|
+
*
|
|
541
|
+
* Network errors, non-2xx responses, parse errors, and adapter "schema
|
|
542
|
+
* mismatch" `null`s all count toward the attempt cap and trigger fall-
|
|
543
|
+
* through to the next endpoint. The function NEVER throws — callers
|
|
544
|
+
* branch on `null`.
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* const geo = await probeExitGeo({ proxy: "http://eu-residential:..." , matrix });
|
|
548
|
+
* if (geo === null) {
|
|
549
|
+
* // → privacy-fallback per LaunchOptions.geoConsistency
|
|
550
|
+
* } else {
|
|
551
|
+
* // → check geo.country / geo.timezone vs matrix
|
|
552
|
+
* }
|
|
553
|
+
*/
|
|
554
|
+
export async function probeExitGeo(opts: ProbeOptions): Promise<ExitGeo | null> {
|
|
555
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
556
|
+
const perEndpointTimeoutMs = opts.perEndpointTimeoutMs ?? DEFAULT_PER_ENDPOINT_TIMEOUT_MS;
|
|
557
|
+
const fetchFn = opts.fetch ?? defaultFetch;
|
|
558
|
+
const shuffle = opts.shuffle ?? defaultShuffle;
|
|
559
|
+
const order = shuffle(ADAPTERS);
|
|
560
|
+
const cap = Math.min(maxAttempts, order.length);
|
|
561
|
+
for (let i = 0; i < cap; i += 1) {
|
|
562
|
+
const adapter = order[i];
|
|
563
|
+
if (adapter === undefined) continue;
|
|
564
|
+
// Wrap the fetch call in a fresh Promise so synchronous throws (e.g.
|
|
565
|
+
// `dlopen` failure when the cdylib isn't built — common in test envs
|
|
566
|
+
// and on first install before `bun run rust:build`) become rejections
|
|
567
|
+
// and route through `withTimeout`'s null path, NOT through the
|
|
568
|
+
// probeExitGeo throw seam. The brief: probe NEVER throws.
|
|
569
|
+
const respOrNull = await withTimeout(
|
|
570
|
+
new Promise<Response>((resolve, reject) => {
|
|
571
|
+
try {
|
|
572
|
+
fetchFn(adapter.url, {
|
|
573
|
+
preset: opts.matrix.wreqPreset,
|
|
574
|
+
...(opts.proxy !== undefined ? { proxy: opts.proxy } : {}),
|
|
575
|
+
timeoutMs: perEndpointTimeoutMs,
|
|
576
|
+
}).then(resolve, reject);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
579
|
+
}
|
|
580
|
+
}),
|
|
581
|
+
perEndpointTimeoutMs,
|
|
582
|
+
);
|
|
583
|
+
if (respOrNull === null) continue;
|
|
584
|
+
if (!respOrNull.ok) continue;
|
|
585
|
+
let json: unknown;
|
|
586
|
+
try {
|
|
587
|
+
json = await respOrNull.json();
|
|
588
|
+
} catch {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
let parsed: ExitGeo | null;
|
|
592
|
+
try {
|
|
593
|
+
parsed = adapter.parse(json);
|
|
594
|
+
} catch {
|
|
595
|
+
// Adapters MUST return null on schema mismatch, but we belt-and-
|
|
596
|
+
// suspender against future bugs.
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (parsed === null) continue;
|
|
600
|
+
return parsed;
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,16 @@ export {
|
|
|
21
21
|
} from "./cdp/router";
|
|
22
22
|
// Error surface.
|
|
23
23
|
export { NotImplementedError } from "./errors";
|
|
24
|
+
// Exit-IP / TZ / locale reconciliation (task 0262, PLAN.md §9).
|
|
25
|
+
export {
|
|
26
|
+
type GeoConsistencyMode,
|
|
27
|
+
GeoMismatchError,
|
|
28
|
+
type GeoReconcileResult,
|
|
29
|
+
localeRegion,
|
|
30
|
+
reconcileGeoConsistency,
|
|
31
|
+
tzOffsetMinutes,
|
|
32
|
+
} from "./geo-consistency";
|
|
33
|
+
export { type ExitGeo, type ProbeOptions, probeExitGeo } from "./geo-probe";
|
|
24
34
|
// Public surface — exported here so users only need `@mochi.js/core`.
|
|
25
35
|
export {
|
|
26
36
|
type ChallengeLaunchOptions,
|