@muhkoo/connect 0.2.0-alpha.2 → 0.4.0-alpha.1

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,11 +1889,31 @@ 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;
1916
+ urlProvider;
1897
1917
  socket = null;
1898
1918
  _isConnected = false;
1899
1919
  _isConnecting = false;
@@ -1906,16 +1926,29 @@ class WSTransport extends EventCore {
1906
1926
  // Outbound queue (raw frame strings — already serialized by caller)
1907
1927
  outboundQueue = [];
1908
1928
  maxQueueSize;
1929
+ // Heartbeat / liveness state
1930
+ heartbeatInterval;
1931
+ heartbeatTimeout;
1932
+ pingFrame;
1933
+ pongFrame;
1934
+ heartbeatTimer = null;
1935
+ pongTimer = null;
1909
1936
  constructor(opts) {
1910
1937
  super();
1911
1938
  if (!opts || !opts.url) {
1912
1939
  throw new Error("WSTransport: `url` is required");
1913
1940
  }
1914
1941
  this.url = opts.url;
1942
+ this.urlProvider = opts.urlProvider;
1943
+ this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
1915
1944
  this.autoReconnect = opts.autoReconnect ?? true;
1916
1945
  this.reconnectDelay = opts.reconnectDelay ?? 3000;
1917
1946
  this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
1918
1947
  this.maxQueueSize = opts.maxQueueSize ?? 100;
1948
+ this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
1949
+ this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
1950
+ this.pingFrame = opts.pingFrame ?? "ping";
1951
+ this.pongFrame = opts.pongFrame ?? "pong";
1919
1952
  }
1920
1953
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
1921
1954
  async connect() {
@@ -1923,6 +1956,18 @@ class WSTransport extends EventCore {
1923
1956
  throw new Error("WSTransport: already connected or connecting");
1924
1957
  }
1925
1958
  this._isConnecting = true;
1959
+ // On a RECONNECT, refresh the URL (the baked-in single-use ticket is spent).
1960
+ // The first connect uses `url` as-is. If the provider throws (e.g. can't
1961
+ // mint a ticket), surface it and let scheduleReconnect retry.
1962
+ if (this.urlProvider && this.reconnectAttempts > 0) {
1963
+ try {
1964
+ this.url = await this.urlProvider();
1965
+ }
1966
+ catch (err) {
1967
+ this._isConnecting = false;
1968
+ throw err;
1969
+ }
1970
+ }
1926
1971
  return new Promise((resolve, reject) => {
1927
1972
  try {
1928
1973
  const socket = new WebSocket(this.url);
@@ -1933,9 +1978,19 @@ class WSTransport extends EventCore {
1933
1978
  this.reconnectAttempts = 0;
1934
1979
  this.emit(EventCoreEvents.CONNECTED, undefined);
1935
1980
  this.flushQueue();
1981
+ this.startHeartbeat();
1936
1982
  resolve();
1937
1983
  };
1938
1984
  socket.onmessage = (event) => {
1985
+ // Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
1986
+ // the liveness deadline. The layer above never sees keep-alive frames.
1987
+ if (event.data === this.pongFrame) {
1988
+ if (this.pongTimer) {
1989
+ clearTimeout(this.pongTimer);
1990
+ this.pongTimer = null;
1991
+ }
1992
+ return;
1993
+ }
1939
1994
  // Raw inbound frame — layer above is responsible for parsing.
1940
1995
  this.emit(EventCoreEvents.MESSAGE, event.data);
1941
1996
  };
@@ -1950,6 +2005,7 @@ class WSTransport extends EventCore {
1950
2005
  const wasConnected = this._isConnected;
1951
2006
  this._isConnected = false;
1952
2007
  this._isConnecting = false;
2008
+ this.stopHeartbeat();
1953
2009
  this.emit(EventCoreEvents.DISCONNECTED, undefined);
1954
2010
  if (this.autoReconnect && wasConnected) {
1955
2011
  this.scheduleReconnect();
@@ -1968,6 +2024,7 @@ class WSTransport extends EventCore {
1968
2024
  clearTimeout(this.reconnectTimeout);
1969
2025
  this.reconnectTimeout = null;
1970
2026
  }
2027
+ this.stopHeartbeat();
1971
2028
  this.autoReconnect = false;
1972
2029
  if (this.socket) {
1973
2030
  try {
@@ -1998,6 +2055,78 @@ class WSTransport extends EventCore {
1998
2055
  queuedFrames() { return this.outboundQueue.length; }
1999
2056
  reconnectAttemptCount() { return this.reconnectAttempts; }
2000
2057
  // -- internals -------------------------------------------------------------
2058
+ /** Begin the keep-alive loop (no-op when disabled). */
2059
+ startHeartbeat() {
2060
+ if (this.heartbeatInterval <= 0)
2061
+ return;
2062
+ this.stopHeartbeat();
2063
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
2064
+ }
2065
+ /** Stop the keep-alive loop and clear any pending liveness deadline. */
2066
+ stopHeartbeat() {
2067
+ if (this.heartbeatTimer) {
2068
+ clearInterval(this.heartbeatTimer);
2069
+ this.heartbeatTimer = null;
2070
+ }
2071
+ if (this.pongTimer) {
2072
+ clearTimeout(this.pongTimer);
2073
+ this.pongTimer = null;
2074
+ }
2075
+ }
2076
+ /**
2077
+ * Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
2078
+ * `heartbeatTimeout`, the connection is considered dead and we force a
2079
+ * reconnect — this is what catches a silently-dropped TCP connection that
2080
+ * never fired `onclose` (the failure mode that "loses connection with the
2081
+ * Accelerator" on long sessions).
2082
+ */
2083
+ sendHeartbeat() {
2084
+ if (!this._isConnected || !this.socket)
2085
+ return;
2086
+ try {
2087
+ this.socket.send(this.pingFrame);
2088
+ }
2089
+ catch {
2090
+ this.forceReconnect();
2091
+ return;
2092
+ }
2093
+ // A still-pending deadline means the previous pong never came back.
2094
+ if (this.pongTimer)
2095
+ return;
2096
+ this.pongTimer = setTimeout(() => {
2097
+ this.pongTimer = null;
2098
+ this.forceReconnect();
2099
+ }, this.heartbeatTimeout);
2100
+ }
2101
+ /**
2102
+ * Tear down a connection we believe is dead and kick off reconnect, without
2103
+ * waiting on the OS/browser to eventually fire `onclose` (which a half-open
2104
+ * socket may never do). We detach the handlers first so the normal close path
2105
+ * can't double-fire the reconnect.
2106
+ */
2107
+ forceReconnect() {
2108
+ this.stopHeartbeat();
2109
+ const sock = this.socket;
2110
+ const wasConnected = this._isConnected;
2111
+ this.socket = null;
2112
+ this._isConnected = false;
2113
+ this._isConnecting = false;
2114
+ if (sock) {
2115
+ sock.onopen = null;
2116
+ sock.onmessage = null;
2117
+ sock.onerror = null;
2118
+ sock.onclose = null;
2119
+ try {
2120
+ sock.close();
2121
+ }
2122
+ catch { /* already gone */ }
2123
+ }
2124
+ this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
2125
+ this.emit(EventCoreEvents.DISCONNECTED, undefined);
2126
+ if (this.autoReconnect && wasConnected) {
2127
+ this.scheduleReconnect();
2128
+ }
2129
+ }
2001
2130
  scheduleReconnect() {
2002
2131
  if (this.maxReconnectAttempts > 0 &&
2003
2132
  this.reconnectAttempts >= this.maxReconnectAttempts) {
@@ -2011,7 +2140,12 @@ class WSTransport extends EventCore {
2011
2140
  await this.connect();
2012
2141
  }
2013
2142
  catch {
2014
- // scheduleReconnect will fire again from onclose
2143
+ // A failed reconnect never opened the socket, so its `onclose` won't
2144
+ // re-schedule (that path is guarded by `wasConnected`). Re-schedule here
2145
+ // so we keep retrying (with a fresh ticket each time) until we succeed
2146
+ // or hit the attempt cap.
2147
+ if (this.autoReconnect)
2148
+ this.scheduleReconnect();
2015
2149
  }
2016
2150
  }, this.reconnectDelay);
2017
2151
  }