@mochi.js/core 0.2.2 → 0.6.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.
@@ -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
@@ -19,8 +19,25 @@ export {
19
19
  type SendOptions,
20
20
  type Unsubscribe,
21
21
  } from "./cdp/router";
22
+ // Auto-pick host-OS-matching profile when `LaunchOptions.profile` is omitted
23
+ // (task 0272 — paired with the strategic thesis in task 0271).
24
+ export {
25
+ defaultProfileForHost,
26
+ EXPLICIT_PROFILE_IDS,
27
+ resolveDefaultProfileForHost,
28
+ } from "./default-profile";
22
29
  // Error surface.
23
30
  export { NotImplementedError } from "./errors";
31
+ // Exit-IP / TZ / locale reconciliation (task 0262, PLAN.md §9).
32
+ export {
33
+ type GeoConsistencyMode,
34
+ GeoMismatchError,
35
+ type GeoReconcileResult,
36
+ localeRegion,
37
+ reconcileGeoConsistency,
38
+ tzOffsetMinutes,
39
+ } from "./geo-consistency";
40
+ export { type ExitGeo, type ProbeOptions, probeExitGeo } from "./geo-probe";
24
41
  // Public surface — exported here so users only need `@mochi.js/core`.
25
42
  export {
26
43
  type ChallengeLaunchOptions,
@@ -30,16 +47,33 @@ export {
30
47
  mochi,
31
48
  type ProfileId,
32
49
  type ProxyConfig,
50
+ resolveHeadlessMode,
33
51
  } from "./launch";
52
+ // Linux-server environment detection. Pure helpers for users who want to
53
+ // introspect what mochi inferred (and override `headlessMode` from there).
54
+ // Task 0258 — `mochi.detectLinuxServerEnv()` calls `probeLinuxServerEnv`.
34
55
  export {
56
+ detectLinuxServerEnv,
57
+ type LinuxServerEnv,
58
+ type LinuxServerProbes,
59
+ probeLinuxServerEnv,
60
+ snapshotProbes,
61
+ } from "./linux-server";
62
+ export {
63
+ ALL_BROWSER_PERMISSIONS,
64
+ type BrowserPermission,
35
65
  type Cookie,
66
+ type DomStorage,
67
+ type DomStorageOptions,
36
68
  type GotoOptions,
69
+ type GrantAllPermissionsOptions,
37
70
  type HumanClickOptions,
38
71
  type HumanMoveOptions,
39
72
  type HumanScrollOptions,
40
73
  type HumanTypeOptions,
41
74
  Page,
42
75
  type PageInit,
76
+ type ScreenshotOptions,
43
77
  type WaitForOptions,
44
78
  type WaitState,
45
79
  type WaitUntil,
@@ -48,5 +82,13 @@ export { ElementHandle, type ElementHandleInit } from "./page/element-handle";
48
82
  // Proxy URL parsing — exported so tests + downstream tools can normalize
49
83
  // proxy strings without going through `launch()`.
50
84
  export { type ParsedProxy, parseProxyUrl } from "./proxy-auth";
51
- export { Session, type SessionInit, type StorageSnapshot } from "./session";
85
+ export {
86
+ COOKIE_JAR_FORMAT_VERSION,
87
+ type CookieJar,
88
+ type CookieJarFile,
89
+ type CookieJarOptions,
90
+ Session,
91
+ type SessionInit,
92
+ type StorageSnapshot,
93
+ } from "./session";
52
94
  export { VERSION } from "./version";