@muhkoo/connect 0.2.0-alpha.2 → 0.3.0-alpha.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.
@@ -1889,10 +1889,29 @@ function isCipherFrame(frame) {
1889
1889
  * - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
1890
1890
  * plus raw inbound MESSAGE events
1891
1891
  */
1892
+ /** Monotonic source of unique transport ids when a caller doesn't supply one. */
1893
+ let __wsInstanceSeq = 0;
1892
1894
  class WSTransport extends EventCore {
1893
- emit = EventCore.emit;
1894
- on = EventCore.on;
1895
- off = EventCore.off;
1895
+ /**
1896
+ * Per-instance event scope. `EventCore` is a process-global static emitter,
1897
+ * so without namespacing every WSTransport would hear every other one's
1898
+ * frames. We prefix each event with this transport's `id` and expose
1899
+ * `on`/`off`/`emit` that scope transparently — callers still use the plain
1900
+ * `EventCoreEvents` enum and only ever see their own connection's events.
1901
+ */
1902
+ id;
1903
+ emit = (event, data) => {
1904
+ EventCore.emit(this.scopedEvent(event), data);
1905
+ };
1906
+ on = (event, handler) => {
1907
+ EventCore.on(this.scopedEvent(event), handler);
1908
+ };
1909
+ off = (event, handler) => {
1910
+ EventCore.off(this.scopedEvent(event), handler);
1911
+ };
1912
+ scopedEvent(event) {
1913
+ return `${this.id}:${event}`;
1914
+ }
1896
1915
  url;
1897
1916
  socket = null;
1898
1917
  _isConnected = false;
@@ -1906,16 +1925,28 @@ class WSTransport extends EventCore {
1906
1925
  // Outbound queue (raw frame strings — already serialized by caller)
1907
1926
  outboundQueue = [];
1908
1927
  maxQueueSize;
1928
+ // Heartbeat / liveness state
1929
+ heartbeatInterval;
1930
+ heartbeatTimeout;
1931
+ pingFrame;
1932
+ pongFrame;
1933
+ heartbeatTimer = null;
1934
+ pongTimer = null;
1909
1935
  constructor(opts) {
1910
1936
  super();
1911
1937
  if (!opts || !opts.url) {
1912
1938
  throw new Error("WSTransport: `url` is required");
1913
1939
  }
1914
1940
  this.url = opts.url;
1941
+ this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
1915
1942
  this.autoReconnect = opts.autoReconnect ?? true;
1916
1943
  this.reconnectDelay = opts.reconnectDelay ?? 3000;
1917
1944
  this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
1918
1945
  this.maxQueueSize = opts.maxQueueSize ?? 100;
1946
+ this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
1947
+ this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
1948
+ this.pingFrame = opts.pingFrame ?? "ping";
1949
+ this.pongFrame = opts.pongFrame ?? "pong";
1919
1950
  }
1920
1951
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
1921
1952
  async connect() {
@@ -1933,9 +1964,19 @@ class WSTransport extends EventCore {
1933
1964
  this.reconnectAttempts = 0;
1934
1965
  this.emit(EventCoreEvents.CONNECTED, undefined);
1935
1966
  this.flushQueue();
1967
+ this.startHeartbeat();
1936
1968
  resolve();
1937
1969
  };
1938
1970
  socket.onmessage = (event) => {
1971
+ // Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
1972
+ // the liveness deadline. The layer above never sees keep-alive frames.
1973
+ if (event.data === this.pongFrame) {
1974
+ if (this.pongTimer) {
1975
+ clearTimeout(this.pongTimer);
1976
+ this.pongTimer = null;
1977
+ }
1978
+ return;
1979
+ }
1939
1980
  // Raw inbound frame — layer above is responsible for parsing.
1940
1981
  this.emit(EventCoreEvents.MESSAGE, event.data);
1941
1982
  };
@@ -1950,6 +1991,7 @@ class WSTransport extends EventCore {
1950
1991
  const wasConnected = this._isConnected;
1951
1992
  this._isConnected = false;
1952
1993
  this._isConnecting = false;
1994
+ this.stopHeartbeat();
1953
1995
  this.emit(EventCoreEvents.DISCONNECTED, undefined);
1954
1996
  if (this.autoReconnect && wasConnected) {
1955
1997
  this.scheduleReconnect();
@@ -1968,6 +2010,7 @@ class WSTransport extends EventCore {
1968
2010
  clearTimeout(this.reconnectTimeout);
1969
2011
  this.reconnectTimeout = null;
1970
2012
  }
2013
+ this.stopHeartbeat();
1971
2014
  this.autoReconnect = false;
1972
2015
  if (this.socket) {
1973
2016
  try {
@@ -1998,6 +2041,78 @@ class WSTransport extends EventCore {
1998
2041
  queuedFrames() { return this.outboundQueue.length; }
1999
2042
  reconnectAttemptCount() { return this.reconnectAttempts; }
2000
2043
  // -- internals -------------------------------------------------------------
2044
+ /** Begin the keep-alive loop (no-op when disabled). */
2045
+ startHeartbeat() {
2046
+ if (this.heartbeatInterval <= 0)
2047
+ return;
2048
+ this.stopHeartbeat();
2049
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
2050
+ }
2051
+ /** Stop the keep-alive loop and clear any pending liveness deadline. */
2052
+ stopHeartbeat() {
2053
+ if (this.heartbeatTimer) {
2054
+ clearInterval(this.heartbeatTimer);
2055
+ this.heartbeatTimer = null;
2056
+ }
2057
+ if (this.pongTimer) {
2058
+ clearTimeout(this.pongTimer);
2059
+ this.pongTimer = null;
2060
+ }
2061
+ }
2062
+ /**
2063
+ * Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
2064
+ * `heartbeatTimeout`, the connection is considered dead and we force a
2065
+ * reconnect — this is what catches a silently-dropped TCP connection that
2066
+ * never fired `onclose` (the failure mode that "loses connection with the
2067
+ * Accelerator" on long sessions).
2068
+ */
2069
+ sendHeartbeat() {
2070
+ if (!this._isConnected || !this.socket)
2071
+ return;
2072
+ try {
2073
+ this.socket.send(this.pingFrame);
2074
+ }
2075
+ catch {
2076
+ this.forceReconnect();
2077
+ return;
2078
+ }
2079
+ // A still-pending deadline means the previous pong never came back.
2080
+ if (this.pongTimer)
2081
+ return;
2082
+ this.pongTimer = setTimeout(() => {
2083
+ this.pongTimer = null;
2084
+ this.forceReconnect();
2085
+ }, this.heartbeatTimeout);
2086
+ }
2087
+ /**
2088
+ * Tear down a connection we believe is dead and kick off reconnect, without
2089
+ * waiting on the OS/browser to eventually fire `onclose` (which a half-open
2090
+ * socket may never do). We detach the handlers first so the normal close path
2091
+ * can't double-fire the reconnect.
2092
+ */
2093
+ forceReconnect() {
2094
+ this.stopHeartbeat();
2095
+ const sock = this.socket;
2096
+ const wasConnected = this._isConnected;
2097
+ this.socket = null;
2098
+ this._isConnected = false;
2099
+ this._isConnecting = false;
2100
+ if (sock) {
2101
+ sock.onopen = null;
2102
+ sock.onmessage = null;
2103
+ sock.onerror = null;
2104
+ sock.onclose = null;
2105
+ try {
2106
+ sock.close();
2107
+ }
2108
+ catch { /* already gone */ }
2109
+ }
2110
+ this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
2111
+ this.emit(EventCoreEvents.DISCONNECTED, undefined);
2112
+ if (this.autoReconnect && wasConnected) {
2113
+ this.scheduleReconnect();
2114
+ }
2115
+ }
2001
2116
  scheduleReconnect() {
2002
2117
  if (this.maxReconnectAttempts > 0 &&
2003
2118
  this.reconnectAttempts >= this.maxReconnectAttempts) {