@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.mjs
CHANGED
|
@@ -656,9 +656,7 @@ var WebSocketManager = class {
|
|
|
656
656
|
timeoutMs: this.config.heartbeatConfig.timeoutMs
|
|
657
657
|
}, "Heartbeat timeout - triggering reconnection");
|
|
658
658
|
this.stopHeartbeat();
|
|
659
|
-
this.connectionProvider.
|
|
660
|
-
logger.error({ err }, "Error closing ConnectionProvider on heartbeat timeout");
|
|
661
|
-
});
|
|
659
|
+
this.connectionProvider.forceReconnect();
|
|
662
660
|
}
|
|
663
661
|
}
|
|
664
662
|
/**
|
|
@@ -951,14 +949,17 @@ var QueryManager = class {
|
|
|
951
949
|
}
|
|
952
950
|
/**
|
|
953
951
|
* Send query subscription message to server.
|
|
952
|
+
* Includes field projection when specified in the query filter.
|
|
954
953
|
*/
|
|
955
954
|
sendQuerySubscription(query) {
|
|
955
|
+
const filter = query.getFilter();
|
|
956
956
|
this.config.sendMessage({
|
|
957
957
|
type: "QUERY_SUB",
|
|
958
958
|
payload: {
|
|
959
959
|
queryId: query.id,
|
|
960
960
|
mapName: query.getMapName(),
|
|
961
|
-
query:
|
|
961
|
+
query: filter,
|
|
962
|
+
fields: filter.fields
|
|
962
963
|
}
|
|
963
964
|
});
|
|
964
965
|
}
|
|
@@ -1128,6 +1129,16 @@ var QueryManager = class {
|
|
|
1128
1129
|
logger.debug({ queryCount: this.queries.size, hybridCount: this.hybridQueries.size }, "QueryManager: resubscribing all queries");
|
|
1129
1130
|
for (const query of this.queries.values()) {
|
|
1130
1131
|
this.sendQuerySubscription(query);
|
|
1132
|
+
const filter = query.getFilter();
|
|
1133
|
+
if (filter.fields && filter.fields.length > 0 && query.merkleRootHash !== 0) {
|
|
1134
|
+
this.config.sendMessage({
|
|
1135
|
+
type: "QUERY_SYNC_INIT",
|
|
1136
|
+
payload: {
|
|
1137
|
+
queryId: query.id,
|
|
1138
|
+
rootHash: query.merkleRootHash
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1131
1142
|
}
|
|
1132
1143
|
for (const query of this.hybridQueries.values()) {
|
|
1133
1144
|
if (query.hasFTSPredicate()) {
|
|
@@ -1345,7 +1356,7 @@ var LockManager = class {
|
|
|
1345
1356
|
/**
|
|
1346
1357
|
* Handle lock granted message from server.
|
|
1347
1358
|
*/
|
|
1348
|
-
handleLockGranted(requestId, fencingToken) {
|
|
1359
|
+
handleLockGranted(requestId, _name, fencingToken) {
|
|
1349
1360
|
const req = this.pendingLockRequests.get(requestId);
|
|
1350
1361
|
if (req) {
|
|
1351
1362
|
clearTimeout(req.timer);
|
|
@@ -1356,7 +1367,7 @@ var LockManager = class {
|
|
|
1356
1367
|
/**
|
|
1357
1368
|
* Handle lock released message from server.
|
|
1358
1369
|
*/
|
|
1359
|
-
handleLockReleased(requestId, success) {
|
|
1370
|
+
handleLockReleased(requestId, _name, success) {
|
|
1360
1371
|
const req = this.pendingLockRequests.get(requestId);
|
|
1361
1372
|
if (req) {
|
|
1362
1373
|
clearTimeout(req.timer);
|
|
@@ -1773,6 +1784,8 @@ import { LWWMap } from "@topgunbuild/core";
|
|
|
1773
1784
|
var MerkleSyncHandler = class {
|
|
1774
1785
|
constructor(config) {
|
|
1775
1786
|
this.lastSyncTimestamp = 0;
|
|
1787
|
+
/** Accumulated sync stats per map, flushed after a quiet period */
|
|
1788
|
+
this.syncStats = /* @__PURE__ */ new Map();
|
|
1776
1789
|
this.config = config;
|
|
1777
1790
|
}
|
|
1778
1791
|
/**
|
|
@@ -1794,7 +1807,8 @@ var MerkleSyncHandler = class {
|
|
|
1794
1807
|
* Compares root hashes and requests buckets if mismatch detected.
|
|
1795
1808
|
*/
|
|
1796
1809
|
async handleSyncRespRoot(payload) {
|
|
1797
|
-
const { mapName,
|
|
1810
|
+
const { mapName, timestamp } = payload;
|
|
1811
|
+
const rootHash = Number(payload.rootHash);
|
|
1798
1812
|
const map = this.config.getMap(mapName);
|
|
1799
1813
|
if (map instanceof LWWMap) {
|
|
1800
1814
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
@@ -1822,9 +1836,11 @@ var MerkleSyncHandler = class {
|
|
|
1822
1836
|
if (map instanceof LWWMap) {
|
|
1823
1837
|
const tree = map.getMerkleTree();
|
|
1824
1838
|
const localBuckets = tree.getBuckets(path);
|
|
1839
|
+
let mismatchCount = 0;
|
|
1825
1840
|
for (const [bucketKey, remoteHash] of Object.entries(buckets)) {
|
|
1826
1841
|
const localHash = localBuckets[bucketKey] || 0;
|
|
1827
1842
|
if (localHash !== remoteHash) {
|
|
1843
|
+
mismatchCount++;
|
|
1828
1844
|
const newPath = path + bucketKey;
|
|
1829
1845
|
this.config.sendMessage({
|
|
1830
1846
|
type: "MERKLE_REQ_BUCKET",
|
|
@@ -1851,7 +1867,17 @@ var MerkleSyncHandler = class {
|
|
|
1851
1867
|
}
|
|
1852
1868
|
}
|
|
1853
1869
|
if (updateCount > 0) {
|
|
1854
|
-
|
|
1870
|
+
const existing = this.syncStats.get(mapName);
|
|
1871
|
+
if (existing) {
|
|
1872
|
+
existing.count += updateCount;
|
|
1873
|
+
clearTimeout(existing.timer);
|
|
1874
|
+
}
|
|
1875
|
+
const stats = existing ?? { count: updateCount, timer: void 0 };
|
|
1876
|
+
if (!existing) this.syncStats.set(mapName, stats);
|
|
1877
|
+
stats.timer = setTimeout(() => {
|
|
1878
|
+
logger.info({ mapName, count: stats.count }, "Synced records from server");
|
|
1879
|
+
this.syncStats.delete(mapName);
|
|
1880
|
+
}, 100);
|
|
1855
1881
|
}
|
|
1856
1882
|
}
|
|
1857
1883
|
}
|
|
@@ -2134,6 +2160,8 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2134
2160
|
},
|
|
2135
2161
|
// SYNC handlers
|
|
2136
2162
|
"OP_ACK": (msg) => delegates.handleOpAck(msg),
|
|
2163
|
+
"OP_REJECTED": (msg) => delegates.handleOpRejected(msg),
|
|
2164
|
+
"ERROR": (msg) => delegates.handleError(msg),
|
|
2137
2165
|
"SYNC_RESP_ROOT": (msg) => managers.merkleSyncHandler.handleSyncRespRoot(msg.payload),
|
|
2138
2166
|
"SYNC_RESP_BUCKETS": (msg) => managers.merkleSyncHandler.handleSyncRespBuckets(msg.payload),
|
|
2139
2167
|
"SYNC_RESP_LEAF": (msg) => managers.merkleSyncHandler.handleSyncRespLeaf(msg.payload),
|
|
@@ -2156,12 +2184,12 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2156
2184
|
},
|
|
2157
2185
|
// LOCK handlers
|
|
2158
2186
|
"LOCK_GRANTED": (msg) => {
|
|
2159
|
-
const { requestId, fencingToken } = msg.payload;
|
|
2160
|
-
managers.lockManager.handleLockGranted(requestId, fencingToken);
|
|
2187
|
+
const { requestId, name, fencingToken } = msg.payload;
|
|
2188
|
+
managers.lockManager.handleLockGranted(requestId, name, fencingToken);
|
|
2161
2189
|
},
|
|
2162
2190
|
"LOCK_RELEASED": (msg) => {
|
|
2163
|
-
const { requestId, success } = msg.payload;
|
|
2164
|
-
managers.lockManager.handleLockReleased(requestId, success);
|
|
2191
|
+
const { requestId, name, success } = msg.payload;
|
|
2192
|
+
managers.lockManager.handleLockReleased(requestId, name, success);
|
|
2165
2193
|
},
|
|
2166
2194
|
// GC handler
|
|
2167
2195
|
"GC_PRUNE": (msg) => delegates.handleGcPrune(msg),
|
|
@@ -2199,10 +2227,7 @@ function registerClientMessageHandlers(router, delegates, managers) {
|
|
|
2199
2227
|
managers.searchClient.handleSearchResponse(msg.payload);
|
|
2200
2228
|
},
|
|
2201
2229
|
"SEARCH_UPDATE": () => {
|
|
2202
|
-
}
|
|
2203
|
-
// HYBRID handlers
|
|
2204
|
-
"HYBRID_QUERY_RESP": (msg) => delegates.handleHybridQueryResponse(msg.payload),
|
|
2205
|
-
"HYBRID_QUERY_DELTA": (msg) => delegates.handleHybridQueryDelta(msg.payload)
|
|
2230
|
+
}
|
|
2206
2231
|
});
|
|
2207
2232
|
}
|
|
2208
2233
|
|
|
@@ -2337,8 +2362,8 @@ var SyncEngine = class {
|
|
|
2337
2362
|
handleServerEvent: (msg) => this.handleServerEvent(msg),
|
|
2338
2363
|
handleServerBatchEvent: (msg) => this.handleServerBatchEvent(msg),
|
|
2339
2364
|
handleGcPrune: (msg) => this.handleGcPrune(msg),
|
|
2340
|
-
|
|
2341
|
-
|
|
2365
|
+
handleOpRejected: (msg) => this.handleOpRejected(msg),
|
|
2366
|
+
handleError: (msg) => this.handleError(msg)
|
|
2342
2367
|
},
|
|
2343
2368
|
{
|
|
2344
2369
|
topicManager: this.topicManager,
|
|
@@ -2491,6 +2516,15 @@ var SyncEngine = class {
|
|
|
2491
2516
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
2492
2517
|
if (pending.length === 0) return;
|
|
2493
2518
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
2519
|
+
const connectionProvider = this.webSocketManager.getConnectionProvider();
|
|
2520
|
+
if (connectionProvider.sendBatch) {
|
|
2521
|
+
const results = connectionProvider.sendBatch(pending.map((op) => ({ key: op.key, message: op })));
|
|
2522
|
+
const failedKeys = [...results.entries()].filter(([, success]) => !success).map(([key]) => key);
|
|
2523
|
+
if (failedKeys.length > 0) {
|
|
2524
|
+
logger.warn({ failedKeys, count: failedKeys.length }, "Some batch operations failed to send");
|
|
2525
|
+
}
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2494
2528
|
this.sendMessage({
|
|
2495
2529
|
type: "OP_BATCH",
|
|
2496
2530
|
payload: {
|
|
@@ -2511,7 +2545,7 @@ var SyncEngine = class {
|
|
|
2511
2545
|
this.authToken = token;
|
|
2512
2546
|
this.tokenProvider = null;
|
|
2513
2547
|
const state = this.stateMachine.getState();
|
|
2514
|
-
if (state === "AUTHENTICATING" /* AUTHENTICATING */
|
|
2548
|
+
if (state === "AUTHENTICATING" /* AUTHENTICATING */) {
|
|
2515
2549
|
this.sendAuth();
|
|
2516
2550
|
} else if (state === "BACKOFF" /* BACKOFF */ || state === "DISCONNECTED" /* DISCONNECTED */) {
|
|
2517
2551
|
logger.info("Auth token set during backoff/disconnect. Reconnecting immediately.");
|
|
@@ -2616,9 +2650,10 @@ var SyncEngine = class {
|
|
|
2616
2650
|
return;
|
|
2617
2651
|
}
|
|
2618
2652
|
await this.messageRouter.route(message);
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
this.
|
|
2653
|
+
const ts = message.timestamp;
|
|
2654
|
+
if (ts && typeof ts === "object" && "millis" in ts && "counter" in ts && "nodeId" in ts) {
|
|
2655
|
+
this.hlc.update(ts);
|
|
2656
|
+
this.lastSyncTimestamp = Number(ts.millis);
|
|
2622
2657
|
await this.saveOpLog();
|
|
2623
2658
|
}
|
|
2624
2659
|
}
|
|
@@ -2672,20 +2707,37 @@ var SyncEngine = class {
|
|
|
2672
2707
|
this.writeConcernManager.resolveWriteConcernPromise(result.opId, result);
|
|
2673
2708
|
}
|
|
2674
2709
|
}
|
|
2710
|
+
const lastIdNum = parseInt(lastId, 10);
|
|
2675
2711
|
let maxSyncedId = -1;
|
|
2676
2712
|
let ackedCount = 0;
|
|
2677
|
-
|
|
2678
|
-
|
|
2713
|
+
if (!isNaN(lastIdNum)) {
|
|
2714
|
+
this.opLog.forEach((op) => {
|
|
2715
|
+
if (op.id) {
|
|
2716
|
+
const opIdNum = parseInt(op.id, 10);
|
|
2717
|
+
if (!isNaN(opIdNum) && opIdNum <= lastIdNum) {
|
|
2718
|
+
if (!op.synced) {
|
|
2719
|
+
ackedCount++;
|
|
2720
|
+
}
|
|
2721
|
+
op.synced = true;
|
|
2722
|
+
if (opIdNum > maxSyncedId) {
|
|
2723
|
+
maxSyncedId = opIdNum;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
} else {
|
|
2729
|
+
logger.warn({ lastId }, "OP_ACK has non-numeric lastId \u2014 marking all pending ops as synced");
|
|
2730
|
+
this.opLog.forEach((op) => {
|
|
2679
2731
|
if (!op.synced) {
|
|
2680
2732
|
ackedCount++;
|
|
2733
|
+
op.synced = true;
|
|
2734
|
+
const opIdNum = parseInt(op.id, 10);
|
|
2735
|
+
if (!isNaN(opIdNum) && opIdNum > maxSyncedId) {
|
|
2736
|
+
maxSyncedId = opIdNum;
|
|
2737
|
+
}
|
|
2681
2738
|
}
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
if (!isNaN(idNum) && idNum > maxSyncedId) {
|
|
2685
|
-
maxSyncedId = idNum;
|
|
2686
|
-
}
|
|
2687
|
-
}
|
|
2688
|
-
});
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2689
2741
|
if (maxSyncedId !== -1) {
|
|
2690
2742
|
this.storageAdapter.markOpsSynced(maxSyncedId).catch((err) => logger.error({ err }, "Failed to mark ops synced"));
|
|
2691
2743
|
}
|
|
@@ -2694,18 +2746,18 @@ var SyncEngine = class {
|
|
|
2694
2746
|
}
|
|
2695
2747
|
}
|
|
2696
2748
|
handleQueryResp(message) {
|
|
2697
|
-
const { queryId, results, nextCursor, hasMore, cursorStatus } = message.payload;
|
|
2749
|
+
const { queryId, results, nextCursor, hasMore, cursorStatus, merkleRootHash } = message.payload;
|
|
2698
2750
|
const query = this.queryManager.getQueries().get(queryId);
|
|
2699
2751
|
if (query) {
|
|
2700
|
-
query.onResult(results, "server");
|
|
2752
|
+
query.onResult(results, "server", merkleRootHash);
|
|
2701
2753
|
query.updatePaginationInfo({ nextCursor, hasMore, cursorStatus });
|
|
2702
2754
|
}
|
|
2703
2755
|
}
|
|
2704
2756
|
handleQueryUpdate(message) {
|
|
2705
|
-
const { queryId, key, value,
|
|
2757
|
+
const { queryId, key, value, changeType } = message.payload;
|
|
2706
2758
|
const query = this.queryManager.getQueries().get(queryId);
|
|
2707
2759
|
if (query) {
|
|
2708
|
-
query.onUpdate(key,
|
|
2760
|
+
query.onUpdate(key, changeType === "LEAVE" ? null : value);
|
|
2709
2761
|
}
|
|
2710
2762
|
}
|
|
2711
2763
|
async handleServerEvent(message) {
|
|
@@ -3110,31 +3162,24 @@ var SyncEngine = class {
|
|
|
3110
3162
|
return this.queryManager.runLocalHybridQuery(mapName, filter);
|
|
3111
3163
|
}
|
|
3112
3164
|
/**
|
|
3113
|
-
* Handle
|
|
3165
|
+
* Handle operation rejected by server (permission denied, validation failure, etc.).
|
|
3114
3166
|
*/
|
|
3115
|
-
|
|
3116
|
-
const
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
}
|
|
3167
|
+
handleOpRejected(message) {
|
|
3168
|
+
const { opId, reason, code } = message.payload;
|
|
3169
|
+
logger.warn({ opId, reason, code }, "Operation rejected by server");
|
|
3170
|
+
this.writeConcernManager.resolveWriteConcernPromise(opId, {
|
|
3171
|
+
opId,
|
|
3172
|
+
success: false,
|
|
3173
|
+
achievedLevel: "FIRE_AND_FORGET",
|
|
3174
|
+
error: reason
|
|
3175
|
+
});
|
|
3125
3176
|
}
|
|
3126
3177
|
/**
|
|
3127
|
-
* Handle
|
|
3178
|
+
* Handle generic error message from server.
|
|
3128
3179
|
*/
|
|
3129
|
-
|
|
3130
|
-
const
|
|
3131
|
-
|
|
3132
|
-
if (payload.type === "LEAVE") {
|
|
3133
|
-
query.onUpdate(payload.key, null);
|
|
3134
|
-
} else {
|
|
3135
|
-
query.onUpdate(payload.key, payload.value, payload.score, payload.matchedTerms);
|
|
3136
|
-
}
|
|
3137
|
-
}
|
|
3180
|
+
handleError(message) {
|
|
3181
|
+
const { code, message: errorMessage, details } = message.payload;
|
|
3182
|
+
logger.error({ code, message: errorMessage, details }, "Server error received");
|
|
3138
3183
|
}
|
|
3139
3184
|
};
|
|
3140
3185
|
|
|
@@ -3241,12 +3286,15 @@ var QueryHandle = class {
|
|
|
3241
3286
|
// Pagination info
|
|
3242
3287
|
this._paginationInfo = { hasMore: false, cursorStatus: "none" };
|
|
3243
3288
|
this.paginationListeners = /* @__PURE__ */ new Set();
|
|
3289
|
+
/** Merkle root hash from last server QUERY_RESP — used for delta reconnect */
|
|
3290
|
+
this.merkleRootHash = 0;
|
|
3244
3291
|
// Track if we've received authoritative server response
|
|
3245
3292
|
this.hasReceivedServerData = false;
|
|
3246
3293
|
this.id = crypto.randomUUID();
|
|
3247
3294
|
this.syncEngine = syncEngine;
|
|
3248
3295
|
this.mapName = mapName;
|
|
3249
3296
|
this.filter = filter;
|
|
3297
|
+
this.fields = filter.fields;
|
|
3250
3298
|
}
|
|
3251
3299
|
subscribe(callback) {
|
|
3252
3300
|
this.listeners.add(callback);
|
|
@@ -3282,7 +3330,7 @@ var QueryHandle = class {
|
|
|
3282
3330
|
* - This prevents clearing local data when server hasn't loaded from storage yet
|
|
3283
3331
|
* - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
|
|
3284
3332
|
*/
|
|
3285
|
-
onResult(items, source = "server") {
|
|
3333
|
+
onResult(items, source = "server", merkleRootHash) {
|
|
3286
3334
|
logger.debug({
|
|
3287
3335
|
mapName: this.mapName,
|
|
3288
3336
|
itemCount: items.length,
|
|
@@ -3297,6 +3345,9 @@ var QueryHandle = class {
|
|
|
3297
3345
|
if (source === "server" && items.length > 0) {
|
|
3298
3346
|
this.hasReceivedServerData = true;
|
|
3299
3347
|
}
|
|
3348
|
+
if (merkleRootHash !== void 0) {
|
|
3349
|
+
this.merkleRootHash = merkleRootHash;
|
|
3350
|
+
}
|
|
3300
3351
|
const newKeys = new Set(items.map((i) => i.key));
|
|
3301
3352
|
const removedKeys = [];
|
|
3302
3353
|
for (const key of this.currentResults.keys()) {
|
|
@@ -4018,8 +4069,8 @@ var SearchHandle = class {
|
|
|
4018
4069
|
handleSearchUpdate(message) {
|
|
4019
4070
|
if (message.type !== "SEARCH_UPDATE") return;
|
|
4020
4071
|
if (message.payload?.subscriptionId !== this.subscriptionId) return;
|
|
4021
|
-
const { key, value, score, matchedTerms,
|
|
4022
|
-
switch (
|
|
4072
|
+
const { key, value, score, matchedTerms, changeType } = message.payload;
|
|
4073
|
+
switch (changeType) {
|
|
4023
4074
|
case "ENTER":
|
|
4024
4075
|
this.results.set(key, {
|
|
4025
4076
|
key,
|
|
@@ -4343,6 +4394,30 @@ import {
|
|
|
4343
4394
|
DEFAULT_CONNECTION_POOL_CONFIG
|
|
4344
4395
|
} from "@topgunbuild/core";
|
|
4345
4396
|
import { serialize as serialize2, deserialize as deserialize3 } from "@topgunbuild/core";
|
|
4397
|
+
|
|
4398
|
+
// src/connection/WebSocketConnection.ts
|
|
4399
|
+
var ConnectionReadyState = {
|
|
4400
|
+
CONNECTING: 0,
|
|
4401
|
+
OPEN: 1,
|
|
4402
|
+
CLOSING: 2,
|
|
4403
|
+
CLOSED: 3
|
|
4404
|
+
};
|
|
4405
|
+
var WebSocketConnection = class {
|
|
4406
|
+
constructor(ws) {
|
|
4407
|
+
this.ws = ws;
|
|
4408
|
+
}
|
|
4409
|
+
send(data) {
|
|
4410
|
+
this.ws.send(data);
|
|
4411
|
+
}
|
|
4412
|
+
close() {
|
|
4413
|
+
this.ws.close();
|
|
4414
|
+
}
|
|
4415
|
+
get readyState() {
|
|
4416
|
+
return this.ws.readyState;
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
|
|
4420
|
+
// src/cluster/ConnectionPool.ts
|
|
4346
4421
|
var ConnectionPool = class {
|
|
4347
4422
|
constructor(config = {}) {
|
|
4348
4423
|
this.listeners = /* @__PURE__ */ new Map();
|
|
@@ -4414,10 +4489,17 @@ var ConnectionPool = class {
|
|
|
4414
4489
|
return;
|
|
4415
4490
|
}
|
|
4416
4491
|
}
|
|
4492
|
+
for (const [existingId, existingConn] of this.connections) {
|
|
4493
|
+
if (existingConn.endpoint === endpoint && existingId !== nodeId) {
|
|
4494
|
+
this.remapNodeId(existingId, nodeId);
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4417
4498
|
const connection = {
|
|
4418
4499
|
nodeId,
|
|
4419
4500
|
endpoint,
|
|
4420
4501
|
socket: null,
|
|
4502
|
+
cachedConnection: null,
|
|
4421
4503
|
state: "DISCONNECTED",
|
|
4422
4504
|
lastSeen: 0,
|
|
4423
4505
|
latencyMs: 0,
|
|
@@ -4452,15 +4534,34 @@ var ConnectionPool = class {
|
|
|
4452
4534
|
}
|
|
4453
4535
|
logger.info({ nodeId }, "Node removed from connection pool");
|
|
4454
4536
|
}
|
|
4537
|
+
/**
|
|
4538
|
+
* Remap a node from one ID to another, preserving the existing connection.
|
|
4539
|
+
* Used when the server-assigned node ID differs from the temporary seed ID.
|
|
4540
|
+
*/
|
|
4541
|
+
remapNodeId(oldId, newId) {
|
|
4542
|
+
const connection = this.connections.get(oldId);
|
|
4543
|
+
if (!connection) return;
|
|
4544
|
+
connection.nodeId = newId;
|
|
4545
|
+
this.connections.delete(oldId);
|
|
4546
|
+
this.connections.set(newId, connection);
|
|
4547
|
+
if (this.primaryNodeId === oldId) {
|
|
4548
|
+
this.primaryNodeId = newId;
|
|
4549
|
+
}
|
|
4550
|
+
logger.info({ oldId, newId }, "Node ID remapped");
|
|
4551
|
+
this.emit("node:remapped", oldId, newId);
|
|
4552
|
+
}
|
|
4455
4553
|
/**
|
|
4456
4554
|
* Get connection for a specific node
|
|
4457
4555
|
*/
|
|
4458
4556
|
getConnection(nodeId) {
|
|
4459
4557
|
const connection = this.connections.get(nodeId);
|
|
4460
|
-
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
4558
|
+
if (!connection || connection.state !== "CONNECTED" && connection.state !== "AUTHENTICATED" || !connection.socket) {
|
|
4461
4559
|
return null;
|
|
4462
4560
|
}
|
|
4463
|
-
|
|
4561
|
+
if (!connection.cachedConnection) {
|
|
4562
|
+
connection.cachedConnection = new WebSocketConnection(connection.socket);
|
|
4563
|
+
}
|
|
4564
|
+
return connection.cachedConnection;
|
|
4464
4565
|
}
|
|
4465
4566
|
/**
|
|
4466
4567
|
* Get primary connection (first/seed node)
|
|
@@ -4474,8 +4575,11 @@ var ConnectionPool = class {
|
|
|
4474
4575
|
*/
|
|
4475
4576
|
getAnyHealthyConnection() {
|
|
4476
4577
|
for (const [nodeId, conn] of this.connections) {
|
|
4477
|
-
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
4478
|
-
|
|
4578
|
+
if ((conn.state === "CONNECTED" || conn.state === "AUTHENTICATED") && conn.socket) {
|
|
4579
|
+
if (!conn.cachedConnection) {
|
|
4580
|
+
conn.cachedConnection = new WebSocketConnection(conn.socket);
|
|
4581
|
+
}
|
|
4582
|
+
return { nodeId, connection: conn.cachedConnection };
|
|
4479
4583
|
}
|
|
4480
4584
|
}
|
|
4481
4585
|
return null;
|
|
@@ -4531,7 +4635,7 @@ var ConnectionPool = class {
|
|
|
4531
4635
|
* Get list of connected node IDs
|
|
4532
4636
|
*/
|
|
4533
4637
|
getConnectedNodes() {
|
|
4534
|
-
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
4638
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "CONNECTED" || conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
4535
4639
|
}
|
|
4536
4640
|
/**
|
|
4537
4641
|
* Get all node IDs
|
|
@@ -4540,11 +4644,11 @@ var ConnectionPool = class {
|
|
|
4540
4644
|
return Array.from(this.connections.keys());
|
|
4541
4645
|
}
|
|
4542
4646
|
/**
|
|
4543
|
-
* Check if node
|
|
4647
|
+
* Check if node has an open WebSocket connection
|
|
4544
4648
|
*/
|
|
4545
4649
|
isNodeConnected(nodeId) {
|
|
4546
4650
|
const conn = this.connections.get(nodeId);
|
|
4547
|
-
return conn?.state === "AUTHENTICATED";
|
|
4651
|
+
return conn?.state === "CONNECTED" || conn?.state === "AUTHENTICATED";
|
|
4548
4652
|
}
|
|
4549
4653
|
/**
|
|
4550
4654
|
* Check if connected to a specific node.
|
|
@@ -4609,25 +4713,26 @@ var ConnectionPool = class {
|
|
|
4609
4713
|
};
|
|
4610
4714
|
socket.onmessage = (event) => {
|
|
4611
4715
|
connection.lastSeen = Date.now();
|
|
4612
|
-
this.handleMessage(nodeId, event);
|
|
4716
|
+
this.handleMessage(connection.nodeId, event);
|
|
4613
4717
|
};
|
|
4614
4718
|
socket.onerror = (error) => {
|
|
4615
|
-
logger.error({ nodeId, error }, "WebSocket error");
|
|
4616
|
-
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
4719
|
+
logger.error({ nodeId: connection.nodeId, error }, "WebSocket error");
|
|
4720
|
+
this.emit("error", connection.nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
4617
4721
|
};
|
|
4618
4722
|
socket.onclose = () => {
|
|
4619
4723
|
const wasConnected = connection.state === "AUTHENTICATED";
|
|
4620
4724
|
connection.state = "DISCONNECTED";
|
|
4621
4725
|
connection.socket = null;
|
|
4726
|
+
connection.cachedConnection = null;
|
|
4622
4727
|
if (wasConnected) {
|
|
4623
|
-
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
4728
|
+
this.emit("node:disconnected", connection.nodeId, "Connection closed");
|
|
4624
4729
|
}
|
|
4625
|
-
this.scheduleReconnect(nodeId);
|
|
4730
|
+
this.scheduleReconnect(connection.nodeId);
|
|
4626
4731
|
};
|
|
4627
4732
|
} catch (error) {
|
|
4628
4733
|
connection.state = "FAILED";
|
|
4629
|
-
logger.error({ nodeId, error }, "Failed to connect");
|
|
4630
|
-
this.scheduleReconnect(nodeId);
|
|
4734
|
+
logger.error({ nodeId: connection.nodeId, error }, "Failed to connect");
|
|
4735
|
+
this.scheduleReconnect(connection.nodeId);
|
|
4631
4736
|
}
|
|
4632
4737
|
}
|
|
4633
4738
|
sendAuth(connection) {
|
|
@@ -4656,18 +4761,15 @@ var ConnectionPool = class {
|
|
|
4656
4761
|
logger.info({ nodeId }, "Authenticated with node");
|
|
4657
4762
|
this.emit("node:healthy", nodeId);
|
|
4658
4763
|
this.flushPendingMessages(connection);
|
|
4659
|
-
return;
|
|
4660
4764
|
}
|
|
4661
4765
|
if (message.type === "AUTH_REQUIRED") {
|
|
4662
4766
|
if (this.authToken) {
|
|
4663
4767
|
this.sendAuth(connection);
|
|
4664
4768
|
}
|
|
4665
|
-
return;
|
|
4666
4769
|
}
|
|
4667
4770
|
if (message.type === "AUTH_FAIL") {
|
|
4668
4771
|
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
4669
4772
|
connection.state = "FAILED";
|
|
4670
|
-
return;
|
|
4671
4773
|
}
|
|
4672
4774
|
if (message.type === "PONG") {
|
|
4673
4775
|
if (message.timestamp) {
|
|
@@ -4675,10 +4777,6 @@ var ConnectionPool = class {
|
|
|
4675
4777
|
}
|
|
4676
4778
|
return;
|
|
4677
4779
|
}
|
|
4678
|
-
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
4679
|
-
this.emit("message", nodeId, message);
|
|
4680
|
-
return;
|
|
4681
|
-
}
|
|
4682
4780
|
this.emit("message", nodeId, message);
|
|
4683
4781
|
}
|
|
4684
4782
|
flushPendingMessages(connection) {
|
|
@@ -4849,17 +4947,17 @@ var PartitionRouter = class {
|
|
|
4849
4947
|
}
|
|
4850
4948
|
return null;
|
|
4851
4949
|
}
|
|
4852
|
-
const
|
|
4853
|
-
if (
|
|
4854
|
-
return { nodeId: routing.nodeId,
|
|
4950
|
+
const connection = this.connectionPool.getConnection(routing.nodeId);
|
|
4951
|
+
if (connection) {
|
|
4952
|
+
return { nodeId: routing.nodeId, connection };
|
|
4855
4953
|
}
|
|
4856
4954
|
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
4857
4955
|
if (partition) {
|
|
4858
4956
|
for (const backupId of partition.backupNodeIds) {
|
|
4859
|
-
const
|
|
4860
|
-
if (
|
|
4957
|
+
const backupConnection = this.connectionPool.getConnection(backupId);
|
|
4958
|
+
if (backupConnection) {
|
|
4861
4959
|
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
4862
|
-
return { nodeId: backupId,
|
|
4960
|
+
return { nodeId: backupId, connection: backupConnection };
|
|
4863
4961
|
}
|
|
4864
4962
|
}
|
|
4865
4963
|
}
|
|
@@ -5135,7 +5233,7 @@ var PartitionRouter = class {
|
|
|
5135
5233
|
};
|
|
5136
5234
|
|
|
5137
5235
|
// src/cluster/ClusterClient.ts
|
|
5138
|
-
var
|
|
5236
|
+
var _ClusterClient = class _ClusterClient {
|
|
5139
5237
|
constructor(config) {
|
|
5140
5238
|
this.listeners = /* @__PURE__ */ new Map();
|
|
5141
5239
|
this.initialized = false;
|
|
@@ -5148,6 +5246,8 @@ var ClusterClient = class {
|
|
|
5148
5246
|
};
|
|
5149
5247
|
// Circuit breaker state per node
|
|
5150
5248
|
this.circuits = /* @__PURE__ */ new Map();
|
|
5249
|
+
// Debounce timer for partition map requests on reconnect
|
|
5250
|
+
this.partitionMapRequestTimer = null;
|
|
5151
5251
|
this.config = config;
|
|
5152
5252
|
this.circuitBreakerConfig = {
|
|
5153
5253
|
...DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
@@ -5239,14 +5339,14 @@ var ClusterClient = class {
|
|
|
5239
5339
|
this.requestPartitionMapRefresh();
|
|
5240
5340
|
return this.getFallbackConnection();
|
|
5241
5341
|
}
|
|
5242
|
-
const
|
|
5243
|
-
if (!
|
|
5342
|
+
const connection = this.connectionPool.getConnection(owner);
|
|
5343
|
+
if (!connection) {
|
|
5244
5344
|
this.routingMetrics.fallbackRoutes++;
|
|
5245
5345
|
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
5246
5346
|
return this.getFallbackConnection();
|
|
5247
5347
|
}
|
|
5248
5348
|
this.routingMetrics.directRoutes++;
|
|
5249
|
-
return
|
|
5349
|
+
return connection;
|
|
5250
5350
|
}
|
|
5251
5351
|
/**
|
|
5252
5352
|
* Get fallback connection when owner is unavailable.
|
|
@@ -5254,10 +5354,10 @@ var ClusterClient = class {
|
|
|
5254
5354
|
*/
|
|
5255
5355
|
getFallbackConnection() {
|
|
5256
5356
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5257
|
-
if (!conn?.
|
|
5357
|
+
if (!conn?.connection) {
|
|
5258
5358
|
throw new Error("No healthy connection available");
|
|
5259
5359
|
}
|
|
5260
|
-
return conn.
|
|
5360
|
+
return conn.connection;
|
|
5261
5361
|
}
|
|
5262
5362
|
/**
|
|
5263
5363
|
* Request a partition map refresh in the background.
|
|
@@ -5268,9 +5368,23 @@ var ClusterClient = class {
|
|
|
5268
5368
|
logger.error({ err }, "Failed to refresh partition map");
|
|
5269
5369
|
});
|
|
5270
5370
|
}
|
|
5371
|
+
/**
|
|
5372
|
+
* Debounce partition map requests to prevent flooding when multiple nodes
|
|
5373
|
+
* reconnect simultaneously. Coalesces rapid requests into a single request
|
|
5374
|
+
* sent to the most recently connected node.
|
|
5375
|
+
*/
|
|
5376
|
+
debouncedPartitionMapRequest(nodeId) {
|
|
5377
|
+
if (this.partitionMapRequestTimer) {
|
|
5378
|
+
clearTimeout(this.partitionMapRequestTimer);
|
|
5379
|
+
}
|
|
5380
|
+
this.partitionMapRequestTimer = setTimeout(() => {
|
|
5381
|
+
this.partitionMapRequestTimer = null;
|
|
5382
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
5383
|
+
}, _ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS);
|
|
5384
|
+
}
|
|
5271
5385
|
/**
|
|
5272
5386
|
* Request partition map from a specific node.
|
|
5273
|
-
* Called on
|
|
5387
|
+
* Called on node connection via debounced handler.
|
|
5274
5388
|
*/
|
|
5275
5389
|
requestPartitionMapFromNode(nodeId) {
|
|
5276
5390
|
const socket = this.connectionPool.getConnection(nodeId);
|
|
@@ -5439,8 +5553,8 @@ var ClusterClient = class {
|
|
|
5439
5553
|
* Send directly to partition owner
|
|
5440
5554
|
*/
|
|
5441
5555
|
sendDirect(key, message) {
|
|
5442
|
-
const
|
|
5443
|
-
if (!
|
|
5556
|
+
const route = this.partitionRouter.routeToConnection(key);
|
|
5557
|
+
if (!route) {
|
|
5444
5558
|
logger.warn({ key }, "No route available for key");
|
|
5445
5559
|
return false;
|
|
5446
5560
|
}
|
|
@@ -5451,7 +5565,7 @@ var ClusterClient = class {
|
|
|
5451
5565
|
mapVersion: this.partitionRouter.getMapVersion()
|
|
5452
5566
|
}
|
|
5453
5567
|
};
|
|
5454
|
-
connection.
|
|
5568
|
+
route.connection.send(serialize3(routedMessage));
|
|
5455
5569
|
return true;
|
|
5456
5570
|
}
|
|
5457
5571
|
/**
|
|
@@ -5555,10 +5669,23 @@ var ClusterClient = class {
|
|
|
5555
5669
|
async refreshPartitionMap() {
|
|
5556
5670
|
await this.partitionRouter.refreshPartitionMap();
|
|
5557
5671
|
}
|
|
5672
|
+
/**
|
|
5673
|
+
* Force reconnect all connections in the pool.
|
|
5674
|
+
*/
|
|
5675
|
+
forceReconnect() {
|
|
5676
|
+
this.connectionPool.close();
|
|
5677
|
+
this.connect().catch((err) => {
|
|
5678
|
+
logger.error({ err }, "ClusterClient forceReconnect failed");
|
|
5679
|
+
});
|
|
5680
|
+
}
|
|
5558
5681
|
/**
|
|
5559
5682
|
* Shutdown cluster client (IConnectionProvider interface).
|
|
5560
5683
|
*/
|
|
5561
5684
|
async close() {
|
|
5685
|
+
if (this.partitionMapRequestTimer) {
|
|
5686
|
+
clearTimeout(this.partitionMapRequestTimer);
|
|
5687
|
+
this.partitionMapRequestTimer = null;
|
|
5688
|
+
}
|
|
5562
5689
|
this.partitionRouter.close();
|
|
5563
5690
|
this.connectionPool.close();
|
|
5564
5691
|
this.initialized = false;
|
|
@@ -5581,23 +5708,23 @@ var ClusterClient = class {
|
|
|
5581
5708
|
return this.partitionRouter;
|
|
5582
5709
|
}
|
|
5583
5710
|
/**
|
|
5584
|
-
* Get any healthy
|
|
5711
|
+
* Get any healthy connection (IConnectionProvider interface).
|
|
5585
5712
|
* @throws Error if not connected
|
|
5586
5713
|
*/
|
|
5587
5714
|
getAnyConnection() {
|
|
5588
5715
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5589
|
-
if (!conn?.
|
|
5716
|
+
if (!conn?.connection) {
|
|
5590
5717
|
throw new Error("No healthy connection available");
|
|
5591
5718
|
}
|
|
5592
|
-
return conn.
|
|
5719
|
+
return conn.connection;
|
|
5593
5720
|
}
|
|
5594
5721
|
/**
|
|
5595
|
-
* Get any healthy
|
|
5722
|
+
* Get any healthy connection, or null if none available.
|
|
5596
5723
|
* Use this for optional connection checks.
|
|
5597
5724
|
*/
|
|
5598
5725
|
getAnyConnectionOrNull() {
|
|
5599
5726
|
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
5600
|
-
return conn?.
|
|
5727
|
+
return conn?.connection ?? null;
|
|
5601
5728
|
}
|
|
5602
5729
|
// ============================================
|
|
5603
5730
|
// Circuit Breaker Methods
|
|
@@ -5688,9 +5815,7 @@ var ClusterClient = class {
|
|
|
5688
5815
|
setupEventHandlers() {
|
|
5689
5816
|
this.connectionPool.on("node:connected", (nodeId) => {
|
|
5690
5817
|
logger.debug({ nodeId }, "Node connected");
|
|
5691
|
-
|
|
5692
|
-
this.requestPartitionMapFromNode(nodeId);
|
|
5693
|
-
}
|
|
5818
|
+
this.debouncedPartitionMapRequest(nodeId);
|
|
5694
5819
|
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
5695
5820
|
this.emit("connected");
|
|
5696
5821
|
}
|
|
@@ -5745,6 +5870,8 @@ var ClusterClient = class {
|
|
|
5745
5870
|
});
|
|
5746
5871
|
}
|
|
5747
5872
|
};
|
|
5873
|
+
_ClusterClient.PARTITION_MAP_REQUEST_DEBOUNCE_MS = 500;
|
|
5874
|
+
var ClusterClient = _ClusterClient;
|
|
5748
5875
|
|
|
5749
5876
|
// src/connection/SingleServerProvider.ts
|
|
5750
5877
|
var DEFAULT_CONFIG = {
|
|
@@ -5760,14 +5887,18 @@ var SingleServerProvider = class {
|
|
|
5760
5887
|
this.reconnectTimer = null;
|
|
5761
5888
|
this.isClosing = false;
|
|
5762
5889
|
this.listeners = /* @__PURE__ */ new Map();
|
|
5890
|
+
this.onlineHandler = null;
|
|
5891
|
+
this.offlineHandler = null;
|
|
5763
5892
|
this.url = config.url;
|
|
5764
5893
|
this.config = {
|
|
5765
5894
|
url: config.url,
|
|
5766
5895
|
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
5767
5896
|
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
5768
5897
|
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
5769
|
-
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
5898
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs,
|
|
5899
|
+
listenNetworkEvents: config.listenNetworkEvents ?? true
|
|
5770
5900
|
};
|
|
5901
|
+
this.setupNetworkListeners();
|
|
5771
5902
|
}
|
|
5772
5903
|
/**
|
|
5773
5904
|
* Connect to the WebSocket server.
|
|
@@ -5776,6 +5907,9 @@ var SingleServerProvider = class {
|
|
|
5776
5907
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
5777
5908
|
return;
|
|
5778
5909
|
}
|
|
5910
|
+
if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false) {
|
|
5911
|
+
throw new Error("Browser is offline \u2014 skipping connection attempt");
|
|
5912
|
+
}
|
|
5779
5913
|
this.isClosing = false;
|
|
5780
5914
|
return new Promise((resolve, reject) => {
|
|
5781
5915
|
try {
|
|
@@ -5828,7 +5962,7 @@ var SingleServerProvider = class {
|
|
|
5828
5962
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
5829
5963
|
throw new Error("Not connected");
|
|
5830
5964
|
}
|
|
5831
|
-
return this.ws;
|
|
5965
|
+
return new WebSocketConnection(this.ws);
|
|
5832
5966
|
}
|
|
5833
5967
|
/**
|
|
5834
5968
|
* Get any available connection.
|
|
@@ -5879,6 +6013,7 @@ var SingleServerProvider = class {
|
|
|
5879
6013
|
*/
|
|
5880
6014
|
async close() {
|
|
5881
6015
|
this.isClosing = true;
|
|
6016
|
+
this.teardownNetworkListeners();
|
|
5882
6017
|
if (this.reconnectTimer) {
|
|
5883
6018
|
clearTimeout(this.reconnectTimer);
|
|
5884
6019
|
this.reconnectTimer = null;
|
|
@@ -5917,6 +6052,10 @@ var SingleServerProvider = class {
|
|
|
5917
6052
|
clearTimeout(this.reconnectTimer);
|
|
5918
6053
|
this.reconnectTimer = null;
|
|
5919
6054
|
}
|
|
6055
|
+
if (typeof globalThis.navigator !== "undefined" && globalThis.navigator.onLine === false && this.config.listenNetworkEvents) {
|
|
6056
|
+
logger.info({ url: this.url }, "Browser offline \u2014 waiting for online event instead of polling");
|
|
6057
|
+
return;
|
|
6058
|
+
}
|
|
5920
6059
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
5921
6060
|
logger.error(
|
|
5922
6061
|
{ attempts: this.reconnectAttempts, url: this.url },
|
|
@@ -5952,6 +6091,37 @@ var SingleServerProvider = class {
|
|
|
5952
6091
|
delay = delay * (0.5 + Math.random());
|
|
5953
6092
|
return Math.floor(delay);
|
|
5954
6093
|
}
|
|
6094
|
+
/**
|
|
6095
|
+
* Force-close the current WebSocket and immediately schedule reconnection.
|
|
6096
|
+
* Unlike close(), this does NOT set isClosing and preserves reconnect behavior.
|
|
6097
|
+
* Resets the reconnect counter so the full backoff budget is available.
|
|
6098
|
+
*
|
|
6099
|
+
* Critically, this does NOT wait for the TCP close handshake (which can
|
|
6100
|
+
* hang 20+ seconds on a dead network). Instead it strips all handlers from
|
|
6101
|
+
* the old WebSocket, fires a best-effort close(), nulls the reference, and
|
|
6102
|
+
* schedules reconnect right away.
|
|
6103
|
+
*/
|
|
6104
|
+
forceReconnect() {
|
|
6105
|
+
this.reconnectAttempts = 0;
|
|
6106
|
+
this.isClosing = false;
|
|
6107
|
+
if (this.reconnectTimer) {
|
|
6108
|
+
clearTimeout(this.reconnectTimer);
|
|
6109
|
+
this.reconnectTimer = null;
|
|
6110
|
+
}
|
|
6111
|
+
if (this.ws) {
|
|
6112
|
+
this.ws.onopen = null;
|
|
6113
|
+
this.ws.onclose = null;
|
|
6114
|
+
this.ws.onerror = null;
|
|
6115
|
+
this.ws.onmessage = null;
|
|
6116
|
+
try {
|
|
6117
|
+
this.ws.close();
|
|
6118
|
+
} catch {
|
|
6119
|
+
}
|
|
6120
|
+
this.ws = null;
|
|
6121
|
+
}
|
|
6122
|
+
this.emit("disconnected", "default");
|
|
6123
|
+
this.scheduleReconnect();
|
|
6124
|
+
}
|
|
5955
6125
|
/**
|
|
5956
6126
|
* Get the WebSocket URL this provider connects to.
|
|
5957
6127
|
*/
|
|
@@ -5971,6 +6141,43 @@ var SingleServerProvider = class {
|
|
|
5971
6141
|
resetReconnectAttempts() {
|
|
5972
6142
|
this.reconnectAttempts = 0;
|
|
5973
6143
|
}
|
|
6144
|
+
/**
|
|
6145
|
+
* Listen for browser 'online' event to trigger instant reconnect
|
|
6146
|
+
* when network comes back. Only active in browser environments.
|
|
6147
|
+
*/
|
|
6148
|
+
setupNetworkListeners() {
|
|
6149
|
+
if (!this.config.listenNetworkEvents) return;
|
|
6150
|
+
if (typeof globalThis.addEventListener !== "function") return;
|
|
6151
|
+
this.onlineHandler = () => {
|
|
6152
|
+
if (this.isClosing) return;
|
|
6153
|
+
if (this.isConnected()) return;
|
|
6154
|
+
logger.info({ url: this.url }, "Network online detected \u2014 forcing reconnect");
|
|
6155
|
+
this.forceReconnect();
|
|
6156
|
+
};
|
|
6157
|
+
this.offlineHandler = () => {
|
|
6158
|
+
if (this.isClosing) return;
|
|
6159
|
+
if (!this.isConnected()) return;
|
|
6160
|
+
logger.info({ url: this.url }, "Network offline detected \u2014 disconnecting immediately");
|
|
6161
|
+
this.forceReconnect();
|
|
6162
|
+
};
|
|
6163
|
+
globalThis.addEventListener("online", this.onlineHandler);
|
|
6164
|
+
globalThis.addEventListener("offline", this.offlineHandler);
|
|
6165
|
+
}
|
|
6166
|
+
/**
|
|
6167
|
+
* Remove browser network event listeners.
|
|
6168
|
+
*/
|
|
6169
|
+
teardownNetworkListeners() {
|
|
6170
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
6171
|
+
if (this.onlineHandler) {
|
|
6172
|
+
globalThis.removeEventListener("online", this.onlineHandler);
|
|
6173
|
+
this.onlineHandler = null;
|
|
6174
|
+
}
|
|
6175
|
+
if (this.offlineHandler) {
|
|
6176
|
+
globalThis.removeEventListener("offline", this.offlineHandler);
|
|
6177
|
+
this.offlineHandler = null;
|
|
6178
|
+
}
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
5974
6181
|
};
|
|
5975
6182
|
|
|
5976
6183
|
// src/TopGunClient.ts
|
|
@@ -6028,7 +6235,13 @@ var TopGunClient = class {
|
|
|
6028
6235
|
});
|
|
6029
6236
|
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
6030
6237
|
} else {
|
|
6031
|
-
const singleServerProvider = new SingleServerProvider({
|
|
6238
|
+
const singleServerProvider = new SingleServerProvider({
|
|
6239
|
+
url: config.serverUrl,
|
|
6240
|
+
maxReconnectAttempts: config.backoff?.maxRetries,
|
|
6241
|
+
reconnectDelayMs: config.backoff?.initialDelayMs,
|
|
6242
|
+
backoffMultiplier: config.backoff?.multiplier,
|
|
6243
|
+
maxReconnectDelayMs: config.backoff?.maxDelayMs
|
|
6244
|
+
});
|
|
6032
6245
|
this.syncEngine = new SyncEngine({
|
|
6033
6246
|
nodeId: this.nodeId,
|
|
6034
6247
|
connectionProvider: singleServerProvider,
|
|
@@ -7078,6 +7291,24 @@ import { LWWMap as LWWMap4, Predicates } from "@topgunbuild/core";
|
|
|
7078
7291
|
|
|
7079
7292
|
// src/connection/HttpSyncProvider.ts
|
|
7080
7293
|
import { serialize as serialize5, deserialize as deserialize5 } from "@topgunbuild/core";
|
|
7294
|
+
var HttpConnection = class {
|
|
7295
|
+
constructor(provider) {
|
|
7296
|
+
this.provider = provider;
|
|
7297
|
+
}
|
|
7298
|
+
send(data) {
|
|
7299
|
+
if (typeof data === "string") {
|
|
7300
|
+
const encoder = new TextEncoder();
|
|
7301
|
+
this.provider.send(encoder.encode(data));
|
|
7302
|
+
} else {
|
|
7303
|
+
this.provider.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
7304
|
+
}
|
|
7305
|
+
}
|
|
7306
|
+
close() {
|
|
7307
|
+
}
|
|
7308
|
+
get readyState() {
|
|
7309
|
+
return this.provider.isConnected() ? ConnectionReadyState.OPEN : ConnectionReadyState.CLOSED;
|
|
7310
|
+
}
|
|
7311
|
+
};
|
|
7081
7312
|
var HttpSyncProvider = class {
|
|
7082
7313
|
constructor(config) {
|
|
7083
7314
|
this.listeners = /* @__PURE__ */ new Map();
|
|
@@ -7119,17 +7350,17 @@ var HttpSyncProvider = class {
|
|
|
7119
7350
|
}
|
|
7120
7351
|
/**
|
|
7121
7352
|
* Get connection for a specific key.
|
|
7122
|
-
*
|
|
7353
|
+
* Returns an HttpConnection that queues operations for the next poll cycle.
|
|
7123
7354
|
*/
|
|
7124
7355
|
getConnection(_key) {
|
|
7125
|
-
|
|
7356
|
+
return new HttpConnection(this);
|
|
7126
7357
|
}
|
|
7127
7358
|
/**
|
|
7128
7359
|
* Get any available connection.
|
|
7129
|
-
*
|
|
7360
|
+
* Returns an HttpConnection that queues operations for the next poll cycle.
|
|
7130
7361
|
*/
|
|
7131
7362
|
getAnyConnection() {
|
|
7132
|
-
|
|
7363
|
+
return new HttpConnection(this);
|
|
7133
7364
|
}
|
|
7134
7365
|
/**
|
|
7135
7366
|
* Check if connected (last HTTP request succeeded).
|
|
@@ -7210,6 +7441,17 @@ var HttpSyncProvider = class {
|
|
|
7210
7441
|
logger.warn({ err }, "HTTP sync provider: failed to deserialize message");
|
|
7211
7442
|
}
|
|
7212
7443
|
}
|
|
7444
|
+
/**
|
|
7445
|
+
* Force reconnect by restarting the polling loop.
|
|
7446
|
+
*/
|
|
7447
|
+
forceReconnect() {
|
|
7448
|
+
this.stopPolling();
|
|
7449
|
+
this.connected = false;
|
|
7450
|
+
this.emit("disconnected", "default");
|
|
7451
|
+
this.connect().catch((err) => {
|
|
7452
|
+
logger.error({ err }, "HttpSyncProvider forceReconnect failed");
|
|
7453
|
+
});
|
|
7454
|
+
}
|
|
7213
7455
|
/**
|
|
7214
7456
|
* Close the HTTP sync provider.
|
|
7215
7457
|
* Stops the polling loop, clears queued operations, and sets disconnected state.
|
|
@@ -7482,6 +7724,14 @@ var AutoConnectionProvider = class {
|
|
|
7482
7724
|
}
|
|
7483
7725
|
this.activeProvider.send(data, key);
|
|
7484
7726
|
}
|
|
7727
|
+
/**
|
|
7728
|
+
* Force reconnect by delegating to the active provider.
|
|
7729
|
+
*/
|
|
7730
|
+
forceReconnect() {
|
|
7731
|
+
if (this.activeProvider) {
|
|
7732
|
+
this.activeProvider.forceReconnect();
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7485
7735
|
/**
|
|
7486
7736
|
* Close the active underlying provider.
|
|
7487
7737
|
*/
|
|
@@ -7538,6 +7788,7 @@ export {
|
|
|
7538
7788
|
ClusterClient,
|
|
7539
7789
|
ConflictResolverClient,
|
|
7540
7790
|
ConnectionPool,
|
|
7791
|
+
ConnectionReadyState,
|
|
7541
7792
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
7542
7793
|
DEFAULT_CLUSTER_CONFIG,
|
|
7543
7794
|
EncryptedStorageAdapter,
|
|
@@ -7559,6 +7810,7 @@ export {
|
|
|
7559
7810
|
TopGunClient,
|
|
7560
7811
|
TopicHandle,
|
|
7561
7812
|
VALID_TRANSITIONS,
|
|
7813
|
+
WebSocketConnection,
|
|
7562
7814
|
isValidTransition,
|
|
7563
7815
|
logger
|
|
7564
7816
|
};
|