@guava-ai/guava-sdk 0.17.0 → 0.19.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 (100) hide show
  1. package/dist/examples/example.test.d.ts +5 -0
  2. package/dist/examples/example.test.js +46 -0
  3. package/dist/examples/example.test.js.map +1 -0
  4. package/dist/examples/help-desk.d.ts +3 -1
  5. package/dist/examples/help-desk.js +25 -9
  6. package/dist/examples/help-desk.js.map +1 -1
  7. package/dist/examples/property-insurance.js +4 -1
  8. package/dist/examples/property-insurance.js.map +1 -1
  9. package/dist/examples/restaurant-waitlist.js +4 -1
  10. package/dist/examples/restaurant-waitlist.js.map +1 -1
  11. package/dist/examples/scheduling-outbound.js +6 -0
  12. package/dist/examples/scheduling-outbound.js.map +1 -1
  13. package/dist/src/action-item.d.ts +4 -4
  14. package/dist/src/agent.d.ts +81 -16
  15. package/dist/src/agent.js +394 -127
  16. package/dist/src/agent.js.map +1 -1
  17. package/dist/src/auth.d.ts +27 -0
  18. package/dist/src/auth.js +127 -0
  19. package/dist/src/auth.js.map +1 -0
  20. package/dist/src/call.d.ts +1 -1
  21. package/dist/src/call.js +2 -2
  22. package/dist/src/call.js.map +1 -1
  23. package/dist/src/client.d.ts +4 -11
  24. package/dist/src/client.js +22 -14
  25. package/dist/src/client.js.map +1 -1
  26. package/dist/src/commands.d.ts +3 -3
  27. package/dist/src/events.d.ts +22 -0
  28. package/dist/src/events.js +19 -5
  29. package/dist/src/events.js.map +1 -1
  30. package/dist/src/helpers/llm.d.ts +2 -0
  31. package/dist/src/helpers/llm.js +17 -0
  32. package/dist/src/helpers/llm.js.map +1 -0
  33. package/dist/src/index.d.ts +2 -0
  34. package/dist/src/index.js +5 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logging.js +16 -11
  37. package/dist/src/logging.js.map +1 -1
  38. package/dist/src/socket/call-info.d.ts +35 -0
  39. package/dist/src/socket/call-info.js +59 -0
  40. package/dist/src/socket/call-info.js.map +1 -0
  41. package/dist/src/socket/client.d.ts +51 -0
  42. package/dist/src/socket/client.js +455 -0
  43. package/dist/src/socket/client.js.map +1 -0
  44. package/dist/src/socket/listen-inbound.d.ts +83 -0
  45. package/dist/src/socket/listen-inbound.js +82 -0
  46. package/dist/src/socket/listen-inbound.js.map +1 -0
  47. package/dist/src/socket/protocol.d.ts +127 -0
  48. package/dist/src/socket/protocol.js +69 -0
  49. package/dist/src/socket/protocol.js.map +1 -0
  50. package/dist/src/socket/utils.d.ts +8 -0
  51. package/dist/src/socket/utils.js +26 -0
  52. package/dist/src/socket/utils.js.map +1 -0
  53. package/dist/src/telemetry.d.ts +3 -3
  54. package/dist/src/telemetry.js +9 -7
  55. package/dist/src/telemetry.js.map +1 -1
  56. package/dist/src/testing/chat.d.ts +2 -0
  57. package/dist/src/testing/chat.js +181 -0
  58. package/dist/src/testing/chat.js.map +1 -0
  59. package/dist/src/testing/mocks.d.ts +6 -0
  60. package/dist/src/testing/mocks.js +14 -0
  61. package/dist/src/testing/mocks.js.map +1 -0
  62. package/dist/src/testing/protocol.d.ts +46 -0
  63. package/dist/src/testing/protocol.js +61 -0
  64. package/dist/src/testing/protocol.js.map +1 -0
  65. package/dist/src/testing/session.d.ts +26 -0
  66. package/dist/src/testing/session.js +219 -0
  67. package/dist/src/testing/session.js.map +1 -0
  68. package/dist/src/utils.d.ts +1 -0
  69. package/dist/src/utils.js +15 -1
  70. package/dist/src/utils.js.map +1 -1
  71. package/dist/src/version.d.ts +1 -1
  72. package/dist/src/version.js +1 -1
  73. package/dist/src/webrtc-helper.js +11 -11
  74. package/examples/example.test.ts +58 -0
  75. package/examples/help-desk.ts +14 -3
  76. package/examples/property-insurance.ts +3 -1
  77. package/examples/restaurant-waitlist.ts +3 -1
  78. package/examples/scheduling-outbound.ts +7 -0
  79. package/package.json +9 -1
  80. package/src/agent.ts +372 -162
  81. package/src/auth.ts +109 -0
  82. package/src/call.ts +3 -3
  83. package/src/client.ts +32 -15
  84. package/src/events.ts +24 -10
  85. package/src/helpers/llm.ts +20 -0
  86. package/src/index.ts +2 -0
  87. package/src/logging.ts +21 -13
  88. package/src/socket/call-info.ts +30 -0
  89. package/src/socket/client.ts +433 -0
  90. package/src/socket/listen-inbound.ts +62 -0
  91. package/src/socket/protocol.ts +89 -0
  92. package/src/socket/utils.ts +25 -0
  93. package/src/telemetry.ts +11 -8
  94. package/src/testing/chat.ts +196 -0
  95. package/src/testing/mocks.ts +12 -0
  96. package/src/testing/protocol.ts +40 -0
  97. package/src/testing/session.ts +218 -0
  98. package/src/utils.ts +15 -1
  99. package/src/version.ts +1 -1
  100. package/src/webrtc-helper.ts +11 -11
