@taphubhq/sdk-core 0.25.6 → 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 +567 -29
- package/dist/index.d.mts +360 -12
- package/dist/index.d.ts +360 -12
- package/dist/index.js +560 -29
- package/package.json +1 -1
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,
|
|
@@ -59,6 +63,7 @@ __export(index_exports, {
|
|
|
59
63
|
computeBaseline: () => computeBaseline,
|
|
60
64
|
errorFunction: () => errorFunction,
|
|
61
65
|
isCancelled: () => isCancelled,
|
|
66
|
+
isKnownRegion: () => isKnownRegion,
|
|
62
67
|
isLoss: () => isLoss,
|
|
63
68
|
isPending: () => isPending,
|
|
64
69
|
isTerminal: () => isTerminal,
|
|
@@ -67,6 +72,8 @@ __export(index_exports, {
|
|
|
67
72
|
normalPDF: () => normalPDF,
|
|
68
73
|
normaliseLang: () => normaliseLang,
|
|
69
74
|
pairIdFromBidResultTopic: () => pairIdFromBidResultTopic,
|
|
75
|
+
probeNearestRegion: () => probeNearestRegion,
|
|
76
|
+
resolveRegionBaseUrl: () => resolveRegionBaseUrl,
|
|
70
77
|
roundCoefToSignificantDigits: () => roundCoefToSignificantDigits
|
|
71
78
|
});
|
|
72
79
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -224,6 +231,36 @@ var AgencyPairModule = class {
|
|
|
224
231
|
}
|
|
225
232
|
};
|
|
226
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
|
+
|
|
227
264
|
// src/modules/auth/normalise.ts
|
|
228
265
|
function normaliseGoogleResponse(body) {
|
|
229
266
|
return {
|
|
@@ -295,6 +332,7 @@ function normaliseUserLoginResponse(body) {
|
|
|
295
332
|
throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
|
|
296
333
|
}
|
|
297
334
|
const wallet = node.user.defaultWallet;
|
|
335
|
+
const homeRegion = node.homeRegion ?? void 0;
|
|
298
336
|
return {
|
|
299
337
|
accessToken: node.accessToken,
|
|
300
338
|
user: {
|
|
@@ -309,9 +347,17 @@ function normaliseUserLoginResponse(body) {
|
|
|
309
347
|
isEnabled: wallet.isEnable
|
|
310
348
|
}
|
|
311
349
|
},
|
|
312
|
-
isDemo: false
|
|
350
|
+
isDemo: false,
|
|
351
|
+
homeRegion
|
|
313
352
|
};
|
|
314
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
|
+
}
|
|
315
361
|
function validateLoginBody(body) {
|
|
316
362
|
if (typeof body.access_token !== "string" || body.access_token === "") {
|
|
317
363
|
throw new TaphubServerError("Invalid response from server", {
|
|
@@ -340,10 +386,21 @@ var CREATE_DEMO_USER_MUTATION = `
|
|
|
340
386
|
}
|
|
341
387
|
}
|
|
342
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
|
+
`;
|
|
343
397
|
var USER_LOGIN_MUTATION = `
|
|
344
398
|
mutation UserLogin($input: LoginInput!) {
|
|
345
399
|
userLogin(input: $input) {
|
|
346
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
|
|
347
404
|
user {
|
|
348
405
|
id
|
|
349
406
|
agencyUid
|
|
@@ -365,16 +422,32 @@ var AuthModule = class {
|
|
|
365
422
|
#rest;
|
|
366
423
|
#graphql;
|
|
367
424
|
#graphqlUser;
|
|
425
|
+
#graphqlUserData;
|
|
368
426
|
#setToken;
|
|
427
|
+
#isDemo;
|
|
369
428
|
#agencyId;
|
|
429
|
+
#getRegion;
|
|
430
|
+
#setRegion;
|
|
370
431
|
#onLoginSuccess;
|
|
371
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;
|
|
372
441
|
constructor(deps) {
|
|
373
442
|
this.#rest = deps.rest;
|
|
374
443
|
this.#graphql = deps.graphql;
|
|
375
444
|
this.#graphqlUser = deps.graphqlUser;
|
|
445
|
+
this.#graphqlUserData = deps.graphqlUserData ?? deps.graphqlUser;
|
|
376
446
|
this.#setToken = deps.setToken;
|
|
447
|
+
this.#isDemo = deps.isDemo ?? (() => false);
|
|
377
448
|
this.#agencyId = deps.agencyId;
|
|
449
|
+
this.#getRegion = deps.getRegion;
|
|
450
|
+
this.#setRegion = deps.setRegion;
|
|
378
451
|
this.#onLoginSuccess = deps.onLoginSuccess;
|
|
379
452
|
this.#onLogout = deps.onLogout;
|
|
380
453
|
}
|
|
@@ -429,14 +502,35 @@ var AuthModule = class {
|
|
|
429
502
|
return result;
|
|
430
503
|
}
|
|
431
504
|
async loginWithSession(sessionToken, opts) {
|
|
432
|
-
const
|
|
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 };
|
|
433
527
|
const body = await this.#graphqlUser.request(
|
|
434
528
|
USER_LOGIN_MUTATION,
|
|
435
529
|
variables,
|
|
436
530
|
opts
|
|
437
531
|
);
|
|
438
532
|
const result = normaliseUserLoginResponse(body);
|
|
439
|
-
this.#setToken(result.accessToken, { isDemo: false });
|
|
533
|
+
this.#setToken(result.accessToken, { isDemo: false, homeRegion: result.homeRegion });
|
|
440
534
|
if (this.#onLoginSuccess) {
|
|
441
535
|
try {
|
|
442
536
|
await this.#onLoginSuccess();
|
|
@@ -445,6 +539,60 @@ var AuthModule = class {
|
|
|
445
539
|
}
|
|
446
540
|
return result;
|
|
447
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
|
+
}
|
|
448
596
|
async logout() {
|
|
449
597
|
this.#setToken(null);
|
|
450
598
|
if (this.#onLogout) {
|
|
@@ -1109,18 +1257,30 @@ function normaliseCandles(list) {
|
|
|
1109
1257
|
coefMults: c.coefMults
|
|
1110
1258
|
}));
|
|
1111
1259
|
}
|
|
1260
|
+
function isAgencyComposite(id) {
|
|
1261
|
+
return id.includes(":");
|
|
1262
|
+
}
|
|
1263
|
+
function stripAgencyPrefix(id) {
|
|
1264
|
+
return id.slice(id.indexOf(":") + 1);
|
|
1265
|
+
}
|
|
1112
1266
|
function normalisePairInfo(node) {
|
|
1113
1267
|
if (typeof node.id !== "string" || node.id === "" || typeof node.pair !== "string" || node.pair === "") {
|
|
1114
1268
|
throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
|
|
1115
1269
|
}
|
|
1270
|
+
const composite = isAgencyComposite(node.id);
|
|
1116
1271
|
return {
|
|
1117
|
-
id: node.id,
|
|
1272
|
+
id: composite ? stripAgencyPrefix(node.id) : node.id,
|
|
1118
1273
|
pair: node.pair,
|
|
1119
|
-
|
|
1120
|
-
|
|
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 ?? "",
|
|
1121
1278
|
source: node.source,
|
|
1122
1279
|
// REVIEW[bid-260602]: maps node.agencyPairId (was: node.gameId)
|
|
1123
|
-
|
|
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)
|
|
1124
1284
|
};
|
|
1125
1285
|
}
|
|
1126
1286
|
|
|
@@ -1146,10 +1306,7 @@ var BUILDER_AVAILABLE_GAME_PAIRS_QUERY = `query BuilderAvailableGamePairs($gamep
|
|
|
1146
1306
|
builderAvailableGamePairs(gameplayId: $gameplayId) {
|
|
1147
1307
|
id
|
|
1148
1308
|
pair
|
|
1149
|
-
gameplayId
|
|
1150
|
-
gameplayName
|
|
1151
1309
|
source
|
|
1152
|
-
agencyPairId
|
|
1153
1310
|
}
|
|
1154
1311
|
}`;
|
|
1155
1312
|
|
|
@@ -1194,8 +1351,11 @@ var PairModule = class {
|
|
|
1194
1351
|
/**
|
|
1195
1352
|
* Returns available game pairs, optionally filtered by gameplay.
|
|
1196
1353
|
*
|
|
1197
|
-
*
|
|
1198
|
-
* `
|
|
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.
|
|
1199
1359
|
*
|
|
1200
1360
|
* @example
|
|
1201
1361
|
* const pairs = await client.pair.availableGamePairs({ gameplayId: 'taptrading' });
|
|
@@ -1245,6 +1405,9 @@ function walletBalanceTopic(userId) {
|
|
|
1245
1405
|
function agencyPairStatsTopic(aid, pairId) {
|
|
1246
1406
|
return `public/agency/${aid}/pair/${pairId}/stats`;
|
|
1247
1407
|
}
|
|
1408
|
+
function userMigrationTopic(userId) {
|
|
1409
|
+
return `user/${userId}/migration`;
|
|
1410
|
+
}
|
|
1248
1411
|
function userBidsWildcardTopic(userId) {
|
|
1249
1412
|
return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
|
|
1250
1413
|
}
|
|
@@ -1271,12 +1434,14 @@ function userScopedTopicsFor(gameId, userId) {
|
|
|
1271
1434
|
return USER_SCOPED_SUFFIXES.map((s) => topicFor(gameId, s, userId));
|
|
1272
1435
|
}
|
|
1273
1436
|
function createMqttTransport(endpoint, opts = {}) {
|
|
1437
|
+
const resolveEndpoint = () => typeof endpoint === "function" ? endpoint() : endpoint;
|
|
1274
1438
|
let client = null;
|
|
1275
1439
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
1276
1440
|
const candleSubscriptions = /* @__PURE__ */ new Map();
|
|
1277
1441
|
const statsSubscriptions = /* @__PURE__ */ new Map();
|
|
1278
1442
|
const walletSubscriptions = /* @__PURE__ */ new Map();
|
|
1279
1443
|
const userBidsSubscriptions = /* @__PURE__ */ new Map();
|
|
1444
|
+
const migrationSubscriptions = /* @__PURE__ */ new Map();
|
|
1280
1445
|
const { onLifecycle, auth } = opts;
|
|
1281
1446
|
let connectStartedAt = 0;
|
|
1282
1447
|
function fireLifecycle(event) {
|
|
@@ -1289,7 +1454,7 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1289
1454
|
function ensureConnected() {
|
|
1290
1455
|
if (client) return client;
|
|
1291
1456
|
connectStartedAt = Date.now();
|
|
1292
|
-
client = import_mqtt.default.connect(
|
|
1457
|
+
client = import_mqtt.default.connect(resolveEndpoint(), {
|
|
1293
1458
|
clean: true,
|
|
1294
1459
|
reconnectPeriod: 2e3,
|
|
1295
1460
|
connectTimeout: 1e4,
|
|
@@ -1330,6 +1495,9 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1330
1495
|
for (const entry of userBidsSubscriptions.values()) {
|
|
1331
1496
|
c.subscribe(entry.pattern, { qos: 1 });
|
|
1332
1497
|
}
|
|
1498
|
+
for (const entry of migrationSubscriptions.values()) {
|
|
1499
|
+
c.subscribe(entry.topic, { qos: 1 });
|
|
1500
|
+
}
|
|
1333
1501
|
fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
|
|
1334
1502
|
});
|
|
1335
1503
|
client.on("reconnect", () => {
|
|
@@ -1378,6 +1546,20 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1378
1546
|
walletSub.onMessage(receivedTopic, payload2);
|
|
1379
1547
|
return;
|
|
1380
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
|
+
}
|
|
1381
1563
|
let userBidsPayload;
|
|
1382
1564
|
for (const sub of userBidsSubscriptions.values()) {
|
|
1383
1565
|
if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
|
|
@@ -1491,6 +1673,19 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1491
1673
|
if (client) client.unsubscribe(entry.pattern);
|
|
1492
1674
|
userBidsSubscriptions.delete(userId);
|
|
1493
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
|
+
},
|
|
1494
1689
|
unsubscribeAll(gameId, userId) {
|
|
1495
1690
|
const matches = entriesForGame(gameId);
|
|
1496
1691
|
if (matches.length === 0) return;
|
|
@@ -1515,6 +1710,12 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1515
1710
|
}
|
|
1516
1711
|
subscriptions.delete(subKey(target.gameId, target.userId));
|
|
1517
1712
|
},
|
|
1713
|
+
reconnect() {
|
|
1714
|
+
if (!client) return;
|
|
1715
|
+
client.end(true);
|
|
1716
|
+
client = null;
|
|
1717
|
+
ensureConnected();
|
|
1718
|
+
},
|
|
1518
1719
|
close() {
|
|
1519
1720
|
if (client) {
|
|
1520
1721
|
for (const sub of subscriptions.values()) {
|
|
@@ -1534,12 +1735,16 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1534
1735
|
for (const entry of userBidsSubscriptions.values()) {
|
|
1535
1736
|
client.unsubscribe(entry.pattern);
|
|
1536
1737
|
}
|
|
1738
|
+
for (const entry of migrationSubscriptions.values()) {
|
|
1739
|
+
client.unsubscribe(entry.topic);
|
|
1740
|
+
}
|
|
1537
1741
|
}
|
|
1538
1742
|
subscriptions.clear();
|
|
1539
1743
|
candleSubscriptions.clear();
|
|
1540
1744
|
statsSubscriptions.clear();
|
|
1541
1745
|
walletSubscriptions.clear();
|
|
1542
1746
|
userBidsSubscriptions.clear();
|
|
1747
|
+
migrationSubscriptions.clear();
|
|
1543
1748
|
if (client) {
|
|
1544
1749
|
client.end(true);
|
|
1545
1750
|
client = null;
|
|
@@ -1549,7 +1754,7 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1549
1754
|
}
|
|
1550
1755
|
|
|
1551
1756
|
// src/modules/realtime/index.ts
|
|
1552
|
-
var
|
|
1757
|
+
var import_eventemitter35 = __toESM(require("eventemitter3"));
|
|
1553
1758
|
|
|
1554
1759
|
// src/modules/realtime/GameChannel.ts
|
|
1555
1760
|
var import_eventemitter3 = __toESM(require("eventemitter3"));
|
|
@@ -1561,9 +1766,9 @@ var GameChannel = class extends import_eventemitter3.default {
|
|
|
1561
1766
|
}
|
|
1562
1767
|
};
|
|
1563
1768
|
|
|
1564
|
-
// src/modules/realtime/
|
|
1769
|
+
// src/modules/realtime/MigrationChannel.ts
|
|
1565
1770
|
var import_eventemitter32 = __toESM(require("eventemitter3"));
|
|
1566
|
-
var
|
|
1771
|
+
var MigrationChannel = class extends import_eventemitter32.default {
|
|
1567
1772
|
userId;
|
|
1568
1773
|
constructor(userId) {
|
|
1569
1774
|
super();
|
|
@@ -1571,9 +1776,19 @@ var UserBidsChannel = class extends import_eventemitter32.default {
|
|
|
1571
1776
|
}
|
|
1572
1777
|
};
|
|
1573
1778
|
|
|
1574
|
-
// src/modules/realtime/
|
|
1779
|
+
// src/modules/realtime/UserBidsChannel.ts
|
|
1575
1780
|
var import_eventemitter33 = __toESM(require("eventemitter3"));
|
|
1576
|
-
var
|
|
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 {
|
|
1577
1792
|
userId;
|
|
1578
1793
|
constructor(userId) {
|
|
1579
1794
|
super();
|
|
@@ -1664,6 +1879,19 @@ function mapWireWalletBalance(raw) {
|
|
|
1664
1879
|
reason: p.reason ?? ""
|
|
1665
1880
|
};
|
|
1666
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
|
+
}
|
|
1667
1895
|
function mapWireConfig(raw) {
|
|
1668
1896
|
const p = raw;
|
|
1669
1897
|
return {
|
|
@@ -1704,13 +1932,15 @@ function mapWireToEvent(topic, payload) {
|
|
|
1704
1932
|
function normaliseUserId2(userId) {
|
|
1705
1933
|
return userId && userId !== "" ? userId : null;
|
|
1706
1934
|
}
|
|
1707
|
-
var RealtimeModule = class extends
|
|
1935
|
+
var RealtimeModule = class extends import_eventemitter35.default {
|
|
1708
1936
|
#transport;
|
|
1709
1937
|
#entries = /* @__PURE__ */ new Map();
|
|
1710
1938
|
#walletEntries = /* @__PURE__ */ new Map();
|
|
1711
1939
|
// keyed by userId
|
|
1712
1940
|
#userBidsEntries = /* @__PURE__ */ new Map();
|
|
1713
1941
|
// keyed by userId
|
|
1942
|
+
#migrationEntries = /* @__PURE__ */ new Map();
|
|
1943
|
+
// keyed by userId
|
|
1714
1944
|
#agencyId;
|
|
1715
1945
|
constructor(mqttEndpointOrOptions) {
|
|
1716
1946
|
super();
|
|
@@ -1899,6 +2129,57 @@ var RealtimeModule = class extends import_eventemitter34.default {
|
|
|
1899
2129
|
entry.channel.removeAllListeners();
|
|
1900
2130
|
this.#transport.unsubscribeUserBids(cleanUserId);
|
|
1901
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
|
+
}
|
|
1902
2183
|
/**
|
|
1903
2184
|
* Subscribe to public market candle data for a pair, keyed by pairId
|
|
1904
2185
|
* (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
|
|
@@ -1941,6 +2222,15 @@ var RealtimeModule = class extends import_eventemitter34.default {
|
|
|
1941
2222
|
if (!this.#agencyId) return;
|
|
1942
2223
|
this.#transport.unsubscribeAgencyPairStats(this.#agencyId, pairId);
|
|
1943
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
|
+
}
|
|
1944
2234
|
disconnect() {
|
|
1945
2235
|
for (const entry of this.#entries.values()) {
|
|
1946
2236
|
entry.channel.removeAllListeners();
|
|
@@ -1954,6 +2244,10 @@ var RealtimeModule = class extends import_eventemitter34.default {
|
|
|
1954
2244
|
entry.channel.removeAllListeners();
|
|
1955
2245
|
}
|
|
1956
2246
|
this.#userBidsEntries.clear();
|
|
2247
|
+
for (const entry of this.#migrationEntries.values()) {
|
|
2248
|
+
entry.channel.removeAllListeners();
|
|
2249
|
+
}
|
|
2250
|
+
this.#migrationEntries.clear();
|
|
1957
2251
|
this.#transport.close();
|
|
1958
2252
|
}
|
|
1959
2253
|
};
|
|
@@ -2046,6 +2340,9 @@ var LIST_ENABLED_CURRENCIES_QUERY = `query ListEnabledCurrencies($input: ListEna
|
|
|
2046
2340
|
listEnabledCurrencies(input: $input) { code unit unitSymbol }
|
|
2047
2341
|
}`;
|
|
2048
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
|
+
}`;
|
|
2049
2346
|
var USER_PNL_QUERY = `query UserPnL($period: String) {
|
|
2050
2347
|
userPnL(period: $period) {
|
|
2051
2348
|
gain total_wagered total_payout total_bids total_wins pnlRank volRank
|
|
@@ -2095,6 +2392,29 @@ var UserModule = class {
|
|
|
2095
2392
|
);
|
|
2096
2393
|
return normaliseMyWalletByCurrencyResponse(body);
|
|
2097
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
|
+
}
|
|
2098
2418
|
get currencies() {
|
|
2099
2419
|
return this.#currencies;
|
|
2100
2420
|
}
|
|
@@ -2545,6 +2865,21 @@ function createRestProbe(monitor) {
|
|
|
2545
2865
|
};
|
|
2546
2866
|
}
|
|
2547
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
|
+
|
|
2548
2883
|
// src/storage/index.ts
|
|
2549
2884
|
var MemoryAdapter = class {
|
|
2550
2885
|
store = /* @__PURE__ */ new Map();
|
|
@@ -2601,6 +2936,11 @@ var TaphubStorage = {
|
|
|
2601
2936
|
}
|
|
2602
2937
|
};
|
|
2603
2938
|
|
|
2939
|
+
// src/transport/shared/baseUrl.ts
|
|
2940
|
+
function resolveBaseUrl(input) {
|
|
2941
|
+
return typeof input === "function" ? input() : input;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2604
2944
|
// src/transport/shared/errors.ts
|
|
2605
2945
|
function mapNetworkErrorToTaphubError(err) {
|
|
2606
2946
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
@@ -2756,7 +3096,7 @@ function createGraphQLTransport(deps) {
|
|
|
2756
3096
|
}
|
|
2757
3097
|
async function execute(token, query, variables, opts) {
|
|
2758
3098
|
const fetchImpl = resolveFetch();
|
|
2759
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
3099
|
+
const base = resolveBaseUrl(baseUrl).replace(/\/+$/, "");
|
|
2760
3100
|
const op = extractOp(query);
|
|
2761
3101
|
const url = `${base}?${op}`;
|
|
2762
3102
|
const headers = buildHeaders({ token, hasBody: true, agencyId });
|
|
@@ -2900,7 +3240,7 @@ function createRestTransport(deps) {
|
|
|
2900
3240
|
}
|
|
2901
3241
|
async function request(method, path, body, opts) {
|
|
2902
3242
|
const fetchImpl = resolveFetch();
|
|
2903
|
-
const url = buildUrl(baseUrl, path);
|
|
3243
|
+
const url = buildUrl(resolveBaseUrl(baseUrl), path);
|
|
2904
3244
|
const token = getToken();
|
|
2905
3245
|
const hasBody = body !== void 0;
|
|
2906
3246
|
const headers = buildHeaders({ token, hasBody });
|
|
@@ -2970,6 +3310,9 @@ function tokenStorageKey(agencyId) {
|
|
|
2970
3310
|
function isDemoStorageKey(agencyId) {
|
|
2971
3311
|
return `taphub:${agencyId}:isDemo`;
|
|
2972
3312
|
}
|
|
3313
|
+
function regionStorageKey(agencyId) {
|
|
3314
|
+
return `taphub:${agencyId}:region`;
|
|
3315
|
+
}
|
|
2973
3316
|
var TaphubClient = class {
|
|
2974
3317
|
agencyId;
|
|
2975
3318
|
endpoint;
|
|
@@ -2996,11 +3339,29 @@ var TaphubClient = class {
|
|
|
2996
3339
|
bus;
|
|
2997
3340
|
#token;
|
|
2998
3341
|
#isDemo;
|
|
3342
|
+
/** User's home region (multi-geo, design D4). null → default base URL. */
|
|
3343
|
+
#region;
|
|
2999
3344
|
#tokenKey;
|
|
3000
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;
|
|
3001
3353
|
#rest;
|
|
3002
3354
|
#graphql;
|
|
3003
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;
|
|
3004
3365
|
constructor(config) {
|
|
3005
3366
|
if (!config.agencyId) {
|
|
3006
3367
|
throw new TaphubValidationError("agencyId is required", {
|
|
@@ -3025,39 +3386,62 @@ var TaphubClient = class {
|
|
|
3025
3386
|
const restProbe = createRestProbe(this.network);
|
|
3026
3387
|
this.#tokenKey = tokenStorageKey(this.agencyId);
|
|
3027
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;
|
|
3028
3393
|
this.#token = this.storage.get(this.#tokenKey);
|
|
3029
3394
|
this.#isDemo = this.storage.get(this.#isDemoKey) === "1";
|
|
3395
|
+
this.#region = this.storage.get(this.#regionKey);
|
|
3030
3396
|
this.#rest = createRestTransport({
|
|
3031
|
-
|
|
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(),
|
|
3032
3401
|
getToken: () => this.getToken(),
|
|
3033
3402
|
fetch: config.fetch,
|
|
3034
3403
|
onRequest: restProbe
|
|
3035
3404
|
});
|
|
3036
|
-
const baseApi = this.endpoint.replace(/\/+$/, "");
|
|
3037
3405
|
this.#graphql = createGraphQLTransport({
|
|
3038
|
-
baseUrl: `${
|
|
3406
|
+
baseUrl: () => `${this.#originForRegion()}/grid-api/grid-gql`,
|
|
3039
3407
|
getToken: () => this.getToken(),
|
|
3040
3408
|
agencyId: this.agencyId,
|
|
3041
3409
|
fetch: config.fetch,
|
|
3042
3410
|
onRequest: graphqlProbe
|
|
3043
3411
|
});
|
|
3044
3412
|
this.#graphqlUser = createGraphQLTransport({
|
|
3045
|
-
baseUrl: `${
|
|
3413
|
+
baseUrl: () => `${this.#originForRegion()}/taphub-user-service/th-user-gql`,
|
|
3046
3414
|
getToken: () => this.getToken(),
|
|
3047
3415
|
agencyId: this.agencyId,
|
|
3048
3416
|
fetch: config.fetch,
|
|
3049
3417
|
onRequest: graphqlProbe
|
|
3050
3418
|
});
|
|
3419
|
+
this.#graphqlData = withWrongRegionRetry(this.#graphql, (region) => this.#setRegion(region));
|
|
3420
|
+
this.#graphqlUserData = withWrongRegionRetry(
|
|
3421
|
+
this.#graphqlUser,
|
|
3422
|
+
(region) => this.#setRegion(region)
|
|
3423
|
+
);
|
|
3051
3424
|
this.user = new UserModule({
|
|
3052
|
-
graphql: this.#
|
|
3053
|
-
graphqlUser: this.#
|
|
3425
|
+
graphql: this.#graphqlData,
|
|
3426
|
+
graphqlUser: this.#graphqlUserData
|
|
3054
3427
|
});
|
|
3055
3428
|
this.auth = new AuthModule({
|
|
3056
3429
|
rest: this.#rest,
|
|
3057
3430
|
graphql: this.#graphql,
|
|
3058
3431
|
graphqlUser: this.#graphqlUser,
|
|
3432
|
+
// Region-routed + wrong-region-retrying transport for token refresh only.
|
|
3433
|
+
graphqlUserData: this.#graphqlUserData,
|
|
3059
3434
|
setToken: (t, opts) => this.setToken(t, opts),
|
|
3435
|
+
isDemo: () => this.isDemo(),
|
|
3060
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),
|
|
3061
3445
|
onLoginSuccess: () => this.user.refreshCurrencies().then(() => void 0).catch(() => void 0),
|
|
3062
3446
|
onLogout: () => {
|
|
3063
3447
|
this.user.clearCurrencies();
|
|
@@ -3066,14 +3450,17 @@ var TaphubClient = class {
|
|
|
3066
3450
|
const clockSync = new ClockSync();
|
|
3067
3451
|
this.pair = new PairModule({ graphql: this.#graphql, clockSync });
|
|
3068
3452
|
this.bid = new BidModule({
|
|
3069
|
-
graphql: this.#
|
|
3453
|
+
graphql: this.#graphqlData,
|
|
3070
3454
|
getClockOffset: () => clockSync.getOffset()
|
|
3071
3455
|
});
|
|
3072
3456
|
this.leaderboard = new LeaderboardModule({ graphql: this.#graphql });
|
|
3073
3457
|
this.agencyPairs = new AgencyPairModule({ graphql: this.#graphql });
|
|
3074
3458
|
this.locale = new LocaleModule({ graphql: this.#graphql });
|
|
3075
3459
|
this.realtime = config.mqttEndpoint ? new RealtimeModule({
|
|
3076
|
-
|
|
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(),
|
|
3077
3464
|
agencyId: this.agencyId,
|
|
3078
3465
|
mqttAuth: config.mqttAuth,
|
|
3079
3466
|
onMqttLifecycle: createMqttProbe(this.network)
|
|
@@ -3133,18 +3520,87 @@ var TaphubClient = class {
|
|
|
3133
3520
|
getToken() {
|
|
3134
3521
|
return this.#token;
|
|
3135
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
|
+
}
|
|
3136
3587
|
setToken(token, opts) {
|
|
3137
3588
|
this.#token = token;
|
|
3138
3589
|
if (token === null) {
|
|
3139
3590
|
this.#isDemo = false;
|
|
3591
|
+
this.#region = null;
|
|
3140
3592
|
this.storage.remove(this.#tokenKey);
|
|
3141
3593
|
this.storage.remove(this.#isDemoKey);
|
|
3594
|
+
this.storage.remove(this.#regionKey);
|
|
3142
3595
|
return;
|
|
3143
3596
|
}
|
|
3144
3597
|
const isDemo = opts?.isDemo ?? false;
|
|
3145
3598
|
this.#isDemo = isDemo;
|
|
3146
3599
|
this.storage.set(this.#tokenKey, token);
|
|
3147
3600
|
this.storage.set(this.#isDemoKey, isDemo ? "1" : "0");
|
|
3601
|
+
if (opts?.homeRegion !== void 0) {
|
|
3602
|
+
this.#setRegion(opts.homeRegion);
|
|
3603
|
+
}
|
|
3148
3604
|
}
|
|
3149
3605
|
isDemo() {
|
|
3150
3606
|
return this.#isDemo;
|
|
@@ -3165,6 +3621,81 @@ var TaphubClient = class {
|
|
|
3165
3621
|
}
|
|
3166
3622
|
};
|
|
3167
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
|
+
|
|
3168
3699
|
// src/modules/realtime/types.ts
|
|
3169
3700
|
var CANDLE_EVENT = {
|
|
3170
3701
|
NEW: "new",
|
|
@@ -3371,10 +3902,14 @@ function computeBaseline(candleClose, candleTimeSec, cellSizeValue, cellSizeTime
|
|
|
3371
3902
|
BidModule,
|
|
3372
3903
|
CANDLE_EVENT,
|
|
3373
3904
|
DEFAULT_CHART_HISTORY_LIMIT,
|
|
3905
|
+
DEFAULT_PROBE_TTL_SECONDS,
|
|
3906
|
+
DEFAULT_REGION,
|
|
3907
|
+
KNOWN_REGIONS,
|
|
3374
3908
|
LeaderboardModule,
|
|
3375
3909
|
LocaleModule,
|
|
3376
3910
|
NetworkQualityMonitor,
|
|
3377
3911
|
PairModule,
|
|
3912
|
+
REGION_PROBE_CACHE_KEY,
|
|
3378
3913
|
RealtimeModule,
|
|
3379
3914
|
TaphubAuthError,
|
|
3380
3915
|
TaphubClient,
|
|
@@ -3395,6 +3930,7 @@ function computeBaseline(candleClose, candleTimeSec, cellSizeValue, cellSizeTime
|
|
|
3395
3930
|
computeBaseline,
|
|
3396
3931
|
errorFunction,
|
|
3397
3932
|
isCancelled,
|
|
3933
|
+
isKnownRegion,
|
|
3398
3934
|
isLoss,
|
|
3399
3935
|
isPending,
|
|
3400
3936
|
isTerminal,
|
|
@@ -3403,5 +3939,7 @@ function computeBaseline(candleClose, candleTimeSec, cellSizeValue, cellSizeTime
|
|
|
3403
3939
|
normalPDF,
|
|
3404
3940
|
normaliseLang,
|
|
3405
3941
|
pairIdFromBidResultTopic,
|
|
3942
|
+
probeNearestRegion,
|
|
3943
|
+
resolveRegionBaseUrl,
|
|
3406
3944
|
roundCoefToSignificantDigits
|
|
3407
3945
|
});
|