@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.
- package/README.md +160 -0
- package/dist/buzz-client.cjs.js +601 -0
- package/dist/buzz-client.cjs.js.map +1 -0
- package/dist/buzz-client.cjs.min.js +2 -0
- package/dist/buzz-client.cjs.min.js.map +1 -0
- package/dist/buzz-client.esm.js +593 -0
- package/dist/buzz-client.esm.js.map +1 -0
- package/dist/buzz-client.esm.min.js +2 -0
- package/dist/buzz-client.esm.min.js.map +1 -0
- package/dist/buzz-client.umd.js +606 -0
- package/dist/buzz-client.umd.js.map +1 -0
- package/dist/buzz-client.umd.min.js +2 -0
- package/dist/buzz-client.umd.min.js.map +1 -0
- package/dist/types/config.d.ts +7 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/fault_tolerant_websocket.d.ts +27 -0
- package/dist/types/fault_tolerant_websocket.d.ts.map +1 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/throttled_queue.d.ts +55 -0
- package/dist/types/throttled_queue.d.ts.map +1 -0
- package/dist/types/token-manager.d.ts +18 -0
- package/dist/types/token-manager.d.ts.map +1 -0
- package/dist/types/topic.d.ts +17 -0
- package/dist/types/topic.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/lib/config.ts +63 -0
- package/src/lib/fault_tolerant_websocket.ts +143 -0
- package/src/lib/index.ts +362 -0
- package/src/lib/throttled_queue.ts +136 -0
- package/src/lib/token-manager.ts +75 -0
- package/src/lib/topic.ts +74 -0
package/src/lib/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/topic.ts
ADDED
|
@@ -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
|
+
}
|