@@ -0,0 +1,433 @@
1
+ import WebSocket from "ws";
2
+ import { randomBytes } from "node:crypto";
3
+ import * as z from "zod";
4
+ import type { Client } from "../client.ts";
5
+ import type { Logger } from "../logging.ts";
6
+ import { getDefaultLogger } from "../logging.ts";
7
+ import { EventCounter } from "./utils.ts";
8
+ import { telemetryClient } from "../telemetry.ts";
9
+ import {
10
+ type CloseReason,
11
+ type GuavaClientMessage,
12
+ GuavaClose,
13
+ GuavaOpenAck,
14
+ GuavaServerMessage,
15
+ } from "./protocol.ts";
16
+
17
+ function sleep(ms: number): Promise<void> {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ function reconnectDelay(attempt: number): number {
22
+ if (attempt <= 3) return 1000 + (Math.random() - 0.5) * 1000;
23
+ if (attempt <= 5) return 5000 + (Math.random() - 0.5) * 4000;
24
+ return 10_000 + (Math.random() - 0.5) * 10_000;
25
+ }
26
+
27
+ const MAX_RECONNECT_ATTEMPTS = 10;
28
+ const MAX_CONNECTIONS_PER_MINUTE = 15;
29
+ const PING_INTERVAL_MS = 10_000;
30
+ const SOCKET_DEAD_TIMEOUT_MS = 30_000;
31
+
32
+ export class GuavaSocketClosedError extends Error {
33
+ readonly reason: CloseReason;
34
+ readonly description: string;
35
+
36
+ constructor(reason?: CloseReason, description?: string) {
37
+ const r = reason ?? "unknown";
38
+ const d = description ?? "No description provided.";
39
+ super(`Guava socket closed. Reason: ${r}. Description: ${d}`);
40
+ this.reason = r;
41
+ this.description = d;
42
+ }
43
+ }
44
+
45
+ export class GuavaSocketConnectionFailed extends Error {
46
+ constructor() {
47
+ super("Couldn't connect to the Guava server after multiple attempts.");
48
+ }
49
+ }
50
+
51
+ const HandshakeResponse = z.union([GuavaOpenAck, GuavaClose]);
52
+
53
+ function _sendMessage(ws: WebSocket, message: GuavaClientMessage): void {
54
+ ws.send(JSON.stringify(message));
55
+ }
56
+
57
+ @telemetryClient.trackClass()
58
+ export class GuavaSocket<SendT, RecvT> {
59
+ private readonly _socketCreateTime = Date.now();
60
+ private readonly _connectionId = randomBytes(10).toString("hex");
61
+ private readonly _openCounter = new EventCounter(60);
62
+ private readonly _logger: Logger;
63
+
64
+ private _lastSeenSequence = 0;
65
+ private _lastSentSequence = 0;
66
+ private _rtxBuffer: Array<[number, Record<string, unknown>]> = [];
67
+
68
+ private _state: "never-opened" | "open" | "closed" = "never-opened";
69
+ private _ws?: WebSocket;
70
+ private _shouldClose = false;
71
+
72
+ private _closeReason?: CloseReason;
73
+ private _closeDescription?: string;
74
+
75
+ // Async recv queue: buffered payloads waiting to be consumed, and waiters blocked on _recv()
76
+ private _recvBuffer: Array<Record<string, unknown>> = [];
77
+ private _recvWaiters: Array<{
78
+ resolve: (v: Record<string, unknown>) => void;
79
+ reject: (e: Error) => void;
80
+ }> = [];
81
+
82
+ private _readyResolve?: () => void;
83
+ private _readyReject?: (e: Error) => void;
84
+ private _reconnectLoopPromise?: Promise<void>;
85
+
86
+ constructor(
87
+ private readonly _name: string,
88
+ private readonly _connectionUrl: string,
89
+ private readonly _client: Client,
90
+ private readonly _serializer: (msg: SendT) => Record<string, unknown>,
91
+ private readonly _deserializer: (payload: Record<string, unknown>) => RecvT,
92
+ private readonly _maxAgeSeconds?: number,
93
+ logger?: Logger,
94
+ ) {
95
+ this._logger = logger ?? getDefaultLogger();
96
+ }
97
+
98
+ isOpen(): boolean {
99
+ return this._state === "open";
100
+ }
101
+
102
+ private _setCloseReason(reason: CloseReason, description: string): void {
103
+ if (this._closeReason === undefined) {
104
+ this._closeReason = reason;
105
+ this._closeDescription = description;
106
+ }
107
+ }
108
+
109
+ private _pruneRtxBuffer(peerLastSeenSequence: number): void {
110
+ while (this._rtxBuffer.length > 0 && this._rtxBuffer[0]![0] <= peerLastSeenSequence) {
111
+ this._rtxBuffer.shift();
112
+ }
113
+ }
114
+
115
+ private _pushPayload(payload: Record<string, unknown>): void {
116
+ const waiter = this._recvWaiters.shift();
117
+ if (waiter) {
118
+ waiter.resolve(payload);
119
+ } else {
120
+ this._recvBuffer.push(payload);
121
+ }
122
+ }
123
+
124
+ private _rejectAllWaiters(): void {
125
+ const err = new GuavaSocketClosedError(this._closeReason, this._closeDescription);
126
+ for (const waiter of this._recvWaiters) {
127
+ waiter.reject(err);
128
+ }
129
+ this._recvWaiters = [];
130
+ }
131
+
132
+ private async _establishSocket(isReopen: boolean): Promise<WebSocket> {
133
+ for (let attempt = 1; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) {
134
+ const headers = await this._client.headers();
135
+
136
+ try {
137
+ const ws = await new Promise<WebSocket>((resolve, reject) => {
138
+ const socket = new WebSocket(this._connectionUrl, { headers });
139
+ let settled = false;
140
+
141
+ const settle = (fn: () => void) => {
142
+ if (!settled) {
143
+ settled = true;
144
+ socket.removeAllListeners();
145
+ fn();
146
+ }
147
+ };
148
+
149
+ socket.once("error", (err) => settle(() => reject(err)));
150
+ socket.once("close", () =>
151
+ settle(() => reject(new Error("Connection closed before open-ack"))),
152
+ );
153
+
154
+ socket.once("open", () => {
155
+ _sendMessage(socket, {
156
+ message_type: "open",
157
+ name: this._name,
158
+ connection_id: this._connectionId,
159
+ is_reopen: isReopen,
160
+ last_seen_sequence: this._lastSeenSequence,
161
+ });
162
+
163
+ const ackTimeout = setTimeout(() => {
164
+ settle(() => {
165
+ socket.close();
166
+ reject(new Error("Timed out waiting for open-ack"));
167
+ });
168
+ }, 10_000);
169
+
170
+ socket.once("message", (data) => {
171
+ clearTimeout(ackTimeout);
172
+ try {
173
+ const msg = HandshakeResponse.parse(JSON.parse(data.toString()));
174
+
175
+ if (msg.message_type === "close") {
176
+ this._setCloseReason(msg.reason, msg.description);
177
+ settle(() => {
178
+ socket.close();
179
+ reject(new GuavaSocketClosedError(msg.reason, msg.description));
180
+ });
181
+ } else {
182
+ // Retransmit any messages the server hasn't seen
183
+ for (const [seq, payload] of this._rtxBuffer) {
184
+ if (seq > msg.last_seen_sequence) {
185
+ _sendMessage(socket, { message_type: "message", sequence: seq, payload });
186
+ }
187
+ }
188
+ this._pruneRtxBuffer(msg.last_seen_sequence);
189
+ settle(() => resolve(socket));
190
+ }
191
+ } catch (e) {
192
+ settle(() => {
193
+ socket.close();
194
+ reject(e);
195
+ });
196
+ }
197
+ });
198
+ });
199
+ });
200
+
201
+ return ws;
202
+ } catch (e) {
203
+ if (e instanceof GuavaSocketClosedError) throw e;
204
+
205
+ if (attempt >= MAX_RECONNECT_ATTEMPTS) {
206
+ this._setCloseReason(
207
+ "reconnection-failed",
208
+ `Couldn't connect after ${attempt} attempts.`,
209
+ );
210
+ throw new GuavaSocketConnectionFailed();
211
+ }
212
+
213
+ this._logger.error(
214
+ "Failed to connect (attempt %d/%d). Retrying in a few seconds...\n",
215
+ attempt,
216
+ MAX_RECONNECT_ATTEMPTS,
217
+ e,
218
+ );
219
+ await sleep(reconnectDelay(attempt));
220
+ }
221
+ }
222
+
223
+ throw new GuavaSocketConnectionFailed();
224
+ }
225
+
226
+ private _runConnection(ws: WebSocket): Promise<boolean> {
227
+ // Resolves true if we should reconnect, false if we should stop.
228
+ return new Promise<boolean>((resolve) => {
229
+ let pingTimer: ReturnType<typeof setTimeout>;
230
+ let deadTimer: ReturnType<typeof setTimeout>;
231
+
232
+ const resetPingTimer = () => {
233
+ clearTimeout(pingTimer);
234
+ pingTimer = setTimeout(() => {
235
+ this._logger.debug("Haven't seen any messages in a while. Sending a ping...");
236
+ _sendMessage(ws, { message_type: "ping", ping_timestamp: Date.now() });
237
+ resetPingTimer();
238
+ }, PING_INTERVAL_MS);
239
+ };
240
+
241
+ const resetDeadTimer = () => {
242
+ clearTimeout(deadTimer);
243
+ deadTimer = setTimeout(() => {
244
+ this._logger.warn(
245
+ "No messages received from server in %dms. Assuming socket is dead, reconnecting...",
246
+ SOCKET_DEAD_TIMEOUT_MS,
247
+ );
248
+ ws.terminate();
249
+ }, SOCKET_DEAD_TIMEOUT_MS);
250
+ };
251
+
252
+ resetPingTimer();
253
+ resetDeadTimer();
254
+
255
+ ws.on("message", (data) => {
256
+ resetPingTimer();
257
+ resetDeadTimer();
258
+
259
+ let msg: z.infer<typeof GuavaServerMessage>;
260
+ try {
261
+ msg = GuavaServerMessage.parse(JSON.parse(data.toString()));
262
+ } catch {
263
+ this._logger.warn("Received unparseable message from server.");
264
+ return;
265
+ }
266
+
267
+ switch (msg.message_type) {
268
+ case "ping":
269
+ _sendMessage(ws, {
270
+ message_type: "pong",
271
+ ping_timestamp: msg.ping_timestamp,
272
+ pong_timestamp: Date.now(),
273
+ });
274
+ break;
275
+ case "pong":
276
+ break;
277
+ case "close":
278
+ this._setCloseReason(msg.reason, msg.description);
279
+ this._shouldClose = true;
280
+ ws.close();
281
+ break;
282
+ case "message":
283
+ if (msg.sequence > this._lastSeenSequence) {
284
+ this._lastSeenSequence = msg.sequence;
285
+ this._pushPayload(msg.payload as Record<string, unknown>);
286
+ }
287
+ _sendMessage(ws, { message_type: "ack", last_seen_sequence: this._lastSeenSequence });
288
+ break;
289
+ case "ack":
290
+ this._pruneRtxBuffer(msg.last_seen_sequence);
291
+ break;
292
+ }
293
+ });
294
+
295
+ ws.once("close", () => {
296
+ this._logger.debug("Closing websocket connection...");
297
+ clearTimeout(pingTimer);
298
+ clearTimeout(deadTimer);
299
+ ws.removeAllListeners();
300
+ resolve(!this._shouldClose);
301
+ });
302
+
303
+ ws.once("error", (err) => {
304
+ this._logger.debug("Websocket connection error...", err);
305
+ clearTimeout(pingTimer);
306
+ clearTimeout(deadTimer);
307
+ ws.removeAllListeners();
308
+ resolve(!this._shouldClose);
309
+ });
310
+ });
311
+ }
312
+
313
+ private async _reconnectLoop(): Promise<void> {
314
+ let isFirstConnect = true;
315
+
316
+ try {
317
+ while (!this._shouldClose) {
318
+ let ws: WebSocket;
319
+ try {
320
+ ws = await this._establishSocket(!isFirstConnect);
321
+ } catch (e) {
322
+ this._readyReject?.(e as Error);
323
+ return;
324
+ }
325
+
326
+ this._ws = ws;
327
+ this._openCounter.addEvent();
328
+ this._state = "open";
329
+ this._logger.debug("GuavaSocket connection established.");
330
+
331
+ if (isFirstConnect) {
332
+ this._readyResolve?.();
333
+ isFirstConnect = false;
334
+ }
335
+
336
+ if (this._openCounter.count() >= MAX_CONNECTIONS_PER_MINUTE) {
337
+ this._setCloseReason(
338
+ "server-error",
339
+ "Too many connections in the last minute. The server is probably in a bad state.",
340
+ );
341
+ ws.close();
342
+ return;
343
+ }
344
+
345
+ const shouldReconnect = await this._runConnection(ws);
346
+ if (!shouldReconnect) return;
347
+
348
+ if (
349
+ this._maxAgeSeconds !== undefined &&
350
+ Date.now() > this._socketCreateTime + this._maxAgeSeconds * 1000
351
+ ) {
352
+ this._setCloseReason("other", "The socket hit its max age limit.");
353
+ return;
354
+ }
355
+
356
+ await sleep(reconnectDelay(1));
357
+ }
358
+ } finally {
359
+ this._state = "closed";
360
+ this._ws?.close();
361
+ this._ws = undefined;
362
+ this._rejectAllWaiters();
363
+ this._logger.debug("GuavaSocket closed.");
364
+ }
365
+ }
366
+
367
+ async connect(): Promise<this> {
368
+ if (this._state !== "never-opened") throw new Error("connect() already called");
369
+
370
+ const readyPromise = new Promise<void>((resolve, reject) => {
371
+ this._readyResolve = resolve;
372
+ this._readyReject = reject;
373
+ });
374
+
375
+ this._reconnectLoopPromise = this._reconnectLoop();
376
+ await readyPromise;
377
+ return this;
378
+ }
379
+
380
+ async [Symbol.asyncDispose](): Promise<void> {
381
+ await this.close();
382
+ }
383
+
384
+ async close(): Promise<void> {
385
+ if (!this._reconnectLoopPromise) throw new Error("connect() has not been called");
386
+ this._setCloseReason("done", "The socket was closed by the client.");
387
+ this._shouldClose = true;
388
+ this._ws?.close();
389
+ await this._reconnectLoopPromise;
390
+ }
391
+
392
+ send(message: SendT): void {
393
+ if (this._state === "never-opened") throw new Error("connect() has not been called");
394
+ if (this._state === "closed")
395
+ throw new GuavaSocketClosedError(this._closeReason, this._closeDescription);
396
+
397
+ const payload = this._serializer(message);
398
+ this._lastSentSequence++;
399
+ this._rtxBuffer.push([this._lastSentSequence, payload]);
400
+
401
+ try {
402
+ if (this._ws) {
403
+ _sendMessage(this._ws, {
404
+ message_type: "message",
405
+ sequence: this._lastSentSequence,
406
+ payload,
407
+ });
408
+ }
409
+ } catch {
410
+ // Connection is down; the reconnect loop will retransmit from the RTX buffer.
411
+ }
412
+ }
413
+
414
+ async _recv(): Promise<RecvT> {
415
+ if (this._state === "never-opened") throw new Error("connect() has not been called");
416
+ if (this._state === "closed")
417
+ throw new GuavaSocketClosedError(this._closeReason, this._closeDescription);
418
+
419
+ const buffered = this._recvBuffer.shift();
420
+ if (buffered !== undefined) return this._deserializer(buffered);
421
+
422
+ const payload = await new Promise<Record<string, unknown>>((resolve, reject) => {
423
+ this._recvWaiters.push({ resolve, reject });
424
+ });
425
+ return this._deserializer(payload);
426
+ }
427
+
428
+ async *[Symbol.asyncIterator](): AsyncGenerator<RecvT> {
429
+ while (true) {
430
+ yield await this._recv();
431
+ }
432
+ }
433
+ }
@@ -0,0 +1,62 @@
1
+ import * as z from "zod";
2
+ import { CallInfo } from "./call-info.ts";
3
+ export { CallInfo } from "./call-info.ts";
4
+
5
+ // -------- SERVER MESSAGES --------
6
+
7
+ export const ListenStarted = z.object({
8
+ message_type: z.literal("listen-started"),
9
+ other_listeners: z.number().int(),
10
+ });
11
+ export type ListenStarted = z.infer<typeof ListenStarted>;
12
+
13
+ export const IncomingCall = z.object({
14
+ message_type: z.literal("incoming-call"),
15
+ call_id: z.string(),
16
+ });
17
+ export type IncomingCall = z.infer<typeof IncomingCall>;
18
+
19
+ export const AssignCall = z.object({
20
+ message_type: z.literal("assign-call"),
21
+ call_id: z.string(),
22
+ call_info: CallInfo,
23
+ });
24
+ export type AssignCall = z.infer<typeof AssignCall>;
25
+
26
+ export const ServerMessage = z.discriminatedUnion("message_type", [
27
+ ListenStarted,
28
+ IncomingCall,
29
+ AssignCall,
30
+ ]);
31
+ export type ServerMessage = z.infer<typeof ServerMessage>;
32
+
33
+ // -------- CLIENT MESSAGES --------
34
+
35
+ export const ClaimCall = z.object({
36
+ message_type: z.literal("claim-call"),
37
+ call_id: z.string(),
38
+ });
39
+ export type ClaimCall = z.infer<typeof ClaimCall>;
40
+
41
+ export const AnswerCall = z.object({
42
+ message_type: z.literal("answer-call"),
43
+ call_id: z.string(),
44
+ });
45
+ export type AnswerCall = z.infer<typeof AnswerCall>;
46
+
47
+ export const DeclineCall = z.object({
48
+ message_type: z.literal("decline-call"),
49
+ call_id: z.string(),
50
+ });
51
+ export type DeclineCall = z.infer<typeof DeclineCall>;
52
+
53
+ export const ClientMessage = z.discriminatedUnion("message_type", [
54
+ ClaimCall,
55
+ AnswerCall,
56
+ DeclineCall,
57
+ ]);
58
+ export type ClientMessage = z.infer<typeof ClientMessage>;
59
+
60
+ export function decodeServerMessage(payload: Record<string, unknown>): ServerMessage {
61
+ return ServerMessage.parse(payload);
62
+ }
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+
3
+ export const CloseReason = z.enum([
4
+ "authentication-failure",
5
+ "state-lost",
6
+ "done",
7
+ "other",
8
+ "server-error",
9
+ "reconnection-failed",
10
+ "unknown",
11
+ ]);
12
+ export type CloseReason = z.infer<typeof CloseReason>;
13
+
14
+ // -------- CLIENT-ONLY MESSAGES ------------
15
+
16
+ export const GuavaOpen = z.object({
17
+ message_type: z.literal("open"),
18
+ name: z.string(),
19
+ connection_id: z.string(),
20
+ is_reopen: z.boolean(),
21
+ last_seen_sequence: z.number().int(),
22
+ });
23
+ export type GuavaOpen = z.infer<typeof GuavaOpen>;
24
+
25
+ // -------- SERVER-ONLY MESSAGES ------------
26
+
27
+ export const GuavaOpenAck = z.object({
28
+ message_type: z.literal("open-ack"),
29
+ is_reopen: z.boolean(),
30
+ last_seen_sequence: z.number().int(),
31
+ });
32
+ export type GuavaOpenAck = z.infer<typeof GuavaOpenAck>;
33
+
34
+ // -------- BIDIRECTIONAL MESSAGES ------------
35
+
36
+ export const GuavaClose = z.object({
37
+ message_type: z.literal("close"),
38
+ reason: CloseReason,
39
+ description: z.string(),
40
+ });
41
+ export type GuavaClose = z.infer<typeof GuavaClose>;
42
+
43
+ export const GuavaMessage = z.object({
44
+ message_type: z.literal("message"),
45
+ sequence: z.number().int(),
46
+ payload: z.record(z.string(), z.unknown()),
47
+ });
48
+ export type GuavaMessage = z.infer<typeof GuavaMessage>;
49
+
50
+ export const GuavaPing = z.object({
51
+ message_type: z.literal("ping"),
52
+ ping_timestamp: z.number().int(),
53
+ });
54
+ export type GuavaPing = z.infer<typeof GuavaPing>;
55
+
56
+ export const GuavaPong = z.object({
57
+ message_type: z.literal("pong"),
58
+ ping_timestamp: z.number().int(),
59
+ pong_timestamp: z.number().int(),
60
+ });
61
+ export type GuavaPong = z.infer<typeof GuavaPong>;
62
+
63
+ export const GuavaAck = z.object({
64
+ message_type: z.literal("ack"),
65
+ last_seen_sequence: z.number().int(),
66
+ });
67
+ export type GuavaAck = z.infer<typeof GuavaAck>;
68
+
69
+ // -------- UNION TYPES ------------
70
+
71
+ export const GuavaClientMessage = z.discriminatedUnion("message_type", [
72
+ GuavaOpen,
73
+ GuavaClose,
74
+ GuavaMessage,
75
+ GuavaPing,
76
+ GuavaPong,
77
+ GuavaAck,
78
+ ]);
79
+ export type GuavaClientMessage = z.infer<typeof GuavaClientMessage>;
80
+
81
+ export const GuavaServerMessage = z.discriminatedUnion("message_type", [
82
+ GuavaOpenAck,
83
+ GuavaClose,
84
+ GuavaMessage,
85
+ GuavaPing,
86
+ GuavaPong,
87
+ GuavaAck,
88
+ ]);
89
+ export type GuavaServerMessage = z.infer<typeof GuavaServerMessage>;
@@ -0,0 +1,25 @@
1
+ export class EventCounter {
2
+ private readonly windowMs: number;
3
+ private readonly timestamps: number[] = [];
4
+
5
+ constructor(windowSeconds: number) {
6
+ this.windowMs = windowSeconds * 1000;
7
+ }
8
+
9
+ addEvent(): void {
10
+ this.timestamps.push(performance.now());
11
+ this.evictOld();
12
+ }
13
+
14
+ count(): number {
15
+ this.evictOld();
16
+ return this.timestamps.length;
17
+ }
18
+
19
+ private evictOld(): void {
20
+ const cutoff = performance.now() - this.windowMs;
21
+ while (this.timestamps.length > 0 && this.timestamps[0]! <= cutoff) {
22
+ this.timestamps.shift();
23
+ }
24
+ }
25
+ }
package/src/telemetry.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getDefaultLogger, type Logger } from "./logging.ts";
2
- import { getBaseUrl, fetchOrThrow } from "./utils.ts";
2
+ import { fetchOrThrow } from "./utils.ts";
3
+ import type { Client } from "./client.ts";
3
4
 
