@topgunbuild/client 0.11.0 → 0.13.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/LICENSE +201 -97
- package/dist/index.d.mts +141 -32
- package/dist/index.d.ts +141 -32
- package/dist/index.js +365 -111
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +363 -111
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -3
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ __export(index_exports, {
|
|
|
36
36
|
ClusterClient: () => ClusterClient,
|
|
37
37
|
ConflictResolverClient: () => ConflictResolverClient,
|
|
38
38
|
ConnectionPool: () => ConnectionPool,
|
|
39
|
+
ConnectionReadyState: () => ConnectionReadyState,
|
|
39
40
|
DEFAULT_BACKPRESSURE_CONFIG: () => DEFAULT_BACKPRESSURE_CONFIG,
|
|
40
41
|
DEFAULT_CLUSTER_CONFIG: () => DEFAULT_CLUSTER_CONFIG,
|
|
41
42
|
EncryptedStorageAdapter: () => EncryptedStorageAdapter,
|
|
@@ -57,6 +58,7 @@ __export(index_exports, {
|
|
|
57
58
|
TopGunClient: () => TopGunClient,
|
|
58
59
|
TopicHandle: () => TopicHandle,
|
|
59
60
|
VALID_TRANSITIONS: () => VALID_TRANSITIONS,
|
|
61
|
+
WebSocketConnection: () => WebSocketConnection,
|
|
60
62
|
isValidTransition: () => isValidTransition,
|
|
61
63
|
logger: () => logger
|
|
62
64
|
});
|
|
@@ -720,9 +722,7 @@ var WebSocketManager = class {
|
|
|
720
722
|
timeoutMs: this.config.heartbeatConfig.timeoutMs
|
|
721
723
|
}, "Heartbeat timeout - triggering reconnection");
|
|
722
724
|
this.stopHeartbeat();
|
|
723
|
-
this.connectionProvider.
|
|
724
|
-
logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
|
|
725
|
-
});
|
|
725
|
+
this.connectionProvider.forceReconnect();
|
|
726
726
|
}
|
|
727
727
|
}
|
|
728
728
|
/**
|
|
@@ -1015,14 +1015,17 @@ var QueryManager = class {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
/**
|
|
1017
1017
|
* Send query subscription message to server.
|
|
1018
|
+
* Includes field projection when specified in the query filter.
|
|
1018
1019
|
*/
|
|
1019
1020
|
sendQuerySubscription(query) {
|
|
1021
|
+
const filter = query.getFilter();
|
|
1020
1022
|
this.config.sendMessage({
|
|
1021
1023
|
type: "QUERY_SUB",
|
|
1022
1024
|
payload: {
|
|
1023
1025
|
queryId: query.id,
|
|
1024
1026
|
mapName: query.getMapName(),
|
|
1025
|
-
query:
|
|
1027
|
+
query: filter,
|
|
1028
|
+
fields: filter.fields
|
|
1026
1029
|
}
|
|
1027
1030
|
});
|
|
1028
1031
|
}
|
|
@@ -1192,6 +1195,16 @@ var QueryManager = class {
|
|
|
1192
1195
|
logger.debug({ queryCount: this.queries.size, hybridCount: this.hybridQueries.size }, "QueryManager: resubscribing all queries");
|
|
1193
1196
|
for (const query of this.queries.values()) {
|
|
1194
1197
|
this.sendQuerySubscription(query);
|
|
1198
|
+
const filter = query.getFilter();
|
|
1199
|
+
if (filter.fields && filter.fields.length > 0 && query.merkleRootHash !== 0) {
|
|
1200
|
+
this.config.sendMessage({
|
|
1201
|
+
type: "QUERY_SYNC_INIT",
|
|
1202
|
+
payload: {
|
|
1203
|
+
queryId: query.id,
|
|
1204
|
+
rootHash: query.merkleRootHash
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1195
1208
|
}
|
|
1196
1209
|
for (const query of this.hybridQueries.values()) {
|
|
1197
1210
|
if (query.hasFTSPredicate()) {
|
|
@@ -1409,7 +1422,7 @@ var LockManager = class {
|
|
|
1409
1422
|
/**
|
|
1410
1423
|
* Handle lock granted message from server.
|
|
1411
1424
|
*/
|
|
1412
|
-
handleLockGranted(requestId, fencingToken) {
|
|
1425
|
+
handleLockGranted(requestId, _name, fencingToken) {
|
|
1413
1426
|
const req = this.pendingLockRequests.get(requestId);
|
|
1414
1427
|
if (req) {
|
|
1415
1428
|
clearTimeout(req.timer);
|
|
@@ -1420,7 +1433,7 @@ var LockManager = class {
|
|
|
1420
1433
|
/**
|
|
1421
1434
|
* Handle lock released message from server.
|
|
1422
1435
|
*/
|
|
1423
|
-
handleLockReleased(requestId, success) {
|
|
1436
|
+
handleLockReleased(requestId, _name, success) {
|
|
1424
1437
|
const req = this.pendingLockRequests.get(requestId);
|
|
1425
1438
|
if (req) {
|
|
1426
1439
|
clearTimeout(req.timer);
|
|
@@ -1837,6 +1850,8 @@ var import_core3 = require("@topgunbuild/core");
|
|
|
1837
1850
|
var MerkleSyncHandler = class {
|
|
1838
1851
|
constructor(config) {
|
|
1839
1852
|
this.lastSyncTimestamp = 0;
|
|
1853
|
+
/** Accumulated sync stats per map, flushed after a quiet period */
|
|
1854
|
+
this.syncStats = /* @__PURE__ */ new Map();
|
|
1840
1855
|
this.config = config;
|
|
1841
1856
|
}
|
|
1842
1857
|
/**
|
|
@@ -1858,7 +1873,8 @@ var MerkleSyncHandler = class {
|
|
|
1858
1873
|
* Compares root hashes and requests buckets if mismatch detected.
|
|
1859
1874
|
*/
|
|
1860
1875
|
async handleSyncRespRoot(payload) {
|
|
1861
|
-
const { mapName,
|
|
1876
|
+
const { mapName, timestamp } = payload;
|
|
1877
|
+
const rootHash = Number(payload.rootHash);
|
|
1862
1878
|
const map = this.config.getMap(mapName);
|
|
1863
1879
|
if (map instanceof import_core3.LWWMap) {
|
|
1864
1880
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
@@ -1886,9 +1902,11 @@ var MerkleSyncHandler = class {
|
|
|
1886
1902
|
if (map instanceof import_core3.LWWMap) {
|
|
1887
1903
|
const tree = map.getMerkleTree();
|
|
1888
1904
|
const localBuckets = tree.getBuckets(path);
|
|
1905
|
+
let mismatchCount = 0;
|
|
1889
1906
|
for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
|
|
1890
1907
|
const localHash = localBuckets[bucketKey] || 0;
|
|
1891
1908
|
if (localHash !== remoteHash) {
|
|
1909
|
+
mismatchCount++;
|
|
1892
1910
|
const newPath = path + bucketKey;
|
|
1893
1911
|
this.config.sendMessage({
|
|
1894
1912
|
type: "MERKLE_REQ_BUCKET",
|
|
@@ -1915,7 +1933,17 @@ var MerkleSyncHandler = class {
|
|
|
1915
1933
|
}
|
|
1916
1934
|
}
|
|
1917
1935
|
if (updateCount > 0) {
|
|
1918
|
-
|
|
1936
|
+
const existing = this.syncStats.get(mapName);
|
|
1937
|
+
if (existing) {
|
|
1938
|
+
existing.count += updateCount;
|
|
1939
|
+
clearTimeout(existing.timer);
|
|
1940
|
+
}
|
|
1941
|
+
const stats = existing ?? { count: updateCount, timer: void 0 };
|
|
1942
|
+
if (!existing) this.syncStats.set(mapName, stats);
|
|
1943
|
+
stats.timer = setTimeout(() => {
|
|
1944
|
+
logger.info({ mapName, count: stats.count }, "Synced records from server");
|
|
1945
|
+
this.syncStats.delete(mapName);
|
|
1946
|
+
}, 100);
|
|
1919
1947
|
}
|
|
1920
1948
|
}
|
|
1921
1949
|
}
|
|
@@ -2198,6 +2226,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2198
2226
|
},
|
|
2199
2227
|
// SYNC handlers
|
|
2200
2228
|
"OP_ACK": (msg) => delegates.handleOpAck(msg),
|
|
2229
|
+
"OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
|
|
2230
|
+
"ERROR": (msg) => delegates.handleError(msg),
|
|
2201
2231
|
"SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
|
|
2202
2232
|
"SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
|
|
2203
2233
|
"SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
|
|
@@ -2220,12 +2250,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2220
2250
|
},
|
|
2221
2251
|
// LOCK handlers
|
|
2222
2252
|
"LOCK_GRANTED": (msg) => {
|
|
2223
|
-
const { requestId, fencingToken } = msg.payload;
|
|
2224
|
-
managers.lockManager.handleLockGranted(requestId, fencingToken);
|
|
2253
|
+
const { requestId, name, fencingToken } = msg.payload;
|
|
2254
|
+
managers.lockManager.handleLockGranted(requestId, name, fencingToken);
|
|
2225
2255
|
},
|
|
2226
2256
|
"LOCK_RELEASED": (msg) => {
|
|
2227
|
-
const { requestId, success } = msg.payload;
|
|
2228
|
-
managers.lockManager.handleLockReleased(requestId, success);
|
|
2257
|
+
const { requestId, name, success } = msg.payload;
|
|
2258
|
+
managers.lockManager.handleLockReleased(requestId, name, success);
|
|
2229
2259
|
},
|
|
2230
2260
|
// GC handler
|
|
2231
2261
|
"GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
|
|
@@ -2263,10 +2293,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2263
2293
|
managers.searchClient.handleSearchResponse(msg.payload);
|
|
2264
2294
|
},
|
|
2265
2295
|
"SEARCH_UPDATE": () => {
|
|
2266
|
-
}
|
|
2267
|
-
// HYBRID handlers
|
|
2268
|
-
"HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
|
|
2269
|
-
"HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
|
|
2296
|
+
}
|
|
2270
2297
|
});
|
|
2271
2298
|
}
|
|
2272
2299
|
|
|
@@ -2401,8 +2428,8 @@ var SyncEngine = class {
|
|
|
2401
2428
|
handleServerEvent: (msg) => this.handleServerEvent(msg),
|
|
2402
2429
|
handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
|
|
2403
2430
|
handleGcPrune: (msg) => this.handleGcPrune(msg),
|
|
2404
|
-
|
|
2405
|
-
|
|
2431
|
+
handleOpRejected: (msg) => this.handleOpRejected(msg),
|
|
2432
|
+
handleError: (msg) => this.handleError(msg)
|
|
2406
2433
|
},
|
|
2407
2434
|
{
|
|
2408
2435
|
topicManager: this.topicManager,
|
|
@@ -2555,6 +2582,15 @@ var SyncEngine = class {
|
|
|
2555
2582
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
2556
2583
|
if (pending.length === 0) return;
|
|
2557
2584
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
2585
|
+
const connectionProvider = this.webSocketManager.getConnectionProvider();
|
|
2586
|
+
if (connectionProvider.sendBatch) {
|
|
2587
|
+
const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
|
|
2588
|
+
const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
|
|
2589
|
+
if (failedKeys.length > 0) {
|
|
2590
|
+
logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
|
|
2591
|
+
}
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2558
2594
|
this.sendMessage({
|
|
2559
2595
|
type: "OP_BATCH",
|
|
2560
2596
|
payload: {
|
|
@@ -2575,7 +2611,7 @@ var SyncEngine = class {
|
|
|
2575
2611
|
this.authToken = token;
|
|
2576
2612
|
this.tokenProvider = null;
|
|
2577
2613
|
const state = this.stateMachine.getState();
|
|
2578
|
-
if (state === "AUTHENTICATING" /* AUTHENTICATING */
|
|
2614
|
+
if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
|
|
2579
2615
|
this.sendAuth();
|
|
2580
2616
|
} else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
|
|
2581
2617
|
logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
|
|
@@ -2680,9 +2716,10 @@ var SyncEngine = class {
|
|
|
2680
2716
|
return;
|
|
2681
2717
|
}
|
|
2682
2718
|
await this.messageRouter.route(message);
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
this.
|
|
2719
|
+
const ts = message.timestamp;
|
|
2720
|
+
if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
|
|
2721
|
+
this.hlc.update(ts);
|
|
2722
|
+
this.lastSyncTimestamp = Number(ts.millis);
|
|
2686
2723
|
await this.saveOpLog();
|
|
2687
2724
|
}
|
|
2688
2725
|
}
|
|
@@ -2736,20 +2773,37 @@ var SyncEngine = class {
|
|
|
2736
2773
|
this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
|
|
2737
2774
|
}
|
|
2738
2775
|
}
|
|
2776
|
+
const lastIdNum = parseInt(lastId, 10);
|
|
2739
2777
|
let maxSyncedId = -1;
|
|
2740
2778
|
let ackedCount = 0;
|
|
2741
|
-
|
|
2742
|
-
|
|
2779
|
+
if (!isNaN(lastIdNum)) {
|
|
2780
|
+
this.opLog.forEach((op) => {
|
|
2781
|
+
if (op.id) {
|
|
2782
|
+
const opIdNum = parseInt(op.id, 10);
|
|
2783
|
+
if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
|
|
2784
|
+
if (!op.synced) {
|
|
2785
|
+
ackedCount++;
|
|
2786
|
+
}
|
|
2787
|
+
op.synced = true;
|
|
2788
|
+
if (opIdNum > maxSyncedId) {
|
|
2789
|
+
maxSyncedId = opIdNum;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
});
|
|
2794
|
+
} else {
|
|
2795
|
+
logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
|
|
2796
|
+
this.opLog.forEach((op) => {
|
|
2743
2797
|
if (!op.synced) {
|
|
2744
2798
|
ackedCount++;
|
|
2799
|
+
op.synced = true;
|
|
2800
|
+
const opIdNum = parseInt(op.id, 10);
|
|
2801
|
+
if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
|
|
2802
|
+
maxSyncedId = opIdNum;
|
|
2803
|
+
}
|
|
2745
2804
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
if (!isNaN(idNum) && idNum > maxSyncedId) {
|
|
2749
|
-
maxSyncedId = idNum;
|
|
2750
|
-
}
|
|
2751
|
-
}
|
|
2752
|
-
});
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2753
2807
|
if (maxSyncedId !== -1) {
|
|
2754
2808
|
this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
|
|
2755
2809
|
}
|
|
@@ -2758,18 +2812,18 @@ var SyncEngine = class {
|
|
|
2758
2812
|
}
|
|
2759
2813
|
}
|
|
2760
2814
|
handleQueryResp(message) {
|
|
2761
|
-
const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
|
|
2815
|
+
const { queryId, results, nextCursor, hasMore, cursorStatus, merkleRootHash } = message.payload;
|
|
2762
2816
|
const query = this.queryManager.getQueries().get(queryId);
|
|
2763
2817
|
if (query) {
|
|
2764
|
-
query.onResult(results, "server");
|
|
2818
|
+
query.onResult(results, "server", merkleRootHash);
|
|
2765
2819
|
query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
|
|
2766
2820
|
}
|
|
2767
2821
|
}
|
|
2768
2822
|
handleQueryUpdate(message) {
|
|
2769
|
-
const { queryId, key, value,
|
|
2823
|
+
const { queryId, key, value, changeType } = message.payload;
|
|
2770
2824
|
const query = this.queryManager.getQueries().get(queryId);
|
|
2771
2825
|
if (query) {
|
|
2772
|
-
query.onUpdate(key,
|
|
2826
|
+
query.onUpdate(key, changeType === "LEAVE" ? null : value);
|
|
2773
2827
|
}
|
|
2774
2828
|
}
|
|
2775
2829
|
async handleServerEvent(message) {
|
|
@@ -3174,31 +3228,24 @@ var SyncEngine = class {
|
|
|
3174
3228
|
return this.queryManager.runLocalHybridQuery(mapName, filter);
|
|
3175
3229
|
}
|
|
3176
3230
|
/**
|
|
3177
|
-
* Handle
|
|
3231
|
+
* Handle operation rejected by server (permission denied, validation failure, etc.).
|
|
3178
3232
|
*/
|
|
3179
|
-
|
|
3180
|
-
const
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
}
|
|
3233
|
+
handleOpRejected(message) {
|
|
3234
|
+
const { opId, reason, code } = message.payload;
|
|
3235
|
+
logger.warn({ opId, reason, code }, "Operation rejected by server");
|
|
3236
|
+
this.writeConcernManager.resolveWriteConcernPromise(opId, {
|
|
3237
|
+
opId,
|
|
3238
|
+
success: false,
|
|
3239
|
+
achievedLevel: "FIRE_AND_FORGET",
|
|
3240
|
+
error: reason
|
|
3241
|
+
});
|
|
3189
3242
|
}
|
|
3190
3243
|
/**
|
|
3191
|
-
* Handle
|
|
3244
|
+
* Handle generic error message from server.
|
|
3192
3245
|
*/
|
|
3193
|
-
|
|
3194
|
-
const
|
|
3195
|
-
|
|
3196
|
-
if (payload.type === "LEAVE") {
|
|
3197
|
-
query.onUpdate(payload.key, null);
|
|
3198
|
-
} else {
|
|
3199
|
-
query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
|
|
3200
|
-
}
|
|
3201
|
-
}
|
|
3246
|
+
handleError(message) {
|
|
3247
|
+
const { code, message: errorMessage, details } = message.payload;
|
|
3248
|
+
logger.error({ code, message: errorMessage, details }, "Server error received");
|
|
3202
3249
|
}
|
|
3203
3250
|
};
|
|
3204
3251
|
|
|
@@ -3305,12 +3352,15 @@ var QueryHandle = class {
|
|
|
3305
3352
|
// Pagination info
|
|
3306
3353
|
this._paginationInfo = { hasMore: false, cursorStatus: "none" };
|
|
3307
3354
|
this.paginationListeners = /* @__PURE__ */ new Set();
|
|
3355
|
+
/** Merkle root hash from last server QUERY_RESP — used for delta reconnect */
|
|
3356
|
+
this.merkleRootHash = 0;
|
|
3308
3357
|
// Track if we've received authoritative server response
|
|
3309
3358
|
this.hasReceivedServerData = false;
|
|
3310
3359
|
this.id = crypto.randomUUID();
|
|
3311
3360
|
this.syncEngine = syncEngine;
|
|
3312
3361
|
this.mapName = mapName;
|
|
3313
3362
|
this.filter = filter;
|
|
3363
|
+
this.fields = filter.fields;
|
|
3314
3364
|
}
|
|
3315
3365
|
subscribe(callback) {
|
|
3316
3366
|
this.listeners.add(callback);
|
|
@@ -3346,7 +3396,7 @@ var QueryHandle = class {
|
|
|
3346
3396
|
* - This prevents clearing local data when server hasn't loaded from storage yet
|
|
3347
3397
|
* - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
|
|
3348
3398
|
*/
|
|
3349
|
-
onResult(items, source = "server") {
|
|
3399
|
+
onResult(items, source = "server", merkleRootHash) {
|
|
3350
3400
|
logger.debug({
|
|
3351
3401
|
mapName: this.mapName,
|
|
3352
3402
|
itemCount: items.length,
|
|
@@ -3361,6 +3411,9 @@ var QueryHandle = class {
|
|
|
3361
3411
|
if (source === "server" && items.length > 0) {
|
|
3362
3412
|
this.hasReceivedServerData = true;
|
|
3363
3413
|
}
|
|
3414
|
+
if (merkleRootHash !== void 0) {
|
|
3415
|
+
this.merkleRootHash = merkleRootHash;
|
|
3416
|
+
}
|
|
3364
3417
|
const newKeys = new Set(items.map((i) => i.key));
|
|
3365
3418
|
const removedKeys = [];
|
|
3366
3419
|
for (const key of this.currentResults.keys()) {
|
|
@@ -4082,8 +4135,8 @@ var SearchHandle = class {
|
|
|
4082
4135
|
handleSearchUpdate(message) {
|
|
4083
4136
|
if (message.type !== "SEARCH_UPDATE") return;
|
|
4084
4137
|
if (message.payload?.subscriptionId !== this.subscriptionId) return;
|
|
4085
|
-
const { key, value, score, matchedTerms,
|
|
4086
|
-
switch (
|
|
4138
|
+
const { key, value, score, matchedTerms, changeType } = message.payload;
|
|
4139
|
+
switch (changeType) {
|
|
4087
4140
|
case "ENTER":
|
|
4088
4141
|
this.results.set(key, {
|
|
4089
4142
|
key,
|
|
@@ -4400,6 +4453,30 @@ var import_core10 = require("@topgunbuild/core");
|
|
|
4400
4453
|
// src/cluster/ConnectionPool.ts
|
|
4401
4454
|
var import_core7 = require("@topgunbuild/core");
|
|
4402
4455
|
var import_core8 = require("@topgunbuild/core");
|
|
4456
|
+
|
|
4457
|
+
// src/connection/WebSocketConnection.ts
|
|
4458
|
+
var ConnectionReadyState = {
|
|
4459
|
+
CONNECTING: 0,
|
|
4460
|
+
OPEN: 1,
|
|
4461
|
+
CLOSING: 2,
|
|
4462
|
+
CLOSED: 3
|
|
4463
|
+
};
|
|
4464
|
+
var WebSocketConnection = class {
|
|
4465
|
+
constructor(ws) {
|
|
4466
|
+
this.ws = ws;
|
|
4467
|
+
}
|
|
4468
|
+
send(data) {
|
|
4469
|
+
this.ws.send(data);
|
|
4470
|
+
}
|
|
4471
|
+
close() {
|
|
4472
|
+
this.ws.close();
|
|
4473
|
+
}
|
|
4474
|
+
get readyState() {
|
|
4475
|
+
return this.ws.readyState;
|
|
4476
|
+
}
|
|
4477
|
+
};
|
|
4478
|
+
|
|
4479
|
+
// src/cluster/ConnectionPool.ts
|
|
4403
4480
|
var ConnectionPool = class {
|
|
4404
4481
|
constructor(config = {}) {
|
|
4405
4482
|
this.listeners = /* @__PURE__ */ new Map();
|
|
@@ -4471,10 +4548,17 @@ var ConnectionPool = class {
|
|
|
4471
4548
|
return;
|
|
4472
4549
|
}
|
|
4473
4550
|
}
|
|
4551
|
+
for (const [existingId, existingConn] of this.connections) {
|
|
4552
|
+
if (existingConn.endpoint === endpoint && existingId !== nodeId) {
|
|
4553
|
+
this.remapNodeId(existingId, nodeId);
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4474
4557
|
const connection = {
|
|
4475
4558
|
nodeId,
|
|
4476
4559
|
endpoint,
|
|
4477
4560
|
socket: null,
|
|
4561
|
+
cachedConnection: null,
|
|
4478
4562
|
state: "DISCONNECTED",
|
|
4479
4563
|
lastSeen: 0,
|
|
4480
4564
|
latencyMs: 0,
|
|
@@ -4509,15 +4593,34 @@ var ConnectionPool = class {
|
|
|
4509
4593
|
}
|
|
4510
4594
|
logger.info({ nodeId }, "Node removed from connection pool");
|
|
4511
4595
|
}
|
|
4596
|
+
/**
|
|
4597
|
+
* Remap a node from one ID to another, preserving the existing connection.
|
|
4598
|
+
* Used when the server-assigned node ID differs from the temporary seed ID.
|
|
4599
|
+
*/
|
|
4600
|
+
remapNodeId(oldId, newId) {
|
|
4601
|
+
const connection = this.connections.get(oldId);
|
|
4602
|
+
if (!connection) return;
|
|
4603
|
+
connection.nodeId = newId;
|
|
4604
|
+
this.connections.delete(oldId);
|
|
4605
|
+
this.connections.set(newId, connection);
|
|
4606
|
+
if (this.primaryNodeId === oldId) {
|
|
4607
|
+
this.primaryNodeId = newId;
|
|
4608
|
+
}
|
|
4609
|
+
logger.info({ oldId, newId }, "Node ID remapped");
|
|
4610
|
+
this.emit("node:remapped", oldId, newId);
|
|
4611
|
+
}
|
|
4512
4612
|
/**
|
|
4513
4613
|
* Get connection for a specific node
|
|
4514
4614
|
*/
|
|
4515
4615
|
getConnection(nodeId) {
|
|
4516
4616
|
const connection = this.connections.get(nodeId);
|
|
4517
|
-
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
4617
|
+
if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
|
|
4518
4618
|
return null;
|
|
4519
4619
|
}
|
|
4520
|
-
|
|
4620
|
+
if (!connection.cachedConnection) {
|
|
4621
|
+
connection.cachedConnection = new WebSocketConnection(connection.socket);
|
|
4622
|
+
}
|
|
4623
|
+
return connection.cachedConnection;
|
|
4521
4624
|
}
|
|
4522
4625
|
/**
|
|
4523
4626
|
* Get primary connection (first/seed node)
|
|
@@ -4531,8 +4634,11 @@ var ConnectionPool = class {
|
|
|
4531
4634
|
*/
|
|
4532
4635
|
getAnyHealthyConnection() {
|
|
4533
4636
|
for (const [nodeId, conn] of this.connections) {
|
|
4534
|
-
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
4535
|
-
|
|
4637
|
+
if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
|
|
4638
|
+
if (!conn.cachedConnection) {
|
|
4639
|
+
conn.cachedConnection = new WebSocketConnection(conn.socket);
|
|
4640
|
+
}
|
|
4641
|
+
return { nodeId, connection: conn.cachedConnection };
|
|
4536
4642
|
}
|
|
4537
4643
|
}
|
|
4538
4644
|
return null;
|
|
@@ -4588,7 +4694,7 @@ var ConnectionPool = class {
|
|
|
4588
4694
|
* Get list of connected node IDs
|
|
4589
4695
|
*/
|
|
4590
4696
|
getConnectedNodes() {
|
|
4591
|
-
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
4697
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
4592
4698
|
}
|
|
4593
4699
|
/**
|
|
4594
4700
|
* Get all node IDs
|
|
@@ -4597,11 +4703,11 @@ var ConnectionPool = class {
|
|
|
4597
4703
|
return Array.from(this.connections.keys());
|
|
4598
4704
|
}
|
|
4599
4705
|
/**
|
|
4600
|
-
* Check if node
|
|
4706
|
+
* Check if node has an open WebSocket connection
|
|
4601
4707
|
*/
|
|
4602
4708
|
isNodeConnected(nodeId) {
|
|
4603
4709
|
const conn = this.connections.get(nodeId);
|
|
4604
|
-
return conn?.state === "AUTHENTICATED";
|
|
4710
|
+
return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
|
|
4605
4711
|
}
|
|
4606
4712
|
/**
|
|
4607
4713
|
* Check if connected to a specific node.
|
|
@@ -4666,25 +4772,26 @@ var ConnectionPool = class {
|
|
|
4666
4772
|
};
|
|
4667
4773
|
socket.onmessage = (event) => {
|
|
4668
4774
|
connection.lastSeen = Date.now();
|
|
4669
|
-
this.handleMessage(nodeId, event);
|
|
4775
|
+
this.handleMessage(connection.nodeId, event);
|
|
4670
4776
|
};
|
|
4671
4777
|
socket.onerror = (error) => {
|
|
4672
|
-
logger.error({ nodeId, error }, "WebSocket error");
|
|
4673
|
-
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
4778
|
+
logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
|
|
4779
|
+
this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
4674
4780
|
};
|
|
4675
4781
|
socket.onclose = () => {
|
|
4676
4782
|
const wasConnected = connection.state === "AUTHENTICATED";
|
|
4677
4783
|
connection.state = "DISCONNECTED";
|
|
4678
4784
|
connection.socket = null;
|
|
4785
|
+
connection.cachedConnection = null;
|
|
4679
4786
|
if (wasConnected) {
|
|
4680
|
-
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
4787
|
+
this.emit("node:disconnected", connection.nodeId, "Connection closed");
|
|
4681
4788
|
}
|
|
4682
|
-
this.scheduleReconnect(nodeId);
|
|
4789
|
+
this.scheduleReconnect(connection.nodeId);
|
|
4683
4790
|
};
|
|
4684
4791
|
} catch (error) {
|
|
4685
4792
|
connection.state = "FAILED";
|
|
4686
|
-
logger.error({ nodeId, error }, "Failed to connect");
|
|
4687
|
-
this.scheduleReconnect(nodeId);
|
|
4793
|
+
logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
|
|
4794
|
+
this.scheduleReconnect(connection.nodeId);
|
|
4688
4795
|
}
|
|
4689
4796
|
}
|
|
4690
4797
|
sendAuth(connection) {
|
|
@@ -4713,18 +4820,15 @@ var ConnectionPool = class {
|
|
|
4713
4820
|
logger.info({ nodeId }, "Authenticated with node");
|
|
4714
4821
|
this.emit("node:healthy", nodeId);
|
|
4715
4822
|
this.flushPendingMessages(connection);
|
|
4716
|
-
return;
|
|
4717
4823
|
}
|
|
4718
4824
|
if (message.type === "AUTH_REQUIRED") {
|
|
4719
4825
|
if (this.authToken) {
|
|
4720
4826
|
this.sendAuth(connection);
|
|
4721
4827
|
}
|
|
4722
|
-
return;
|
|
4723
4828
|
}
|
|
4724
4829
|
if (message.type === "AUTH_FAIL") {
|
|
4725
4830
|
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
4726
4831
|
connection.state = "FAILED";
|
|
4727
|
-
return;
|
|
4728
4832
|
}
|
|
4729
4833
|
if (message.type === "PONG") {
|
|
4730
4834
|
if (message.timestamp) {
|
|
@@ -4732,10 +4836,6 @@ var ConnectionPool = class {
|
|
|
4732
4836
|
}
|
|
4733
4837
|
return;
|
|
4734
4838
|
}
|
|
4735
|
-
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
4736
|
-
this.emit("message", nodeId, message);
|
|
4737
|
-
return;
|
|
4738
|
-
}
|
|
4739
4839
|
this.emit("message", nodeId, message);
|
|
4740
4840
|
}
|
|
4741
4841
|
flushPendingMessages(connection) {
|
|
@@ -4902,17 +5002,17 @@ var PartitionRouter = class {
|
|
|
4902
5002
|
}
|
|
4903
5003
|
return null;
|
|
4904
5004
|
}
|
|
4905
|
-
const
|
|
4906
|
-
if (
|
|
4907
|
-
return { nodeId: routing.nodeId,
|
|
5005
|
+
const connection = this.connectionPool.getConnection(routing.nodeId);
|
|
5006
|
+
if (connection) {
|
|
5007
|
+
return { nodeId: routing.nodeId, connection };
|
|
4908
5008
|
}
|
|
4909
5009
|
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
4910
5010
|
if (partition) {
|
|
4911
5011
|
for (const backupId of partition.backupNodeIds) {
|
|
4912
|
-
const
|
|
4913
|
-
if (
|
|
5012
|
+
const backupConnection = this.connectionPool.getConnection(backupId);
|
|
5013
|
+
if (backupConnection) {
|
|
4914
5014
|
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
4915
|
-
return { nodeId: backupId,
|
|
5015
|
+
return { nodeId: backupId, connection: backupConnection };
|
|
4916
5016
|
}
|
|
4917
5017
|
}
|
|
4918
5018
|
}
|
|
@@ -5188,7 +5288,7 @@ var PartitionRouter = class {
|
|
|
5188
5288
|
};
|
|
5189
5289
|
|
|
5190
5290
|
// src/cluster/ClusterClient.ts
|
|
5191
|
-
var
|
|
5291
|
+
var _ClusterClient = class _ClusterClient {
|
|
5192
5292
|
constructor(config) {
|
|
5193
5293
|
this.listeners = /* @__PURE__ */ new Map();
|
|
5194
5294
|
this.initialized = false;
|
|
@@ -5201,6 +5301,8 @@ var ClusterClient = class {
|
|
|
5201
5301
|
};
|
|
5202
5302
|
// Circuit breaker state per node
|
|
5203
5303
|
this.circuits = /* @__PURE__ */ new Map();
|
|
5304
|
+
// Debounce timer for partition map requests on reconnect
|
|
5305
|
+
this.partitionMapRequestTimer = null;
|
|
5204
5306
|
this.config = config;
|
|
5205
5307
|
this.circuitBreakerConfig = {
|
|
5206
5308
|
...import_core10.DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
@@ -5292,14 +5394,14 @@ var ClusterClient = class {
|
|
|
5292
5394
|
this.requestPartitionMapRefresh();
|
|
5293
5395
|
return this.getFallbackConnection();
|
|
5294
5396
|
}
|
|
5295
|
-
const
|
|
5296
|
-
if (!
|
|
5397
|
+
const connection = this.connectionPool.getConnection(owner);
|
|
5398
|
+
if (!connection) {
|
|
5297
5399
|
this.routingMetrics.fallbackRoutes++;
|
|
5298
5400
|
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
5299
5401
|
return this.getFallbackConnection();
|
|
5300
5402
|
}
|
|
5301
5403
|
this.routingMetrics.directRoutes++;
|
|
5302
|
-
return
|
|
5404
|
+
return connection;
|
|
5303
5405
|
}
|
|
5304
5406
|
/**
|
|
5305
5407
|
* Get fallback connection when owner is unavailable.
|
|
@@ -5307,10 +5409,10 @@ var ClusterClient = class {
|
|
|
5307
5409
|
*/
|
|
5308
5410
|
getFallbackConnection() {
|
|
5309
5411
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5310
|
-
if (!conn?.
|
|
5412
|
+
if (!conn?.connection) {
|
|
5311
5413
|
throw new Error("No healthy connection available");
|
|
5312
5414
|
}
|
|
5313
|
-
return conn.
|
|
5415
|
+
return conn.connection;
|
|
5314
5416
|
}
|
|
5315
5417
|
/**
|
|
5316
5418
|
* Request a partition map refresh in the background.
|
|
@@ -5321,9 +5423,23 @@ var ClusterClient = class {
|
|
|
5321
5423
|
logger.error({ err }, "Failed to refresh partition map");
|
|
5322
5424
|
});
|
|
5323
5425
|
}
|
|
5426
|
+
/**
|
|
5427
|
+
* Debounce partition map requests to prevent flooding when multiple nodes
|
|
5428
|
+
* reconnect simultaneously. Coalesces rapid requests into a single request
|
|
5429
|
+
* sent to the most recently connected node.
|
|
5430
|
+
*/
|
|
5431
|
+
debouncedPartitionMapRequest(nodeId) {
|
|
5432
|
+
if (this.partitionMapRequestTimer) {
|
|
5433
|
+
clearTimeout(this.partitionMapRequestTimer);
|
|
5434
|
+
}
|
|
5435
|
+
this.partitionMapRequestTimer = setTimeout(() => {
|
|
5436
|
+
this.partitionMapRequestTimer = null;
|
|
5437
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
5438
|
+
}, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
|
|
5439
|
+
}
|
|
5324
5440
|
/**
|
|
5325
5441
|
* Request partition map from a specific node.
|
|
5326
|
-
* Called on
|
|
5442
|
+
* Called on node connection via debounced handler.
|
|
5327
5443
|
*/
|
|
5328
5444
|
requestPartitionMapFromNode(nodeId) {
|
|
5329
5445
|
const socket = this.connectionPool.getConnection(nodeId);
|
|
@@ -5492,8 +5608,8 @@ var ClusterClient = class {
|
|
|
5492
5608
|
* Send directly to partition owner
|
|
5493
5609
|
*/
|
|
5494
5610
|
sendDirect(key, message) {
|
|
5495
|
-
const
|
|
5496
|
-
if (!
|
|
5611
|
+
const route = this.partitionRouter.routeToConnection(key);
|
|
5612
|
+
if (!route) {
|
|
5497
5613
|
logger.warn({ key }, "No route available for key");
|
|
5498
5614
|
return false;
|
|
5499
5615
|
}
|
|
@@ -5504,7 +5620,7 @@ var ClusterClient = class {
|
|
|
5504
5620
|
mapVersion: this.partitionRouter.getMapVersion()
|
|
5505
5621
|
}
|
|
5506
5622
|
};
|
|
5507
|
-
connection.
|
|
5623
|
+
route.connection.send((0, import_core10.serialize)(routedMessage));
|
|
5508
5624
|
return true;
|
|
5509
5625
|
}
|
|
5510
5626
|
/**
|
|
@@ -5608,10 +5724,23 @@ var ClusterClient = class {
|
|
|
5608
5724
|
async refreshPartitionMap() {
|
|
5609
5725
|
await this.partitionRouter.refreshPartitionMap();
|
|
5610
5726
|
}
|
|
5727
|
+
/**
|
|
5728
|
+
* Force reconnect all connections in the pool.
|
|
5729
|
+
*/
|
|
5730
|
+
forceReconnect() {
|
|
5731
|
+
this.connectionPool.close();
|
|
5732
|
+
this.connect().catch((err) => {
|
|
5733
|
+
logger.error({ err }, "ClusterClient forceReconnect failed");
|
|
5734
|
+
});
|
|
5735
|
+
}
|
|
5611
5736
|
/**
|
|
5612
5737
|
* Shutdown cluster client (IConnectionProvider interface).
|
|
5613
5738
|
*/
|
|
5614
5739
|
async close() {
|
|
5740
|
+
if (this.partitionMapRequestTimer) {
|
|
5741
|
+
clearTimeout(this.partitionMapRequestTimer);
|
|
5742
|
+
this.partitionMapRequestTimer = null;
|
|
5743
|
+
}
|
|
5615
5744
|
this.partitionRouter.close();
|
|
5616
5745
|
this.connectionPool.close();
|
|
5617
5746
|
this.initialized = false;
|
|
@@ -5634,23 +5763,23 @@ var ClusterClient = class {
|
|
|
5634
5763
|
return this.partitionRouter;
|
|
5635
5764
|
}
|
|
5636
5765
|
/**
|
|
5637
|
-
* Get any healthy
|
|
5766
|
+
* Get any healthy connection (IConnectionProvider interface).
|
|
5638
5767
|
* @throws Error if not connected
|
|
5639
5768
|
*/
|
|
5640
5769
|
getAnyConnection() {
|
|
5641
5770
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5642
|
-
if (!conn?.
|
|
5771
|
+
if (!conn?.connection) {
|
|
5643
5772
|
throw new Error("No healthy connection available");
|
|
5644
5773
|
}
|
|
5645
|
-
return conn.
|
|
5774
|
+
return conn.connection;
|
|
5646
5775
|
}
|
|
5647
5776
|
/**
|
|
5648
|
-
* Get any healthy
|
|
5777
|
+
* Get any healthy connection, or null if none available.
|
|
5649
5778
|
* Use this for optional connection checks.
|
|
5650
5779
|
*/
|
|
5651
5780
|
getAnyConnectionOrNull() {
|
|
5652
5781
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5653
|
-
return conn?.
|
|
5782
|
+
return conn?.connection ?? null;
|
|
5654
5783
|
}
|
|
5655
5784
|
// ============================================
|
|
5656
5785
|
// Circuit Breaker Methods
|
|
@@ -5741,9 +5870,7 @@ var ClusterClient = class {
|
|
|
5741
5870
|
setupEventHandlers() {
|
|
5742
5871
|
this.connectionPool.on("node:connected", (nodeId) => {
|
|
5743
5872
|
logger.debug({ nodeId }, "Node connected");
|
|
5744
|
-
|
|
5745
|
-
this.requestPartitionMapFromNode(nodeId);
|
|
5746
|
-
}
|
|
5873
|
+
this.debouncedPartitionMapRequest(nodeId);
|
|
5747
5874
|
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
5748
5875
|
this.emit("connected");
|
|
5749
5876
|
}
|
|
@@ -5798,6 +5925,8 @@ var ClusterClient = class {
|
|
|
5798
5925
|
});
|
|
5799
5926
|
}
|
|
5800
5927
|
};
|
|
5928
|
+
_ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
|
|
5929
|
+
var ClusterClient = _ClusterClient;
|
|
5801
5930
|
|
|
5802
5931
|
// src/connection/SingleServerProvider.ts
|
|
5803
5932
|
var DEFAULT_CONFIG = {
|
|
@@ -5813,14 +5942,18 @@ var SingleServerProvider = class {
|
|
|
5813
5942
|
this.reconnectTimer = null;
|
|
5814
5943
|
this.isClosing = false;
|
|
5815
5944
|
this.listeners = /* @__PURE__ */ new Map();
|
|
5945
|
+
this.onlineHandler = null;
|
|
5946
|
+
this.offlineHandler = null;
|
|
5816
5947
|
this.url = config.url;
|
|
5817
5948
|
this.config = {
|
|
5818
5949
|
url: config.url,
|
|
5819
5950
|
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
5820
5951
|
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
5821
5952
|
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
5822
|
-
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
5953
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
|
|
5954
|
+
listenNetworkEvents: config.listenNetworkEvents ?? true
|
|
5823
5955
|
};
|
|
5956
|
+
this.setupNetworkListeners();
|
|
5824
5957
|
}
|
|
5825
5958
|
/**
|
|
5826
5959
|
* Connect to the WebSocket server.
|
|
@@ -5829,6 +5962,9 @@ var SingleServerProvider = class {
|
|
|
5829
5962
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
5830
5963
|
return;
|
|
5831
5964
|
}
|
|
5965
|
+
if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
|
|
5966
|
+
throw new Error("Browser is offline \u2014 skipping connection attempt");
|
|
5967
|
+
}
|
|
5832
5968
|
this.isClosing = false;
|
|
5833
5969
|
return new Promise((resolve, reject) => {
|
|
5834
5970
|
try {
|
|
@@ -5881,7 +6017,7 @@ var SingleServerProvider = class {
|
|
|
5881
6017
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
5882
6018
|
throw new Error("Not connected");
|
|
5883
6019
|
}
|
|
5884
|
-
return this.ws;
|
|
6020
|
+
return new WebSocketConnection(this.ws);
|
|
5885
6021
|
}
|
|
5886
6022
|
/**
|
|
5887
6023
|
* Get any available connection.
|
|
@@ -5932,6 +6068,7 @@ var SingleServerProvider = class {
|
|
|
5932
6068
|
*/
|
|
5933
6069
|
async close() {
|
|
5934
6070
|
this.isClosing = true;
|
|
6071
|
+
this.teardownNetworkListeners();
|
|
5935
6072
|
if (this.reconnectTimer) {
|
|
5936
6073
|
clearTimeout(this.reconnectTimer);
|
|
5937
6074
|
this.reconnectTimer = null;
|
|
@@ -5970,6 +6107,10 @@ var SingleServerProvider = class {
|
|
|
5970
6107
|
clearTimeout(this.reconnectTimer);
|
|
5971
6108
|
this.reconnectTimer = null;
|
|
5972
6109
|
}
|
|
6110
|
+
if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
|
|
6111
|
+
logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
|
|
6112
|
+
return;
|
|
6113
|
+
}
|
|
5973
6114
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
5974
6115
|
logger.error(
|
|
5975
6116
|
{ attempts: this.reconnectAttempts, url: this.url },
|
|
@@ -6005,6 +6146,37 @@ var SingleServerProvider = class {
|
|
|
6005
6146
|
delay = delay * (0.5 + Math.random());
|
|
6006
6147
|
return Math.floor(delay);
|
|
6007
6148
|
}
|
|
6149
|
+
/**
|
|
6150
|
+
* Force-close the current WebSocket and immediately schedule reconnection.
|
|
6151
|
+
* Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
|
|
6152
|
+
* Resets the reconnect counter so the full backoff budget is available.
|
|
6153
|
+
*
|
|
6154
|
+
* Critically, this does NOT wait for the TCP close handshake (which can
|
|
6155
|
+
* hang 20+ seconds on a dead network). Instead it strips all handlers from
|
|
6156
|
+
* the old WebSocket, fires a best-effort close(), nulls the reference, and
|
|
6157
|
+
* schedules reconnect right away.
|
|
6158
|
+
*/
|
|
6159
|
+
forceReconnect() {
|
|
6160
|
+
this.reconnectAttempts = 0;
|
|
6161
|
+
this.isClosing = false;
|
|
6162
|
+
if (this.reconnectTimer) {
|
|
6163
|
+
clearTimeout(this.reconnectTimer);
|
|
6164
|
+
this.reconnectTimer = null;
|
|
6165
|
+
}
|
|
6166
|
+
if (this.ws) {
|
|
6167
|
+
this.ws.onopen = null;
|
|
6168
|
+
this.ws.onclose = null;
|
|
6169
|
+
this.ws.onerror = null;
|
|
6170
|
+
this.ws.onmessage = null;
|
|
6171
|
+
try {
|
|
6172
|
+
this.ws.close();
|
|
6173
|
+
} catch {
|
|
6174
|
+
}
|
|
6175
|
+
this.ws = null;
|
|
6176
|
+
}
|
|
6177
|
+
this.emit("disconnected", "default");
|
|
6178
|
+
this.scheduleReconnect();
|
|
6179
|
+
}
|
|
6008
6180
|
/**
|
|
6009
6181
|
* Get the WebSocket URL this provider connects to.
|
|
6010
6182
|
*/
|
|
@@ -6024,6 +6196,43 @@ var SingleServerProvider = class {
|
|
|
6024
6196
|
resetReconnectAttempts() {
|
|
6025
6197
|
this.reconnectAttempts = 0;
|
|
6026
6198
|
}
|
|
6199
|
+
/**
|
|
6200
|
+
* Listen for browser 'online' event to trigger instant reconnect
|
|
6201
|
+
* when network comes back. Only active in browser environments.
|
|
6202
|
+
*/
|
|
6203
|
+
setupNetworkListeners() {
|
|
6204
|
+
if (!this.config.listenNetworkEvents) return;
|
|
6205
|
+
if (typeof globalThis.addEventListener !== "function") return;
|
|
6206
|
+
this.onlineHandler = () => {
|
|
6207
|
+
if (this.isClosing) return;
|
|
6208
|
+
if (this.isConnected()) return;
|
|
6209
|
+
logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
|
|
6210
|
+
this.forceReconnect();
|
|
6211
|
+
};
|
|
6212
|
+
this.offlineHandler = () => {
|
|
6213
|
+
if (this.isClosing) return;
|
|
6214
|
+
if (!this.isConnected()) return;
|
|
6215
|
+
logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
|
|
6216
|
+
this.forceReconnect();
|
|
6217
|
+
};
|
|
6218
|
+
globalThis.addEventListener("online", this.onlineHandler);
|
|
6219
|
+
globalThis.addEventListener("offline", this.offlineHandler);
|
|
6220
|
+
}
|
|
6221
|
+
/**
|
|
6222
|
+
* Remove browser network event listeners.
|
|
6223
|
+
*/
|
|
6224
|
+
teardownNetworkListeners() {
|
|
6225
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
6226
|
+
if (this.onlineHandler) {
|
|
6227
|
+
globalThis.removeEventListener("online", this.onlineHandler);
|
|
6228
|
+
this.onlineHandler = null;
|
|
6229
|
+
}
|
|
6230
|
+
if (this.offlineHandler) {
|
|
6231
|
+
globalThis.removeEventListener("offline", this.offlineHandler);
|
|
6232
|
+
this.offlineHandler = null;
|
|
6233
|
+
}
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6027
6236
|
};
|
|
6028
6237
|
|
|
6029
6238
|
// src/TopGunClient.ts
|
|
@@ -6081,7 +6290,13 @@ var TopGunClient = class {
|
|
|
6081
6290
|
});
|
|
6082
6291
|
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
6083
6292
|
} else {
|
|
6084
|
-
const singleServerProvider = new SingleServerProvider({
|
|
6293
|
+
const singleServerProvider = new SingleServerProvider({
|
|
6294
|
+
url: config.serverUrl,
|
|
6295
|
+
maxReconnectAttempts: config.backoff?.maxRetries,
|
|
6296
|
+
reconnectDelayMs: config.backoff?.initialDelayMs,
|
|
6297
|
+
backoffMultiplier: config.backoff?.multiplier,
|
|
6298
|
+
maxReconnectDelayMs: config.backoff?.maxDelayMs
|
|
6299
|
+
});
|
|
6085
6300
|
this.syncEngine = new SyncEngine({
|
|
6086
6301
|
nodeId: this.nodeId,
|
|
6087
6302
|
connectionProvider: singleServerProvider,
|
|
@@ -7131,6 +7346,24 @@ var import_core14 = require("@topgunbuild/core");
|
|
|
7131
7346
|
|
|
7132
7347
|
// src/connection/HttpSyncProvider.ts
|
|
7133
7348
|
var import_core13 = require("@topgunbuild/core");
|
|
7349
|
+
var HttpConnection = class {
|
|
7350
|
+
constructor(provider) {
|
|
7351
|
+
this.provider = provider;
|
|
7352
|
+
}
|
|
7353
|
+
send(data) {
|
|
7354
|
+
if (typeof data === "string") {
|
|
7355
|
+
const encoder = new TextEncoder();
|
|
7356
|
+
this.provider.send(encoder.encode(data));
|
|
7357
|
+
} else {
|
|
7358
|
+
this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
7359
|
+
}
|
|
7360
|
+
}
|
|
7361
|
+
close() {
|
|
7362
|
+
}
|
|
7363
|
+
get readyState() {
|
|
7364
|
+
return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
|
|
7365
|
+
}
|
|
7366
|
+
};
|
|
7134
7367
|
var HttpSyncProvider = class {
|
|
7135
7368
|
constructor(config) {
|
|
7136
7369
|
this.listeners = /* @__PURE__ */ new Map();
|
|
@@ -7172,17 +7405,17 @@ var HttpSyncProvider = class {
|
|
|
7172
7405
|
}
|
|
7173
7406
|
/**
|
|
7174
7407
|
* Get connection for a specific key.
|
|
7175
|
-
*
|
|
7408
|
+
* Returns an HttpConnection that queues operations for the next poll cycle.
|
|
7176
7409
|
*/
|
|
7177
7410
|
getConnection(_key) {
|
|
7178
|
-
|
|
7411
|
+
return new HttpConnection(this);
|
|
7179
7412
|
}
|
|
7180
7413
|
/**
|
|
7181
7414
|
* Get any available connection.
|
|
7182
|
-
*
|
|
7415
|
+
* Returns an HttpConnection that queues operations for the next poll cycle.
|
|
7183
7416
|
*/
|
|
7184
7417
|
getAnyConnection() {
|
|
7185
|
-
|
|
7418
|
+
return new HttpConnection(this);
|
|
7186
7419
|
}
|
|
7187
7420
|
/**
|
|
7188
7421
|
* Check if connected (last HTTP request succeeded).
|
|
@@ -7263,6 +7496,17 @@ var HttpSyncProvider = class {
|
|
|
7263
7496
|
logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
|
|
7264
7497
|
}
|
|
7265
7498
|
}
|
|
7499
|
+
/**
|
|
7500
|
+
* Force reconnect by restarting the polling loop.
|
|
7501
|
+
*/
|
|
7502
|
+
forceReconnect() {
|
|
7503
|
+
this.stopPolling();
|
|
7504
|
+
this.connected = false;
|
|
7505
|
+
this.emit("disconnected", "default");
|
|
7506
|
+
this.connect().catch((err) => {
|
|
7507
|
+
logger.error({ err }, "HttpSyncProvider forceReconnect failed");
|
|
7508
|
+
});
|
|
7509
|
+
}
|
|
7266
7510
|
/**
|
|
7267
7511
|
* Close the HTTP sync provider.
|
|
7268
7512
|
* Stops the polling loop, clears queued operations, and sets disconnected state.
|
|
@@ -7535,6 +7779,14 @@ var AutoConnectionProvider = class {
|
|
|
7535
7779
|
}
|
|
7536
7780
|
this.activeProvider.send(data, key);
|
|
7537
7781
|
}
|
|
7782
|
+
/**
|
|
7783
|
+
* Force reconnect by delegating to the active provider.
|
|
7784
|
+
*/
|
|
7785
|
+
forceReconnect() {
|
|
7786
|
+
if (this.activeProvider) {
|
|
7787
|
+
this.activeProvider.forceReconnect();
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7538
7790
|
/**
|
|
7539
7791
|
* Close the active underlying provider.
|
|
7540
7792
|
*/
|
|
@@ -7592,6 +7844,7 @@ var AutoConnectionProvider = class {
|
|
|
7592
7844
|
ClusterClient,
|
|
7593
7845
|
ConflictResolverClient,
|
|
7594
7846
|
ConnectionPool,
|
|
7847
|
+
ConnectionReadyState,
|
|
7595
7848
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
7596
7849
|
DEFAULT_CLUSTER_CONFIG,
|
|
7597
7850
|
EncryptedStorageAdapter,
|
|
@@ -7613,6 +7866,7 @@ var AutoConnectionProvider = class {
|
|
|
7613
7866
|
TopGunClient,
|
|
7614
7867
|
TopicHandle,
|
|
7615
7868
|
VALID_TRANSITIONS,
|
|
7869
|
+
WebSocketConnection,
|
|
7616
7870
|
isValidTransition,
|
|
7617
7871
|
logger
|
|
7618
7872
|
});
|