@topgunbuild/server 0.1.0 → 0.2.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/dist/index.mjs CHANGED
@@ -1209,6 +1209,8 @@ var SystemManager = class {
1209
1209
  // src/ServerCoordinator.ts
1210
1210
  var GC_INTERVAL_MS = 60 * 60 * 1e3;
1211
1211
  var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
1212
+ var CLIENT_HEARTBEAT_TIMEOUT_MS = 2e4;
1213
+ var CLIENT_HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
1212
1214
  var ServerCoordinator = class {
1213
1215
  constructor(config) {
1214
1216
  this.clients = /* @__PURE__ */ new Map();
@@ -1329,6 +1331,7 @@ var ServerCoordinator = class {
1329
1331
  });
1330
1332
  }
1331
1333
  this.startGarbageCollection();
1334
+ this.startHeartbeatCheck();
1332
1335
  }
1333
1336
  /** Wait for server to be fully ready (ports assigned) */
1334
1337
  ready() {
@@ -1379,6 +1382,10 @@ var ServerCoordinator = class {
1379
1382
  clearInterval(this.gcInterval);
1380
1383
  this.gcInterval = void 0;
1381
1384
  }
1385
+ if (this.heartbeatCheckInterval) {
1386
+ clearInterval(this.heartbeatCheckInterval);
1387
+ this.heartbeatCheckInterval = void 0;
1388
+ }
1382
1389
  if (this.lockManager) {
1383
1390
  this.lockManager.stop();
1384
1391
  }
@@ -1395,8 +1402,10 @@ var ServerCoordinator = class {
1395
1402
  socket: ws,
1396
1403
  isAuthenticated: false,
1397
1404
  subscriptions: /* @__PURE__ */ new Set(),
1398
- lastActiveHlc: this.hlc.now()
1405
+ lastActiveHlc: this.hlc.now(),
1399
1406
  // Initialize with current time
1407
+ lastPingReceived: Date.now()
1408
+ // Initialize heartbeat tracking
1400
1409
  };
1401
1410
  this.clients.set(clientId, connection);
1402
1411
  this.metricsService.setConnectedClients(this.clients.size);
@@ -1492,6 +1501,10 @@ var ServerCoordinator = class {
1492
1501
  return;
1493
1502
  }
1494
1503
  const message = parseResult.data;
1504
+ if (message.type === "PING") {
1505
+ this.handlePing(client, message.timestamp);
1506
+ return;
1507
+ }
1495
1508
  this.updateClientHlc(client, message);
1496
1509
  if (!client.isAuthenticated) {
1497
1510
  if (message.type === "AUTH") {
@@ -1858,6 +1871,184 @@ var ServerCoordinator = class {
1858
1871
  }
1859
1872
  break;
1860
1873
  }
1874
+ // ============ ORMap Sync Message Handlers ============
1875
+ case "ORMAP_SYNC_INIT": {
1876
+ if (!this.securityManager.checkPermission(client.principal, message.mapName, "READ")) {
1877
+ client.socket.send(serialize2({
1878
+ type: "ERROR",
1879
+ payload: { code: 403, message: `Access Denied for map ${message.mapName}` }
1880
+ }));
1881
+ return;
1882
+ }
1883
+ const lastSync = message.lastSyncTimestamp || 0;
1884
+ const now = Date.now();
1885
+ if (lastSync > 0 && now - lastSync > GC_AGE_MS) {
1886
+ logger.warn({ clientId: client.id, lastSync, age: now - lastSync }, "ORMap client too old, sending SYNC_RESET_REQUIRED");
1887
+ client.socket.send(serialize2({
1888
+ type: "SYNC_RESET_REQUIRED",
1889
+ payload: { mapName: message.mapName }
1890
+ }));
1891
+ return;
1892
+ }
1893
+ logger.info({ clientId: client.id, mapName: message.mapName }, "Client requested ORMap sync");
1894
+ this.metricsService.incOp("GET", message.mapName);
1895
+ try {
1896
+ const mapForSync = await this.getMapAsync(message.mapName, "OR");
1897
+ if (mapForSync instanceof ORMap2) {
1898
+ const tree = mapForSync.getMerkleTree();
1899
+ const rootHash = tree.getRootHash();
1900
+ client.socket.send(serialize2({
1901
+ type: "ORMAP_SYNC_RESP_ROOT",
1902
+ payload: {
1903
+ mapName: message.mapName,
1904
+ rootHash,
1905
+ timestamp: this.hlc.now()
1906
+ }
1907
+ }));
1908
+ } else {
1909
+ client.socket.send(serialize2({
1910
+ type: "ERROR",
1911
+ payload: { code: 400, message: `Map ${message.mapName} is not an ORMap` }
1912
+ }));
1913
+ }
1914
+ } catch (err) {
1915
+ logger.error({ err, mapName: message.mapName }, "Failed to load map for ORMAP_SYNC_INIT");
1916
+ client.socket.send(serialize2({
1917
+ type: "ERROR",
1918
+ payload: { code: 500, message: `Failed to load map ${message.mapName}` }
1919
+ }));
1920
+ }
1921
+ break;
1922
+ }
1923
+ case "ORMAP_MERKLE_REQ_BUCKET": {
1924
+ if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "READ")) {
1925
+ client.socket.send(serialize2({
1926
+ type: "ERROR",
1927
+ payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
1928
+ }));
1929
+ return;
1930
+ }
1931
+ const { mapName, path } = message.payload;
1932
+ try {
1933
+ const mapForBucket = await this.getMapAsync(mapName, "OR");
1934
+ if (mapForBucket instanceof ORMap2) {
1935
+ const tree = mapForBucket.getMerkleTree();
1936
+ const buckets = tree.getBuckets(path);
1937
+ const isLeaf = tree.isLeaf(path);
1938
+ if (isLeaf) {
1939
+ const keys = tree.getKeysInBucket(path);
1940
+ const entries = [];
1941
+ for (const key of keys) {
1942
+ const recordsMap = mapForBucket.getRecordsMap(key);
1943
+ if (recordsMap && recordsMap.size > 0) {
1944
+ entries.push({
1945
+ key,
1946
+ records: Array.from(recordsMap.values()),
1947
+ tombstones: mapForBucket.getTombstones()
1948
+ });
1949
+ }
1950
+ }
1951
+ client.socket.send(serialize2({
1952
+ type: "ORMAP_SYNC_RESP_LEAF",
1953
+ payload: { mapName, path, entries }
1954
+ }));
1955
+ } else {
1956
+ client.socket.send(serialize2({
1957
+ type: "ORMAP_SYNC_RESP_BUCKETS",
1958
+ payload: { mapName, path, buckets }
1959
+ }));
1960
+ }
1961
+ }
1962
+ } catch (err) {
1963
+ logger.error({ err, mapName }, "Failed to load map for ORMAP_MERKLE_REQ_BUCKET");
1964
+ }
1965
+ break;
1966
+ }
1967
+ case "ORMAP_DIFF_REQUEST": {
1968
+ if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "READ")) {
1969
+ client.socket.send(serialize2({
1970
+ type: "ERROR",
1971
+ payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
1972
+ }));
1973
+ return;
1974
+ }
1975
+ const { mapName: diffMapName, keys } = message.payload;
1976
+ try {
1977
+ const mapForDiff = await this.getMapAsync(diffMapName, "OR");
1978
+ if (mapForDiff instanceof ORMap2) {
1979
+ const entries = [];
1980
+ const allTombstones = mapForDiff.getTombstones();
1981
+ for (const key of keys) {
1982
+ const recordsMap = mapForDiff.getRecordsMap(key);
1983
+ entries.push({
1984
+ key,
1985
+ records: recordsMap ? Array.from(recordsMap.values()) : [],
1986
+ tombstones: allTombstones
1987
+ });
1988
+ }
1989
+ client.socket.send(serialize2({
1990
+ type: "ORMAP_DIFF_RESPONSE",
1991
+ payload: { mapName: diffMapName, entries }
1992
+ }));
1993
+ }
1994
+ } catch (err) {
1995
+ logger.error({ err, mapName: diffMapName }, "Failed to load map for ORMAP_DIFF_REQUEST");
1996
+ }
1997
+ break;
1998
+ }
1999
+ case "ORMAP_PUSH_DIFF": {
2000
+ if (!this.securityManager.checkPermission(client.principal, message.payload.mapName, "PUT")) {
2001
+ client.socket.send(serialize2({
2002
+ type: "ERROR",
2003
+ payload: { code: 403, message: `Access Denied for map ${message.payload.mapName}` }
2004
+ }));
2005
+ return;
2006
+ }
2007
+ const { mapName: pushMapName, entries: pushEntries } = message.payload;
2008
+ try {
2009
+ const mapForPush = await this.getMapAsync(pushMapName, "OR");
2010
+ if (mapForPush instanceof ORMap2) {
2011
+ let totalAdded = 0;
2012
+ let totalUpdated = 0;
2013
+ for (const entry of pushEntries) {
2014
+ const { key, records, tombstones } = entry;
2015
+ const result = mapForPush.mergeKey(key, records, tombstones);
2016
+ totalAdded += result.added;
2017
+ totalUpdated += result.updated;
2018
+ }
2019
+ if (totalAdded > 0 || totalUpdated > 0) {
2020
+ logger.info({ mapName: pushMapName, added: totalAdded, updated: totalUpdated, clientId: client.id }, "Merged ORMap diff from client");
2021
+ for (const entry of pushEntries) {
2022
+ for (const record of entry.records) {
2023
+ this.broadcast({
2024
+ type: "SERVER_EVENT",
2025
+ payload: {
2026
+ mapName: pushMapName,
2027
+ eventType: "OR_ADD",
2028
+ key: entry.key,
2029
+ orRecord: record
2030
+ }
2031
+ }, client.id);
2032
+ }
2033
+ }
2034
+ if (this.storage) {
2035
+ for (const entry of pushEntries) {
2036
+ const recordsMap = mapForPush.getRecordsMap(entry.key);
2037
+ if (recordsMap && recordsMap.size > 0) {
2038
+ await this.storage.store(pushMapName, entry.key, {
2039
+ type: "OR",
2040
+ records: Array.from(recordsMap.values())
2041
+ });
2042
+ }
2043
+ }
2044
+ }
2045
+ }
2046
+ }
2047
+ } catch (err) {
2048
+ logger.error({ err, mapName: pushMapName }, "Failed to process ORMAP_PUSH_DIFF");
2049
+ }
2050
+ break;
2051
+ }
1861
2052
  default:
