@topgunbuild/server 0.1.0 → 0.2.0-alpha
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.d.mts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +261 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -199,6 +199,7 @@ declare class ServerCoordinator {
|
|
|
199
199
|
private systemManager;
|
|
200
200
|
private pendingClusterQueries;
|
|
201
201
|
private gcInterval?;
|
|
202
|
+
private heartbeatCheckInterval?;
|
|
202
203
|
private gcReports;
|
|
203
204
|
private mapLoadingPromises;
|
|
204
205
|
private _actualPort;
|
|
@@ -231,6 +232,27 @@ declare class ServerCoordinator {
|
|
|
231
232
|
getMapAsync(name: string, typeHint?: 'LWW' | 'OR'): Promise<LWWMap<string, any> | ORMap<string, any>>;
|
|
232
233
|
private loadMapFromStorage;
|
|
233
234
|
private startGarbageCollection;
|
|
235
|
+
/**
|
|
236
|
+
* Starts the periodic check for dead clients (those that haven't sent PING).
|
|
237
|
+
*/
|
|
238
|
+
private startHeartbeatCheck;
|
|
239
|
+
/**
|
|
240
|
+
* Handles incoming PING message from client.
|
|
241
|
+
* Responds with PONG immediately.
|
|
242
|
+
*/
|
|
243
|
+
private handlePing;
|
|
244
|
+
/**
|
|
245
|
+
* Checks if a client is still alive based on heartbeat.
|
|
246
|
+
*/
|
|
247
|
+
isClientAlive(clientId: string): boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Returns how long the client has been idle (no PING received).
|
|
250
|
+
*/
|
|
251
|
+
getClientIdleTime(clientId: string): number;
|
|
252
|
+
/**
|
|
253
|
+
* Evicts clients that haven't sent a PING within the timeout period.
|
|
254
|
+
*/
|
|
255
|
+
private evictDeadClients;
|
|
234
256
|
private reportLocalHlc;
|
|
235
257
|
private handleGcReport;
|
|
236
258
|
private performGarbageCollection;
|
package/dist/index.d.ts
CHANGED
|
@@ -199,6 +199,7 @@ declare class ServerCoordinator {
|
|
|
199
199
|
private systemManager;
|
|
200
200
|
private pendingClusterQueries;
|
|
201
201
|
private gcInterval?;
|
|
202
|
+
private heartbeatCheckInterval?;
|
|
202
203
|
private gcReports;
|
|
203
204
|
private mapLoadingPromises;
|
|
204
205
|
private _actualPort;
|
|
@@ -231,6 +232,27 @@ declare class ServerCoordinator {
|
|
|
231
232
|
getMapAsync(name: string, typeHint?: 'LWW' | 'OR'): Promise<LWWMap<string, any> | ORMap<string, any>>;
|
|
232
233
|
private loadMapFromStorage;
|
|
233
234
|
private startGarbageCollection;
|
|
235
|
+
/**
|
|
236
|
+
* Starts the periodic check for dead clients (those that haven't sent PING).
|
|
237
|
+
*/
|
|
238
|
+
private startHeartbeatCheck;
|
|
239
|
+
/**
|
|
240
|
+
* Handles incoming PING message from client.
|
|
241
|
+
* Responds with PONG immediately.
|
|
242
|
+
*/
|
|
243
|
+
private handlePing;
|
|
244
|
+
/**
|
|
245
|
+
* Checks if a client is still alive based on heartbeat.
|
|
246
|
+
*/
|
|
247
|
+
isClientAlive(clientId: string): boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Returns how long the client has been idle (no PING received).
|
|
250
|
+
*/
|
|
251
|
+
getClientIdleTime(clientId: string): number;
|
|
252
|
+
/**
|
|
253
|
+
* Evicts clients that haven't sent a PING within the timeout period.
|
|
254
|
+
*/
|
|
255
|
+
private evictDeadClients;
|
|
234
256
|
private reportLocalHlc;
|
|
235
257
|
private handleGcReport;
|
|
236
258
|
private performGarbageCollection;
|
package/dist/index.js
CHANGED
|
@@ -1251,6 +1251,8 @@ var SystemManager = class {
|
|
|
1251
1251
|
// src/ServerCoordinator.ts
|
|
1252
1252
|
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
1253
1253
|
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1254
|
+
var CLIENT_HEARTBEAT_TIMEOUT_MS = 2e4;
|
|
1255
|
+
var CLIENT_HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
1254
1256
|
var ServerCoordinator = class {
|
|
1255
1257
|
constructor(config) {
|
|
1256
1258
|
this.clients = /* @__PURE__ */ new Map();
|
|
@@ -1371,6 +1373,7 @@ var ServerCoordinator = class {
|
|
|
1371
1373
|
});
|
|
1372
1374
|
}
|
|
1373
1375
|
this.startGarbageCollection();
|
|
1376
|
+
this.startHeartbeatCheck();
|
|
1374
1377
|
}
|
|
1375
1378
|
/** Wait for server to be fully ready (ports assigned) */
|
|
1376
1379
|
ready() {
|
|
@@ -1421,6 +1424,10 @@ var ServerCoordinator = class {
|
|
|
1421
1424
|
clearInterval(this.gcInterval);
|
|
1422
1425
|
this.gcInterval = void 0;
|
|
1423
1426
|
}
|
|
1427
|
+
if (this.heartbeatCheckInterval) {
|
|
1428
|
+
clearInterval(this.heartbeatCheckInterval);
|
|
1429
|
+
this.heartbeatCheckInterval = void 0;
|
|
1430
|
+
}
|
|
1424
1431
|
if (this.lockManager) {
|
|
1425
1432
|
this.lockManager.stop();
|
|
1426
1433
|
}
|
|
@@ -1437,8 +1444,10 @@ var ServerCoordinator = class {
|
|
|
1437
1444
|
socket: ws,
|
|
1438
1445
|
isAuthenticated: false,
|
|
1439
1446
|
subscriptions: /* @__PURE__ */ new Set(),
|
|
1440
|
-
lastActiveHlc: this.hlc.now()
|
|
1447
|
+
lastActiveHlc: this.hlc.now(),
|
|
1441
1448
|
// Initialize with current time
|
|
1449
|
+
lastPingReceived: Date.now()
|
|
1450
|
+
// Initialize heartbeat tracking
|
|
1442
1451
|
};
|
|
1443
1452
|
this.clients.set(clientId, connection);
|
|
1444
1453
|
this.metricsService.setConnectedClients(this.clients.size);
|
|
@@ -1534,6 +1543,10 @@ var ServerCoordinator = class {
|
|
|
1534
1543
|
return;
|
|
1535
1544
|
}
|
|
1536
1545
|
const message = parseResult.data;
|
|
1546
|
+
if (message.type === "PING") {
|
|
1547
|
+
this.handlePing(client, message.timestamp);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1537
1550
|
this.updateClientHlc(client, message);
|
|
1538
1551
|
if (!client.isAuthenticated) {
|
|
1539
1552
|
if (message.type === "AUTH") {
|
|
@@ -1900,6 +1913,184 @@ var ServerCoordinator = class {
|
|
|
1900
1913
|
}
|
|
1901
1914
|
break;
|
|
1902
1915
|
}
|
|
1916
|
+
// ============ ORMap Sync Message Handlers ============
|
|
1917
|
+
case "ORMAP_SYNC_INIT": {
|
|
1918
|
+
if (!this.securityManager.checkPermission(client.principal, message.mapName, "READ")) {
|
|
1919
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1920
|
+
type: "ERROR",
|
|
1921
|
+
payload: { code: 403, message: `Access Denied for map ${message.mapName}` }
|
|
1922
|
+
}));
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const lastSync = message.lastSyncTimestamp || 0;
|
|
1926
|
+
const now = Date.now();
|
|
1927
|
+
if (lastSync > 0 && now - lastSync > GC_AGE_MS) {
|
|
1928
|
+
logger.warn({ clientId: client.id, lastSync, age: now - lastSync }, "ORMap client too old, sending SYNC_RESET_REQUIRED");
|
|
1929
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1930
|
+
type: "SYNC_RESET_REQUIRED",
|
|
1931
|
+
payload: { mapName: message.mapName }
|
|
1932
|
+
}));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
logger.info({ clientId: client.id, mapName: message.mapName }, "Client requested ORMap sync");
|
|
1936
|
+
this.metricsService.incOp("GET", message.mapName);
|
|
1937
|
+
try {
|
|
1938
|
+
const mapForSync = await this.getMapAsync(message.mapName, "OR");
|
|
1939
|
+
if (mapForSync instanceof import_core4.ORMap) {
|
|
1940
|
+
const tree = mapForSync.getMerkleTree();
|
|
1941
|
+
const rootHash = tree.getRootHash();
|
|
1942
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1943
|
+
type: "ORMAP_SYNC_RESP_ROOT",
|
|
1944
|
+
payload: {
|
|
1945
|
+
mapName: message.mapName,
|
|
1946
|
+
rootHash,
|
|
1947
|
+
timestamp: this.hlc.now()
|
|
1948
|
+
}
|
|
1949
|
+
}));
|
|
1950
|
+
} else {
|
|
1951
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1952
|
+
type: "ERROR",
|
|
1953
|
+
payload: { code: 400, message: `Map ${message.mapName} is not an ORMap` }
|
|
1954
|
+
}));
|
|
1955
|
+
}
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
logger.error({ err, mapName: message.mapName }, "Failed to load map for ORMAP_SYNC_INIT");
|
|
1958
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1959
|
+
type: "ERROR",
|
|
1960
|
+
payload: { code: 500, message: `Failed to load map ${message.mapName}` }
|
|
1961
|
+
}));
|
|
1962
|
+
}
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
case "ORMAP_MERKLE_REQ_BUCKET": {
|
|
1966
|
+
if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "READ")) {
|
|
1967
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1968
|
+
type: "ERROR",
|
|
1969
|
+
payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
|
|
1970
|
+
}));
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const { mapName, path } = message.payload;
|
|
1974
|
+
try {
|
|
1975
|
+
const mapForBucket = await this.getMapAsync(mapName, "OR");
|
|
1976
|
+
if (mapForBucket instanceof import_core4.ORMap) {
|
|
1977
|
+
const tree = mapForBucket.getMerkleTree();
|
|
1978
|
+
const buckets = tree.getBuckets(path);
|
|
1979
|
+
const isLeaf = tree.isLeaf(path);
|
|
1980
|
+
if (isLeaf) {
|
|
1981
|
+
const keys = tree.getKeysInBucket(path);
|
|
1982
|
+
const entries = [];
|
|
1983
|
+
for (const key of keys) {
|
|
1984
|
+
const recordsMap = mapForBucket.getRecordsMap(key);
|
|
1985
|
+
if (recordsMap && recordsMap.size > 0) {
|
|
1986
|
+
entries.push({
|
|
1987
|
+
key,
|
|
1988
|
+
records: Array.from(recordsMap.values()),
|
|
1989
|
+
tombstones: mapForBucket.getTombstones()
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1994
|
+
type: "ORMAP_SYNC_RESP_LEAF",
|
|
1995
|
+
payload: { mapName, path, entries }
|
|
1996
|
+
}));
|
|
1997
|
+
} else {
|
|
1998
|
+
client.socket.send((0, import_core4.serialize)({
|
|
1999
|
+
type: "ORMAP_SYNC_RESP_BUCKETS",
|
|
2000
|
+
payload: { mapName, path, buckets }
|
|
2001
|
+
}));
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
logger.error({ err, mapName }, "Failed to load map for ORMAP_MERKLE_REQ_BUCKET");
|
|
2006
|
+
}
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
case "ORMAP_DIFF_REQUEST": {
|
|
2010
|
+
if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "READ")) {
|
|
2011
|
+
client.socket.send((0, import_core4.serialize)({
|
|
2012
|
+
type: "ERROR",
|
|
2013
|
+
payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
|
|
2014
|
+
}));
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const { mapName: diffMapName, keys } = message.payload;
|
|
2018
|
+
try {
|
|
2019
|
+
const mapForDiff = await this.getMapAsync(diffMapName, "OR");
|
|
2020
|
+
if (mapForDiff instanceof import_core4.ORMap) {
|
|
2021
|
+
const entries = [];
|
|
2022
|
+
const allTombstones = mapForDiff.getTombstones();
|
|
2023
|
+
for (const key of keys) {
|
|
2024
|
+
const recordsMap = mapForDiff.getRecordsMap(key);
|
|
2025
|
+
entries.push({
|
|
2026
|
+
key,
|
|
2027
|
+
records: recordsMap ? Array.from(recordsMap.values()) : [],
|
|
2028
|
+
tombstones: allTombstones
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
client.socket.send((0, import_core4.serialize)({
|
|
2032
|
+
type: "ORMAP_DIFF_RESPONSE",
|
|
2033
|
+
payload: { mapName: diffMapName, entries }
|
|
2034
|
+
}));
|
|
2035
|
+
}
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
logger.error({ err, mapName: diffMapName }, "Failed to load map for ORMAP_DIFF_REQUEST");
|
|
2038
|
+
}
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
case "ORMAP_PUSH_DIFF": {
|
|
2042
|
+
if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "PUT")) {
|
|
2043
|
+
client.socket.send((0, import_core4.serialize)({
|
|
2044
|
+
type: "ERROR",
|
|
2045
|
+
payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
|
|
2046
|
+
}));
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
const { mapName: pushMapName, entries: pushEntries } = message.payload;
|
|
2050
|
+
try {
|
|
2051
|
+
const mapForPush = await this.getMapAsync(pushMapName, "OR");
|
|
2052
|
+
if (mapForPush instanceof import_core4.ORMap) {
|
|
2053
|
+
let totalAdded = 0;
|
|
2054
|
+
let totalUpdated = 0;
|
|
2055
|
+
for (const entry of pushEntries) {
|
|
2056
|
+
const { key, records, tombstones } = entry;
|
|
2057
|
+
const result = mapForPush.mergeKey(key, records, tombstones);
|
|
2058
|
+
totalAdded += result.added;
|
|
2059
|
+
totalUpdated += result.updated;
|
|
2060
|
+
}
|
|
2061
|
+
if (totalAdded > 0 || totalUpdated > 0) {
|
|
2062
|
+
logger.info({ mapName: pushMapName, added: totalAdded, updated: totalUpdated, clientId: client.id }, "Merged ORMap diff from client");
|
|
2063
|
+
for (const entry of pushEntries) {
|
|
2064
|
+
for (const record of entry.records) {
|
|
2065
|
+
this.broadcast({
|
|
2066
|
+
type: "SERVER_EVENT",
|
|
2067
|
+
payload: {
|
|
2068
|
+
mapName: pushMapName,
|
|
2069
|
+
eventType: "OR_ADD",
|
|
2070
|
+
key: entry.key,
|
|
2071
|
+
orRecord: record
|
|
2072
|
+
}
|
|
2073
|
+
}, client.id);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
if (this.storage) {
|
|
2077
|
+
for (const entry of pushEntries) {
|
|
2078
|
+
const recordsMap = mapForPush.getRecordsMap(entry.key);
|
|
2079
|
+
if (recordsMap && recordsMap.size > 0) {
|
|
2080
|
+
await this.storage.store(pushMapName, entry.key, {
|
|
2081
|
+
type: "OR",
|
|
2082
|
+
records: Array.from(recordsMap.values())
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
logger.error({ err, mapName: pushMapName }, "Failed to process ORMAP_PUSH_DIFF");
|
|
2091
|
+
}
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
1903
2094
|
default:
|
|
1904
2095
|
logger.warn({ type: message.type }, "Unknown message type");
|
|
1905
2096
|
}
|
|
@@ -2422,6 +2613,75 @@ var ServerCoordinator = class {
|
|
|
2422
2613
|
this.reportLocalHlc();
|
|
2423
2614
|
}, GC_INTERVAL_MS);
|
|
2424
2615
|
}
|
|
2616
|
+
// ============ Heartbeat Methods ============
|
|
2617
|
+
/**
|
|
2618
|
+
* Starts the periodic check for dead clients (those that haven't sent PING).
|
|
2619
|
+
*/
|
|
2620
|
+
startHeartbeatCheck() {
|
|
2621
|
+
this.heartbeatCheckInterval = setInterval(() => {
|
|
2622
|
+
this.evictDeadClients();
|
|
2623
|
+
}, CLIENT_HEARTBEAT_CHECK_INTERVAL_MS);
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Handles incoming PING message from client.
|
|
2627
|
+
* Responds with PONG immediately.
|
|
2628
|
+
*/
|
|
2629
|
+
handlePing(client, clientTimestamp) {
|
|
2630
|
+
client.lastPingReceived = Date.now();
|
|
2631
|
+
const pongMessage = {
|
|
2632
|
+
type: "PONG",
|
|
2633
|
+
timestamp: clientTimestamp,
|
|
2634
|
+
serverTime: Date.now()
|
|
2635
|
+
};
|
|
2636
|
+
if (client.socket.readyState === import_ws2.WebSocket.OPEN) {
|
|
2637
|
+
client.socket.send((0, import_core4.serialize)(pongMessage));
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Checks if a client is still alive based on heartbeat.
|
|
2642
|
+
*/
|
|
2643
|
+
isClientAlive(clientId) {
|
|
2644
|
+
const client = this.clients.get(clientId);
|
|
2645
|
+
if (!client) return false;
|
|
2646
|
+
const idleTime = Date.now() - client.lastPingReceived;
|
|
2647
|
+
return idleTime < CLIENT_HEARTBEAT_TIMEOUT_MS;
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Returns how long the client has been idle (no PING received).
|
|
2651
|
+
*/
|
|
2652
|
+
getClientIdleTime(clientId) {
|
|
2653
|
+
const client = this.clients.get(clientId);
|
|
2654
|
+
if (!client) return Infinity;
|
|
2655
|
+
return Date.now() - client.lastPingReceived;
|
|
2656
|
+
}
|
|
2657
|
+
/**
|
|
2658
|
+
* Evicts clients that haven't sent a PING within the timeout period.
|
|
2659
|
+
*/
|
|
2660
|
+
evictDeadClients() {
|
|
2661
|
+
const now = Date.now();
|
|
2662
|
+
const deadClients = [];
|
|
2663
|
+
for (const [clientId, client] of this.clients) {
|
|
2664
|
+
if (client.isAuthenticated) {
|
|
2665
|
+
const idleTime = now - client.lastPingReceived;
|
|
2666
|
+
if (idleTime > CLIENT_HEARTBEAT_TIMEOUT_MS) {
|
|
2667
|
+
deadClients.push(clientId);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
for (const clientId of deadClients) {
|
|
2672
|
+
const client = this.clients.get(clientId);
|
|
2673
|
+
if (client) {
|
|
2674
|
+
logger.warn({
|
|
2675
|
+
clientId,
|
|
2676
|
+
idleTime: now - client.lastPingReceived,
|
|
2677
|
+
timeoutMs: CLIENT_HEARTBEAT_TIMEOUT_MS
|
|
2678
|
+
}, "Evicting dead client (heartbeat timeout)");
|
|
2679
|
+
if (client.socket.readyState === import_ws2.WebSocket.OPEN) {
|
|
2680
|
+
client.socket.close(4002, "Heartbeat timeout");
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2425
2685
|
reportLocalHlc() {
|
|
2426
2686
|
let minHlc = this.hlc.now();
|
|
2427
2687
|
for (const client of this.clients.values()) {
|