4
5
  const QUEUE_MAX_SIZE = 100;
5
6
  const UPLOAD_INTERVAL_MS = 10_000;
@@ -25,12 +26,12 @@ interface QueuedEvent {
25
26
  }
26
27
 
27
28
  export abstract class BaseTelemetryClient {
28
- protected sdkHeaders: Record<string, string> = {};
29
+ protected _sdkClient: Client | null = null;
29
30
 
30
31
  abstract sendEvent(event: TelemetryEvent, data?: Record<string, unknown>): void;
31
32
 
32
- setSdkHeaders(headers: Record<string, string>) {
33
- this.sdkHeaders = headers;
33
+ setSdkClient(client: Client) {
34
+ this._sdkClient = client;
34
35
  }
35
36
 
36
37
  trackClass(onlyExceptions = new Set<string>()) {
@@ -70,11 +71,9 @@ export abstract class BaseTelemetryClient {
70
71
  export class TelemetryClient extends BaseTelemetryClient {
71
72
  private queue: QueuedEvent[] = [];
72
73
  private timer: ReturnType<typeof setInterval>;
73
- private readonly baseUrl: string;
74
74
 
75
75
  constructor() {
76
76
  super();
77
- this.baseUrl = getBaseUrl();
78
77
  this.timer = setInterval(() => {
79
78
  void this.uploadEvents();
80
79
  }, UPLOAD_INTERVAL_MS);
@@ -94,6 +93,10 @@ export class TelemetryClient extends BaseTelemetryClient {
94
93
  }
95
94
 
96
95
  private async uploadEvents() {
96
+ if (!this._sdkClient) {
97
+ logger.debug("No SDK client. Cannot upload telemetry events.");
98
+ return;
99
+ }
97
100
  const payload = this.queue.splice(0);
98
101
  if (!payload.length) {
99
102
  logger.debug("No events to upload.");
@@ -101,10 +104,10 @@ export class TelemetryClient extends BaseTelemetryClient {
101
104
  }
102
105
  logger.debug(`Uploading ${payload.length} telemetry events.`);
103
106
  try {
104
- const url = new URL("v1/upload-telemetry", this.baseUrl);
107
+ const url = new URL("v1/upload-telemetry", this._sdkClient.getHttpBase());
105
108
  await fetchOrThrow(url, {
106
109
  method: "POST",
107
- headers: { ...this.sdkHeaders, "Content-Type": "application/json" },
110
+ headers: { ...(await this._sdkClient.headers()), "Content-Type": "application/json" },
108
111
  body: JSON.stringify({ events: payload }),
109
112
  });
110
113
  } catch (e) {