@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/README.md +351 -9
- package/dist/index.cjs +686 -31
- package/dist/index.d.mts +428 -12
- package/dist/index.d.ts +428 -12
- package/dist/index.js +674 -30
- 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,
|
|
@@ -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
|
|
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
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1194
|
-
* `
|
|
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(
|
|
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
|
|
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/
|
|
1769
|
+
// src/modules/realtime/MigrationChannel.ts
|
|
1561
1770
|
var import_eventemitter32 = __toESM(require("eventemitter3"));
|
|
1562
|
-
var
|
|
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/
|
|
1779
|
+
// src/modules/realtime/UserBidsChannel.ts
|
|
1571
1780
|
var import_eventemitter33 = __toESM(require("eventemitter3"));
|
|
1572
|
-
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 {
|
|
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
|
|
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
|
-
|
|
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: `${
|
|
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: `${
|
|
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.#
|
|
3049
|
-
graphqlUser: this.#
|
|
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.#
|
|
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
|
-
|
|
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
|
});
|