1862
2053
  logger.warn({ type: message.type }, "Unknown message type");
1863
2054
  }
@@ -2380,6 +2571,75 @@ var ServerCoordinator = class {
2380
2571
  this.reportLocalHlc();
2381
2572
  }, GC_INTERVAL_MS);
2382
2573
  }
2574
+ // ============ Heartbeat Methods ============
2575
+ /**
2576
+ * Starts the periodic check for dead clients (those that haven't sent PING).
2577
+ */
2578
+ startHeartbeatCheck() {
2579
+ this.heartbeatCheckInterval = setInterval(() => {
2580
+ this.evictDeadClients();
2581
+ }, CLIENT_HEARTBEAT_CHECK_INTERVAL_MS);
2582
+ }
2583
+ /**
2584
+ * Handles incoming PING message from client.
2585
+ * Responds with PONG immediately.
2586
+ */
2587
+ handlePing(client, clientTimestamp) {
2588
+ client.lastPingReceived = Date.now();
2589
+ const pongMessage = {
2590
+ type: "PONG",
2591
+ timestamp: clientTimestamp,
2592
+ serverTime: Date.now()
2593
+ };
2594
+ if (client.socket.readyState === WebSocket2.OPEN) {
2595
+ client.socket.send(serialize2(pongMessage));
2596
+ }
2597
+ }
2598
+ /**
2599
+ * Checks if a client is still alive based on heartbeat.
2600
+ */
2601
+ isClientAlive(clientId) {
2602
+ const client = this.clients.get(clientId);
2603
+ if (!client) return false;
2604
+ const idleTime = Date.now() - client.lastPingReceived;
2605
+ return idleTime < CLIENT_HEARTBEAT_TIMEOUT_MS;
2606
+ }
2607
+ /**
2608
+ * Returns how long the client has been idle (no PING received).
2609
+ */
2610
+ getClientIdleTime(clientId) {
2611
+ const client = this.clients.get(clientId);
2612
+ if (!client) return Infinity;
2613
+ return Date.now() - client.lastPingReceived;
2614
+ }
2615
+ /**
2616
+ * Evicts clients that haven't sent a PING within the timeout period.
2617
+ */
2618
+ evictDeadClients() {
2619
+ const now = Date.now();
2620
+ const deadClients = [];
2621
+ for (const [clientId, client] of this.clients) {
2622
+ if (client.isAuthenticated) {
2623
+ const idleTime = now - client.lastPingReceived;
2624
+ if (idleTime > CLIENT_HEARTBEAT_TIMEOUT_MS) {
2625
+ deadClients.push(clientId);
2626
+ }
2627
+ }
2628
+ }
2629
+ for (const clientId of deadClients) {
2630
+ const client = this.clients.get(clientId);
2631
+ if (client) {
2632
+ logger.warn({
2633
+ clientId,
2634
+ idleTime: now - client.lastPingReceived,
2635
+ timeoutMs: CLIENT_HEARTBEAT_TIMEOUT_MS
2636
+ }, "Evicting dead client (heartbeat timeout)");
2637
+ if (client.socket.readyState === WebSocket2.OPEN) {
2638
+ client.socket.close(4002, "Heartbeat timeout");
2639
+ }
2640
+ }
2641
+ }
2642
+ }
2383
2643
  reportLocalHlc() {
2384
2644
  let minHlc = this.hlc.now();
2385
2645
  for (const client of this.clients.values()) {