@taphubhq/sdk-core 0.25.6 → 0.26.1
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 +3 -3
package/dist/index.js
CHANGED
|
@@ -151,6 +151,36 @@ var AgencyPairModule = class {
|
|
|
151
151
|
}
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
+
// src/transport/graphql/wrongRegionRetry.ts
|
|
155
|
+
var WRONG_REGION_CODE = "Auth_WrongRegion";
|
|
156
|
+
function wrongRegionTarget(err) {
|
|
157
|
+
if (!(err instanceof TaphubError) || err.code !== WRONG_REGION_CODE) return null;
|
|
158
|
+
const details = err.details;
|
|
159
|
+
const meta = details?.errors?.[0]?.extensions?.meta;
|
|
160
|
+
const homeRegion = meta?.homeRegion;
|
|
161
|
+
return typeof homeRegion === "string" && homeRegion !== "" ? homeRegion : null;
|
|
162
|
+
}
|
|
163
|
+
function withWrongRegionRetry(transport, setRegion) {
|
|
164
|
+
async function retryOnce(call) {
|
|
165
|
+
try {
|
|
166
|
+
return await call();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const homeRegion = wrongRegionTarget(err);
|
|
169
|
+
if (homeRegion === null) throw err;
|
|
170
|
+
setRegion(homeRegion);
|
|
171
|
+
return await call();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
request(query, variables, opts) {
|
|
176
|
+
return retryOnce(() => transport.request(query, variables, opts));
|
|
177
|
+
},
|
|
178
|
+
publicRequest(query, variables, opts) {
|
|
179
|
+
return retryOnce(() => transport.publicRequest(query, variables, opts));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
// src/modules/auth/normalise.ts
|
|
155
185
|
function normaliseGoogleResponse(body) {
|
|
156
186
|
return {
|
|
@@ -222,6 +252,7 @@ function normaliseUserLoginResponse(body) {
|
|
|
222
252
|
throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
|
|
223
253
|
}
|
|
224
254
|
const wallet = node.user.defaultWallet;
|
|
255
|
+
const homeRegion = node.homeRegion ?? void 0;
|
|
225
256
|
return {
|
|
226
257
|
accessToken: node.accessToken,
|
|
227
258
|
user: {
|
|
@@ -236,9 +267,17 @@ function normaliseUserLoginResponse(body) {
|
|
|
236
267
|
isEnabled: wallet.isEnable
|
|
237
268
|
}
|
|
238
269
|
},
|
|
239
|
-
isDemo: false
|
|
270
|
+
isDemo: false,
|
|
271
|
+
homeRegion
|
|
240
272
|
};
|
|
241
273
|
}
|
|
274
|
+
function normaliseRefreshResponse(body) {
|
|
275
|
+
const node = body.refreshTapHubToken;
|
|
276
|
+
if (!node || typeof node.accessToken !== "string" || node.accessToken === "" || typeof node.refreshToken !== "string" || node.refreshToken === "") {
|
|
277
|
+
throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
|
|
278
|
+
}
|
|
279
|
+
return { taphubToken: node.accessToken, refreshToken: node.refreshToken };
|
|
280
|
+
}
|
|
242
281
|
function validateLoginBody(body) {
|
|
243
282
|
if (typeof body.access_token !== "string" || body.access_token === "") {
|
|
244
283
|
throw new TaphubServerError("Invalid response from server", {
|
|
@@ -267,10 +306,21 @@ var CREATE_DEMO_USER_MUTATION = `
|
|
|
267
306
|
}
|
|
268
307
|
}
|
|
269
308
|
`;
|
|
309
|
+
var REFRESH_TAPHUB_TOKEN_MUTATION = `
|
|
310
|
+
mutation refreshTapHubToken($refreshToken: String!, $clientMeta: JSON) {
|
|
311
|
+
refreshTapHubToken(refreshToken: $refreshToken, clientMeta: $clientMeta) {
|
|
312
|
+
accessToken
|
|
313
|
+
refreshToken
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
`;
|
|
270
317
|
var USER_LOGIN_MUTATION = `
|
|
271
318
|
mutation UserLogin($input: LoginInput!) {
|
|
272
319
|
userLogin(input: $input) {
|
|
273
320
|
accessToken
|
|
321
|
+
# multi-geo (infra-260715): home region sourced from the global region-directory
|
|
322
|
+
# (first join stores the supplied region; the stored region wins thereafter).
|
|
323
|
+
homeRegion
|
|
274
324
|
user {
|
|
275
325
|
id
|
|
276
326
|
agencyUid
|
|
@@ -292,16 +342,32 @@ var AuthModule = class {
|
|
|
292
342
|
#rest;
|
|
293
343
|
#graphql;
|
|
294
344
|
#graphqlUser;
|
|
345
|
+
#graphqlUserData;
|
|
295
346
|
#setToken;
|
|
347
|
+
#isDemo;
|
|
296
348
|
#agencyId;
|
|
349
|
+
#getRegion;
|
|
350
|
+
#setRegion;
|
|
297
351
|
#onLoginSuccess;
|
|
298
352
|
#onLogout;
|
|
353
|
+
/**
|
|
354
|
+
* Single-flight cache for {@link refreshTapHubToken}, keyed by the region the
|
|
355
|
+
* in-flight refresh was started under. Concurrent callers in the SAME region
|
|
356
|
+
* share the one network round-trip; a region change mid-flight (getRegion now
|
|
357
|
+
* differs) means the next caller starts a FRESH refresh targeting the new
|
|
358
|
+
* region rather than reusing a promise bound to the old cluster (task 2.3).
|
|
359
|
+
*/
|
|
360
|
+
#refreshInflight = null;
|
|
299
361
|
constructor(deps) {
|
|
300
362
|
this.#rest = deps.rest;
|
|
301
363
|
this.#graphql = deps.graphql;
|
|
302
364
|
this.#graphqlUser = deps.graphqlUser;
|
|
365
|
+
this.#graphqlUserData = deps.graphqlUserData ?? deps.graphqlUser;
|
|
303
366
|
this.#setToken = deps.setToken;
|
|
367
|
+
this.#isDemo = deps.isDemo ?? (() => false);
|
|
304
368
|
this.#agencyId = deps.agencyId;
|
|
369
|
+
this.#getRegion = deps.getRegion;
|
|
370
|
+
this.#setRegion = deps.setRegion;
|
|
305
371
|
this.#onLoginSuccess = deps.onLoginSuccess;
|
|
306
372
|
this.#onLogout = deps.onLogout;
|
|
307
373
|
}
|
|
@@ -356,14 +422,35 @@ var AuthModule = class {
|
|
|
356
422
|
return result;
|
|
357
423
|
}
|
|
358
424
|
async loginWithSession(sessionToken, opts) {
|
|
359
|
-
const
|
|
425
|
+
const currentRegion = opts?.region ?? this.#getRegion?.() ?? void 0;
|
|
426
|
+
try {
|
|
427
|
+
return await this.#sendLogin(sessionToken, currentRegion, opts);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
const homeRegion = wrongRegionTarget(err);
|
|
430
|
+
if (homeRegion === null || !this.#setRegion) throw err;
|
|
431
|
+
this.#setRegion(homeRegion);
|
|
432
|
+
return await this.#sendLogin(sessionToken, homeRegion, opts);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Single userLogin attempt against the (region-resolved) user-service endpoint.
|
|
437
|
+
* On success it stamps the token + home region and fires onLoginSuccess. Split
|
|
438
|
+
* out so {@link loginWithSession} can re-invoke it once after an
|
|
439
|
+
* Auth_WrongRegion region switch without duplicating the post-login wiring.
|
|
440
|
+
*/
|
|
441
|
+
async #sendLogin(sessionToken, region, opts) {
|
|
442
|
+
const input = { sessionToken, agencyId: this.#agencyId };
|
|
443
|
+
if (region !== void 0 && region !== null) {
|
|
444
|
+
input.region = region;
|
|
445
|
+
}
|
|
446
|
+
const variables = { input };
|
|
360
447
|
const body = await this.#graphqlUser.request(
|
|
361
448
|
USER_LOGIN_MUTATION,
|
|
362
449
|
variables,
|
|
363
450
|
opts
|
|
364
451
|
);
|
|
365
452
|
const result = normaliseUserLoginResponse(body);
|
|
366
|
-
this.#setToken(result.accessToken, { isDemo: false });
|
|
453
|
+
this.#setToken(result.accessToken, { isDemo: false, homeRegion: result.homeRegion });
|
|
367
454
|
if (this.#onLoginSuccess) {
|
|
368
455
|
try {
|
|
369
456
|
await this.#onLoginSuccess();
|
|
@@ -372,6 +459,60 @@ var AuthModule = class {
|
|
|
372
459
|
}
|
|
373
460
|
return result;
|
|
374
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Exchange a refresh token for a rotated (taphubToken, refreshToken) pair via
|
|
464
|
+
* the user-service (region-routed through `graphqlUserData`, so a user pinned
|
|
465
|
+
* to a non-default region refreshes against THEIR cluster — the bug this change
|
|
466
|
+
* fixes). On success the SDK's own token is updated and the rotated pair is
|
|
467
|
+
* returned so the caller can persist it (rotation contract: present the NEW
|
|
468
|
+
* refresh token next time).
|
|
469
|
+
*
|
|
470
|
+
* - **Demo-skips-refresh:** demo users have no refresh path and reject with
|
|
471
|
+
* `Refresh_DemoNotSupported` (no network call). The caller reacts by clearing
|
|
472
|
+
* auth / re-creating the demo user.
|
|
473
|
+
* - **Single-flight:** concurrent callers share one in-flight request, keyed by
|
|
474
|
+
* the current region. A region change mid-flight re-targets the next caller.
|
|
475
|
+
* - **Wrong-region backstop:** `graphqlUserData` retries once on
|
|
476
|
+
* `Auth_WrongRegion`, so a stale region self-corrects.
|
|
477
|
+
*
|
|
478
|
+
* Invalid/expired/reused refresh tokens surface as the transport's typed error
|
|
479
|
+
* (e.g. `ErrRefreshTokenInvalid`); network failures surface as
|
|
480
|
+
* `TaphubNetworkError`. The caller decides how to react (clear vs retry-later).
|
|
481
|
+
*/
|
|
482
|
+
refreshTapHubToken(refreshToken, opts) {
|
|
483
|
+
if (this.#isDemo()) {
|
|
484
|
+
return Promise.reject(
|
|
485
|
+
new TaphubValidationError("demo users have no refresh path", {
|
|
486
|
+
code: "Refresh_DemoNotSupported"
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const region = this.#getRegion?.() ?? null;
|
|
491
|
+
if (this.#refreshInflight && this.#refreshInflight.region === region) {
|
|
492
|
+
return this.#refreshInflight.promise;
|
|
493
|
+
}
|
|
494
|
+
const promise = this.#doRefresh(refreshToken, opts).finally(() => {
|
|
495
|
+
if (this.#refreshInflight?.promise === promise) {
|
|
496
|
+
this.#refreshInflight = null;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
this.#refreshInflight = { region, promise };
|
|
500
|
+
return promise;
|
|
501
|
+
}
|
|
502
|
+
async #doRefresh(refreshToken, opts) {
|
|
503
|
+
const variables = { refreshToken };
|
|
504
|
+
if (opts?.clientMeta !== void 0) {
|
|
505
|
+
variables.clientMeta = opts.clientMeta;
|
|
506
|
+
}
|
|
507
|
+
const body = await this.#graphqlUserData.request(
|
|
508
|
+
REFRESH_TAPHUB_TOKEN_MUTATION,
|
|
509
|
+
variables,
|
|
510
|
+
opts?.signal ? { signal: opts.signal } : void 0
|
|
511
|
+
);
|
|
512
|
+
const result = normaliseRefreshResponse(body);
|
|
513
|
+
this.#setToken(result.taphubToken, { isDemo: false });
|
|
514
|
+
return result;
|
|
515
|
+
}
|
|
375
516
|
async logout() {
|
|
376
517
|
this.#setToken(null);
|
|
377
518
|
if (this.#onLogout) {
|
|
@@ -1036,18 +1177,30 @@ function normaliseCandles(list) {
|
|
|
1036
1177
|
coefMults: c.coefMults
|
|
1037
1178
|
}));
|
|
1038
1179
|
}
|
|
1180
|
+
function isAgencyComposite(id) {
|
|
1181
|
+
return id.includes(":");
|
|
1182
|
+
}
|
|
1183
|
+
function stripAgencyPrefix(id) {
|
|
1184
|
+
return id.slice(id.indexOf(":") + 1);
|
|
1185
|
+
}
|
|
1039
1186
|
function normalisePairInfo(node) {
|
|
1040
1187
|
if (typeof node.id !== "string" || node.id === "" || typeof node.pair !== "string" || node.pair === "") {
|
|
1041
1188
|
throw new TaphubServerError("Invalid response from server", { code: "InvalidResponse" });
|
|
1042
1189
|
}
|
|
1190
|
+
const composite = isAgencyComposite(node.id);
|
|
1043
1191
|
return {
|
|
1044
|
-
id: node.id,
|
|
1192
|
+
id: composite ? stripAgencyPrefix(node.id) : node.id,
|
|
1045
1193
|
pair: node.pair,
|
|
1046
|
-
|
|
1047
|
-
|
|
1194
|
+
// available-pairs-retired-fields: the server no longer sends these; default
|
|
1195
|
+
// rather than emit `undefined` through a non-optional entity property.
|
|
1196
|
+
gameplayId: node.gameplayId ?? "",
|
|
1197
|
+
gameplayName: node.gameplayName ?? "",
|
|
1048
1198
|
source: node.source,
|
|
1049
1199
|
// REVIEW[bid-260602]: maps node.agencyPairId (was: node.gameId)
|
|
1050
|
-
|
|
1200
|
+
// Fall back to the wire `id` only when it IS the composite — otherwise stay
|
|
1201
|
+
// null, as the retired field did, so nobody builds an MQTT topic out of a
|
|
1202
|
+
// catalog id.
|
|
1203
|
+
agencyPairId: node.agencyPairId ?? (composite ? node.id : null)
|
|
1051
1204
|
};
|
|
1052
1205
|
}
|
|
1053
1206
|
|
|
@@ -1073,10 +1226,7 @@ var BUILDER_AVAILABLE_GAME_PAIRS_QUERY = `query BuilderAvailableGamePairs($gamep
|
|
|
1073
1226
|
builderAvailableGamePairs(gameplayId: $gameplayId) {
|
|
1074
1227
|
id
|
|
1075
1228
|
pair
|
|
1076
|
-
gameplayId
|
|
1077
|
-
gameplayName
|
|
1078
1229
|
source
|
|
1079
|
-
agencyPairId
|
|
1080
1230
|
}
|
|
1081
1231
|
}`;
|
|
1082
1232
|
|
|
@@ -1121,8 +1271,11 @@ var PairModule = class {
|
|
|
1121
1271
|
/**
|
|
1122
1272
|
* Returns available game pairs, optionally filtered by gameplay.
|
|
1123
1273
|
*
|
|
1124
|
-
*
|
|
1125
|
-
* `
|
|
1274
|
+
* Each entry's `id` is the bare catalog pair id, safe to pass straight back
|
|
1275
|
+
* as a `pairId` argument. When called with a valid JWT (authenticated
|
|
1276
|
+
* builder), `agencyPairId` additionally carries the agency composite — use
|
|
1277
|
+
* THAT one as the MQTT topic `game/{gameId}/candle`. It is `null` for
|
|
1278
|
+
* anonymous callers.
|
|
1126
1279
|
*
|
|
1127
1280
|
* @example
|
|
1128
1281
|
* const pairs = await client.pair.availableGamePairs({ gameplayId: 'taptrading' });
|
|
@@ -1172,6 +1325,9 @@ function walletBalanceTopic(userId) {
|
|
|
1172
1325
|
function agencyPairStatsTopic(aid, pairId) {
|
|
1173
1326
|
return `public/agency/${aid}/pair/${pairId}/stats`;
|
|
1174
1327
|
}
|
|
1328
|
+
function userMigrationTopic(userId) {
|
|
1329
|
+
return `user/${userId}/migration`;
|
|
1330
|
+
}
|
|
1175
1331
|
function userBidsWildcardTopic(userId) {
|
|
1176
1332
|
return `${TOPIC_PREFIX}/+/user/${userId}/bid_result`;
|
|
1177
1333
|
}
|
|
@@ -1198,12 +1354,14 @@ function userScopedTopicsFor(gameId, userId) {
|
|
|
1198
1354
|
return USER_SCOPED_SUFFIXES.map((s) => topicFor(gameId, s, userId));
|
|
1199
1355
|
}
|
|
1200
1356
|
function createMqttTransport(endpoint, opts = {}) {
|
|
1357
|
+
const resolveEndpoint = () => typeof endpoint === "function" ? endpoint() : endpoint;
|
|
1201
1358
|
let client = null;
|
|
1202
1359
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
1203
1360
|
const candleSubscriptions = /* @__PURE__ */ new Map();
|
|
1204
1361
|
const statsSubscriptions = /* @__PURE__ */ new Map();
|
|
1205
1362
|
const walletSubscriptions = /* @__PURE__ */ new Map();
|
|
1206
1363
|
const userBidsSubscriptions = /* @__PURE__ */ new Map();
|
|
1364
|
+
const migrationSubscriptions = /* @__PURE__ */ new Map();
|
|
1207
1365
|
const { onLifecycle, auth } = opts;
|
|
1208
1366
|
let connectStartedAt = 0;
|
|
1209
1367
|
function fireLifecycle(event) {
|
|
@@ -1216,7 +1374,7 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1216
1374
|
function ensureConnected() {
|
|
1217
1375
|
if (client) return client;
|
|
1218
1376
|
connectStartedAt = Date.now();
|
|
1219
|
-
client = mqtt.connect(
|
|
1377
|
+
client = mqtt.connect(resolveEndpoint(), {
|
|
1220
1378
|
clean: true,
|
|
1221
1379
|
reconnectPeriod: 2e3,
|
|
1222
1380
|
connectTimeout: 1e4,
|
|
@@ -1257,6 +1415,9 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1257
1415
|
for (const entry of userBidsSubscriptions.values()) {
|
|
1258
1416
|
c.subscribe(entry.pattern, { qos: 1 });
|
|
1259
1417
|
}
|
|
1418
|
+
for (const entry of migrationSubscriptions.values()) {
|
|
1419
|
+
c.subscribe(entry.topic, { qos: 1 });
|
|
1420
|
+
}
|
|
1260
1421
|
fireLifecycle({ kind: "connect", rttMs: Date.now() - connectStartedAt });
|
|
1261
1422
|
});
|
|
1262
1423
|
client.on("reconnect", () => {
|
|
@@ -1305,6 +1466,20 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1305
1466
|
walletSub.onMessage(receivedTopic, payload2);
|
|
1306
1467
|
return;
|
|
1307
1468
|
}
|
|
1469
|
+
const migrationSub = [...migrationSubscriptions.values()].find(
|
|
1470
|
+
(s) => s.topic === receivedTopic
|
|
1471
|
+
);
|
|
1472
|
+
if (migrationSub) {
|
|
1473
|
+
let payload2;
|
|
1474
|
+
try {
|
|
1475
|
+
payload2 = JSON.parse(message.toString());
|
|
1476
|
+
} catch {
|
|
1477
|
+
migrationSub.onError(new Error(`Invalid JSON on topic ${receivedTopic}`));
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
migrationSub.onMessage(receivedTopic, payload2);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1308
1483
|
let userBidsPayload;
|
|
1309
1484
|
for (const sub of userBidsSubscriptions.values()) {
|
|
1310
1485
|
if (!topicMatchesWildcard(sub.pattern, receivedTopic)) continue;
|
|
@@ -1418,6 +1593,19 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1418
1593
|
if (client) client.unsubscribe(entry.pattern);
|
|
1419
1594
|
userBidsSubscriptions.delete(userId);
|
|
1420
1595
|
},
|
|
1596
|
+
subscribeMigration(userId, onMessage, onError) {
|
|
1597
|
+
if (migrationSubscriptions.has(userId)) return;
|
|
1598
|
+
const topic = userMigrationTopic(userId);
|
|
1599
|
+
const mqttClient = ensureConnected();
|
|
1600
|
+
migrationSubscriptions.set(userId, { topic, onMessage, onError });
|
|
1601
|
+
mqttClient.subscribe(topic, { qos: 1 });
|
|
1602
|
+
},
|
|
1603
|
+
unsubscribeMigration(userId) {
|
|
1604
|
+
const entry = migrationSubscriptions.get(userId);
|
|
1605
|
+
if (!entry) return;
|
|
1606
|
+
if (client) client.unsubscribe(entry.topic);
|
|
1607
|
+
migrationSubscriptions.delete(userId);
|
|
1608
|
+
},
|
|
1421
1609
|
unsubscribeAll(gameId, userId) {
|
|
1422
1610
|
const matches = entriesForGame(gameId);
|
|
1423
1611
|
if (matches.length === 0) return;
|
|
@@ -1442,6 +1630,12 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1442
1630
|
}
|
|
1443
1631
|
subscriptions.delete(subKey(target.gameId, target.userId));
|
|
1444
1632
|
},
|
|
1633
|
+
reconnect() {
|
|
1634
|
+
if (!client) return;
|
|
1635
|
+
client.end(true);
|
|
1636
|
+
client = null;
|
|
1637
|
+
ensureConnected();
|
|
1638
|
+
},
|
|
1445
1639
|
close() {
|
|
1446
1640
|
if (client) {
|
|
1447
1641
|
for (const sub of subscriptions.values()) {
|
|
@@ -1461,12 +1655,16 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1461
1655
|
for (const entry of userBidsSubscriptions.values()) {
|
|
1462
1656
|
client.unsubscribe(entry.pattern);
|
|
1463
1657
|
}
|
|
1658
|
+
for (const entry of migrationSubscriptions.values()) {
|
|
1659
|
+
client.unsubscribe(entry.topic);
|
|
1660
|
+
}
|
|
1464
1661
|
}
|
|
1465
1662
|
subscriptions.clear();
|
|
1466
1663
|
candleSubscriptions.clear();
|
|
1467
1664
|
statsSubscriptions.clear();
|
|
1468
1665
|
walletSubscriptions.clear();
|
|
1469
1666
|
userBidsSubscriptions.clear();
|
|
1667
|
+
migrationSubscriptions.clear();
|
|
1470
1668
|
if (client) {
|
|
1471
1669
|
client.end(true);
|
|
1472
1670
|
client = null;
|
|
@@ -1476,7 +1674,7 @@ function createMqttTransport(endpoint, opts = {}) {
|
|
|
1476
1674
|
}
|
|
1477
1675
|
|
|
1478
1676
|
// src/modules/realtime/index.ts
|
|
1479
|
-
import
|
|
1677
|
+
import EventEmitter5 from "eventemitter3";
|
|
1480
1678
|
|
|
1481
1679
|
// src/modules/realtime/GameChannel.ts
|
|
1482
1680
|
import EventEmitter from "eventemitter3";
|
|
@@ -1488,9 +1686,9 @@ var GameChannel = class extends EventEmitter {
|
|
|
1488
1686
|
}
|
|
1489
1687
|
};
|
|
1490
1688
|
|
|
1491
|
-
// src/modules/realtime/
|
|
1689
|
+
// src/modules/realtime/MigrationChannel.ts
|
|
1492
1690
|
import EventEmitter2 from "eventemitter3";
|
|
1493
|
-
var
|
|
1691
|
+
var MigrationChannel = class extends EventEmitter2 {
|
|
1494
1692
|
userId;
|
|
1495
1693
|
constructor(userId) {
|
|
1496
1694
|
super();
|
|
@@ -1498,9 +1696,19 @@ var UserBidsChannel = class extends EventEmitter2 {
|
|
|
1498
1696
|
}
|
|
1499
1697
|
};
|
|
1500
1698
|
|
|
1501
|
-
// src/modules/realtime/
|
|
1699
|
+
// src/modules/realtime/UserBidsChannel.ts
|
|
1502
1700
|
import EventEmitter3 from "eventemitter3";
|
|
1503
|
-
var
|
|
1701
|
+
var UserBidsChannel = class extends EventEmitter3 {
|
|
1702
|
+
userId;
|
|
1703
|
+
constructor(userId) {
|
|
1704
|
+
super();
|
|
1705
|
+
this.userId = userId;
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
// src/modules/realtime/WalletChannel.ts
|
|
1710
|
+
import EventEmitter4 from "eventemitter3";
|
|
1711
|
+
var WalletChannel = class extends EventEmitter4 {
|
|
1504
1712
|
userId;
|
|
1505
1713
|
constructor(userId) {
|
|
1506
1714
|
super();
|
|
@@ -1591,6 +1799,19 @@ function mapWireWalletBalance(raw) {
|
|
|
1591
1799
|
reason: p.reason ?? ""
|
|
1592
1800
|
};
|
|
1593
1801
|
}
|
|
1802
|
+
function mapWireMigrationCompleted(raw) {
|
|
1803
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
1804
|
+
const p = raw;
|
|
1805
|
+
if (p.type !== "migration_completed") return null;
|
|
1806
|
+
if (typeof p.toRegion !== "string" || p.toRegion === "") return null;
|
|
1807
|
+
return {
|
|
1808
|
+
type: "migration_completed",
|
|
1809
|
+
migrationId: p.migrationId,
|
|
1810
|
+
fromRegion: p.fromRegion,
|
|
1811
|
+
toRegion: p.toRegion,
|
|
1812
|
+
completedAt: p.completedAt
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1594
1815
|
function mapWireConfig(raw) {
|
|
1595
1816
|
const p = raw;
|
|
1596
1817
|
return {
|
|
@@ -1631,13 +1852,15 @@ function mapWireToEvent(topic, payload) {
|
|
|
1631
1852
|
function normaliseUserId2(userId) {
|
|
1632
1853
|
return userId && userId !== "" ? userId : null;
|
|
1633
1854
|
}
|
|
1634
|
-
var RealtimeModule = class extends
|
|
1855
|
+
var RealtimeModule = class extends EventEmitter5 {
|
|
1635
1856
|
#transport;
|
|
1636
1857
|
#entries = /* @__PURE__ */ new Map();
|
|
1637
1858
|
#walletEntries = /* @__PURE__ */ new Map();
|
|
1638
1859
|
// keyed by userId
|
|
1639
1860
|
#userBidsEntries = /* @__PURE__ */ new Map();
|
|
1640
1861
|
// keyed by userId
|
|
1862
|
+
#migrationEntries = /* @__PURE__ */ new Map();
|
|
1863
|
+
// keyed by userId
|
|
1641
1864
|
#agencyId;
|
|
1642
1865
|
constructor(mqttEndpointOrOptions) {
|
|
1643
1866
|
super();
|
|
@@ -1826,6 +2049,57 @@ var RealtimeModule = class extends EventEmitter4 {
|
|
|
1826
2049
|
entry.channel.removeAllListeners();
|
|
1827
2050
|
this.#transport.unsubscribeUserBids(cleanUserId);
|
|
1828
2051
|
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Subscribe to the user-scoped migration completion stream for `userId`,
|
|
2054
|
+
* topic `user/{userId}/migration` (ux-260730). Returns a `MigrationChannel`
|
|
2055
|
+
* that emits `migrationCompleted` when the user's home-region migration
|
|
2056
|
+
* finishes. Malformed or schema-mismatched payloads are dropped silently.
|
|
2057
|
+
*
|
|
2058
|
+
* Reference-counted by `userId`, mirroring `subscribeWallet`. Intended
|
|
2059
|
+
* lifecycle (design D7): hold the channel only while the client is in the
|
|
2060
|
+
* migrating state; unsubscribe on completion/logout. The publisher retains
|
|
2061
|
+
* the completion message, so subscribing after the fact still delivers it.
|
|
2062
|
+
*/
|
|
2063
|
+
subscribeMigration(userId) {
|
|
2064
|
+
const cleanUserId = normaliseUserId2(userId);
|
|
2065
|
+
if (!cleanUserId) {
|
|
2066
|
+
throw new TaphubError("userId is required to subscribe to migration events", {
|
|
2067
|
+
code: "UserIdRequired"
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
const existing = this.#migrationEntries.get(cleanUserId);
|
|
2071
|
+
if (existing) {
|
|
2072
|
+
existing.refcount += 1;
|
|
2073
|
+
return existing.channel;
|
|
2074
|
+
}
|
|
2075
|
+
const channel = new MigrationChannel(cleanUserId);
|
|
2076
|
+
const onMessage = (_topic, payload) => {
|
|
2077
|
+
const mapped = mapWireMigrationCompleted(payload);
|
|
2078
|
+
if (!mapped) return;
|
|
2079
|
+
channel.emit("migrationCompleted", mapped);
|
|
2080
|
+
};
|
|
2081
|
+
const onError = (err) => {
|
|
2082
|
+
channel.emit("error", err);
|
|
2083
|
+
};
|
|
2084
|
+
this.#migrationEntries.set(cleanUserId, { userId: cleanUserId, channel, refcount: 1 });
|
|
2085
|
+
this.#transport.subscribeMigration(cleanUserId, onMessage, onError);
|
|
2086
|
+
return channel;
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Decrement the migration subscription refcount for `userId`. Tears down the
|
|
2090
|
+
* MQTT topic and removes the channel only at zero. No-op if absent.
|
|
2091
|
+
*/
|
|
2092
|
+
unsubscribeMigration(userId) {
|
|
2093
|
+
const cleanUserId = normaliseUserId2(userId);
|
|
2094
|
+
if (!cleanUserId) return;
|
|
2095
|
+
const entry = this.#migrationEntries.get(cleanUserId);
|
|
2096
|
+
if (!entry) return;
|
|
2097
|
+
entry.refcount -= 1;
|
|
2098
|
+
if (entry.refcount > 0) return;
|
|
2099
|
+
this.#migrationEntries.delete(cleanUserId);
|
|
2100
|
+
entry.channel.removeAllListeners();
|
|
2101
|
+
this.#transport.unsubscribeMigration(cleanUserId);
|
|
2102
|
+
}
|
|
1829
2103
|
/**
|
|
1830
2104
|
* Subscribe to public market candle data for a pair, keyed by pairId
|
|
1831
2105
|
* (the #2 game_pairs.id, e.g. "grid-ETH-USD"). Independent of game/agency —
|
|
@@ -1868,6 +2142,15 @@ var RealtimeModule = class extends EventEmitter4 {
|
|
|
1868
2142
|
if (!this.#agencyId) return;
|
|
1869
2143
|
this.#transport.unsubscribeAgencyPairStats(this.#agencyId, pairId);
|
|
1870
2144
|
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Reconnect the MQTT transport, re-resolving its (possibly region-scoped)
|
|
2147
|
+
* endpoint. All active subscriptions are preserved and re-declared on the new
|
|
2148
|
+
* connection (multi-geo home-region switch). No-op when not currently
|
|
2149
|
+
* connected. Delegates to the transport's `reconnect`.
|
|
2150
|
+
*/
|
|
2151
|
+
reconnect() {
|
|
2152
|
+
this.#transport.reconnect();
|
|
2153
|
+
}
|
|
1871
2154
|
disconnect() {
|
|
1872
2155
|
for (const entry of this.#entries.values()) {
|
|
1873
2156
|
entry.channel.removeAllListeners();
|
|
@@ -1881,6 +2164,10 @@ var RealtimeModule = class extends EventEmitter4 {
|
|
|
1881
2164
|
entry.channel.removeAllListeners();
|
|
1882
2165
|
}
|
|
1883
2166
|
this.#userBidsEntries.clear();
|
|
2167
|
+
for (const entry of this.#migrationEntries.values()) {
|
|
2168
|
+
entry.channel.removeAllListeners();
|
|
2169
|
+
}
|
|
2170
|
+
this.#migrationEntries.clear();
|
|
1884
2171
|
this.#transport.close();
|
|
1885
2172
|
}
|
|
1886
2173
|
};
|
|
@@ -1973,6 +2260,9 @@ var LIST_ENABLED_CURRENCIES_QUERY = `query ListEnabledCurrencies($input: ListEna
|
|
|
1973
2260
|
listEnabledCurrencies(input: $input) { code unit unitSymbol }
|
|
1974
2261
|
}`;
|
|
1975
2262
|
var MY_WALLET_BY_CURRENCY_QUERY = "query MyWalletByCurrency($currency: String!) { myWalletByCurrency(currency: $currency) { id amount currency isEnable } }";
|
|
2263
|
+
var REQUEST_REGION_MIGRATION_MUTATION = `mutation RequestRegionMigration($input: RequestRegionMigrationInput!) {
|
|
2264
|
+
requestRegionMigration(input: $input) { migrationId }
|
|
2265
|
+
}`;
|
|
1976
2266
|
var USER_PNL_QUERY = `query UserPnL($period: String) {
|
|
1977
2267
|
userPnL(period: $period) {
|
|
1978
2268
|
gain total_wagered total_payout total_bids total_wins pnlRank volRank
|
|
@@ -2022,6 +2312,29 @@ var UserModule = class {
|
|
|
2022
2312
|
);
|
|
2023
2313
|
return normaliseMyWalletByCurrencyResponse(body);
|
|
2024
2314
|
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Start migrating the authenticated user's home region (multi-geo Phase 2).
|
|
2317
|
+
* User-service validates the JWT, checks the feature flag, and forwards to the
|
|
2318
|
+
* region directory (cooldown / single-flight / region-set validation happen
|
|
2319
|
+
* server-side). Typed rejections keep their `extensions.code`
|
|
2320
|
+
* (Migration_Unavailable, Migration_CooldownActive, …) on the error's `code`
|
|
2321
|
+
* so hosts can localise them.
|
|
2322
|
+
*/
|
|
2323
|
+
async requestRegionMigration(toRegion, opts) {
|
|
2324
|
+
const body = await this.#graphqlUser.request(
|
|
2325
|
+
REQUEST_REGION_MIGRATION_MUTATION,
|
|
2326
|
+
{ input: { toRegion } },
|
|
2327
|
+
opts
|
|
2328
|
+
);
|
|
2329
|
+
const migrationId = body?.requestRegionMigration?.migrationId;
|
|
2330
|
+
if (typeof migrationId !== "string" || migrationId === "") {
|
|
2331
|
+
throw new TaphubServerError("Invalid response from server", {
|
|
2332
|
+
code: "INVALID_RESPONSE",
|
|
2333
|
+
details: body
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
return { migrationId };
|
|
2337
|
+
}
|
|
2025
2338
|
get currencies() {
|
|
2026
2339
|
return this.#currencies;
|
|
2027
2340
|
}
|
|
@@ -2472,6 +2785,21 @@ function createRestProbe(monitor) {
|
|
|
2472
2785
|
};
|
|
2473
2786
|
}
|
|
2474
2787
|
|
|
2788
|
+
// src/region.ts
|
|
2789
|
+
var KNOWN_REGIONS = ["sg", "eu", "jp"];
|
|
2790
|
+
var DEFAULT_REGION = "sg";
|
|
2791
|
+
function isKnownRegion(region) {
|
|
2792
|
+
return region != null && KNOWN_REGIONS.includes(region);
|
|
2793
|
+
}
|
|
2794
|
+
function resolveRegionBaseUrl(region, domains, fallbackBaseUrl) {
|
|
2795
|
+
if (region == null || region === "") {
|
|
2796
|
+
return fallbackBaseUrl;
|
|
2797
|
+
}
|
|
2798
|
+
const map = domains ?? {};
|
|
2799
|
+
const effectiveRegion = isKnownRegion(region) ? region : DEFAULT_REGION;
|
|
2800
|
+
return map[effectiveRegion] ?? map[DEFAULT_REGION] ?? fallbackBaseUrl;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2475
2803
|
// src/storage/index.ts
|
|
2476
2804
|
var MemoryAdapter = class {
|
|
2477
2805
|
store = /* @__PURE__ */ new Map();
|
|
@@ -2528,6 +2856,11 @@ var TaphubStorage = {
|
|
|
2528
2856
|
}
|
|
2529
2857
|
};
|
|
2530
2858
|
|
|
2859
|
+
// src/transport/shared/baseUrl.ts
|
|
2860
|
+
function resolveBaseUrl(input) {
|
|
2861
|
+
return typeof input === "function" ? input() : input;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2531
2864
|
// src/transport/shared/errors.ts
|
|
2532
2865
|
function mapNetworkErrorToTaphubError(err) {
|
|
2533
2866
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
@@ -2683,7 +3016,7 @@ function createGraphQLTransport(deps) {
|
|
|
2683
3016
|
}
|
|
2684
3017
|
async function execute(token, query, variables, opts) {
|
|
2685
3018
|
const fetchImpl = resolveFetch();
|
|
2686
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
3019
|
+
const base = resolveBaseUrl(baseUrl).replace(/\/+$/, "");
|
|
2687
3020
|
const op = extractOp(query);
|
|
2688
3021
|
const url = `${base}?${op}`;
|
|
2689
3022
|
const headers = buildHeaders({ token, hasBody: true, agencyId });
|
|
@@ -2827,7 +3160,7 @@ function createRestTransport(deps) {
|
|
|
2827
3160
|
}
|
|
2828
3161
|
async function request(method, path, body, opts) {
|
|
2829
3162
|
const fetchImpl = resolveFetch();
|
|
2830
|
-
const url = buildUrl(baseUrl, path);
|
|
3163
|
+
const url = buildUrl(resolveBaseUrl(baseUrl), path);
|
|
2831
3164
|
const token = getToken();
|
|
2832
3165
|
const hasBody = body !== void 0;
|
|
2833
3166
|
const headers = buildHeaders({ token, hasBody });
|
|
@@ -2897,6 +3230,9 @@ function tokenStorageKey(agencyId) {
|
|
|
2897
3230
|
function isDemoStorageKey(agencyId) {
|
|
2898
3231
|
return `taphub:${agencyId}:isDemo`;
|
|
2899
3232
|
}
|
|
3233
|
+
function regionStorageKey(agencyId) {
|
|
3234
|
+
return `taphub:${agencyId}:region`;
|
|
3235
|
+
}
|
|
2900
3236
|
var TaphubClient = class {
|
|
2901
3237
|
agencyId;
|
|
2902
3238
|
endpoint;
|
|
@@ -2923,11 +3259,29 @@ var TaphubClient = class {
|
|
|
2923
3259
|
bus;
|
|
2924
3260
|
#token;
|
|
2925
3261
|
#isDemo;
|
|
3262
|
+
/** User's home region (multi-geo, design D4). null → default base URL. */
|
|
3263
|
+
#region;
|
|
2926
3264
|
#tokenKey;
|
|
2927
3265
|
#isDemoKey;
|
|
3266
|
+
#regionKey;
|
|
3267
|
+
/** Region → base URL map (config, never hardcoded). undefined → always use `endpoint`. */
|
|
3268
|
+
#regionDomains;
|
|
3269
|
+
/** Fixed MQTT broker endpoint (fallback for region resolution). undefined → no realtime. */
|
|
3270
|
+
#mqttEndpoint;
|
|
3271
|
+
/** Region → MQTT endpoint map (config, never hardcoded). undefined → always use `#mqttEndpoint`. */
|
|
3272
|
+
#regionMqttEndpoints;
|
|
2928
3273
|
#rest;
|
|
2929
3274
|
#graphql;
|
|
2930
3275
|
#graphqlUser;
|
|
3276
|
+
/**
|
|
3277
|
+
* Auth_WrongRegion-retrying variants of the two GraphQL transports, used by the
|
|
3278
|
+
* user-scoped DATA modules (user / bid) and token refresh. A data call that
|
|
3279
|
+
* lands on the wrong cluster switches the region and retries once (multi-geo
|
|
3280
|
+
* backstop). Login keeps the raw transports — it runs its own wrong-region
|
|
3281
|
+
* retry, so wrapping there would double-retry.
|
|
3282
|
+
*/
|
|
3283
|
+
#graphqlData;
|
|
3284
|
+
#graphqlUserData;
|
|
2931
3285
|
constructor(config) {
|
|
2932
3286
|
if (!config.agencyId) {
|
|
2933
3287
|
throw new TaphubValidationError("agencyId is required", {
|
|
@@ -2952,39 +3306,62 @@ var TaphubClient = class {
|
|
|
2952
3306
|
const restProbe = createRestProbe(this.network);
|
|
2953
3307
|
this.#tokenKey = tokenStorageKey(this.agencyId);
|
|
2954
3308
|
this.#isDemoKey = isDemoStorageKey(this.agencyId);
|
|
3309
|
+
this.#regionKey = regionStorageKey(this.agencyId);
|
|
3310
|
+
this.#regionDomains = config.regionDomains;
|
|
3311
|
+
this.#mqttEndpoint = config.mqttEndpoint;
|
|
3312
|
+
this.#regionMqttEndpoints = config.regionMqttEndpoints;
|
|
2955
3313
|
this.#token = this.storage.get(this.#tokenKey);
|
|
2956
3314
|
this.#isDemo = this.storage.get(this.#isDemoKey) === "1";
|
|
3315
|
+
this.#region = this.storage.get(this.#regionKey);
|
|
2957
3316
|
this.#rest = createRestTransport({
|
|
2958
|
-
|
|
3317
|
+
// Thunk: re-resolved per request so a post-login region change re-targets the
|
|
3318
|
+
// origin without rebuilding the transport. Resolves to `endpoint` when no
|
|
3319
|
+
// region domains are configured (pre-multi-geo behaviour unchanged).
|
|
3320
|
+
baseUrl: () => this.#originForRegion(),
|
|
2959
3321
|
getToken: () => this.getToken(),
|
|
2960
3322
|
fetch: config.fetch,
|
|
2961
3323
|
onRequest: restProbe
|
|
2962
3324
|
});
|
|
2963
|
-
const baseApi = this.endpoint.replace(/\/+$/, "");
|
|
2964
3325
|
this.#graphql = createGraphQLTransport({
|
|
2965
|
-
baseUrl: `${
|
|
3326
|
+
baseUrl: () => `${this.#originForRegion()}/grid-api/grid-gql`,
|
|
2966
3327
|
getToken: () => this.getToken(),
|
|
2967
3328
|
agencyId: this.agencyId,
|
|
2968
3329
|
fetch: config.fetch,
|
|
2969
3330
|
onRequest: graphqlProbe
|
|
2970
3331
|
});
|
|
2971
3332
|
this.#graphqlUser = createGraphQLTransport({
|
|
2972
|
-
baseUrl: `${
|
|
3333
|
+
baseUrl: () => `${this.#originForRegion()}/taphub-user-service/th-user-gql`,
|
|
2973
3334
|
getToken: () => this.getToken(),
|
|
2974
3335
|
agencyId: this.agencyId,
|
|
2975
3336
|
fetch: config.fetch,
|
|
2976
3337
|
onRequest: graphqlProbe
|
|
2977
3338
|
});
|
|
3339
|
+
this.#graphqlData = withWrongRegionRetry(this.#graphql, (region) => this.#setRegion(region));
|
|
3340
|
+
this.#graphqlUserData = withWrongRegionRetry(
|
|
3341
|
+
this.#graphqlUser,
|
|
3342
|
+
(region) => this.#setRegion(region)
|
|
3343
|
+
);
|
|
2978
3344
|
this.user = new UserModule({
|
|
2979
|
-
graphql: this.#
|
|
2980
|
-
graphqlUser: this.#
|
|
3345
|
+
graphql: this.#graphqlData,
|
|
3346
|
+
graphqlUser: this.#graphqlUserData
|
|
2981
3347
|
});
|
|
2982
3348
|
this.auth = new AuthModule({
|
|
2983
3349
|
rest: this.#rest,
|
|
2984
3350
|
graphql: this.#graphql,
|
|
2985
3351
|
graphqlUser: this.#graphqlUser,
|
|
3352
|
+
// Region-routed + wrong-region-retrying transport for token refresh only.
|
|
3353
|
+
graphqlUserData: this.#graphqlUserData,
|
|
2986
3354
|
setToken: (t, opts) => this.setToken(t, opts),
|
|
3355
|
+
isDemo: () => this.isDemo(),
|
|
2987
3356
|
agencyId: this.agencyId,
|
|
3357
|
+
// Multi-geo: login sends the client's current (persisted) region; the server
|
|
3358
|
+
// pins it on first join and returns the stored home region thereafter.
|
|
3359
|
+
getRegion: () => this.getRegion(),
|
|
3360
|
+
// Multi-geo: a login that lands on the wrong cluster is rejected with
|
|
3361
|
+
// Auth_WrongRegion + the user's homeRegion; the auth module silently
|
|
3362
|
+
// switches the client's region here and retries once. Kept separate from
|
|
3363
|
+
// setToken so a region redirect never disturbs the auth/token state.
|
|
3364
|
+
setRegion: (region) => this.#setRegion(region),
|
|
2988
3365
|
onLoginSuccess: () => this.user.refreshCurrencies().then(() => void 0).catch(() => void 0),
|
|
2989
3366
|
onLogout: () => {
|
|
2990
3367
|
this.user.clearCurrencies();
|
|
@@ -2993,14 +3370,17 @@ var TaphubClient = class {
|
|
|
2993
3370
|
const clockSync = new ClockSync();
|
|
2994
3371
|
this.pair = new PairModule({ graphql: this.#graphql, clockSync });
|
|
2995
3372
|
this.bid = new BidModule({
|
|
2996
|
-
graphql: this.#
|
|
3373
|
+
graphql: this.#graphqlData,
|
|
2997
3374
|
getClockOffset: () => clockSync.getOffset()
|
|
2998
3375
|
});
|
|
2999
3376
|
this.leaderboard = new LeaderboardModule({ graphql: this.#graphql });
|
|
3000
3377
|
this.agencyPairs = new AgencyPairModule({ graphql: this.#graphql });
|
|
3001
3378
|
this.locale = new LocaleModule({ graphql: this.#graphql });
|
|
3002
3379
|
this.realtime = config.mqttEndpoint ? new RealtimeModule({
|
|
3003
|
-
|
|
3380
|
+
// Thunk: re-resolved at connect time so a post-login region change
|
|
3381
|
+
// re-targets the broker without rebuilding the module (multi-geo D4).
|
|
3382
|
+
// Resolves to `mqttEndpoint` when no region MQTT map is configured.
|
|
3383
|
+
mqttEndpoint: () => this.#resolveMqttEndpoint(),
|
|
3004
3384
|
agencyId: this.agencyId,
|
|
3005
3385
|
mqttAuth: config.mqttAuth,
|
|
3006
3386
|
onMqttLifecycle: createMqttProbe(this.network)
|
|
@@ -3060,18 +3440,87 @@ var TaphubClient = class {
|
|
|
3060
3440
|
getToken() {
|
|
3061
3441
|
return this.#token;
|
|
3062
3442
|
}
|
|
3443
|
+
/**
|
|
3444
|
+
* Current API origin, resolved from the home region (multi-geo, design D4).
|
|
3445
|
+
* Trailing slashes trimmed so the service-path suffix composes cleanly. Falls
|
|
3446
|
+
* back to `endpoint` when no region domains are configured or the region is
|
|
3447
|
+
* unknown/absent.
|
|
3448
|
+
*/
|
|
3449
|
+
#originForRegion() {
|
|
3450
|
+
return resolveRegionBaseUrl(this.#region, this.#regionDomains, this.endpoint).replace(
|
|
3451
|
+
/\/+$/,
|
|
3452
|
+
""
|
|
3453
|
+
);
|
|
3454
|
+
}
|
|
3455
|
+
/**
|
|
3456
|
+
* Current MQTT broker endpoint, resolved from the home region (multi-geo,
|
|
3457
|
+
* design D4). Falls back to the configured `mqttEndpoint` when no region MQTT
|
|
3458
|
+
* map is configured or the region is unknown/absent. Only meaningful when
|
|
3459
|
+
* `mqttEndpoint` was configured (otherwise `realtime` is undefined).
|
|
3460
|
+
*/
|
|
3461
|
+
#resolveMqttEndpoint() {
|
|
3462
|
+
return resolveRegionBaseUrl(this.#region, this.#regionMqttEndpoints, this.#mqttEndpoint ?? "");
|
|
3463
|
+
}
|
|
3464
|
+
/** The user's home region, once resolved from a login response. */
|
|
3465
|
+
getRegion() {
|
|
3466
|
+
return this.#region;
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Set the home region and persist it (survives reloads like the token/isDemo). Shared
|
|
3470
|
+
* by the login path (setToken with a homeRegion) and the wrong-region redirect handler
|
|
3471
|
+
* (task 1.13). A pure region update — it never touches the token or isDemo, so a
|
|
3472
|
+
* mid-session redirect cannot disturb the auth state.
|
|
3473
|
+
*/
|
|
3474
|
+
#setRegion(region) {
|
|
3475
|
+
if (this.#region === region) return;
|
|
3476
|
+
const prevMqttEndpoint = this.realtime ? this.#resolveMqttEndpoint() : null;
|
|
3477
|
+
this.#region = region;
|
|
3478
|
+
this.storage.set(this.#regionKey, region);
|
|
3479
|
+
if (this.realtime && prevMqttEndpoint !== null) {
|
|
3480
|
+
const nextMqttEndpoint = this.#resolveMqttEndpoint();
|
|
3481
|
+
if (nextMqttEndpoint !== prevMqttEndpoint) {
|
|
3482
|
+
this.realtime.reconnect();
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Complete a home-region migration by cutting the client over to `toRegion`
|
|
3488
|
+
* (ux-260730-migration-complete-notify). Intended to be called after a
|
|
3489
|
+
* `migrationCompleted` event (realtime `subscribeMigration`) or an equivalent
|
|
3490
|
+
* poll result confirmed the migration finished — by then the server-side home
|
|
3491
|
+
* region already points at `toRegion`.
|
|
3492
|
+
*
|
|
3493
|
+
* Purpose-named public wrapper over the private `#setRegion` primitive (the
|
|
3494
|
+
* generic region setter stays private so third-party builders cannot
|
|
3495
|
+
* arbitrarily re-target regions). Persists the region and re-targets the
|
|
3496
|
+
* GQL/REST transports; MQTT reconnects only when the resolved broker endpoint
|
|
3497
|
+
* actually changes. Region validation is lenient, matching `region.ts`: an
|
|
3498
|
+
* unknown region is stored as-is and resolves to the default region's
|
|
3499
|
+
* endpoints (enum-fallback, never throws). Idempotent for the already-current
|
|
3500
|
+
* region (no reconnect), and a safe endpoint-level no-op when no region
|
|
3501
|
+
* domain/MQTT maps are configured.
|
|
3502
|
+
*/
|
|
3503
|
+
completeRegionMigration(toRegion) {
|
|
3504
|
+
if (toRegion == null || toRegion === "") return;
|
|
3505
|
+
this.#setRegion(toRegion);
|
|
3506
|
+
}
|
|
3063
3507
|
setToken(token, opts) {
|
|
3064
3508
|
this.#token = token;
|
|
3065
3509
|
if (token === null) {
|
|
3066
3510
|
this.#isDemo = false;
|
|
3511
|
+
this.#region = null;
|
|
3067
3512
|
this.storage.remove(this.#tokenKey);
|
|
3068
3513
|
this.storage.remove(this.#isDemoKey);
|
|
3514
|
+
this.storage.remove(this.#regionKey);
|
|
3069
3515
|
return;
|
|
3070
3516
|
}
|
|
3071
3517
|
const isDemo = opts?.isDemo ?? false;
|
|
3072
3518
|
this.#isDemo = isDemo;
|
|
3073
3519
|
this.storage.set(this.#tokenKey, token);
|
|
3074
3520
|
this.storage.set(this.#isDemoKey, isDemo ? "1" : "0");
|
|
3521
|
+
if (opts?.homeRegion !== void 0) {
|
|
3522
|
+
this.#setRegion(opts.homeRegion);
|
|
3523
|
+
}
|
|
3075
3524
|
}
|
|
3076
3525
|
isDemo() {
|
|
3077
3526
|
return this.#isDemo;
|
|
@@ -3092,6 +3541,81 @@ var TaphubClient = class {
|
|
|
3092
3541
|
}
|
|
3093
3542
|
};
|
|
3094
3543
|
|
|
3544
|
+
// src/regionProbe.ts
|
|
3545
|
+
var REGION_PROBE_CACHE_KEY = "taphub:region-probe";
|
|
3546
|
+
var DEFAULT_PROBE_TTL_SECONDS = 3600;
|
|
3547
|
+
var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
|
|
3548
|
+
var DEFAULT_PROBE_PATH = "/grid-api/grid-gql";
|
|
3549
|
+
var PROBE_QUERY = "{serverTime}";
|
|
3550
|
+
function readFreshCache(storage, key, now, ttlSeconds) {
|
|
3551
|
+
try {
|
|
3552
|
+
const raw = storage.get(key);
|
|
3553
|
+
if (!raw) return null;
|
|
3554
|
+
const parsed = JSON.parse(raw);
|
|
3555
|
+
if (!parsed || typeof parsed.region !== "string" || typeof parsed.ts !== "number") {
|
|
3556
|
+
return null;
|
|
3557
|
+
}
|
|
3558
|
+
if (now() - parsed.ts >= ttlSeconds * 1e3) return null;
|
|
3559
|
+
return parsed.region;
|
|
3560
|
+
} catch {
|
|
3561
|
+
return null;
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
function writeCache(storage, key, region, now) {
|
|
3565
|
+
try {
|
|
3566
|
+
storage.set(key, JSON.stringify({ region, ts: now() }));
|
|
3567
|
+
} catch {
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
async function probeNearestRegion(domains, opts = {}) {
|
|
3571
|
+
const {
|
|
3572
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
3573
|
+
storage = autoDetectStorage(),
|
|
3574
|
+
ttlSeconds = DEFAULT_PROBE_TTL_SECONDS,
|
|
3575
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
3576
|
+
probePath = DEFAULT_PROBE_PATH,
|
|
3577
|
+
now = Date.now,
|
|
3578
|
+
cacheKey = REGION_PROBE_CACHE_KEY
|
|
3579
|
+
} = opts;
|
|
3580
|
+
const entries = Object.entries(domains ?? {}).filter(
|
|
3581
|
+
(entry) => typeof entry[1] === "string" && entry[1] !== ""
|
|
3582
|
+
);
|
|
3583
|
+
if (entries.length < 2) return null;
|
|
3584
|
+
const cached = readFreshCache(storage, cacheKey, now, ttlSeconds);
|
|
3585
|
+
if (cached !== null && entries.some(([region]) => region === cached)) {
|
|
3586
|
+
return cached;
|
|
3587
|
+
}
|
|
3588
|
+
if (typeof fetchImpl !== "function") return null;
|
|
3589
|
+
const controller = new AbortController();
|
|
3590
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3591
|
+
try {
|
|
3592
|
+
const winner = await Promise.any(
|
|
3593
|
+
entries.map(async ([region, baseUrl]) => {
|
|
3594
|
+
const url = baseUrl.replace(/\/+$/, "") + probePath;
|
|
3595
|
+
const response = await fetchImpl(url, {
|
|
3596
|
+
method: "POST",
|
|
3597
|
+
headers: { "content-type": "application/json" },
|
|
3598
|
+
body: JSON.stringify({ query: PROBE_QUERY }),
|
|
3599
|
+
signal: controller.signal
|
|
3600
|
+
});
|
|
3601
|
+
const body = await response.json();
|
|
3602
|
+
const serverTime = body?.data?.serverTime;
|
|
3603
|
+
if (typeof serverTime !== "number") {
|
|
3604
|
+
throw new Error(`region probe: "${region}" returned no serverTime`);
|
|
3605
|
+
}
|
|
3606
|
+
return region;
|
|
3607
|
+
})
|
|
3608
|
+
);
|
|
3609
|
+
writeCache(storage, cacheKey, winner, now);
|
|
3610
|
+
return winner;
|
|
3611
|
+
} catch {
|
|
3612
|
+
return null;
|
|
3613
|
+
} finally {
|
|
3614
|
+
clearTimeout(timer);
|
|
3615
|
+
controller.abort();
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3095
3619
|
// src/modules/realtime/types.ts
|
|
3096
3620
|
var CANDLE_EVENT = {
|
|
3097
3621
|
NEW: "new",
|
|
@@ -3297,10 +3821,14 @@ export {
|
|
|
3297
3821
|
BidModule,
|
|
3298
3822
|
CANDLE_EVENT,
|
|
3299
3823
|
DEFAULT_CHART_HISTORY_LIMIT,
|
|
3824
|
+
DEFAULT_PROBE_TTL_SECONDS,
|
|
3825
|
+
DEFAULT_REGION,
|
|
3826
|
+
KNOWN_REGIONS,
|
|
3300
3827
|
LeaderboardModule,
|
|
3301
3828
|
LocaleModule,
|
|
3302
3829
|
NetworkQualityMonitor,
|
|
3303
3830
|
PairModule,
|
|
3831
|
+
REGION_PROBE_CACHE_KEY,
|
|
3304
3832
|
RealtimeModule,
|
|
3305
3833
|
TaphubAuthError,
|
|
3306
3834
|
TaphubClient,
|
|
@@ -3321,6 +3849,7 @@ export {
|
|
|
3321
3849
|
computeBaseline,
|
|
3322
3850
|
errorFunction,
|
|
3323
3851
|
isCancelled,
|
|
3852
|
+
isKnownRegion,
|
|
3324
3853
|
isLoss,
|
|
3325
3854
|
isPending,
|
|
3326
3855
|
isTerminal,
|
|
@@ -3329,5 +3858,7 @@ export {
|
|
|
3329
3858
|
normalPDF,
|
|
3330
3859
|
normaliseLang,
|
|
3331
3860
|
pairIdFromBidResultTopic,
|
|
3861
|
+
probeNearestRegion,
|
|
3862
|
+
resolveRegionBaseUrl,
|
|
3332
3863
|
roundCoefToSignificantDigits
|
|
3333
3864
|
};
|