doover-js 0.2.0-alpha.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.
Files changed (59) hide show
  1. package/dist/apis/connections-api.d.ts +6 -0
  2. package/dist/apis/connections-api.js +8 -0
  3. package/dist/client/doover-client.d.ts +13 -0
  4. package/dist/client/doover-client.js +30 -2
  5. package/dist/client/singleton.d.ts +26 -0
  6. package/dist/client/singleton.js +59 -0
  7. package/dist/client/stats.d.ts +55 -0
  8. package/dist/client/stats.js +97 -0
  9. package/dist/gateway/gateway-client.d.ts +30 -6
  10. package/dist/gateway/gateway-client.js +125 -46
  11. package/dist/gateway/types.d.ts +0 -1
  12. package/dist/http/rest-client.d.ts +11 -0
  13. package/dist/http/rest-client.js +17 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +7 -1
  16. package/dist/react/context.d.ts +19 -0
  17. package/dist/react/context.js +28 -0
  18. package/dist/react/index.d.ts +24 -0
  19. package/dist/react/index.js +40 -0
  20. package/dist/react/sharedQueryClient.d.ts +21 -0
  21. package/dist/react/sharedQueryClient.js +39 -0
  22. package/dist/react/useAgentChannel.d.ts +7 -0
  23. package/dist/react/useAgentChannel.js +12 -0
  24. package/dist/react/useAgentConnections.d.ts +8 -0
  25. package/dist/react/useAgentConnections.js +24 -0
  26. package/dist/react/useChannelAggregate.d.ts +13 -0
  27. package/dist/react/useChannelAggregate.js +38 -0
  28. package/dist/react/useChannelMessages.d.ts +23 -0
  29. package/dist/react/useChannelMessages.js +56 -0
  30. package/dist/react/useChannelSubscription.d.ts +16 -0
  31. package/dist/react/useChannelSubscription.js +43 -0
  32. package/dist/react/useConnectionState.d.ts +21 -0
  33. package/dist/react/useConnectionState.js +43 -0
  34. package/dist/react/useMultiAgentAggregates.d.ts +22 -0
  35. package/dist/react/useMultiAgentAggregates.js +82 -0
  36. package/dist/react/useMultiAgentChannelMessages.d.ts +18 -0
  37. package/dist/react/useMultiAgentChannelMessages.js +78 -0
  38. package/dist/react/useSendMessage.d.ts +9 -0
  39. package/dist/react/useSendMessage.js +16 -0
  40. package/dist/react/useSendRpc.d.ts +61 -0
  41. package/dist/react/useSendRpc.js +158 -0
  42. package/dist/react/useTurnCredentials.d.ts +12 -0
  43. package/dist/react/useTurnCredentials.js +25 -0
  44. package/dist/react/useUpdateAggregate.d.ts +14 -0
  45. package/dist/react/useUpdateAggregate.js +25 -0
  46. package/dist/react/useUpdateMessage.d.ts +17 -0
  47. package/dist/react/useUpdateMessage.js +24 -0
  48. package/dist/test/apis.test.js +7 -1
  49. package/dist/test/doover-client.test.js +13 -0
  50. package/dist/test/doover-data-provider.test.js +155 -0
  51. package/dist/test/gateway-client.test.js +122 -8
  52. package/dist/test/react.test.d.ts +1 -0
  53. package/dist/test/react.test.js +353 -0
  54. package/dist/test/singleton.test.d.ts +1 -0
  55. package/dist/test/singleton.test.js +62 -0
  56. package/dist/types/common.d.ts +40 -0
  57. package/dist/viewer/doover-data-provider.d.ts +32 -4
  58. package/dist/viewer/doover-data-provider.js +92 -5
  59. package/package.json +28 -2
@@ -20,4 +20,10 @@ export declare class ConnectionsApi {
20
20
  getAgentSubscriptionHistory(agentId: string, params: SubscriptionHistoryParams): Promise<ConnectionSubscriptionLog[]>;
21
21
  getConnection(connectionId: string): Promise<ConnectionDetails>;
22
22
  getChannelSubscriptions(agentId: string, channelName: string): Promise<ConnectionSubscription[]>;
23
+ /**
24
+ * Ask the server to push the latest state of all channels this agent
25
+ * owns to every active WSS connection. Used to recover from missed updates
26
+ * after a gap in connectivity.
27
+ */
28
+ syncConnection(agentId: string): Promise<unknown>;
23
29
  }
