doover-js 0.1.2 → 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 (84) hide show
  1. package/README.md +104 -10
  2. package/dist/apis/connections-api.d.ts +6 -0
  3. package/dist/apis/connections-api.js +8 -0
  4. package/dist/auth/auth-profile.d.ts +45 -0
  5. package/dist/auth/auth-profile.js +72 -0
  6. package/dist/auth/auth-store.d.ts +14 -0
  7. package/dist/auth/auth-store.js +2 -0
  8. package/dist/auth/build-auth.d.ts +37 -0
  9. package/dist/auth/build-auth.js +75 -0
  10. package/dist/auth/cookie-auth.d.ts +17 -0
  11. package/dist/auth/cookie-auth.js +29 -0
  12. package/dist/auth/doover-auth.d.ts +41 -0
  13. package/dist/auth/doover-auth.js +24 -0
  14. package/dist/auth/doover-token-auth.d.ts +35 -0
  15. package/dist/auth/doover-token-auth.js +178 -0
  16. package/dist/auth/errors.d.ts +3 -0
  17. package/dist/auth/errors.js +10 -0
  18. package/dist/auth/jwt.d.ts +6 -0
  19. package/dist/auth/jwt.js +29 -0
  20. package/dist/client/doover-client.d.ts +15 -0
  21. package/dist/client/doover-client.js +45 -4
  22. package/dist/client/singleton.d.ts +26 -0
  23. package/dist/client/singleton.js +59 -0
  24. package/dist/client/stats.d.ts +55 -0
  25. package/dist/client/stats.js +97 -0
  26. package/dist/gateway/gateway-client.d.ts +34 -8
  27. package/dist/gateway/gateway-client.js +147 -52
  28. package/dist/gateway/types.d.ts +0 -1
  29. package/dist/http/rest-client.d.ts +21 -3
  30. package/dist/http/rest-client.js +33 -2
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.js +23 -5
  33. package/dist/node/config-manager.d.ts +34 -0
  34. package/dist/node/config-manager.js +177 -0
  35. package/dist/node/index.d.ts +1 -0
  36. package/dist/node/index.js +5 -0
  37. package/dist/react/context.d.ts +19 -0
  38. package/dist/react/context.js +28 -0
  39. package/dist/react/index.d.ts +24 -0
  40. package/dist/react/index.js +40 -0
  41. package/dist/react/sharedQueryClient.d.ts +21 -0
  42. package/dist/react/sharedQueryClient.js +39 -0
  43. package/dist/react/useAgentChannel.d.ts +7 -0
  44. package/dist/react/useAgentChannel.js +12 -0
  45. package/dist/react/useAgentConnections.d.ts +8 -0
  46. package/dist/react/useAgentConnections.js +24 -0
  47. package/dist/react/useChannelAggregate.d.ts +13 -0
  48. package/dist/react/useChannelAggregate.js +38 -0
  49. package/dist/react/useChannelMessages.d.ts +23 -0
  50. package/dist/react/useChannelMessages.js +56 -0
  51. package/dist/react/useChannelSubscription.d.ts +16 -0
  52. package/dist/react/useChannelSubscription.js +43 -0
  53. package/dist/react/useConnectionState.d.ts +21 -0
  54. package/dist/react/useConnectionState.js +43 -0
  55. package/dist/react/useMultiAgentAggregates.d.ts +22 -0
  56. package/dist/react/useMultiAgentAggregates.js +82 -0
  57. package/dist/react/useMultiAgentChannelMessages.d.ts +18 -0
  58. package/dist/react/useMultiAgentChannelMessages.js +78 -0
  59. package/dist/react/useSendMessage.d.ts +9 -0
  60. package/dist/react/useSendMessage.js +16 -0
  61. package/dist/react/useSendRpc.d.ts +61 -0
  62. package/dist/react/useSendRpc.js +158 -0
  63. package/dist/react/useTurnCredentials.d.ts +12 -0
  64. package/dist/react/useTurnCredentials.js +25 -0
  65. package/dist/react/useUpdateAggregate.d.ts +14 -0
  66. package/dist/react/useUpdateAggregate.js +25 -0
  67. package/dist/react/useUpdateMessage.d.ts +17 -0
  68. package/dist/react/useUpdateMessage.js +24 -0
  69. package/dist/test/apis.test.js +7 -1
  70. package/dist/test/auth.test.d.ts +1 -0
  71. package/dist/test/auth.test.js +502 -0
  72. package/dist/test/config-manager.test.d.ts +1 -0
  73. package/dist/test/config-manager.test.js +160 -0
  74. package/dist/test/doover-client.test.js +13 -0
  75. package/dist/test/doover-data-provider.test.js +155 -0
  76. package/dist/test/gateway-client.test.js +122 -8
  77. package/dist/test/react.test.d.ts +1 -0
  78. package/dist/test/react.test.js +353 -0
  79. package/dist/test/singleton.test.d.ts +1 -0
  80. package/dist/test/singleton.test.js +62 -0
  81. package/dist/types/common.d.ts +40 -0
  82. package/dist/viewer/doover-data-provider.d.ts +34 -5
  83. package/dist/viewer/doover-data-provider.js +96 -9
  84. package/package.json +33 -2
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DooverTokenAuth = void 0;
4
+ const doover_auth_1 = require("./doover-auth");
5
+ const errors_1 = require("./errors");
6
+ const jwt_1 = require("./jwt");
7
+ /** Buffer in milliseconds before expiry at which we trigger a refresh (30 s). */
8
+ const REFRESH_BUFFER_MS = 30000;
9
+ class DooverTokenAuth extends doover_auth_1.DooverAuth {
10
+ constructor(options) {
11
+ super();
12
+ this.refreshInFlight = null;
13
+ this.token = options.token ?? null;
14
+ this.tokenExpires = this.resolveExpiry(options.tokenExpires, options.token ?? null);
15
+ this.refreshToken = options.refreshToken ?? null;
16
+ this.refreshTokenId = options.refreshTokenId ?? null;
17
+ this.authServerUrl = options.authServerUrl ?? null;
18
+ this.authServerClientId = options.authServerClientId ?? null;
19
+ this.fetchImpl = options.fetchImpl ?? fetch;
20
+ }
21
+ // ------------------------------------------------------------------
22
+ // DooverAuth interface
23
+ // ------------------------------------------------------------------
24
+ async getHttpHeaders() {
25
+ if (!this.token) {
26
+ return {};
27
+ }
28
+ return { Authorization: `Bearer ${this.token}` };
29
+ }
30
+ getFetchCredentials() {
31
+ return "omit";
32
+ }
33
+ async prepareWebSocket(url, canUseHeaders) {
34
+ if (!this.token) {
35
+ return { url };
36
+ }
37
+ if (canUseHeaders) {
38
+ return {
39
+ url,
40
+ headers: { Authorization: `Bearer ${this.token}` },
41
+ };
42
+ }
43
+ // Append token as a query parameter for browser-style WebSocket.
44
+ const parsed = new URL(url);
45
+ parsed.searchParams.set("token", this.token);
46
+ return { url: parsed.toString() };
47
+ }
48
+ setToken(token, tokenExpires) {
49
+ this.token = token;
50
+ this.tokenExpires = this.resolveExpiry(tokenExpires, token);
51
+ }
52
+ async ensureReady() {
53
+ if (!this.needsRefresh()) {
54
+ return;
55
+ }
56
+ // Deduplicate concurrent refresh attempts.
57
+ if (!this.refreshInFlight) {
58
+ this.refreshInFlight = this.refresh().finally(() => {
59
+ this.refreshInFlight = null;
60
+ });
61
+ }
62
+ return this.refreshInFlight;
63
+ }
64
+ // ------------------------------------------------------------------
65
+ // Profile attachment override — load refresh metadata from profile
66
+ // ------------------------------------------------------------------
67
+ attachProfile(profile, configManager) {
68
+ super.attachProfile(profile, configManager);
69
+ // Back-fill fields from the profile where not already set.
70
+ if (!this.token && profile.token) {
71
+ this.token = profile.token;
72
+ }
73
+ if (!this.tokenExpires && profile.tokenExpires) {
74
+ this.tokenExpires = new Date(profile.tokenExpires);
75
+ }
76
+ if (!this.tokenExpires && this.token) {
77
+ this.tokenExpires = (0, jwt_1.decodeTokenExpiry)(this.token);
78
+ }
79
+ if (!this.refreshToken && profile.refreshToken) {
80
+ this.refreshToken = profile.refreshToken;
81
+ }
82
+ if (!this.refreshTokenId && profile.refreshTokenId) {
83
+ this.refreshTokenId = profile.refreshTokenId;
84
+ }
85
+ if (!this.authServerUrl && profile.authServerUrl) {
86
+ this.authServerUrl = profile.authServerUrl;
87
+ }
88
+ if (!this.authServerClientId && profile.authServerClientId) {
89
+ this.authServerClientId = profile.authServerClientId;
90
+ }
91
+ }
92
+ // ------------------------------------------------------------------
93
+ // Internal
94
+ // ------------------------------------------------------------------
95
+ needsRefresh() {
96
+ if (!this.token) {
97
+ return true;
98
+ }
99
+ if (!this.tokenExpires) {
100
+ return false;
101
+ }
102
+ return this.tokenExpires.getTime() - Date.now() < REFRESH_BUFFER_MS;
103
+ }
104
+ async refresh() {
105
+ if (!this.authServerUrl ||
106
+ !this.refreshToken ||
107
+ !this.authServerClientId) {
108
+ throw new errors_1.DooverAuthError("Cannot refresh token: missing authServerUrl, refreshToken, or authServerClientId");
109
+ }
110
+ const url = new URL(`${this.authServerUrl}/oauth2/token`);
111
+ url.searchParams.set("grant_type", "refresh_token");
112
+ if (this.token) {
113
+ url.searchParams.set("access_token", this.token);
114
+ }
115
+ url.searchParams.set("refresh_token", this.refreshToken);
116
+ url.searchParams.set("client_id", this.authServerClientId);
117
+ let response;
118
+ try {
119
+ response = await this.fetchImpl(url.toString(), {
120
+ method: "POST",
121
+ });
122
+ }
123
+ catch (err) {
124
+ throw new errors_1.DooverAuthError(`Token refresh request failed: ${err instanceof Error ? err.message : String(err)}`);
125
+ }
126
+ if (!response.ok) {
127
+ throw new errors_1.DooverAuthError(`Token refresh failed with status ${response.status}`);
128
+ }
129
+ const body = (await response.json());
130
+ if (!body.access_token) {
131
+ throw new errors_1.DooverAuthError("Token refresh response did not contain an access_token");
132
+ }
133
+ this.token = body.access_token;
134
+ this.tokenExpires =
135
+ typeof body.expires_in === "number"
136
+ ? new Date(Date.now() + body.expires_in * 1000)
137
+ : (0, jwt_1.decodeTokenExpiry)(body.access_token);
138
+ if (body.refresh_token) {
139
+ this.refreshToken = body.refresh_token;
140
+ }
141
+ // Persist back to profile / config manager.
142
+ this.persistToProfile();
143
+ }
144
+ persistToProfile() {
145
+ if (!this.profile) {
146
+ return;
147
+ }
148
+ this.profile.token = this.token;
149
+ this.profile.tokenExpires = this.tokenExpires
150
+ ? this.tokenExpires.toISOString()
151
+ : null;
152
+ if (this.refreshToken) {
153
+ this.profile.refreshToken = this.refreshToken;
154
+ }
155
+ if (this.configManager) {
156
+ try {
157
+ this.configManager.create(this.profile);
158
+ this.configManager.write();
159
+ }
160
+ catch (err) {
161
+ throw new errors_1.DooverAuthError(`Failed to persist refreshed token: ${err instanceof Error ? err.message : String(err)}`);
162
+ }
163
+ }
164
+ }
165
+ resolveExpiry(explicit, token) {
166
+ if (explicit instanceof Date) {
167
+ return explicit;
168
+ }
169
+ if (typeof explicit === "number") {
170
+ return new Date(explicit);
171
+ }
172
+ if (token) {
173
+ return (0, jwt_1.decodeTokenExpiry)(token);
174
+ }
175
+ return null;
176
+ }
177
+ }
178
+ exports.DooverTokenAuth = DooverTokenAuth;
@@ -0,0 +1,3 @@
1
+ export declare class DooverAuthError extends Error {
2
+ constructor(message: string);
3
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DooverAuthError = void 0;
4
+ class DooverAuthError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "DooverAuthError";
8
+ }
9
+ }
10
+ exports.DooverAuthError = DooverAuthError;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Decode the `exp` claim from a JWT without verifying the signature.
3
+ * Returns the expiry as a `Date`, or `null` if the token has no `exp` claim
4
+ * or cannot be decoded.
5
+ */
6
+ export declare function decodeTokenExpiry(token: string): Date | null;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decodeTokenExpiry = decodeTokenExpiry;
4
+ /**
5
+ * Decode the `exp` claim from a JWT without verifying the signature.
6
+ * Returns the expiry as a `Date`, or `null` if the token has no `exp` claim
7
+ * or cannot be decoded.
8
+ */
9
+ function decodeTokenExpiry(token) {
10
+ try {
11
+ const parts = token.split(".");
12
+ if (parts.length < 2) {
13
+ return null;
14
+ }
15
+ // Base64url → Base64 → decode
16
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
17
+ const json = typeof atob === "function"
18
+ ? atob(base64)
19
+ : Buffer.from(base64, "base64").toString("utf-8");
20
+ const payload = JSON.parse(json);
21
+ if (typeof payload.exp !== "number") {
22
+ return null;
23
+ }
24
+ return new Date(payload.exp * 1000);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
@@ -8,10 +8,13 @@ import { NotificationsApi } from "../apis/notifications-api";
8
8
  import { PermissionsApi } from "../apis/permissions-api";
9
9
  import { ProcessorsApi } from "../apis/processors-api";
10
10
  import { TurnApi } from "../apis/turn-api";
11
+ import type { DooverAuth } from "../auth/doover-auth";
11
12
  import { GatewayClient } from "../gateway/gateway-client";
12
13
  import { RestClient, type DooverClientConfig } from "../http/rest-client";
13
14
  import { DooverDataProvider } from "../viewer/doover-data-provider";
15
+ import { DooverStatsCollector, type DooverStatsSnapshot } from "./stats";
14
16
  export declare class DooverClient {
17
+ readonly auth: DooverAuth;
15
18
  readonly rest: RestClient;
16
19
  readonly viewer: DooverDataProvider;
17
20
  readonly channels: ChannelsApi;
@@ -25,5 +28,17 @@ export declare class DooverClient {
25
28
  readonly turn: TurnApi;
26
29
  readonly agents: AgentsApi;
27
30
  readonly gateway: GatewayClient;
31
+ /** Opt-in instrumentation. Disabled by default — see {@link enableStats}. */
32
+ readonly stats: DooverStatsCollector;
28
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;
29
44
  }
@@ -11,13 +11,26 @@ const notifications_api_1 = require("../apis/notifications-api");
11
11
  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
- const gateway_client_1 = require("../gateway/gateway-client");
14
+ const build_auth_1 = require("../auth/build-auth");
15
15
  const rest_client_1 = require("../http/rest-client");
16
16
  const doover_data_provider_1 = require("../viewer/doover-data-provider");
17
+ const stats_1 = require("./stats");
17
18
  class DooverClient {
18
19
  constructor(config) {
19
- this.rest = new rest_client_1.RestClient(config);
20
- this.viewer = new doover_data_provider_1.DooverDataProvider(config);
20
+ this.auth = (0, build_auth_1.buildAuth)({
21
+ auth: config.auth,
22
+ profile: config.profile,
23
+ configManager: config.configManager,
24
+ token: config.token,
25
+ tokenExpires: config.tokenExpires,
26
+ refreshToken: config.refreshToken,
27
+ refreshTokenId: config.refreshTokenId,
28
+ authServerUrl: config.authServerUrl,
29
+ authServerClientId: config.authServerClientId,
30
+ fetchImpl: config.fetchImpl,
31
+ });
32
+ this.rest = new rest_client_1.RestClient(config, this.auth);
33
+ this.viewer = new doover_data_provider_1.DooverDataProvider(config, this.auth);
21
34
  this.channels = new channels_api_1.ChannelsApi(this.rest);
22
35
  this.messages = new messages_api_1.MessagesApi(this.rest);
23
36
  this.aggregates = new aggregates_api_1.AggregatesApi(this.rest);
@@ -28,7 +41,35 @@ class DooverClient {
28
41
  this.processors = new processors_api_1.ProcessorsApi(this.rest);
29
42
  this.turn = new turn_api_1.TurnApi(this.rest);
30
43
  this.agents = new agents_api_1.AgentsApi(this.rest);
31
- this.gateway = new gateway_client_1.GatewayClient(config);
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();
32
73
  }
33
74
  }
34
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,3 +1,5 @@
1
+ import type { DooverAuth } from "../auth/doover-auth";
2
+ import type { DooverStatsCollector } from "../client/stats";
1
3
  import type { DooverClientConfig } from "../http/rest-client";
2
4
  import type { GatewayListenerMap, WebSocketSession } from "./types";
3
5
  import type { ChannelRef, JSONValue } from "../types/common";
@@ -5,14 +7,21 @@ export declare class GatewayClient {
5
7
  private readonly config;
6
8
  private socket;
7
9
  private session;
8
- private heartbeatTimer;
9
- private lastHeartbeatAt;
10
- private missedHeartbeats;
11
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;
12
16
  private listeners;
13
17
  private subscriptions;
14
- constructor(config: DooverClientConfig);
15
- connect(): void;
18
+ private readonly auth;
19
+ private stats;
20
+ constructor(config: DooverClientConfig, auth?: DooverAuth);
21
+ /** Attach a stats collector. Call with `null` to detach. */
22
+ setStats(stats: DooverStatsCollector | null): void;
23
+ connect(): Promise<void>;
24
+ private openSocket;
16
25
  disconnect(code?: number, reason?: string): void;
17
26
  on<K extends keyof GatewayListenerMap>(eventName: K, listener: GatewayListenerMap[K]): void;
18
27
  off<K extends keyof GatewayListenerMap>(eventName: K, listener: GatewayListenerMap[K]): void;
@@ -23,15 +32,32 @@ export declare class GatewayClient {
23
32
  syncChannel(channel: ChannelRef): void;
24
33
  sendOneShotMessage(channel: ChannelRef, data: JSONValue): void;
25
34
  getSession(): WebSocketSession | null;
26
- getLatency(): number | null;
27
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>;
28
46
  private handleMessage;
29
47
  private identifyOrResume;
30
48
  private resubscribeAll;
31
- private startHeartbeat;
32
- private stopHeartbeat;
33
49
  private send;
34
50
  private emit;
35
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;
36
62
  private channelKey;
37
63
  }