@taphubhq/sdk-core 0.25.5 → 0.26.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/dist/index.cjs CHANGED
@@ -35,10 +35,14 @@ __export(index_exports, {
35
35
  BidModule: () => BidModule,
36
36
  CANDLE_EVENT: () => CANDLE_EVENT,
37
37
  DEFAULT_CHART_HISTORY_LIMIT: () => DEFAULT_CHART_HISTORY_LIMIT,
38
+ DEFAULT_PROBE_TTL_SECONDS: () => DEFAULT_PROBE_TTL_SECONDS,
39
+ DEFAULT_REGION: () => DEFAULT_REGION,
40
+ KNOWN_REGIONS: () => KNOWN_REGIONS,
38
41
  LeaderboardModule: () => LeaderboardModule,
39
42
  LocaleModule: () => LocaleModule,
40
43
  NetworkQualityMonitor: () => NetworkQualityMonitor,
41
44
  PairModule: () => PairModule,
45
+ REGION_PROBE_CACHE_KEY: () => REGION_PROBE_CACHE_KEY,
42
46
  RealtimeModule: () => RealtimeModule,
43
47
  TaphubAuthError: () => TaphubAuthError,
44
48
  TaphubClient: () => TaphubClient,
@@ -52,10 +56,14 @@ __export(index_exports, {
52
56
  UserModule: () => UserModule,
53
57
  adaptiveSimpson: () => adaptiveSimpson,
54
58
  autoDetectStorage: () => autoDetectStorage,
59
+ calculateCoefficientWrapper: () => calculateCoefficientWrapper,
60
+ calculateProbHit: () => calculateProbHit,
55
61
  calculateProbWin: () => calculateProbWin,
56
62
  calculateProbWin_v2: () => calculateProbWin_v2,
63
+ computeBaseline: () => computeBaseline,
57
64
  errorFunction: () => errorFunction,
58
65
  isCancelled: () => isCancelled,
66
+ isKnownRegion: () => isKnownRegion,
59
67
  isLoss: () => isLoss,
60
68
  isPending: () => isPending,
61
69
  isTerminal: () => isTerminal,
@@ -63,7 +71,10 @@ __export(index_exports, {
63
71
  normalCDF: () => normalCDF,
64
72
  normalPDF: () => normalPDF,
65
73
  normaliseLang: () => normaliseLang,
66
- pairIdFromBidResultTopic: () => pairIdFromBidResultTopic
74
+ pairIdFromBidResultTopic: () => pairIdFromBidResultTopic,
75
+ probeNearestRegion: () => probeNearestRegion,
76
+ resolveRegionBaseUrl: () => resolveRegionBaseUrl,
77
+ roundCoefToSignificantDigits: () => roundCoefToSignificantDigits
67
78
  });
68
79
  module.exports = __toCommonJS(index_exports);
69
80
 
@@ -220,6 +231,36 @@ var AgencyPairModule = class {
220
231
  }
221
232
  };
222
233
 
234
+ // src/transport/graphql/wrongRegionRetry.ts
235
+ var WRONG_REGION_CODE = "Auth_WrongRegion";
236
+ function wrongRegionTarget(err) {
237
+ if (!(err instanceof TaphubError) || err.code !== WRONG_REGION_CODE) return null;
238
+ const details = err.details;
239
+ const meta = details?.errors?.[0]?.extensions?.meta;
240
+ const homeRegion = meta?.homeRegion;
241
+ return typeof homeRegion === "string" && homeRegion !== "" ? homeRegion : null;
242
+ }
243
+ function withWrongRegionRetry(transport, setRegion) {
244
+ async function retryOnce(call) {
245
+ try {
246
+ return await call();
247
+ } catch (err) {
248
+ const homeRegion = wrongRegionTarget(err);
249
+ if (homeRegion === null) throw err;
250
+ setRegion(homeRegion);
251
+ return await call();
252
+ }
253
+ }
254
+ return {
255
+ request(query, variables, opts) {
256
+ return retryOnce(() => transport.request(query, variables, opts));
257
+ },
258
+ publicRequest(query, variables, opts) {
259
+ return retryOnce(() => transport.publicRequest(query, variables, opts));
260
+ }
261
+ };
262
+ }
263
+
223
264
  // src/modules/auth/normalise.ts
224
265
  function normaliseGoogleResponse(body) {
225
266
  return {
@@ -291,6 +332,7 @@ function normaliseUserLoginResponse(body) {
291
332
  throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
292
333
  }
293
334
  const wallet = node.user.defaultWallet;
335
+ const homeRegion = node.homeRegion ?? void 0;
294
336
  return {
295
337
  accessToken: node.accessToken,
296
338
  user: {
@@ -305,9 +347,17 @@ function normaliseUserLoginResponse(body) {
305
347
  isEnabled: wallet.isEnable
306
348
  }
307
349
  },
308
- isDemo: false
350
+ isDemo: false,
351
+ homeRegion
309
352
  };
310
353
  }
354
+ function normaliseRefreshResponse(body) {
355
+ const node = body.refreshTapHubToken;
356
+ if (!node || typeof node.accessToken !== "string" || node.accessToken === "" || typeof node.refreshToken !== "string" || node.refreshToken === "") {
357
+ throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
358
+ }
359
+ return { taphubToken: node.accessToken, refreshToken: node.refreshToken };
360
+ }
311
361
  function validateLoginBody(body) {
312
362
  if (typeof body.access_token !== "string" || body.access_token === "") {
313
363
  throw new TaphubServerError("Invalid response from server", {
@@ -336,10 +386,21 @@ var CREATE_DEMO_USER_MUTATION = `
336
386
  }
337
387
  }
338
388
  `;
389
+ var REFRESH_TAPHUB_TOKEN_MUTATION = `
390
+ mutation refreshTapHubToken($refreshToken: String!, $clientMeta: JSON) {
391
+ refreshTapHubToken(refreshToken: $refreshToken, clientMeta: $clientMeta) {
392
+ accessToken
393
+ refreshToken
394
+ }
395
+ }
396
+ `;
339
397
  var USER_LOGIN_MUTATION = `
340
398
  mutation UserLogin($input: LoginInput!) {
341
399
  userLogin(input: $input) {
342
400
  accessToken
401
+ # multi-geo (infra-260715): home region sourced from the global region-directory
402
+ # (first join stores the supplied region; the stored region wins thereafter).
403
+ homeRegion
343
404
  user {
344
405
  id
345
406
  agencyUid
@@ -361,16 +422,32 @@ var AuthModule = class {
361
422
  #rest;
362
423
  #graphql;
363
424
  #graphqlUser;
425
+ #graphqlUserData;
364
426
  #setToken;
427
+ #isDemo;
365
428
  #agencyId;
429
+ #getRegion;
430
+ #setRegion;
366
431
  #onLoginSuccess;
367
432
  #onLogout;
433
+ /**
434
+ * Single-flight cache for {@link refreshTapHubToken}, keyed by the region the
435
+ * in-flight refresh was started under. Concurrent callers in the SAME region
436
+ * share the one network round-trip; a region change mid-flight (getRegion now
437
+ * differs) means the next caller starts a FRESH refresh targeting the new
438
+ * region rather than reusing a promise bound to the old cluster (task 2.3).
439
+ */
440
+ #refreshInflight = null;
368
441
  constructor(deps) {
369
442
  this.#rest = deps.rest;
370
443
  this.#graphql = deps.graphql;
371
444
  this.#graphqlUser = deps.graphqlUser;
445
+ this.#graphqlUserData = deps.graphqlUserData ?? deps.graphqlUser;
372
446
  this.#setToken = deps.setToken;
447
+ this.#isDemo = deps.isDemo ?? (() => false);
373
448
  this.#agencyId = deps.agencyId;
449
+ this.#getRegion = deps.getRegion;
450
+ this.#setRegion = deps.setRegion;
374
451
  this.#onLoginSuccess = deps.onLoginSuccess;
375
452
  this.#onLogout = deps.onLogout;
376
453
  }
@@ -425,14 +502,35 @@ var AuthModule = class {
425
502
  return result;
426
503
  }
427
504
  async loginWithSession(sessionToken, opts) {
428
- const variables = { input: { sessionToken, agencyId: this.#agencyId } };
505
+ const currentRegion = opts?.region ?? this.#getRegion?.() ?? void 0;
506
+ try {
507
+ return await this.#sendLogin(sessionToken, currentRegion, opts);
508
+ } catch (err) {
509
+ const homeRegion = wrongRegionTarget(err);
510
+ if (homeRegion === null || !this.#setRegion) throw err;
511
+ this.#setRegion(homeRegion);
512
+ return await this.#sendLogin(sessionToken, homeRegion, opts);
513
+ }
514
+ }
515
+ /**
516
+ * Single userLogin attempt against the (region-resolved) user-service endpoint.
517
+ * On success it stamps the token + home region and fires onLoginSuccess. Split
518
+ * out so {@link loginWithSession} can re-invoke it once after an
519
+ * Auth_WrongRegion region switch without duplicating the post-login wiring.
520
+ */
521
+ async #sendLogin(sessionToken, region, opts) {
522
+ const input = { sessionToken, agencyId: this.#agencyId };
523
+ if (region !== void 0 && region !== null) {
524
+ input.region = region;
525
+ }
526
+ const variables = { input };
429
527
  const body = await this.#graphqlUser.request(
430
528
  USER_LOGIN_MUTATION,
431
529
  variables,
432
530
  opts
433
531
  );
434
532
  const result = normaliseUserLoginResponse(body);
435
- this.#setToken(result.accessToken, { isDemo: false });
533
+ this.#setToken(result.accessToken, { isDemo: false, homeRegion: result.homeRegion });
436
534
  if (this.#onLoginSuccess) {
437
535
  try {
438
536
  await this.#onLoginSuccess();
@@ -441,6 +539,60 @@ var AuthModule = class {
441
539
  }
442
540
  return result;
443
541
  }
542
+ /**
543
+ * Exchange a refresh token for a rotated (taphubToken, refreshToken) pair via
544
+ * the user-service (region-routed through `graphqlUserData`, so a user pinned
545
+ * to a non-default region refreshes against THEIR cluster — the bug this change
546
+ * fixes). On success the SDK's own token is updated and the rotated pair is
547
+ * returned so the caller can persist it (rotation contract: present the NEW
548
+ * refresh token next time).
549
+ *
550
+ * - **Demo-skips-refresh:** demo users have no refresh path and reject with
551
+ * `Refresh_DemoNotSupported` (no network call). The caller reacts by clearing
552
+ * auth / re-creating the demo user.
553
+ * - **Single-flight:** concurrent callers share one in-flight request, keyed by
554
+ * the current region. A region change mid-flight re-targets the next caller.
555
+ * - **Wrong-region backstop:** `graphqlUserData` retries once on
556
+ * `Auth_WrongRegion`, so a stale region self-corrects.
557
+ *
558
+ * Invalid/expired/reused refresh tokens surface as the transport's typed error
559
+ * (e.g. `ErrRefreshTokenInvalid`); network failures surface as
560
+ * `TaphubNetworkError`. The caller decides how to react (clear vs retry-later).
561
+ */
562
+ refreshTapHubToken(refreshToken, opts) {
563
+ if (this.#isDemo()) {
564
+ return Promise.reject(
565
+ new TaphubValidationError("demo users have no refresh path", {
566
+ code: "Refresh_DemoNotSupported"
567
+ })
568
+ );
569
+ }
570
+ const region = this.#getRegion?.() ?? null;
571
+ if (this.#refreshInflight && this.#refreshInflight.region === region) {
572
+ return this.#refreshInflight.promise;
573
+ }
574
+ const promise = this.#doRefresh(refreshToken, opts).finally(() => {
575
+ if (this.#refreshInflight?.promise === promise) {
576
+ this.#refreshInflight = null;
577
+ }
578
+ });
579
+ this.#refreshInflight = { region, promise };
580
+ return promise;
581
+ }
582
+ async #doRefresh(refreshToken, opts) {
583
+ const variables = { refreshToken };
584
+ if (opts?.clientMeta !== void 0) {
585
+ variables.clientMeta = opts.clientMeta;
586
+ }
587
+ const body = await this.#graphqlUserData.request(
588
+ REFRESH_TAPHUB_TOKEN_MUTATION,
589
+ variables,
590
+ opts?.signal ? { signal: opts.signal } : void 0
591
+ );
592
+ const result = normaliseRefreshResponse(body);
593
+ this.#setToken(result.taphubToken, { isDemo: false });
594
+ return result;
595
+ }
444
596
  async logout() {
445
597
  this.#setToken(null);
446
598
  if (this.#onLogout) {
@@ -1105,18 +1257,30 @@ function normaliseCandles(list) {
1105
1257
  coefMults: c.coefMults
1106
1258
  }));
1107
1259
  }
1260
+ function isAgencyComposite(id) {
1261
+ return id.includes(":");
1262
+ }
1263
+ function stripAgencyPrefix(id) {
1264
+ return id.slice(id.indexOf(":") + 1);
1265
+ }
1108
1266
  function normalisePairInfo(node) {
1109
1267
  if (typeof node.id !== "string" || node.id === "" || typeof node.pair !== "string" || node.pair === "") {
1110
1268
  throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
1111
1269
  }
1270
+ const composite = isAgencyComposite(node.id);
1112
1271
  return {
1113
- id: node.id,
1272
+ id: composite ? stripAgencyPrefix(node.id) : node.id,
1114
1273
  pair: node.pair,
1115
- gameplayId: node.gameplayId,
1116
- gameplayName: node.gameplayName,
1274
+ // available-pairs-retired-fields: the server no longer sends these; default
1275
+ // rather than emit `undefined` through a non-optional entity property.
1276
+ gameplayId: node.gameplayId ?? "",
1277
+ gameplayName: node.gameplayName ?? "",
1117
1278
  source: node.source,
1118
1279
  // REVIEW[bid-260602]: maps node.agencyPairId (was: node.gameId)
1119
- agencyPairId: node.agencyPairId ?? null
1280
+ // Fall back to the wire `id` only when it IS the composite — otherwise stay
1281
+ // null, as the retired field did, so nobody builds an MQTT topic out of a
1282
+ // catalog id.
1283
+ agencyPairId: node.agencyPairId ?? (composite ? node.id : null)
1120
1284
  };
1121
1285
  }
1122
1286
 
@@ -1142,10 +1306,7 @@ var BUILDER_AVAILABLE_GAME_PAIRS_QUERY = `query BuilderAvailableGamePairs($gamep
1142
1306
  builderAvailableGamePairs(gameplayId: $gameplayId) {
1143
1307
  id
1144
1308
  pair
1145
- gameplayId
1146
- gameplayName
1147
1309
  source
1148
- agencyPairId
1149
1310
  }
1150
1311
  }`;
1151
1312
 
@@ -1190,8 +1351,11 @@ var PairModule = class {
1190
1351
  /**
1191
1352
  * Returns available game pairs, optionally filtered by gameplay.
1192
1353
  *
1193
- * When called with a valid JWT (authenticated builder), each entry includes
1194
- * `gameId` use it directly as the MQTT topic `game/{gameId}/candle`.
1354
+ * Each entry's `id` is the bare catalog pair id, safe to pass straight back
1355
+ * as a `pairId` argument. When called with a valid JWT (authenticated
1356
+ * builder), `agencyPairId` additionally carries the agency composite — use
1357
+ * THAT one as the MQTT topic `game/{gameId}/candle`. It is `null` for
1358
+ * anonymous callers.
1195
1359
  *
1196
1360
  * @example
1197
1361
  * const pairs = await client.pair.availableGamePairs({ gameplayId: 'taptrading' });
@@ -1241,6 +1405,9 @@ function walletBalanceTopic(userId) {
1241
1405
  function agencyPairStatsTopic(aid, pairId) {
1242
1406
  return `public/agency/${aid}/pair/${pairId}/stats`;
1243
1407
  }
1408
+ function userMigrationTopic(userId) {
1409
+ return `user/${userId}/migration`;
1410
+ }
1244
1411
  function userBidsWildcardTopic(userId) {
1245
1412
  return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
1246
1413
  }
@@ -1267,12 +1434,14 @@ function userScopedTopicsFor(gameId, userId) {
1267
1434
  return USER_SCOPED_SUFFIXES.map((s) => topicFor(gameId, s, userId));
1268
1435
  }
1269
1436
  function createMqttTransport(endpoint, opts = {}) {
1437
+ const resolveEndpoint = () => typeof endpoint === "function" ? endpoint() : endpoint;
1270
1438
  let client = null;
1271
1439
  const subscriptions = /* @__PURE__ */ new Map();
1272
1440
  const candleSubscriptions = /* @__PURE__ */ new Map();
1273
1441
  const statsSubscriptions = /* @__PURE__ */ new Map();
1274
1442
  const walletSubscriptions = /* @__PURE__ */ new Map();
1275
1443
  const userBidsSubscriptions = /* @__PURE__ */ new Map();
1444
+ const migrationSubscriptions = /* @__PURE__ */ new Map();
1276
1445
  const { onLifecycle, auth } = opts;
1277
1446
  let connectStartedAt = 0;
1278
1447
  function fireLifecycle(event) {
@@ -1285,7 +1454,7 @@ function createMqttTransport(endpoint, opts = {}) {
1285
1454
  function ensureConnected() {
1286
1455
  if (client) return client;
1287
1456
  connectStartedAt = Date.now();
1288
- client = import_mqtt.default.connect(endpoint, {
1457
+ client = import_mqtt.default.connect(resolveEndpoint(), {
1289
1458
  clean: true,
1290
1459
  reconnectPeriod: 2e3,
1291
1460
  connectTimeout: 1e4,
@@ -1326,6 +1495,9 @@ function createMqttTransport(endpoint, opts = {}) {
1326
1495
  for (const entry of userBidsSubscriptions.values()) {
1327
1496
  c.subscribe(entry.pattern, { qos: 1 });
1328
1497
  }
1498
+ for (const entry of migrationSubscriptions.values()) {
1499
+ c.subscribe(entry.topic, { qos: 1 });
1500
+ }
1329
1501
  fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
1330
1502
  });
1331
1503
  client.on("reconnect", () => {
@@ -1374,6 +1546,20 @@ function createMqttTransport(endpoint, opts = {}) {
1374
1546
  walletSub.onMessage(receivedTopic, payload2);
1375
1547
  return;
1376
1548
  }
1549
+ const migrationSub = [...migrationSubscriptions.values()].find(
1550
+ (s) => s.topic === receivedTopic
1551
+ );
1552
+ if (migrationSub) {
1553
+ let payload2;
1554
+ try {
1555
+ payload2 = JSON.parse(message.toString());
1556
+ } catch {
1557
+ migrationSub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
1558
+ return;
1559
+ }
1560
+ migrationSub.onMessage(receivedTopic, payload2);
1561
+ return;
1562
+ }
1377
1563
  let userBidsPayload;
1378
1564
  for (const sub of userBidsSubscriptions.values()) {
1379
1565
  if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
@@ -1487,6 +1673,19 @@ function createMqttTransport(endpoint, opts = {}) {
1487
1673
  if (client) client.unsubscribe(entry.pattern);
1488
1674
  userBidsSubscriptions.delete(userId);
1489
1675
  },
1676
+ subscribeMigration(userId, onMessage, onError) {
1677
+ if (migrationSubscriptions.has(userId)) return;
1678
+ const topic = userMigrationTopic(userId);
1679
+ const mqttClient = ensureConnected();
1680
+ migrationSubscriptions.set(userId, { topic, onMessage, onError });
1681
+ mqttClient.subscribe(topic, { qos: 1 });
1682
+ },
1683
+ unsubscribeMigration(userId) {
1684
+ const entry = migrationSubscriptions.get(userId);
1685
+ if (!entry) return;
1686
+ if (client) client.unsubscribe(entry.topic);
1687
+ migrationSubscriptions.delete(userId);
1688
+ },
1490
1689
  unsubscribeAll(gameId, userId) {
1491
1690
  const matches = entriesForGame(gameId);
1492
1691
  if (matches.length === 0) return;
@@ -1511,6 +1710,12 @@ function createMqttTransport(endpoint, opts = {}) {
1511
1710
  }
1512
1711
  subscriptions.delete(subKey(target.gameId, target.userId));
1513
1712
  },
1713
+ reconnect() {
1714
+ if (!client) return;
1715
+ client.end(true);
1716
+ client = null;
1717
+ ensureConnected();
1718
+ },
1514
1719
  close() {
1515
1720
  if (client) {
1516
1721
  for (const sub of subscriptions.values()) {
@@ -1530,12 +1735,16 @@ function createMqttTransport(endpoint, opts = {}) {
1530
1735
  for (const entry of userBidsSubscriptions.values()) {
1531
1736
  client.unsubscribe(entry.pattern);
1532
1737
  }
1738
+ for (const entry of migrationSubscriptions.values()) {
1739
+ client.unsubscribe(entry.topic);
1740
+ }
1533
1741
  }
1534
1742
  subscriptions.clear();
1535
1743
  candleSubscriptions.clear();
1536
1744
  statsSubscriptions.clear();
1537
1745
  walletSubscriptions.clear();
1538
1746
  userBidsSubscriptions.clear();
1747
+ migrationSubscriptions.clear();
1539
1748
  if (client) {
1540
1749
  client.end(true);
1541
1750
  client = null;
@@ -1545,7 +1754,7 @@ function createMqttTransport(endpoint, opts = {}) {
1545
1754
  }
1546
1755
 
1547
1756
  // src/modules/realtime/index.ts
1548
- var import_eventemitter34 = __toESM(require("eventemitter3"));
1757
+ var import_eventemitter35 = __toESM(require("eventemitter3"));
1549
1758
 
1550
1759
  // src/modules/realtime/GameChannel.ts
1551
1760
  var import_eventemitter3 = __toESM(require("eventemitter3"));
@@ -1557,9 +1766,9 @@ var GameChannel = class extends import_eventemitter3.default {
1557
1766
  }
1558
1767
  };
1559
1768
 
1560
- // src/modules/realtime/UserBidsChannel.ts
1769
+ // src/modules/realtime/MigrationChannel.ts
1561
1770
  var import_eventemitter32 = __toESM(require("eventemitter3"));
1562
- var UserBidsChannel = class extends import_eventemitter32.default {
1771
+ var MigrationChannel = class extends import_eventemitter32.default {
1563
1772
  userId;
1564
1773
  constructor(userId) {
1565
1774
  super();
@@ -1567,9 +1776,19 @@ var UserBidsChannel = class extends import_eventemitter32.default {
1567
1776
  }
1568
1777
  };
1569
1778
 
1570
- // src/modules/realtime/WalletChannel.ts
1779
+ // src/modules/realtime/UserBidsChannel.ts
1571
1780
  var import_eventemitter33 = __toESM(require("eventemitter3"));
1572
- var WalletChannel = class extends import_eventemitter33.default {
1781
+ var UserBidsChannel = class extends import_eventemitter33.default {
1782
+ userId;
1783
+ constructor(userId) {
1784
+ super();
1785
+ this.userId = userId;
1786
+ }
1787
+ };
1788
+
1789
+ // src/modules/realtime/WalletChannel.ts
1790
+ var import_eventemitter34 = __toESM(require("eventemitter3"));
1791
+ var WalletChannel = class extends import_eventemitter34.default {
1573
1792
  userId;
1574
1793
  constructor(userId) {
1575
1794
  super();
@@ -1660,6 +1879,19 @@ function mapWireWalletBalance(raw) {
1660
1879
  reason: p.reason ?? ""
1661
1880
  };
1662
1881
  }
1882
+ function mapWireMigrationCompleted(raw) {
1883
+ if (raw === null || typeof raw !== "object") return null;
1884
+ const p = raw;
1885
+ if (p.type !== "migration_completed") return null;
1886
+ if (typeof p.toRegion !== "string" || p.toRegion === "") return null;
1887
+ return {
1888
+ type: "migration_completed",
1889
+ migrationId: p.migrationId,
1890
+ fromRegion: p.fromRegion,
1891
+ toRegion: p.toRegion,
1892
+ completedAt: p.completedAt
1893
+ };
1894
+ }
1663
1895
  function mapWireConfig(raw) {
1664
1896
  const p = raw;
1665
1897
  return {
@@ -1700,13 +1932,15 @@ function mapWireToEvent(topic, payload) {
1700
1932
  function normaliseUserId2(userId) {
1701
1933
  return userId && userId !== "" ? userId : null;
1702
1934
  }
1703
- var RealtimeModule = class extends import_eventemitter34.default {
1935
+ var RealtimeModule = class extends import_eventemitter35.default {
1704
1936
  #transport;
1705
1937
  #entries = /* @__PURE__ */ new Map();
1706
1938
  #walletEntries = /* @__PURE__ */ new Map();
1707
1939
  // keyed by userId
1708
1940
  #userBidsEntries = /* @__PURE__ */ new Map();
1709
1941
  // keyed by userId
1942
+ #migrationEntries = /* @__PURE__ */ new Map();
1943
+ // keyed by userId
1710
1944
  #agencyId;
1711
1945
  constructor(mqttEndpointOrOptions) {
1712
1946
  super();
@@ -1895,6 +2129,57 @@ var RealtimeModule = class extends import_eventemitter34.default {
1895
2129
  entry.channel.removeAllListeners();
1896
2130
  this.#transport.unsubscribeUserBids(cleanUserId);
1897
2131
  }
2132
+ /**
2133
+ * Subscribe to the user-scoped migration completion stream for `userId`,
2134
+ * topic `user/{userId}/migration` (ux-260730). Returns a `MigrationChannel`
2135
+ * that emits `migrationCompleted` when the user's home-region migration
2136
+ * finishes. Malformed or schema-mismatched payloads are dropped silently.
2137
+ *
2138
+ * Reference-counted by `userId`, mirroring `subscribeWallet`. Intended
2139
+ * lifecycle (design D7): hold the channel only while the client is in the
2140
+ * migrating state; unsubscribe on completion/logout. The publisher retains
2141
+ * the completion message, so subscribing after the fact still delivers it.
2142
+ */
2143
+ subscribeMigration(userId) {
2144
+ const cleanUserId = normaliseUserId2(userId);
2145
+ if (!cleanUserId) {
2146
+ throw new TaphubError("userId is required to subscribe to migration events", {
2147
+ code: "UserIdRequired"
2148
+ });
2149
+ }
2150
+ const existing = this.#migrationEntries.get(cleanUserId);
2151
+ if (existing) {
2152
+ existing.refcount += 1;
2153
+ return existing.channel;
2154
+ }
2155
+ const channel = new MigrationChannel(cleanUserId);
2156
+ const onMessage = (_topic, payload) => {
2157
+ const mapped = mapWireMigrationCompleted(payload);
2158
+ if (!mapped) return;
2159
+ channel.emit("migrationCompleted", mapped);
2160
+ };
2161
+ const onError = (err) => {
2162
+ channel.emit("error", err);
2163
+ };
2164
+ this.#migrationEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
2165
+ this.#transport.subscribeMigration(cleanUserId, onMessage, onError);
2166
+ return channel;
2167
+ }
2168
+ /**
2169
+ * Decrement the migration subscription refcount for `userId`. Tears down the
2170
+ * MQTT topic and removes the channel only at zero. No-op if absent.
2171
+ */
2172
+ unsubscribeMigration(userId) {
2173
+ const cleanUserId = normaliseUserId2(userId);
2174
+ if (!cleanUserId) return;
2175
+ const entry = this.#migrationEntries.get(cleanUserId);
2176
+ if (!entry) return;
2177
+ entry.refcount -= 1;
2178
+ if (entry.refcount > 0) return;
2179
+ this.#migrationEntries.delete(cleanUserId);
2180
+ entry.channel.removeAllListeners();
2181
+ this.#transport.unsubscribeMigration(cleanUserId);
2182
+ }
1898
2183
  /**
1899
2184
  * Subscribe to public market candle data for a pair, keyed by pairId
1900
2185
  * (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
@@ -1937,6 +2222,15 @@ var RealtimeModule = class extends import_eventemitter34.default {
1937
2222
  if (!this.#agencyId) return;
1938
2223
  this.#transport.unsubscribeAgencyPairStats(this.#agencyId, pairId);
1939
2224
  }
2225
+ /**
2226
+ * Reconnect the MQTT transport, re-resolving its (possibly region-scoped)
2227
+ * endpoint. All active subscriptions are preserved and re-declared on the new
2228
+ * connection (multi-geo home-region switch). No-op when not currently
2229
+ * connected. Delegates to the transport's `reconnect`.
2230
+ */
2231
+ reconnect() {
2232
+ this.#transport.reconnect();
2233
+ }
1940
2234
  disconnect() {
1941
2235
  for (const entry of this.#entries.values()) {
1942
2236
  entry.channel.removeAllListeners();
@@ -1950,6 +2244,10 @@ var RealtimeModule = class extends import_eventemitter34.default {
1950
2244
  entry.channel.removeAllListeners();
1951
2245
  }
1952
2246
  this.#userBidsEntries.clear();
2247
+ for (const entry of this.#migrationEntries.values()) {
2248
+ entry.channel.removeAllListeners();
2249
+ }
2250
+ this.#migrationEntries.clear();
1953
2251
  this.#transport.close();
1954
2252
  }
1955
2253
  };
@@ -2042,6 +2340,9 @@ var LIST_ENABLED_CURRENCIES_QUERY = `query ListEnabledCurrencies($input: ListEna
2042
2340
  listEnabledCurrencies(input: $input) { code unit unitSymbol }
2043
2341
  }`;
2044
2342
  var MY_WALLET_BY_CURRENCY_QUERY = "query MyWalletByCurrency($currency: String!) { myWalletByCurrency(currency: $currency) { id amount currency isEnable } }";
2343
+ var REQUEST_REGION_MIGRATION_MUTATION = `mutation RequestRegionMigration($input: RequestRegionMigrationInput!) {
2344
+ requestRegionMigration(input: $input) { migrationId }
2345
+ }`;
2045
2346
  var USER_PNL_QUERY = `query UserPnL($period: String) {
2046
2347
  userPnL(period: $period) {
2047
2348
  gain total_wagered total_payout total_bids total_wins pnlRank volRank
@@ -2091,6 +2392,29 @@ var UserModule = class {
2091
2392
  );
2092
2393
  return normaliseMyWalletByCurrencyResponse(body);
2093
2394
  }
2395
+ /**
2396
+ * Start migrating the authenticated user's home region (multi-geo Phase 2).
2397
+ * User-service validates the JWT, checks the feature flag, and forwards to the
2398
+ * region directory (cooldown / single-flight / region-set validation happen
2399
+ * server-side). Typed rejections keep their `extensions.code`
2400
+ * (Migration_Unavailable, Migration_CooldownActive, …) on the error's `code`
2401
+ * so hosts can localise them.
2402
+ */
2403
+ async requestRegionMigration(toRegion, opts) {
2404
+ const body = await this.#graphqlUser.request(
2405
+ REQUEST_REGION_MIGRATION_MUTATION,
2406
+ { input: { toRegion } },
2407
+ opts
2408
+ );
2409
+ const migrationId = body?.requestRegionMigration?.migrationId;
2410
+ if (typeof migrationId !== "string" || migrationId === "") {
2411
+ throw new TaphubServerError("Invalid response from server", {
2412
+ code: "INVALID_RESPONSE",
2413
+ details: body
2414
+ });
2415
+ }
2416
+ return { migrationId };
2417
+ }
2094
2418
  get currencies() {
2095
2419
  return this.#currencies;
2096
2420
  }
@@ -2541,6 +2865,21 @@ function createRestProbe(monitor) {
2541
2865
  };
2542
2866
  }
2543
2867
 
2868
+ // src/region.ts
2869
+ var KNOWN_REGIONS = ["sg", "eu", "jp"];
2870
+ var DEFAULT_REGION = "sg";
2871
+ function isKnownRegion(region) {
2872
+ return region != null && KNOWN_REGIONS.includes(region);
2873
+ }
2874
+ function resolveRegionBaseUrl(region, domains, fallbackBaseUrl) {
2875
+ if (region == null || region === "") {
2876
+ return fallbackBaseUrl;
2877
+ }
2878
+ const map = domains ?? {};
2879
+ const effectiveRegion = isKnownRegion(region) ? region : DEFAULT_REGION;
2880
+ return map[effectiveRegion] ?? map[DEFAULT_REGION] ?? fallbackBaseUrl;
2881
+ }
2882
+
2544
2883
  // src/storage/index.ts
2545
2884
  var MemoryAdapter = class {
2546
2885
  store = /* @__PURE__ */ new Map();
@@ -2597,6 +2936,11 @@ var TaphubStorage = {
2597
2936
  }
2598
2937
  };
2599
2938
 
2939
+ // src/transport/shared/baseUrl.ts
2940
+ function resolveBaseUrl(input) {
2941
+ return typeof input === "function" ? input() : input;
2942
+ }
2943
+
2600
2944
  // src/transport/shared/errors.ts
2601
2945
  function mapNetworkErrorToTaphubError(err) {
2602
2946
  if (err instanceof DOMException && err.name === "AbortError") {
@@ -2752,7 +3096,7 @@ function createGraphQLTransport(deps) {
2752
3096
  }
2753
3097
  async function execute(token, query, variables, opts) {
2754
3098
  const fetchImpl = resolveFetch();
2755
- const base = baseUrl.replace(/\/+$/, "");
3099
+ const base = resolveBaseUrl(baseUrl).replace(/\/+$/, "");
2756
3100
  const op = extractOp(query);
2757
3101
  const url = `${base}?${op}`;
2758
3102
  const headers = buildHeaders({ token, hasBody: true, agencyId });
@@ -2896,7 +3240,7 @@ function createRestTransport(deps) {
2896
3240
  }
2897
3241
  async function request(method, path, body, opts) {
2898
3242
  const fetchImpl = resolveFetch();
2899
- const url = buildUrl(baseUrl, path);
3243
+ const url = buildUrl(resolveBaseUrl(baseUrl), path);
2900
3244
  const token = getToken();
2901
3245
  const hasBody = body !== void 0;
2902
3246
  const headers = buildHeaders({ token, hasBody });
@@ -2966,6 +3310,9 @@ function tokenStorageKey(agencyId) {
2966
3310
  function isDemoStorageKey(agencyId) {
2967
3311
  return `taphub:${agencyId}:isDemo`;
2968
3312
  }
3313
+ function regionStorageKey(agencyId) {
3314
+ return `taphub:${agencyId}:region`;
3315
+ }
2969
3316
  var TaphubClient = class {
2970
3317
  agencyId;
2971
3318
  endpoint;
@@ -2992,11 +3339,29 @@ var TaphubClient = class {
2992
3339
  bus;
2993
3340
  #token;
2994
3341
  #isDemo;
3342
+ /** User's home region (multi-geo, design D4). null → default base URL. */
3343
+ #region;
2995
3344
  #tokenKey;
2996
3345
  #isDemoKey;
3346
+ #regionKey;
3347
+ /** Region → base URL map (config, never hardcoded). undefined → always use `endpoint`. */
3348
+ #regionDomains;
3349
+ /** Fixed MQTT broker endpoint (fallback for region resolution). undefined → no realtime. */
3350
+ #mqttEndpoint;
3351
+ /** Region → MQTT endpoint map (config, never hardcoded). undefined → always use `#mqttEndpoint`. */
3352
+ #regionMqttEndpoints;
2997
3353
  #rest;
2998
3354
  #graphql;
2999
3355
  #graphqlUser;
3356
+ /**
3357
+ * Auth_WrongRegion-retrying variants of the two GraphQL transports, used by the
3358
+ * user-scoped DATA modules (user / bid) and token refresh. A data call that
3359
+ * lands on the wrong cluster switches the region and retries once (multi-geo
3360
+ * backstop). Login keeps the raw transports — it runs its own wrong-region
3361
+ * retry, so wrapping there would double-retry.
3362
+ */
3363
+ #graphqlData;
3364
+ #graphqlUserData;
3000
3365
  constructor(config) {
3001
3366
  if (!config.agencyId) {
3002
3367
  throw new TaphubValidationError("agencyId is required", {
@@ -3021,39 +3386,62 @@ var TaphubClient = class {
3021
3386
  const restProbe = createRestProbe(this.network);
3022
3387
  this.#tokenKey = tokenStorageKey(this.agencyId);
3023
3388
  this.#isDemoKey = isDemoStorageKey(this.agencyId);
3389
+ this.#regionKey = regionStorageKey(this.agencyId);
3390
+ this.#regionDomains = config.regionDomains;
3391
+ this.#mqttEndpoint = config.mqttEndpoint;
3392
+ this.#regionMqttEndpoints = config.regionMqttEndpoints;
3024
3393
  this.#token = this.storage.get(this.#tokenKey);
3025
3394
  this.#isDemo = this.storage.get(this.#isDemoKey) === "1";
3395
+ this.#region = this.storage.get(this.#regionKey);
3026
3396
  this.#rest = createRestTransport({
3027
- baseUrl: this.endpoint,
3397
+ // Thunk: re-resolved per request so a post-login region change re-targets the
3398
+ // origin without rebuilding the transport. Resolves to `endpoint` when no
3399
+ // region domains are configured (pre-multi-geo behaviour unchanged).
3400
+ baseUrl: () => this.#originForRegion(),
3028
3401
  getToken: () => this.getToken(),
3029
3402
  fetch: config.fetch,
3030
3403
  onRequest: restProbe
3031
3404
  });
3032
- const baseApi = this.endpoint.replace(/\/+$/, "");
3033
3405
  this.#graphql = createGraphQLTransport({
3034
- baseUrl: `${baseApi}/grid-api/grid-gql`,
3406
+ baseUrl: () => `${this.#originForRegion()}/grid-api/grid-gql`,
3035
3407
  getToken: () => this.getToken(),
3036
3408
  agencyId: this.agencyId,
3037
3409
  fetch: config.fetch,
3038
3410
  onRequest: graphqlProbe
3039
3411
  });
3040
3412
  this.#graphqlUser = createGraphQLTransport({
3041
- baseUrl: `${baseApi}/taphub-user-service/th-user-gql`,
3413
+ baseUrl: () => `${this.#originForRegion()}/taphub-user-service/th-user-gql`,
3042
3414
  getToken: () => this.getToken(),
3043
3415
  agencyId: this.agencyId,
3044
3416
  fetch: config.fetch,
3045
3417
  onRequest: graphqlProbe
3046
3418
  });
3419
+ this.#graphqlData = withWrongRegionRetry(this.#graphql, (region) => this.#setRegion(region));
3420
+ this.#graphqlUserData = withWrongRegionRetry(
3421
+ this.#graphqlUser,
3422
+ (region) => this.#setRegion(region)
3423
+ );
3047
3424
  this.user = new UserModule({
3048
- graphql: this.#graphql,
3049
- graphqlUser: this.#graphqlUser
3425
+ graphql: this.#graphqlData,
3426
+ graphqlUser: this.#graphqlUserData
3050
3427
  });
3051
3428
  this.auth = new AuthModule({
3052
3429
  rest: this.#rest,
3053
3430
  graphql: this.#graphql,
3054
3431
  graphqlUser: this.#graphqlUser,
3432
+ // Region-routed + wrong-region-retrying transport for token refresh only.
3433
+ graphqlUserData: this.#graphqlUserData,
3055
3434
  setToken: (t, opts) => this.setToken(t, opts),
3435
+ isDemo: () => this.isDemo(),
3056
3436
  agencyId: this.agencyId,
3437
+ // Multi-geo: login sends the client's current (persisted) region; the server
3438
+ // pins it on first join and returns the stored home region thereafter.
3439
+ getRegion: () => this.getRegion(),
3440
+ // Multi-geo: a login that lands on the wrong cluster is rejected with
3441
+ // Auth_WrongRegion + the user's homeRegion; the auth module silently
3442
+ // switches the client's region here and retries once. Kept separate from
3443
+ // setToken so a region redirect never disturbs the auth/token state.
3444
+ setRegion: (region) => this.#setRegion(region),
3057
3445
  onLoginSuccess: () => this.user.refreshCurrencies().then(() => void 0).catch(() => void 0),
3058
3446
  onLogout: () => {
3059
3447
  this.user.clearCurrencies();
@@ -3062,14 +3450,17 @@ var TaphubClient = class {
3062
3450
  const clockSync = new ClockSync();
3063
3451
  this.pair = new PairModule({ graphql: this.#graphql, clockSync });
3064
3452
  this.bid = new BidModule({
3065
- graphql: this.#graphql,
3453
+ graphql: this.#graphqlData,
3066
3454
  getClockOffset: () => clockSync.getOffset()
3067
3455
  });
3068
3456
  this.leaderboard = new LeaderboardModule({ graphql: this.#graphql });
3069
3457
  this.agencyPairs = new AgencyPairModule({ graphql: this.#graphql });
3070
3458
  this.locale = new LocaleModule({ graphql: this.#graphql });
3071
3459
  this.realtime = config.mqttEndpoint ? new RealtimeModule({
3072
- mqttEndpoint: config.mqttEndpoint,
3460
+ // Thunk: re-resolved at connect time so a post-login region change
3461
+ // re-targets the broker without rebuilding the module (multi-geo D4).
3462
+ // Resolves to `mqttEndpoint` when no region MQTT map is configured.
3463
+ mqttEndpoint: () => this.#resolveMqttEndpoint(),
3073
3464
  agencyId: this.agencyId,
3074
3465
  mqttAuth: config.mqttAuth,
3075
3466
  onMqttLifecycle: createMqttProbe(this.network)
@@ -3129,18 +3520,87 @@ var TaphubClient = class {
3129
3520
  getToken() {
3130
3521
  return this.#token;
3131
3522
  }
3523
+ /**
3524
+ * Current API origin, resolved from the home region (multi-geo, design D4).
3525
+ * Trailing slashes trimmed so the service-path suffix composes cleanly. Falls
3526
+ * back to `endpoint` when no region domains are configured or the region is
3527
+ * unknown/absent.
3528
+ */
3529
+ #originForRegion() {
3530
+ return resolveRegionBaseUrl(this.#region, this.#regionDomains, this.endpoint).replace(
3531
+ /\/+$/,
3532
+ ""
3533
+ );
3534
+ }
3535
+ /**
3536
+ * Current MQTT broker endpoint, resolved from the home region (multi-geo,
3537
+ * design D4). Falls back to the configured `mqttEndpoint` when no region MQTT
3538
+ * map is configured or the region is unknown/absent. Only meaningful when
3539
+ * `mqttEndpoint` was configured (otherwise `realtime` is undefined).
3540
+ */
3541
+ #resolveMqttEndpoint() {
3542
+ return resolveRegionBaseUrl(this.#region, this.#regionMqttEndpoints, this.#mqttEndpoint ?? "");
3543
+ }
3544
+ /** The user's home region, once resolved from a login response. */
3545
+ getRegion() {
3546
+ return this.#region;
3547
+ }
3548
+ /**
3549
+ * Set the home region and persist it (survives reloads like the token/isDemo). Shared
3550
+ * by the login path (setToken with a homeRegion) and the wrong-region redirect handler
3551
+ * (task 1.13). A pure region update — it never touches the token or isDemo, so a
3552
+ * mid-session redirect cannot disturb the auth state.
3553
+ */
3554
+ #setRegion(region) {
3555
+ if (this.#region === region) return;
3556
+ const prevMqttEndpoint = this.realtime ? this.#resolveMqttEndpoint() : null;
3557
+ this.#region = region;
3558
+ this.storage.set(this.#regionKey, region);
3559
+ if (this.realtime && prevMqttEndpoint !== null) {
3560
+ const nextMqttEndpoint = this.#resolveMqttEndpoint();
3561
+ if (nextMqttEndpoint !== prevMqttEndpoint) {
3562
+ this.realtime.reconnect();
3563
+ }
3564
+ }
3565
+ }
3566
+ /**
3567
+ * Complete a home-region migration by cutting the client over to `toRegion`
3568
+ * (ux-260730-migration-complete-notify). Intended to be called after a
3569
+ * `migrationCompleted` event (realtime `subscribeMigration`) or an equivalent
3570
+ * poll result confirmed the migration finished — by then the server-side home
3571
+ * region already points at `toRegion`.
3572
+ *
3573
+ * Purpose-named public wrapper over the private `#setRegion` primitive (the
3574
+ * generic region setter stays private so third-party builders cannot
3575
+ * arbitrarily re-target regions). Persists the region and re-targets the
3576
+ * GQL/REST transports; MQTT reconnects only when the resolved broker endpoint
3577
+ * actually changes. Region validation is lenient, matching `region.ts`: an
3578
+ * unknown region is stored as-is and resolves to the default region's
3579
+ * endpoints (enum-fallback, never throws). Idempotent for the already-current
3580
+ * region (no reconnect), and a safe endpoint-level no-op when no region
3581
+ * domain/MQTT maps are configured.
3582
+ */
3583
+ completeRegionMigration(toRegion) {
3584
+ if (toRegion == null || toRegion === "") return;
3585
+ this.#setRegion(toRegion);
3586
+ }
3132
3587
  setToken(token, opts) {
3133
3588
  this.#token = token;
3134
3589
  if (token === null) {
3135
3590
  this.#isDemo = false;
3591
+ this.#region = null;
3136
3592
  this.storage.remove(this.#tokenKey);
3137
3593
  this.storage.remove(this.#isDemoKey);
3594
+ this.storage.remove(this.#regionKey);
3138
3595
  return;
3139
3596
  }
3140
3597
  const isDemo = opts?.isDemo ?? false;
3141
3598
  this.#isDemo = isDemo;
3142
3599
  this.storage.set(this.#tokenKey, token);
3143
3600
  this.storage.set(this.#isDemoKey, isDemo ? "1" : "0");
3601
+ if (opts?.homeRegion !== void 0) {
3602
+ this.#setRegion(opts.homeRegion);
3603
+ }
3144
3604
  }
3145
3605
  isDemo() {
3146
3606
  return this.#isDemo;
@@ -3161,6 +3621,81 @@ var TaphubClient = class {
3161
3621
  }
3162
3622
  };
3163
3623
 
3624
+ // src/regionProbe.ts
3625
+ var REGION_PROBE_CACHE_KEY = "taphub:region-probe";
3626
+ var DEFAULT_PROBE_TTL_SECONDS = 3600;
3627
+ var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
3628
+ var DEFAULT_PROBE_PATH = "/grid-api/grid-gql";
3629
+ var PROBE_QUERY = "{serverTime}";
3630
+ function readFreshCache(storage, key, now, ttlSeconds) {
3631
+ try {
3632
+ const raw = storage.get(key);
3633
+ if (!raw) return null;
3634
+ const parsed = JSON.parse(raw);
3635
+ if (!parsed || typeof parsed.region !== "string" || typeof parsed.ts !== "number") {
3636
+ return null;
3637
+ }
3638
+ if (now() - parsed.ts >= ttlSeconds * 1e3) return null;
3639
+ return parsed.region;
3640
+ } catch {
3641
+ return null;
3642
+ }
3643
+ }
3644
+ function writeCache(storage, key, region, now) {
3645
+ try {
3646
+ storage.set(key, JSON.stringify({ region, ts: now() }));
3647
+ } catch {
3648
+ }
3649
+ }
3650
+ async function probeNearestRegion(domains, opts = {}) {
3651
+ const {
3652
+ fetch: fetchImpl = globalThis.fetch,
3653
+ storage = autoDetectStorage(),
3654
+ ttlSeconds = DEFAULT_PROBE_TTL_SECONDS,
3655
+ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
3656
+ probePath = DEFAULT_PROBE_PATH,
3657
+ now = Date.now,
3658
+ cacheKey = REGION_PROBE_CACHE_KEY
3659
+ } = opts;
3660
+ const entries = Object.entries(domains ?? {}).filter(
3661
+ (entry) => typeof entry[1] === "string" && entry[1] !== ""
3662
+ );
3663
+ if (entries.length < 2) return null;
3664
+ const cached = readFreshCache(storage, cacheKey, now, ttlSeconds);
3665
+ if (cached !== null && entries.some(([region]) => region === cached)) {
3666
+ return cached;
3667
+ }
3668
+ if (typeof fetchImpl !== "function") return null;
3669
+ const controller = new AbortController();
3670
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3671
+ try {
3672
+ const winner = await Promise.any(
3673
+ entries.map(async ([region, baseUrl]) => {
3674
+ const url = baseUrl.replace(/\/+$/, "") + probePath;
3675
+ const response = await fetchImpl(url, {
3676
+ method: "POST",
3677
+ headers: { "content-type": "application/json" },
3678
+ body: JSON.stringify({ query: PROBE_QUERY }),
3679
+ signal: controller.signal
3680
+ });
3681
+ const body = await response.json();
3682
+ const serverTime = body?.data?.serverTime;
3683
+ if (typeof serverTime !== "number") {
3684
+ throw new Error(`region probe: "${region}" returned no serverTime`);
3685
+ }
3686
+ return region;
3687
+ })
3688
+ );
3689
+ writeCache(storage, cacheKey, winner, now);
3690
+ return winner;
3691
+ } catch {
3692
+ return null;
3693
+ } finally {
3694
+ clearTimeout(timer);
3695
+ controller.abort();
3696
+ }
3697
+ }
3698
+
3164
3699
  // src/modules/realtime/types.ts
3165
3700
  var CANDLE_EVENT = {
3166
3701
  NEW: "new",
@@ -3251,6 +3786,115 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
3251
3786
  const prob = 1 - normalCDF(z);
3252
3787
  return Math.max(0, Math.min(1, prob));
3253
3788
  }
3789
+ function simpsonForHit(f, a, b, eps = 3e-5, maxDepth = 14) {
3790
+ function simpsonRule(a2, b2) {
3791
+ const c = (a2 + b2) / 2;
3792
+ const h = b2 - a2;
3793
+ return h / 6 * (f(a2) + 4 * f(c) + f(b2));
3794
+ }
3795
+ function recurse(a2, b2, eps2, whole2, depth) {
3796
+ const c = (a2 + b2) / 2;
3797
+ const left = simpsonRule(a2, c);
3798
+ const right = simpsonRule(c, b2);
3799
+ if (depth >= maxDepth || Math.abs(left + right - whole2) <= 15 * eps2) {
3800
+ return left + right + (left + right - whole2) / 15;
3801
+ }
3802
+ return recurse(a2, c, eps2 / 2, left, depth + 1) + recurse(c, b2, eps2 / 2, right, depth + 1);
3803
+ }
3804
+ const whole = simpsonRule(a, b);
3805
+ return recurse(a, b, eps, whole, 0);
3806
+ }
3807
+ function calculateProbHit(time1, time2, price1, price2, creationTime, currentPrice, volatility) {
3808
+ if (volatility <= 0 || currentPrice <= 0) return 0;
3809
+ const sigma = volatility;
3810
+ const L = Math.log(price1 / currentPrice);
3811
+ const U = Math.log(price2 / currentPrice);
3812
+ let T1 = time1 - creationTime;
3813
+ if (T1 < 0) T1 = 0;
3814
+ const dT = time2 - time1;
3815
+ if (dT <= 0) return 0;
3816
+ let probInside;
3817
+ if (T1 <= 0) {
3818
+ probInside = currentPrice >= price1 && currentPrice <= price2 ? 1 : 0;
3819
+ } else {
3820
+ const sqrtT12 = Math.sqrt(T1);
3821
+ const sigSqrtT12 = sigma * sqrtT12;
3822
+ if (sigSqrtT12 < 1e-12) {
3823
+ probInside = currentPrice >= price1 && currentPrice <= price2 ? 1 : 0;
3824
+ } else {
3825
+ probInside = normalCDF(U / sigSqrtT12) - normalCDF(L / sigSqrtT12);
3826
+ }
3827
+ }
3828
+ if (T1 <= 0) {
3829
+ return Math.max(0, Math.min(1, probInside));
3830
+ }
3831
+ const sqrtT1 = Math.sqrt(T1);
3832
+ const sigSqrtT1 = sigma * sqrtT1;
3833
+ const sqrtRatio = Math.sqrt(T1 / dT);
3834
+ const sigSqrtDT = sigma * Math.sqrt(dT);
3835
+ const zU = U / sigSqrtT1;
3836
+ const integrandAbove = (z) => {
3837
+ return 2 * normalCDF(U / sigSqrtDT - z * sqrtRatio) * normalPDF(z);
3838
+ };
3839
+ const pFromAbove = simpsonForHit(integrandAbove, zU, zU + 7, 3e-5, 14);
3840
+ const zL = L / sigSqrtT1;
3841
+ const integrandBelow = (z) => {
3842
+ return 2 * normalCDF(z * sqrtRatio - L / sigSqrtDT) * normalPDF(z);
3843
+ };
3844
+ const pFromBelow = simpsonForHit(integrandBelow, zL - 7, zL, 3e-5, 14);
3845
+ const result = probInside + pFromAbove + pFromBelow;
3846
+ return Math.max(0, Math.min(1, result));
3847
+ }
3848
+ function calculateCoefficientWrapper(params) {
3849
+ const {
3850
+ time1,
3851
+ time2,
3852
+ price1,
3853
+ price2,
3854
+ candleTime,
3855
+ candleClose,
3856
+ volatility,
3857
+ coefMults,
3858
+ cellSizeTime,
3859
+ minCoef
3860
+ } = params;
3861
+ const currentPrice = candleClose;
3862
+ const creationTime = candleTime;
3863
+ const probability = calculateProbHit(
3864
+ time1,
3865
+ time2,
3866
+ price1,
3867
+ price2,
3868
+ creationTime,
3869
+ currentPrice,
3870
+ volatility
3871
+ );
3872
+ if (probability <= 0) return Number.POSITIVE_INFINITY;
3873
+ const rawCoef = 1 / probability;
3874
+ const rawIndex = rawCoef < 1 ? 0 : Math.floor(Math.log2(rawCoef));
3875
+ const coefMultIndex = Math.min(rawIndex, coefMults.length - 1);
3876
+ const multiplier = coefMults[coefMultIndex] || 1;
3877
+ const timeRatio = cellSizeTime;
3878
+ const adjustedProb = probability * (timeRatio / 5);
3879
+ if (adjustedProb <= 0) return Number.POSITIVE_INFINITY;
3880
+ const floor = typeof minCoef === "number" && minCoef > 0 ? minCoef : 1;
3881
+ const finalCoef = Math.max(floor, multiplier / adjustedProb);
3882
+ return roundCoefToSignificantDigits(finalCoef);
3883
+ }
3884
+ function roundCoefToSignificantDigits(value) {
3885
+ if (!Number.isFinite(value) || value <= 0) return value;
3886
+ const magnitude = Math.floor(Math.log10(value));
3887
+ const leadingDigit = Math.floor(value / 10 ** magnitude);
3888
+ const sigDigits = leadingDigit === 1 ? 3 : 2;
3889
+ const factor = 10 ** (sigDigits - magnitude - 1);
3890
+ return Math.round(value * factor) / factor;
3891
+ }
3892
+ function computeBaseline(candleClose, candleTimeSec, cellSizeValue, cellSizeTime, candleSize) {
3893
+ const baseline = Math.floor(candleClose / cellSizeValue) * cellSizeValue;
3894
+ const cellSizeTimeSec = candleSize * cellSizeTime;
3895
+ const baselineTime = Math.floor(candleTimeSec / cellSizeTimeSec) * cellSizeTimeSec + 0.5;
3896
+ return { baseline, baselineTime };
3897
+ }
3254
3898
  // Annotate the CommonJS export names for ESM import in node:
3255
3899
  0 && (module.exports = {
3256
3900
  AgencyPairModule,
@@ -3258,10 +3902,14 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
3258
3902
  BidModule,
3259
3903
  CANDLE_EVENT,
3260
3904
  DEFAULT_CHART_HISTORY_LIMIT,
3905
+ DEFAULT_PROBE_TTL_SECONDS,
3906
+ DEFAULT_REGION,
3907
+ KNOWN_REGIONS,
3261
3908
  LeaderboardModule,
3262
3909
  LocaleModule,
3263
3910
  NetworkQualityMonitor,
3264
3911
  PairModule,
3912
+ REGION_PROBE_CACHE_KEY,
3265
3913
  RealtimeModule,
3266
3914
  TaphubAuthError,
3267
3915
  TaphubClient,
@@ -3275,10 +3923,14 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
3275
3923
  UserModule,
3276
3924
  adaptiveSimpson,
3277
3925
  autoDetectStorage,
3926
+ calculateCoefficientWrapper,
3927
+ calculateProbHit,
3278
3928
  calculateProbWin,
3279
3929
  calculateProbWin_v2,
3930
+ computeBaseline,
3280
3931
  errorFunction,
3281
3932
  isCancelled,
3933
+ isKnownRegion,
3282
3934
  isLoss,
3283
3935
  isPending,
3284
3936
  isTerminal,
@@ -3286,5 +3938,8 @@ function calculateProbWin_v2(time1, time2, _price1, price2, creationTime, curren
3286
3938
  normalCDF,
3287
3939
  normalPDF,
3288
3940
  normaliseLang,
3289
- pairIdFromBidResultTopic
3941
+ pairIdFromBidResultTopic,
3942
+ probeNearestRegion,
3943
+ resolveRegionBaseUrl,
3944
+ roundCoefToSignificantDigits
3290
3945
  });