@@ -20,5 +20,13 @@ class ConnectionsApi {
20
20
  getChannelSubscriptions(agentId, channelName) {
21
21
  return this.rest.get(`/agents/${agentId}/channels/${channelName}/subscriptions`);
22
22
  }
23
+ /**
24
+ * Ask the server to push the latest state of all channels this agent
25
+ * owns to every active WSS connection. Used to recover from missed updates
26
+ * after a gap in connectivity.
27
+ */
28
+ syncConnection(agentId) {
29
+ return this.rest.post(`/agents/${agentId}/connection_sync`, {});
30
+ }
23
31
  }
24
32
  exports.ConnectionsApi = ConnectionsApi;
@@ -12,6 +12,7 @@ import type { DooverAuth } from "../auth/doover-auth";
12
12
  import { GatewayClient } from "../gateway/gateway-client";
13
13
  import { RestClient, type DooverClientConfig } from "../http/rest-client";
14
14
  import { DooverDataProvider } from "../viewer/doover-data-provider";
15
+ import { DooverStatsCollector, type DooverStatsSnapshot } from "./stats";
15
16
  export declare class DooverClient {
16
17
  readonly auth: DooverAuth;
17
18
  readonly rest: RestClient;
@@ -27,5 +28,17 @@ export declare class DooverClient {
27
28
  readonly turn: TurnApi;
28
29
  readonly agents: AgentsApi;
29
30
  readonly gateway: GatewayClient;
31
+ /** Opt-in instrumentation. Disabled by default — see {@link enableStats}. */
32
+ readonly stats: DooverStatsCollector;
30
33
  constructor(config: DooverClientConfig);
34
+ /** Start capturing request/message stats. Off by default. */
35
+ enableStats(): void;
36
+ /** Stop capturing stats. Existing counters are retained; call `stats.reset()` to clear. */
37
+ disableStats(): void;
38
+ /**
39
+ * Snapshot the current stats. Returns zeroed counters if stats were
40
+ * never enabled. Combine with {@link GatewayClient.getSubscriptionCount}
41
+ * and {@link GatewayClient.getSession} for a full debug view.
42
+ */
43
+ getStats(): DooverStatsSnapshot;
31
44
  }
@@ -12,9 +12,9 @@ const permissions_api_1 = require("../apis/permissions-api");
12
12
  const processors_api_1 = require("../apis/processors-api");
13
13
  const turn_api_1 = require("../apis/turn-api");
14
14
  const build_auth_1 = require("../auth/build-auth");
15
- const gateway_client_1 = require("../gateway/gateway-client");
16
15
  const rest_client_1 = require("../http/rest-client");
17
16
  const doover_data_provider_1 = require("../viewer/doover-data-provider");
17
+ const stats_1 = require("./stats");
18
18
  class DooverClient {
19
19
  constructor(config) {
20
20
  this.auth = (0, build_auth_1.buildAuth)({
@@ -41,7 +41,35 @@ class DooverClient {
41
41
  this.processors = new processors_api_1.ProcessorsApi(this.rest);
42
42
  this.turn = new turn_api_1.TurnApi(this.rest);
43
43
  this.agents = new agents_api_1.AgentsApi(this.rest);
44
- this.gateway = new gateway_client_1.GatewayClient(config, this.auth);
44
+ // Reuse the viewer's gateway so `client.gateway` and
45
+ // `client.viewer.gateway` are the same instance → one WebSocket per
46
+ // client. Without this, `client.gateway.connect()` and
47
+ // `client.viewer.subscribeToChannel(...)` each opened their own socket.
48
+ this.gateway = this.viewer.gateway;
49
+ // Stats collector, disabled by default. Attached to both REST clients
50
+ // (facade + viewer's internal) and the shared gateway so every recorded
51
+ // call flows through the same counters. Pay-to-play: record methods
52
+ // short-circuit when disabled.
53
+ this.stats = new stats_1.DooverStatsCollector();
54
+ this.rest.setStats(this.stats);
55
+ this.viewer.rest.setStats(this.stats);
56
+ this.gateway.setStats(this.stats);
57
+ }
58
+ /** Start capturing request/message stats. Off by default. */
59
+ enableStats() {
60
+ this.stats.setEnabled(true);
61
+ }
62
+ /** Stop capturing stats. Existing counters are retained; call `stats.reset()` to clear. */
63
+ disableStats() {
64
+ this.stats.setEnabled(false);
65
+ }
66
+ /**
67
+ * Snapshot the current stats. Returns zeroed counters if stats were
68
+ * never enabled. Combine with {@link GatewayClient.getSubscriptionCount}
69
+ * and {@link GatewayClient.getSession} for a full debug view.
70
+ */
71
+ getStats() {
72
+ return this.stats.snapshot();
45
73
  }
46
74
  }
47
75
  exports.DooverClient = DooverClient;
@@ -0,0 +1,26 @@
1
+ import type { DooverClientConfig } from "../http/rest-client";
2
+ import { DooverClient } from "./doover-client";
3
+ /**
4
+ * Get the process-wide `DooverClient` instance, creating it on first call.
5
+ *
6
+ * Uses `globalThis.__doover_js_client__` to survive module-level duplicates
7
+ * — if this module is loaded more than once (HMR, federation boundaries,
8
+ * multiple bundles) each load still returns the same instance and therefore
9
+ * the same WebSocket + REST configuration.
10
+ *
11
+ * The first caller's `config` wins. Subsequent calls ignore the `config`
12
+ * arg and return the existing instance — log a warning if they differ so
13
+ * drift is visible in dev tools.
14
+ */
15
+ export declare function getDooverClient(config: DooverClientConfig): DooverClient;
16
+ /**
17
+ * Returns the current singleton if one has been initialised, otherwise
18
+ * `null`. Useful for callers that want to read the client opportunistically
19
+ * without forcing construction.
20
+ */
21
+ export declare function peekDooverClient(): DooverClient | null;
22
+ /**
23
+ * Clear the singleton. Primarily for tests — not recommended in production
24
+ * code since any active subscriptions reference the old instance.
25
+ */
26
+ export declare function resetDooverClient(): void;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDooverClient = getDooverClient;
4
+ exports.peekDooverClient = peekDooverClient;
5
+ exports.resetDooverClient = resetDooverClient;
6
+ const doover_client_1 = require("./doover-client");
7
+ const GLOBAL_KEY = "__doover_js_client__";
8
+ function globalBag() {
9
+ return globalThis;
10
+ }
11
+ /**
12
+ * Get the process-wide `DooverClient` instance, creating it on first call.
13
+ *
14
+ * Uses `globalThis.__doover_js_client__` to survive module-level duplicates
15
+ * — if this module is loaded more than once (HMR, federation boundaries,
16
+ * multiple bundles) each load still returns the same instance and therefore
17
+ * the same WebSocket + REST configuration.
18
+ *
19
+ * The first caller's `config` wins. Subsequent calls ignore the `config`
20
+ * arg and return the existing instance — log a warning if they differ so
21
+ * drift is visible in dev tools.
22
+ */
23
+ function getDooverClient(config) {
24
+ const bag = globalBag();
25
+ const existing = bag[GLOBAL_KEY];
26
+ if (existing) {
27
+ if (configsDiffer(existing, config)) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn("[doover-js] getDooverClient called with a config that differs " +
30
+ "from the already-initialised singleton. The existing client is " +
31
+ "being reused; the new config is ignored.");
32
+ }
33
+ return existing;
34
+ }
35
+ const client = new doover_client_1.DooverClient(config);
36
+ bag[GLOBAL_KEY] = client;
37
+ return client;
38
+ }
39
+ /**
40
+ * Returns the current singleton if one has been initialised, otherwise
41
+ * `null`. Useful for callers that want to read the client opportunistically
42
+ * without forcing construction.
43
+ */
44
+ function peekDooverClient() {
45
+ return globalBag()[GLOBAL_KEY] ?? null;
46
+ }
47
+ /**
48
+ * Clear the singleton. Primarily for tests — not recommended in production
49
+ * code since any active subscriptions reference the old instance.
50
+ */
51
+ function resetDooverClient() {
52
+ delete globalBag()[GLOBAL_KEY];
53
+ }
54
+ function configsDiffer(existing, next) {
55
+ const a = existing.rest.config;
56
+ return (a.dataRestUrl !== next.dataRestUrl ||
57
+ a.dataWssUrl !== next.dataWssUrl ||
58
+ a.controlApiUrl !== next.controlApiUrl);
59
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Opt-in instrumentation for `DooverClient`. When enabled, counts REST
3
+ * requests and gateway messages and keeps running latency stats. Disabled
4
+ * by default — the record methods short-circuit so production apps pay
5
+ * nothing unless a debug UI turns it on.
6
+ */
7
+ export interface RestStatsSnapshot {
8
+ enabled: boolean;
9
+ /** Total requests started since stats were enabled (or last reset). */
10
+ totalRequests: number;
11
+ /** Requests currently in flight. */
12
+ pendingRequests: number;
13
+ /** Requests that resolved successfully. */
14
+ completedRequests: number;
15
+ /** Requests that threw / rejected. */
16
+ failedRequests: number;
17
+ /** Mean latency of settled requests, ms. Null until at least one lands. */
18
+ averageLatencyMs: number | null;
19
+ /** Latency of the most recently settled request, ms. */
20
+ lastLatencyMs: number | null;
21
+ }
22
+ export interface GatewayStatsSnapshot {
23
+ enabled: boolean;
24
+ /** Frames we've sent on the gateway socket. */
25
+ messagesSent: number;
26
+ /** Frames we've received on the gateway socket. */
27
+ messagesReceived: number;
28
+ }
29
+ export interface DooverStatsSnapshot {
30
+ rest: RestStatsSnapshot;
31
+ gateway: GatewayStatsSnapshot;
32
+ }
33
+ export declare class DooverStatsCollector {
34
+ private enabled;
35
+ private rTotal;
36
+ private rPending;
37
+ private rCompleted;
38
+ private rFailed;
39
+ private rLatencySum;
40
+ private rLastLatency;
41
+ private gSent;
42
+ private gReceived;
43
+ setEnabled(enabled: boolean): void;
44
+ isEnabled(): boolean;
45
+ reset(): void;
46
+ /**
47
+ * Record the start of a REST request. Returns the start timestamp the
48
+ * caller must hand back to `recordRestEnd`, or `null` if stats are off.
49
+ */
50
+ recordRestStart(): number | null;
51
+ recordRestEnd(startedAt: number | null, succeeded: boolean): void;
52
+ recordGatewaySent(): void;
53
+ recordGatewayReceived(): void;
54
+ snapshot(): DooverStatsSnapshot;
55
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Opt-in instrumentation for `DooverClient`. When enabled, counts REST
4
+ * requests and gateway messages and keeps running latency stats. Disabled
5
+ * by default — the record methods short-circuit so production apps pay
6
+ * nothing unless a debug UI turns it on.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.DooverStatsCollector = void 0;
10
+ class DooverStatsCollector {
11
+ constructor() {
12
+ this.enabled = false;
13
+ this.rTotal = 0;
14
+ this.rPending = 0;
15
+ this.rCompleted = 0;
16
+ this.rFailed = 0;
17
+ this.rLatencySum = 0;
18
+ this.rLastLatency = null;
19
+ this.gSent = 0;
20
+ this.gReceived = 0;
21
+ }
22
+ setEnabled(enabled) {
23
+ this.enabled = enabled;
24
+ }
25
+ isEnabled() {
26
+ return this.enabled;
27
+ }
28
+ reset() {
29
+ this.rTotal = 0;
30
+ this.rPending = 0;
31
+ this.rCompleted = 0;
32
+ this.rFailed = 0;
33
+ this.rLatencySum = 0;
34
+ this.rLastLatency = null;
35
+ this.gSent = 0;
36
+ this.gReceived = 0;
37
+ }
38
+ /**
39
+ * Record the start of a REST request. Returns the start timestamp the
40
+ * caller must hand back to `recordRestEnd`, or `null` if stats are off.
41
+ */
42
+ recordRestStart() {
43
+ if (!this.enabled)
44
+ return null;
45
+ this.rTotal += 1;
46
+ this.rPending += 1;
47
+ return now();
48
+ }
49
+ recordRestEnd(startedAt, succeeded) {
50
+ if (!this.enabled || startedAt === null)
51
+ return;
52
+ this.rPending = Math.max(0, this.rPending - 1);
53
+ if (succeeded)
54
+ this.rCompleted += 1;
55
+ else
56
+ this.rFailed += 1;
57
+ const latency = now() - startedAt;
58
+ this.rLastLatency = latency;
59
+ this.rLatencySum += latency;
60
+ }
61
+ recordGatewaySent() {
62
+ if (!this.enabled)
63
+ return;
64
+ this.gSent += 1;
65
+ }
66
+ recordGatewayReceived() {
67
+ if (!this.enabled)
68
+ return;
69
+ this.gReceived += 1;
70
+ }
71
+ snapshot() {
72
+ const settled = this.rCompleted + this.rFailed;
73
+ return {
74
+ rest: {
75
+ enabled: this.enabled,
76
+ totalRequests: this.rTotal,
77
+ pendingRequests: this.rPending,
78
+ completedRequests: this.rCompleted,
79
+ failedRequests: this.rFailed,
80
+ averageLatencyMs: settled > 0 ? this.rLatencySum / settled : null,
81
+ lastLatencyMs: this.rLastLatency,
82
+ },
83
+ gateway: {
84
+ enabled: this.enabled,
85
+ messagesSent: this.gSent,
86
+ messagesReceived: this.gReceived,
87
+ },
88
+ };
89
+ }
90
+ }
91
+ exports.DooverStatsCollector = DooverStatsCollector;
92
+ function now() {
93
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
94
+ return performance.now();
95
+ }
96
+ return Date.now();
97
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DooverAuth } from "../auth/doover-auth";
2
+ import type { DooverStatsCollector } from "../client/stats";
2
3
  import type { DooverClientConfig } from "../http/rest-client";
3
4
  import type { GatewayListenerMap, WebSocketSession } from "./types";
4
5
  import type { ChannelRef, JSONValue } from "../types/common";
@@ -6,15 +7,21 @@ export declare class GatewayClient {
6
7
  private readonly config;
7
8
  private socket;
8
9
  private session;
9
- private heartbeatTimer;
10
- private lastHeartbeatAt;
11
- private missedHeartbeats;
12
10
  private reconnectTimer;
11
+ private reconnectAttempts;
12
+ /** Set when the consumer explicitly disconnects — suppresses reconnect. */
13
+ private explicitlyDisconnected;
14
+ /** Guards against concurrent connect() callers creating duplicate sockets. */
15
+ private opening;
13
16
  private listeners;
14
17
  private subscriptions;
15
18
  private readonly auth;
19
+ private stats;
16
20
  constructor(config: DooverClientConfig, auth?: DooverAuth);
21
+ /** Attach a stats collector. Call with `null` to detach. */
22
+ setStats(stats: DooverStatsCollector | null): void;
17
23
  connect(): Promise<void>;
24
+ private openSocket;
18
25
  disconnect(code?: number, reason?: string): void;
19
26
  on<K extends keyof GatewayListenerMap>(eventName: K, listener: GatewayListenerMap[K]): void;
20
27
  off<K extends keyof GatewayListenerMap>(eventName: K, listener: GatewayListenerMap[K]): void;
@@ -25,15 +32,32 @@ export declare class GatewayClient {
25
32
  syncChannel(channel: ChannelRef): void;
26
33
  sendOneShotMessage(channel: ChannelRef, data: JSONValue): void;
27
34
  getSession(): WebSocketSession | null;
28
- getLatency(): number | null;
29
35
  isConnected(): boolean;
36
+ /** Number of channels the gateway is currently subscribed to. */
37
+ getSubscriptionCount(): number;
38
+ /** Snapshot of currently subscribed channels (for debug/inspection). */
39
+ getSubscriptions(): ChannelRef[];
40
+ /**
41
+ * Force a fresh connection: close the current socket (without suppressing
42
+ * reconnects) and immediately open a new one. Useful for a manual
43
+ * "reconnect now" control in debug UIs.
44
+ */
45
+ reconnect(): Promise<void>;
30
46
  private handleMessage;
31
47
  private identifyOrResume;
32
48
  private resubscribeAll;
33
- private startHeartbeat;
34
- private stopHeartbeat;
35
49
  private send;
36
50
  private emit;
37
51
  private scheduleReconnect;
52
+ /** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
53
+ private computeReconnectDelay;
54
+ private installLifecycleListeners;
55
+ private handleVisibilityChange;
56
+ private handleOnline;
57
+ /**
58
+ * Called by lifecycle hooks when we have a strong signal that the network
59
+ * or tab has come back — skip the backoff schedule and reconnect now.
60
+ */
61
+ private tryImmediateReconnect;
38
62
  private channelKey;
39
63
  }
@@ -3,15 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GatewayClient = void 0;
4
4
  const errors_1 = require("../http/errors");
5
5
  const snowflake_1 = require("../utils/snowflake");
6
+ const RECONNECT_BASE_MS = 1000;
7
+ const RECONNECT_CAP_MS = 30000;
6
8
  class GatewayClient {
7
9
  constructor(config, auth) {
8
10
  this.config = config;
9
11
  this.socket = null;
10
12
  this.session = null;
11
- this.heartbeatTimer = null;
12
- this.lastHeartbeatAt = null;
13
- this.missedHeartbeats = 0;
14
13
  this.reconnectTimer = null;
14
+ this.reconnectAttempts = 0;
15
+ /** Set when the consumer explicitly disconnects — suppresses reconnect. */
16
+ this.explicitlyDisconnected = false;
17
+ /** Guards against concurrent connect() callers creating duplicate sockets. */
18
+ this.opening = false;
15
19
  this.listeners = {
16
20
  ready: new Set(),
17
21
  channelSync: new Set(),
@@ -26,15 +30,50 @@ class GatewayClient {
26
30
  sessionCancelled: new Set(),
27
31
  open: new Set(),
28
32
  close: new Set(),
29
- heartbeatAck: new Set(),
30
33
  };
31
34
  this.subscriptions = new Map();
35
+ this.stats = null;
36
+ this.handleVisibilityChange = () => {
37
+ if (typeof document === "undefined")
38
+ return;
39
+ if (document.visibilityState !== "visible")
40
+ return;
41
+ this.tryImmediateReconnect();
42
+ };
43
+ this.handleOnline = () => {
44
+ this.tryImmediateReconnect();
45
+ };
32
46
  this.auth = auth ?? null;
47
+ this.installLifecycleListeners();
48
+ }
49
+ /** Attach a stats collector. Call with `null` to detach. */
50
+ setStats(stats) {
51
+ this.stats = stats;
33
52
  }
34
53
  async connect() {
54
+ this.explicitlyDisconnected = false;
35
55
  if (this.socket && this.socket.readyState <= WebSocket.OPEN) {
36
56
  return;
37
57
  }
58
+ // De-dupe concurrent callers. Without this, N simultaneous
59
+ // `subscribeToChannel(...)` calls each await auth, then each create
60
+ // their own socket — the last one wins `this.socket`, but the earlier
61
+ // sockets' `onmessage` handlers still fire and try to send() through
62
+ // `this.socket` (a newer, not-yet-open socket → throws). The flag is
63
+ // cleared synchronously as soon as the new socket is assigned in
64
+ // `openSocket`, so reconnects after close still work.
65
+ if (this.opening) {
66
+ return;
67
+ }
68
+ this.opening = true;
69
+ try {
70
+ await this.openSocket();
71
+ }
72
+ finally {
73
+ this.opening = false;
74
+ }
75
+ }
76
+ async openSocket() {
38
77
  if (this.auth) {
39
78
  await this.auth.ensureReady();
40
79
  }
@@ -42,31 +81,36 @@ class GatewayClient {
42
81
  const wsParams = this.auth
43
82
  ? await this.auth.prepareWebSocket(this.config.dataWssUrl, hasFactory)
44
83
  : { url: this.config.dataWssUrl };
84
+ let socket;
45
85
  if (this.config.webSocketFactory) {
46
- this.socket = this.config.webSocketFactory({
86
+ socket = this.config.webSocketFactory({
47
87
  url: wsParams.url,
48
88
  headers: wsParams.headers,
49
89
  });
50
90
  }
51
91
  else {
52
92
  const WebSocketImpl = this.config.webSocketImpl ?? WebSocket;
53
- this.socket = new WebSocketImpl(wsParams.url);
93
+ socket = new WebSocketImpl(wsParams.url);
54
94
  }
55
- this.socket.onopen = () => this.emit("open");
56
- this.socket.onclose = (event) => {
57
- this.stopHeartbeat();
95
+ this.socket = socket;
96
+ // Release the concurrency gate synchronously now that the new socket is
97
+ // assigned — any subsequent connect() will see it via `this.socket`.
98
+ this.opening = false;
99
+ socket.onopen = () => this.emit("open");
100
+ socket.onclose = (event) => {
58
101
  this.emit("close", event);
59
102
  this.scheduleReconnect();
60
103
  };
61
- this.socket.onerror = () => undefined;
62
- this.socket.onmessage = (event) => this.handleMessage(event.data);
104
+ socket.onerror = () => undefined;
105
+ socket.onmessage = (event) => this.handleMessage(event.data);
63
106
  }
64
107
  disconnect(code, reason) {
65
- this.stopHeartbeat();
108
+ this.explicitlyDisconnected = true;
66
109
  if (this.reconnectTimer) {
67
110
  clearTimeout(this.reconnectTimer);
68
111
  this.reconnectTimer = null;
69
112
  }
113
+ this.reconnectAttempts = 0;
70
114
  this.socket?.close(code, reason);
71
115
  this.socket = null;
72
116
  }
@@ -136,20 +180,44 @@ class GatewayClient {
136
180
  getSession() {
137
181
  return this.session;
138
182
  }
139
- getLatency() {
140
- return this.lastHeartbeatAt === null ? null : Date.now() - this.lastHeartbeatAt;
141
- }
142
183
  isConnected() {
143
184
  return this.socket?.readyState === WebSocket.OPEN;
144
185
  }
186
+ /** Number of channels the gateway is currently subscribed to. */
187
+ getSubscriptionCount() {
188
+ return this.subscriptions.size;
189
+ }
190
+ /** Snapshot of currently subscribed channels (for debug/inspection). */
191
+ getSubscriptions() {
192
+ return [...this.subscriptions.values()].map((s) => s.channel);
193
+ }
194
+ /**
195
+ * Force a fresh connection: close the current socket (without suppressing
196
+ * reconnects) and immediately open a new one. Useful for a manual
197
+ * "reconnect now" control in debug UIs.
198
+ */
199
+ async reconnect() {
200
+ if (this.reconnectTimer) {
201
+ clearTimeout(this.reconnectTimer);
202
+ this.reconnectTimer = null;
203
+ }
204
+ this.reconnectAttempts = 0;
205
+ // Drop the session so the new socket identifies fresh (op 10) rather
206
+ // than attempting to resume a just-closed session (op 11) that the
207
+ // server has already torn down.
208
+ this.session = null;
209
+ if (this.socket) {
210
+ // Suppress the auto-reconnect path so we don't double-schedule.
211
+ const prev = this.socket;
212
+ prev.onclose = null;
213
+ prev.close(1000, "manual reconnect");
214
+ this.socket = null;
215
+ }
216
+ await this.connect();
217
+ }
145
218
  handleMessage(raw) {
219
+ this.stats?.recordGatewayReceived();
146
220
  const message = JSON.parse(raw);
147
- if (message.op === 2) {
148
- this.missedHeartbeats = 0;
149
- const latency = this.lastHeartbeatAt === null ? null : Date.now() - this.lastHeartbeatAt;
150
- this.emit("heartbeatAck", latency);
151
- return;
152
- }
153
221
  if (message.op === 3) {
154
222
  this.session = null;
155
223
  this.emit("sessionCancelled");
@@ -161,9 +229,9 @@ class GatewayClient {
161
229
  switch (message.t) {
162
230
  case "Hello":
163
231
  this.identifyOrResume();
164
- this.startHeartbeat();
165
232
  break;
166
233
  case "Ready":
234
+ this.reconnectAttempts = 0;
167
235
  this.session = message.d;
168
236
  this.emit("ready", message.d);
169
237
  this.resubscribeAll();
@@ -225,33 +293,12 @@ class GatewayClient {
225
293
  this.subscribe(entry.channel, { diff_only: entry.diff_only });
226
294
  }
227
295
  }
228
- startHeartbeat() {
229
- this.stopHeartbeat();
230
- this.heartbeatTimer = setInterval(() => {
231
- if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
232
- return;
233
- }
234
- this.lastHeartbeatAt = Date.now();
235
- this.missedHeartbeats += 1;
236
- if (this.missedHeartbeats > 3) {
237
- this.socket.close(4000, "Missed heartbeats");
238
- return;
239
- }
240
- this.send({ op: 1, d: {} });
241
- }, 20000);
242
- }
243
- stopHeartbeat() {
244
- if (this.heartbeatTimer) {
245
- clearInterval(this.heartbeatTimer);
246
- this.heartbeatTimer = null;
247
- }
248
- this.missedHeartbeats = 0;
249
- }
250
296
  send(payload) {
251
297
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
252
298
  throw new errors_1.DooverGatewayError("WebSocket is not connected");
253
299
  }
254
300
  this.socket.send(JSON.stringify(payload));
301
+ this.stats?.recordGatewaySent();
255
302
  }
256
303
  emit(eventName, ...args) {
257
304
  this.listeners[eventName].forEach((listener) => {
@@ -259,13 +306,45 @@ class GatewayClient {
259
306
  });
260
307
  }
261
308
  scheduleReconnect() {
262
- if (this.reconnectTimer) {
309
+ if (this.reconnectTimer || this.explicitlyDisconnected) {
263
310
  return;
264
311
  }
312
+ const delay = this.computeReconnectDelay();
313
+ this.reconnectAttempts += 1;
265
314
  this.reconnectTimer = setTimeout(() => {
266
315
  this.reconnectTimer = null;
267
316
  void this.connect();
268
- }, 1000);
317
+ }, delay);
318
+ }
319
+ /** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
320
+ computeReconnectDelay() {
321
+ const exp = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempts);
322
+ return Math.floor(Math.random() * exp);
323
+ }
324
+ installLifecycleListeners() {
325
+ if (this.config.disableBrowserLifecycleHooks)
326
+ return;
327
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
328
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
329
+ }
330
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
331
+ window.addEventListener("online", this.handleOnline);
332
+ }
333
+ }
334
+ /**
335
+ * Called by lifecycle hooks when we have a strong signal that the network
336
+ * or tab has come back — skip the backoff schedule and reconnect now.
337
+ */
338
+ tryImmediateReconnect() {
339
+ if (this.explicitlyDisconnected)
340
+ return;
341
+ if (this.isConnected())
342
+ return;
343
+ if (this.reconnectTimer) {
344
+ clearTimeout(this.reconnectTimer);
345
+ this.reconnectTimer = null;
346
+ }
347
+ void this.connect();
269
348
  }
270
349
  channelKey(channel) {
271
350
  return `${channel.agent_id}/${channel.name}`;
@@ -131,5 +131,4 @@ export interface GatewayListenerMap {
131
131
  sessionCancelled: () => void;
132
132
  open: () => void;
133
133
  close: (event: CloseEvent) => void;
134
- heartbeatAck: (latencyMs: number | null) => void;
135
134
  }