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.
- package/dist/apis/connections-api.d.ts +6 -0
- package/dist/apis/connections-api.js +8 -0
- package/dist/client/doover-client.d.ts +13 -0
- package/dist/client/doover-client.js +30 -2
- package/dist/client/singleton.d.ts +26 -0
- package/dist/client/singleton.js +59 -0
- package/dist/client/stats.d.ts +55 -0
- package/dist/client/stats.js +97 -0
- package/dist/gateway/gateway-client.d.ts +30 -6
- package/dist/gateway/gateway-client.js +125 -46
- package/dist/gateway/types.d.ts +0 -1
- package/dist/http/rest-client.d.ts +11 -0
- package/dist/http/rest-client.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -1
- package/dist/react/context.d.ts +19 -0
- package/dist/react/context.js +28 -0
- package/dist/react/index.d.ts +24 -0
- package/dist/react/index.js +40 -0
- package/dist/react/sharedQueryClient.d.ts +21 -0
- package/dist/react/sharedQueryClient.js +39 -0
- package/dist/react/useAgentChannel.d.ts +7 -0
- package/dist/react/useAgentChannel.js +12 -0
- package/dist/react/useAgentConnections.d.ts +8 -0
- package/dist/react/useAgentConnections.js +24 -0
- package/dist/react/useChannelAggregate.d.ts +13 -0
- package/dist/react/useChannelAggregate.js +38 -0
- package/dist/react/useChannelMessages.d.ts +23 -0
- package/dist/react/useChannelMessages.js +56 -0
- package/dist/react/useChannelSubscription.d.ts +16 -0
- package/dist/react/useChannelSubscription.js +43 -0
- package/dist/react/useConnectionState.d.ts +21 -0
- package/dist/react/useConnectionState.js +43 -0
- package/dist/react/useMultiAgentAggregates.d.ts +22 -0
- package/dist/react/useMultiAgentAggregates.js +82 -0
- package/dist/react/useMultiAgentChannelMessages.d.ts +18 -0
- package/dist/react/useMultiAgentChannelMessages.js +78 -0
- package/dist/react/useSendMessage.d.ts +9 -0
- package/dist/react/useSendMessage.js +16 -0
- package/dist/react/useSendRpc.d.ts +61 -0
- package/dist/react/useSendRpc.js +158 -0
- package/dist/react/useTurnCredentials.d.ts +12 -0
- package/dist/react/useTurnCredentials.js +25 -0
- package/dist/react/useUpdateAggregate.d.ts +14 -0
- package/dist/react/useUpdateAggregate.js +25 -0
- package/dist/react/useUpdateMessage.d.ts +17 -0
- package/dist/react/useUpdateMessage.js +24 -0
- package/dist/test/apis.test.js +7 -1
- package/dist/test/doover-client.test.js +13 -0
- package/dist/test/doover-data-provider.test.js +155 -0
- package/dist/test/gateway-client.test.js +122 -8
- package/dist/test/react.test.d.ts +1 -0
- package/dist/test/react.test.js +353 -0
- package/dist/test/singleton.test.d.ts +1 -0
- package/dist/test/singleton.test.js +62 -0
- package/dist/types/common.d.ts +40 -0
- package/dist/viewer/doover-data-provider.d.ts +32 -4
- package/dist/viewer/doover-data-provider.js +92 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
socket = new WebSocketImpl(wsParams.url);
|
|
54
94
|
}
|
|
55
|
-
this.socket
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
104
|
+
socket.onerror = () => undefined;
|
|
105
|
+
socket.onmessage = (event) => this.handleMessage(event.data);
|
|
63
106
|
}
|
|
64
107
|
disconnect(code, reason) {
|
|
65
|
-
this.
|
|
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
|
-
},
|
|
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}`;
|