@arena-im/buzz-client 1.1.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.
@@ -0,0 +1,362 @@
1
+ import {
2
+ FaultTolerantWebSocket,
3
+ WebsocketOptions,
4
+ } from "./fault_tolerant_websocket";
5
+ import { Request, ThrottledQueue } from "./throttled_queue";
6
+ import { TokenManager } from "./token-manager";
7
+
8
+ import { Topic } from "./topic";
9
+ import { BuzzEnvironment, resolveBuzzConfig } from "./config";
10
+
11
+ // These should match buzz' throttling constants
12
+ const throttleWindowDuration = 5000;
13
+ const throttleWindowThreshold = 25;
14
+
15
+ const defaultCommandTimeout = 10 * 1000;
16
+
17
+ type TimerID = ReturnType<typeof setTimeout>;
18
+ export type CommandContent = Record<string, unknown>;
19
+
20
+ export type EventCallback = (event: Event) => void;
21
+ type EventCallbackRegistry = { [K: string]: EventCallback[] };
22
+ type Command<T> = Request & {
23
+ requestId: string;
24
+ timeoutTimer?: TimerID;
25
+ resolve: (value: CommandResponse<T>) => void;
26
+ reject: (reason?: any) => void;
27
+ };
28
+ interface Event {
29
+ topic: string;
30
+ eventName: string;
31
+ }
32
+ type CommandErrorResponseType = "rejected" | "error" | "blocked";
33
+ interface Message {
34
+ eventName?: string;
35
+ responseType?: CommandErrorResponseType | "ack" | "ok";
36
+ requestId?: string;
37
+ topic?: string;
38
+ reason?: string;
39
+ }
40
+ type ClientOptions = {
41
+ commandTimeout?: number;
42
+ websocket?: WebsocketOptions;
43
+ // Optional override for identity service URL when using a custom websocket endpoint
44
+ identityUrl?: string;
45
+ };
46
+ export interface CommandResponse<T> {
47
+ content: T;
48
+ responseType: "ok" | "ack" | "error" | "rejected";
49
+ requestId: string;
50
+ }
51
+
52
+ export class BuzzClient {
53
+ ws: FaultTolerantWebSocket | null = null;
54
+ nextRequestId = 0;
55
+ pendingCommands: Record<string, Command<any>> = {};
56
+ eventListeners: Record<string, EventCallbackRegistry> = {};
57
+ queue;
58
+ private unsubscribeTokenChange: (() => void) | null = null;
59
+ private readonly websocketUrl: string;
60
+ private readonly tokenManager: TokenManager;
61
+
62
+ constructor(
63
+ envOrWebsocketUrl: BuzzEnvironment | string,
64
+ private readonly namespace: string,
65
+ siteId: string,
66
+ token: string | null,
67
+ private options: ClientOptions = {}
68
+ ) {
69
+ // If the first arg is a known environment, resolve from config; otherwise treat it as a direct websocket URL
70
+ const isEnv = envOrWebsocketUrl === "dev" || envOrWebsocketUrl === "prod";
71
+ const envConfig = resolveBuzzConfig(
72
+ isEnv ? (envOrWebsocketUrl as BuzzEnvironment) : "prod"
73
+ );
74
+
75
+ this.websocketUrl = isEnv
76
+ ? envConfig.websocketUrl
77
+ : (envOrWebsocketUrl as string);
78
+
79
+ const identityUrl = this.options.identityUrl ?? envConfig.identityUrl;
80
+ this.tokenManager = new TokenManager(siteId, identityUrl);
81
+ this.tokenManager.setIdToken(token);
82
+
83
+ this.queue = new ThrottledQueue(
84
+ throttleWindowDuration,
85
+ throttleWindowThreshold,
86
+ this.onQueueError
87
+ );
88
+ }
89
+
90
+ private async getWebsocketUrl(): Promise<string> {
91
+ const token = await this.tokenManager.getBuzzToken();
92
+ return `${this.websocketUrl}?ns=${this.namespace}&token=${token}`;
93
+ }
94
+
95
+ private async ensureSocket(): Promise<FaultTolerantWebSocket> {
96
+ this.attachTokenListener();
97
+ if (!this.ws) {
98
+ this.ws = await this.createSocket();
99
+ }
100
+ return this.ws;
101
+ }
102
+
103
+ private attachTokenListener(): void {
104
+ if (this.unsubscribeTokenChange) return;
105
+ this.unsubscribeTokenChange = this.tokenManager.onTokenChange(
106
+ async (token: string | null) => {
107
+ if (!token) return;
108
+ if (this.ws) this.ws.close();
109
+ this.ws = await this.createSocket();
110
+ }
111
+ );
112
+ }
113
+
114
+ setToken(token: string | null) {
115
+ this.tokenManager.setIdToken(token);
116
+ if (this.ws) this.ws.close();
117
+ this.ws = null;
118
+ }
119
+
120
+ private async createSocket() {
121
+ return new FaultTolerantWebSocket(
122
+ await this.getWebsocketUrl(),
123
+ this.onWebsocketOpen,
124
+ this.onWebsocketMessage,
125
+ this.onWebsocketError,
126
+ this.options.websocket
127
+ );
128
+ }
129
+
130
+ async subscribe(topic: string) {
131
+ await this.sendCommand("buzz:subscribe", topic);
132
+ }
133
+
134
+ async unsubscribe(topic: string) {
135
+ await this.sendCommand("buzz:unsubscribe", topic);
136
+ }
137
+
138
+ topic(topic: string) {
139
+ return new Topic(this, topic);
140
+ }
141
+
142
+ async sendCommand<T>(
143
+ type: string,
144
+ topic: string,
145
+ content?: CommandContent
146
+ ): Promise<CommandResponse<T>> {
147
+ const requestId = `${this.nextRequestId++}`;
148
+ const payload = {
149
+ requestId,
150
+ type,
151
+ topic,
152
+ content,
153
+ };
154
+
155
+ const result = new Promise<CommandResponse<T>>((resolve, reject) => {
156
+ const command: Command<T> = {
157
+ requestId,
158
+ run: () => this.sendWithTimeout(payload, command),
159
+ resolve,
160
+ reject,
161
+ };
162
+ this.pendingCommands[requestId] = command;
163
+ this.queue.push(command);
164
+ });
165
+
166
+ // Ensure the websocket connection is established so the queue can run.
167
+ // Without this, the first command could wait forever for onOpen to trigger run().
168
+ void this.ensureSocket();
169
+
170
+ return result;
171
+ }
172
+
173
+ onEvent(topic: string, eventName: string, callback: EventCallback) {
174
+ if (!this.eventListeners[topic]) {
175
+ this.eventListeners[topic] = {};
176
+ }
177
+
178
+ if (!this.eventListeners[topic][eventName]) {
179
+ this.eventListeners[topic][eventName] = [];
180
+ }
181
+
182
+ this.eventListeners[topic][eventName].push(callback);
183
+ }
184
+
185
+ offEvent(topic: string, eventName: string, callback: EventCallback) {
186
+ const topicRegistry = this.eventListeners[topic];
187
+ if (!topicRegistry) return;
188
+ const callbacks = topicRegistry[eventName];
189
+ if (!callbacks) return;
190
+ const index = callbacks.indexOf(callback);
191
+ if (index !== -1) {
192
+ callbacks.splice(index, 1);
193
+ }
194
+ if (callbacks.length === 0) {
195
+ delete topicRegistry[eventName];
196
+ }
197
+ if (Object.keys(topicRegistry).length === 0) {
198
+ delete this.eventListeners[topic];
199
+ }
200
+ }
201
+
202
+ clearEventListeners() {
203
+ this.eventListeners = {};
204
+ }
205
+
206
+ async disconnect() {
207
+ const ws = await this.ensureSocket();
208
+ ws.close();
209
+ this.ws = null;
210
+ this.queue.destroy();
211
+ this.pendingCommands = {};
212
+ this.eventListeners = {};
213
+ }
214
+
215
+ private async sendWithTimeout<T>(payload: unknown, command: Command<T>) {
216
+ const ws = await this.ensureSocket();
217
+ // Ensure the socket is open before attempting to send to avoid race conditions
218
+ if (typeof (ws as any).whenOpen === "function") {
219
+ await (ws as any).whenOpen();
220
+ }
221
+ ws.send(JSON.stringify(payload));
222
+ command.timeoutTimer = setTimeout(() => {
223
+ this.rejectCommand(command, new CommandTimeoutError());
224
+ }, this.options.commandTimeout || defaultCommandTimeout);
225
+ }
226
+
227
+ private dispatchEvent(event: Event) {
228
+ if (
229
+ !this.eventListeners[event.topic] ||
230
+ !this.eventListeners[event.topic][event.eventName]
231
+ ) {
232
+ return;
233
+ }
234
+
235
+ this.eventListeners[event.topic][event.eventName].forEach((callback) =>
236
+ callback(event)
237
+ );
238
+ }
239
+
240
+ // Defined with arrow for correct `this` binding
241
+ private onQueueError = (error: unknown, request: Request) => {
242
+ const requestId = (request as Command<unknown>).requestId;
243
+ const errorMessage = error instanceof Error ? error.message : `${error}`;
244
+ const command = this.pendingCommands[requestId];
245
+ if (command === undefined) {
246
+ console.warn(`Request ID not found; request: ${JSON.stringify(request)}`);
247
+ return;
248
+ }
249
+
250
+ this.rejectCommand(command, new WebsocketError(errorMessage));
251
+ };
252
+
253
+ // Defined with arrow for correct `this` binding
254
+ private onWebsocketOpen = () => {
255
+ this.queue.run();
256
+ };
257
+
258
+ // Defined with arrow for correct `this` binding
259
+ private onWebsocketError = () => {
260
+ // Abort all queued commands
261
+ this.queue.destroy();
262
+ this.queue = new ThrottledQueue(
263
+ throttleWindowDuration,
264
+ throttleWindowThreshold,
265
+ this.onQueueError
266
+ );
267
+ // Fail all pending commands
268
+ const error = new WebsocketError("websocket connection error");
269
+ Object.values(this.pendingCommands).forEach((command) =>
270
+ this.rejectCommand(command, error)
271
+ );
272
+ this.pendingCommands = {};
273
+ };
274
+
275
+ // Defined with arrow for correct `this` binding
276
+ private onWebsocketMessage = (rawMessage: string) => {
277
+ const message = JSON.parse(rawMessage) as Message;
278
+ // Event case
279
+ if (message.eventName && message.topic) {
280
+ this.dispatchEvent(message as Event);
281
+ return;
282
+ }
283
+
284
+ // Command response case
285
+
286
+ // This should never happen if `throttleWindowDuration` and `throttleWindowThreshold`
287
+ // settings match buzz server's throttle settings
288
+ if (message.responseType === "rejected" && message.reason === "throttled") {
289
+ console.error(
290
+ "Buzz server throttled this connection. Review client throttling settings"
291
+ );
292
+ }
293
+
294
+ // no-op
295
+ if (message.responseType === "ack") {
296
+ return;
297
+ }
298
+
299
+ if (message.requestId === undefined) {
300
+ console.warn(`Missing request ID; message: ${JSON.stringify(message)}`);
301
+ return;
302
+ }
303
+
304
+ const requestId = `${message.requestId}`;
305
+ const command = this.pendingCommands[requestId];
306
+ if (command === undefined) {
307
+ console.warn(`Request ID not found; message: ${JSON.stringify(message)}`);
308
+ return;
309
+ }
310
+
311
+ // `responseType` field is absent in some successful responses from buzz.
312
+ // When this is fixed in buzz (set to `ok`) this `undefined` check can be removed
313
+ if (message.responseType === "ok" || message.responseType === undefined) {
314
+ clearTimeout(command.timeoutTimer);
315
+ command.resolve(message as CommandResponse<unknown>);
316
+ delete this.pendingCommands[requestId];
317
+ return;
318
+ }
319
+
320
+ const error = new CommandErrorResponse(
321
+ message.reason || "",
322
+ message.responseType
323
+ );
324
+ this.rejectCommand(command, error);
325
+ };
326
+
327
+ private rejectCommand(command: Command<any>, error: Error) {
328
+ clearTimeout(command.timeoutTimer);
329
+ command.reject(error);
330
+ delete this.pendingCommands[command.requestId];
331
+ }
332
+ }
333
+
334
+ export class CommandErrorResponse extends Error {
335
+ constructor(message: string, public errorType: CommandErrorResponseType) {
336
+ super(message);
337
+ this.name = "CommandErrorResponse";
338
+
339
+ // Fix prototype chain (needed in some environments like TS + ES5)
340
+ Object.setPrototypeOf(this, new.target.prototype);
341
+ }
342
+ }
343
+
344
+ export class CommandTimeoutError extends Error {
345
+ constructor() {
346
+ super("timeout");
347
+ this.name = "CommandTimeoutError";
348
+
349
+ // Fix prototype chain (needed in some environments like TS + ES5)
350
+ Object.setPrototypeOf(this, new.target.prototype);
351
+ }
352
+ }
353
+
354
+ export class WebsocketError extends Error {
355
+ constructor(message: string) {
356
+ super(message);
357
+ this.name = "WebsocketError";
358
+
359
+ // Fix prototype chain (needed in some environments like TS + ES5)
360
+ Object.setPrototypeOf(this, new.target.prototype);
361
+ }
362
+ }
@@ -0,0 +1,136 @@
1
+ // A request queue that executes up to `threshold` requests every `windowDuration` milliseconds,
2
+ // enforcing rate limits to prevent throttling
3
+
4
+ export interface Request {
5
+ run: () => void;
6
+ }
7
+ type OnErrorCallback = (error: unknown, request: Request) => void;
8
+
9
+ const windowSafetyPadding = 500;
10
+
11
+ /**
12
+ * Queue Status Flow:
13
+ *
14
+ * [Buffering]
15
+ * |
16
+ * | run()
17
+ * v
18
+ * [Running] <------------------------.
19
+ * | |
20
+ * | (queue drained) | push() while Idle
21
+ * v |
22
+ * [Idle] -------------------------'
23
+ * |
24
+ * | destroy()
25
+ * v
26
+ * [Destroyed]
27
+ *
28
+ * - Initial state is Buffering: queue accepts requests via push(), but does not process them yet.
29
+ * - run() transitions from Buffering -> Running, starting request processing.
30
+ * - When all queued requests have been processed, transitions to Idle.
31
+ * - If a new request arrives while Idle, transitions back to Running.
32
+ * - destroy() can be called from any state, transitioning to Destroyed.
33
+ * - Once in Destroyed, no further operations are allowed; push() will throw.
34
+ */
35
+ const enum QueueStatus {
36
+ Buffering,
37
+ Idle,
38
+ Running,
39
+ Destroyed,
40
+ }
41
+
42
+ type TimerID = ReturnType<typeof setTimeout>;
43
+
44
+ export class ThrottledQueue {
45
+ requests: Request[] = [];
46
+ requestCount: number = 0;
47
+ waitTimer?: TimerID;
48
+ status = QueueStatus.Buffering;
49
+ windowStart: number = 0;
50
+
51
+ constructor(
52
+ private windowDuration: number,
53
+ private threshold: number,
54
+ private onError: OnErrorCallback
55
+ ) {}
56
+
57
+ push(request: Request) {
58
+ if (this.status === QueueStatus.Destroyed) {
59
+ throw new Error(`push to a destroyed queue`);
60
+ }
61
+
62
+ this.requests.push(request);
63
+ if (this.status === QueueStatus.Idle) {
64
+ this.awake();
65
+ }
66
+ }
67
+
68
+ run() {
69
+ if (this.status !== QueueStatus.Buffering) {
70
+ return;
71
+ }
72
+ console.debug('Throttled queue processing started');
73
+ this.awake();
74
+ }
75
+
76
+ destroy() {
77
+ if (this.waitTimer !== undefined) {
78
+ clearTimeout(this.waitTimer);
79
+ }
80
+ console.debug('Throttled queue destroyed');
81
+ this.status = QueueStatus.Destroyed;
82
+ }
83
+
84
+ private awake() {
85
+ this.status = QueueStatus.Running;
86
+ this.processNext();
87
+ }
88
+
89
+ private processNext() {
90
+ if (this.status === QueueStatus.Destroyed) {
91
+ return;
92
+ }
93
+
94
+ const now = Date.now();
95
+ if (now - this.windowStart > this.windowDuration) {
96
+ this.windowStart = now;
97
+ this.requestCount = 0;
98
+ }
99
+
100
+ const next = this.requests.shift();
101
+ if (next === undefined) {
102
+ this.status = QueueStatus.Idle;
103
+ return;
104
+ }
105
+
106
+ this.runRequest(next);
107
+ this.requestCount++;
108
+ if (this.requestCount === this.threshold) {
109
+ this.waitNextWindow();
110
+ return;
111
+ }
112
+
113
+ // Giving external code a chance to call `push` or `destroy`
114
+ setTimeout(() => this.processNext(), 0);
115
+ }
116
+
117
+ private runRequest(request: Request) {
118
+ try {
119
+ request.run();
120
+ } catch (error) {
121
+ this.onError(error, request);
122
+ }
123
+ }
124
+
125
+ private waitNextWindow() {
126
+ console.warn('Throttling activated');
127
+ const wait = Math.max(
128
+ 0,
129
+ this.windowStart + this.windowDuration - Date.now() + windowSafetyPadding
130
+ );
131
+ this.waitTimer = setTimeout(() => {
132
+ console.debug('Throttling deactivated');
133
+ this.processNext();
134
+ }, wait);
135
+ }
136
+ }
@@ -0,0 +1,75 @@
1
+ export class TokenManager {
2
+ private currentBuzzToken: string | null = null;
3
+ private currentIdToken: string | null | undefined = null;
4
+ private isExchanging = false;
5
+ private pendingPromise: Promise<void> | null = null;
6
+ private listeners = new Set<(token: string | null) => void>();
7
+
8
+ constructor(
9
+ private readonly siteId: string,
10
+ private readonly identityBaseUrl: string
11
+ ) {}
12
+
13
+ private notify(token: string | null): void {
14
+ this.listeners.forEach((l) => l(token));
15
+ }
16
+
17
+ private async exchange(): Promise<void> {
18
+ if (this.isExchanging) return this.pendingPromise ?? Promise.resolve();
19
+ this.isExchanging = true;
20
+ this.pendingPromise = (async () => {
21
+ try {
22
+ if (this.currentIdToken) {
23
+ const data = await this.exchangeAuthenticated(this.currentIdToken);
24
+ this.currentBuzzToken = data.access_token ?? null;
25
+ } else {
26
+ const data = await this.getAnonymous();
27
+ const authToken = data.access_token;
28
+ const exchanged = await this.exchangeAuthenticated(authToken);
29
+ this.currentBuzzToken = exchanged.access_token ?? null;
30
+ }
31
+ } finally {
32
+ this.isExchanging = false;
33
+ this.pendingPromise = null;
34
+ this.notify(this.currentBuzzToken);
35
+ }
36
+ })();
37
+ return this.pendingPromise;
38
+ }
39
+
40
+ private async exchangeAuthenticated(
41
+ idToken: string
42
+ ): Promise<{ access_token: string }> {
43
+ const url = `${
44
+ this.identityBaseUrl
45
+ }/exchange-token?siteId=${encodeURIComponent(
46
+ this.siteId
47
+ )}&accessToken=${encodeURIComponent(idToken)}`;
48
+ const res = await fetch(url, { method: "POST", credentials: "omit" });
49
+ if (!res.ok) throw new Error(`exchangeAuthenticated failed: ${res.status}`);
50
+ return (await res.json()) as { access_token: string };
51
+ }
52
+
53
+ private async getAnonymous(): Promise<{ access_token: string }> {
54
+ const url = `${this.identityBaseUrl}/anonymous-token`;
55
+ const res = await fetch(url, { method: "POST", credentials: "omit" });
56
+ if (!res.ok) throw new Error(`getAnonymous failed: ${res.status}`);
57
+ return (await res.json()) as { access_token: string };
58
+ }
59
+
60
+ public async getBuzzToken(): Promise<string> {
61
+ if (!this.currentBuzzToken) await this.exchange();
62
+ if (!this.currentBuzzToken) throw new Error("Failed to acquire buzz token");
63
+ return this.currentBuzzToken;
64
+ }
65
+
66
+ public async setIdToken(idToken?: string | null): Promise<void> {
67
+ this.currentIdToken = idToken ?? null;
68
+ await this.exchange();
69
+ }
70
+
71
+ public onTokenChange(cb: (token: string | null) => void): () => void {
72
+ this.listeners.add(cb);
73
+ return () => this.listeners.delete(cb);
74
+ }
75
+ }
@@ -0,0 +1,74 @@
1
+ import { type BuzzClient, type CommandContent } from "./index";
2
+
3
+ export type EmitResult<T> = {
4
+ requestId: string;
5
+ type: string;
6
+ responseType: "ok" | "ack" | "error" | "rejected";
7
+ } & T;
8
+
9
+ export class Topic {
10
+ private subscriptionPromise: Promise<void>;
11
+ private listenerWrappers: Record<
12
+ string,
13
+ Map<Function, (event: unknown) => void>
14
+ > = {};
15
+
16
+ constructor(private client: BuzzClient, private topic: string) {
17
+ this.subscriptionPromise = this.client.subscribe(this.topic);
18
+ }
19
+
20
+ public async emit<T>(eventName: string, content?: CommandContent) {
21
+ try {
22
+ await this.subscriptionPromise;
23
+ const result = await this.client.sendCommand(
24
+ eventName,
25
+ this.topic,
26
+ content
27
+ );
28
+ return result as unknown as EmitResult<T>;
29
+ } catch (error) {
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ public async on<T>(eventName: string, callback: (event: T) => void) {
35
+ await this.subscriptionPromise;
36
+ const wrapper = (event: unknown) => {
37
+ callback(event as T);
38
+ };
39
+
40
+ if (!this.listenerWrappers[eventName]) {
41
+ this.listenerWrappers[eventName] = new Map();
42
+ }
43
+ this.listenerWrappers[eventName].set(
44
+ callback as unknown as Function,
45
+ wrapper
46
+ );
47
+
48
+ this.client.onEvent(this.topic, eventName, wrapper);
49
+ }
50
+
51
+ public async off<T>(eventName: string, callback?: (event: T) => void) {
52
+ await this.subscriptionPromise;
53
+
54
+ const wrapperMap = this.listenerWrappers[eventName];
55
+ if (!wrapperMap) return;
56
+
57
+ if (callback) {
58
+ const wrapper = wrapperMap.get(callback as unknown as Function);
59
+ if (wrapper) {
60
+ this.client.offEvent(this.topic, eventName, wrapper as any);
61
+ wrapperMap.delete(callback as unknown as Function);
62
+ }
63
+ if (wrapperMap.size === 0) {
64
+ delete this.listenerWrappers[eventName];
65
+ }
66
+ return;
67
+ }
68
+
69
+ for (const wrapper of wrapperMap.values()) {
70
+ this.client.offEvent(this.topic, eventName, wrapper as any);
71
+ }
72
+ delete this.listenerWrappers[eventName];
73
+ }
74
+ }