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.
- package/README.md +104 -10
- package/dist/apis/connections-api.d.ts +6 -0
- package/dist/apis/connections-api.js +8 -0
- package/dist/auth/auth-profile.d.ts +45 -0
- package/dist/auth/auth-profile.js +72 -0
- package/dist/auth/auth-store.d.ts +14 -0
- package/dist/auth/auth-store.js +2 -0
- package/dist/auth/build-auth.d.ts +37 -0
- package/dist/auth/build-auth.js +75 -0
- package/dist/auth/cookie-auth.d.ts +17 -0
- package/dist/auth/cookie-auth.js +29 -0
- package/dist/auth/doover-auth.d.ts +41 -0
- package/dist/auth/doover-auth.js +24 -0
- package/dist/auth/doover-token-auth.d.ts +35 -0
- package/dist/auth/doover-token-auth.js +178 -0
- package/dist/auth/errors.d.ts +3 -0
- package/dist/auth/errors.js +10 -0
- package/dist/auth/jwt.d.ts +6 -0
- package/dist/auth/jwt.js +29 -0
- package/dist/client/doover-client.d.ts +15 -0
- package/dist/client/doover-client.js +45 -4
- 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 +34 -8
- package/dist/gateway/gateway-client.js +147 -52
- package/dist/gateway/types.d.ts +0 -1
- package/dist/http/rest-client.d.ts +21 -3
- package/dist/http/rest-client.js +33 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +23 -5
- package/dist/node/config-manager.d.ts +34 -0
- package/dist/node/config-manager.js +177 -0
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +5 -0
- 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/auth.test.d.ts +1 -0
- package/dist/test/auth.test.js +502 -0
- package/dist/test/config-manager.test.d.ts +1 -0
- package/dist/test/config-manager.test.js +160 -0
- 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 +34 -5
- package/dist/viewer/doover-data-provider.js +96 -9
- 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,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;
|
package/dist/auth/jwt.js
ADDED
|
@@ -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
|
|
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.
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
}
|