@pushflodev/sdk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1204 @@
1
+ /* @pushflo/sdk - https://pushflo.dev */
2
+
3
+ // src/utils/EventEmitter.ts
4
+ var TypedEventEmitter = class {
5
+ listeners = /* @__PURE__ */ new Map();
6
+ /**
7
+ * Register an event listener
8
+ */
9
+ on(event, handler) {
10
+ if (!this.listeners.has(event)) {
11
+ this.listeners.set(event, /* @__PURE__ */ new Set());
12
+ }
13
+ this.listeners.get(event).add(handler);
14
+ return this;
15
+ }
16
+ /**
17
+ * Register a one-time event listener
18
+ */
19
+ once(event, handler) {
20
+ const onceHandler = (...args) => {
21
+ this.off(event, onceHandler);
22
+ handler(...args);
23
+ };
24
+ return this.on(event, onceHandler);
25
+ }
26
+ /**
27
+ * Remove an event listener
28
+ */
29
+ off(event, handler) {
30
+ const handlers = this.listeners.get(event);
31
+ if (handlers) {
32
+ handlers.delete(handler);
33
+ if (handlers.size === 0) {
34
+ this.listeners.delete(event);
35
+ }
36
+ }
37
+ return this;
38
+ }
39
+ /**
40
+ * Emit an event to all registered listeners
41
+ */
42
+ emit(event, ...args) {
43
+ const handlers = this.listeners.get(event);
44
+ if (!handlers || handlers.size === 0) {
45
+ return false;
46
+ }
47
+ handlers.forEach((handler) => {
48
+ try {
49
+ handler(...args);
50
+ } catch (error) {
51
+ console.error(`Error in event handler for "${String(event)}":`, error);
52
+ }
53
+ });
54
+ return true;
55
+ }
56
+ /**
57
+ * Remove all listeners for an event, or all listeners if no event specified
58
+ */
59
+ removeAllListeners(event) {
60
+ if (event !== void 0) {
61
+ this.listeners.delete(event);
62
+ } else {
63
+ this.listeners.clear();
64
+ }
65
+ return this;
66
+ }
67
+ /**
68
+ * Get the number of listeners for an event
69
+ */
70
+ listenerCount(event) {
71
+ return this.listeners.get(event)?.size ?? 0;
72
+ }
73
+ /**
74
+ * Get all event names with registered listeners
75
+ */
76
+ eventNames() {
77
+ return Array.from(this.listeners.keys());
78
+ }
79
+ };
80
+
81
+ // src/utils/constants.ts
82
+ var DEFAULTS = {
83
+ /** Default PushFlo API base URL */
84
+ BASE_URL: "https://api.pushflo.dev",
85
+ /** WebSocket endpoint path */
86
+ WS_PATH: "/ws",
87
+ /** Connection timeout in milliseconds */
88
+ CONNECTION_TIMEOUT: 3e4,
89
+ /** Heartbeat interval in milliseconds */
90
+ HEARTBEAT_INTERVAL: 25e3,
91
+ /** Initial reconnection delay in milliseconds */
92
+ RECONNECT_DELAY: 1e3,
93
+ /** Maximum reconnection delay in milliseconds */
94
+ MAX_RECONNECT_DELAY: 3e4,
95
+ /** Reconnection delay multiplier for exponential backoff */
96
+ RECONNECT_MULTIPLIER: 1.5,
97
+ /** Maximum number of reconnection attempts (0 = infinite) */
98
+ MAX_RECONNECT_ATTEMPTS: 0};
99
+ var WS_CLIENT_MESSAGES = {
100
+ SUBSCRIBE: "subscribe",
101
+ UNSUBSCRIBE: "unsubscribe",
102
+ PING: "ping"};
103
+ var WS_SERVER_MESSAGES = {
104
+ CONNECTED: "connected",
105
+ SUBSCRIBED: "subscribed",
106
+ UNSUBSCRIBED: "unsubscribed",
107
+ MESSAGE: "message",
108
+ ERROR: "error",
109
+ PONG: "pong"
110
+ };
111
+ var ERROR_CODES = {
112
+ // Connection errors
113
+ CONNECTION_FAILED: "CONNECTION_FAILED",
114
+ CONNECTION_TIMEOUT: "CONNECTION_TIMEOUT",
115
+ CONNECTION_CLOSED: "CONNECTION_CLOSED",
116
+ // Authentication errors
117
+ INVALID_API_KEY: "INVALID_API_KEY",
118
+ UNAUTHORIZED: "UNAUTHORIZED",
119
+ FORBIDDEN: "FORBIDDEN",
120
+ // Network errors
121
+ NETWORK_ERROR: "NETWORK_ERROR",
122
+ REQUEST_TIMEOUT: "REQUEST_TIMEOUT",
123
+ // API errors
124
+ NOT_FOUND: "NOT_FOUND",
125
+ VALIDATION_ERROR: "VALIDATION_ERROR",
126
+ RATE_LIMITED: "RATE_LIMITED",
127
+ SERVER_ERROR: "SERVER_ERROR"};
128
+
129
+ // src/utils/logger.ts
130
+ var LOG_LEVELS = {
131
+ debug: 0,
132
+ info: 1,
133
+ warn: 2,
134
+ error: 3
135
+ };
136
+ var Logger = class {
137
+ enabled;
138
+ prefix;
139
+ minLevel;
140
+ constructor(options = {}) {
141
+ this.enabled = options.debug ?? false;
142
+ this.prefix = options.prefix ?? "[PushFlo]";
143
+ this.minLevel = LOG_LEVELS[options.level ?? "debug"];
144
+ }
145
+ /**
146
+ * Enable or disable logging
147
+ */
148
+ setEnabled(enabled) {
149
+ this.enabled = enabled;
150
+ }
151
+ /**
152
+ * Set minimum log level
153
+ */
154
+ setLevel(level) {
155
+ this.minLevel = LOG_LEVELS[level];
156
+ }
157
+ /**
158
+ * Log a debug message
159
+ */
160
+ debug(message, ...args) {
161
+ this.log("debug", message, ...args);
162
+ }
163
+ /**
164
+ * Log an info message
165
+ */
166
+ info(message, ...args) {
167
+ this.log("info", message, ...args);
168
+ }
169
+ /**
170
+ * Log a warning message
171
+ */
172
+ warn(message, ...args) {
173
+ this.log("warn", message, ...args);
174
+ }
175
+ /**
176
+ * Log an error message
177
+ */
178
+ error(message, ...args) {
179
+ this.log("error", message, ...args);
180
+ }
181
+ log(level, message, ...args) {
182
+ if (!this.enabled && level !== "error") {
183
+ return;
184
+ }
185
+ if (LOG_LEVELS[level] < this.minLevel) {
186
+ return;
187
+ }
188
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
189
+ const formattedMessage = `${this.prefix} ${timestamp} [${level.toUpperCase()}] ${message}`;
190
+ switch (level) {
191
+ case "debug":
192
+ console.debug(formattedMessage, ...args);
193
+ break;
194
+ case "info":
195
+ console.info(formattedMessage, ...args);
196
+ break;
197
+ case "warn":
198
+ console.warn(formattedMessage, ...args);
199
+ break;
200
+ case "error":
201
+ console.error(formattedMessage, ...args);
202
+ break;
203
+ }
204
+ }
205
+ };
206
+ function createLogger(options = {}) {
207
+ return new Logger(options);
208
+ }
209
+
210
+ // src/errors/PushFloError.ts
211
+ var PushFloError = class extends Error {
212
+ /** Error code for programmatic handling */
213
+ code;
214
+ /** Whether this error is potentially recoverable through retry */
215
+ retryable;
216
+ /** Original error that caused this error, if any */
217
+ cause;
218
+ constructor(message, code, options = {}) {
219
+ super(message);
220
+ this.name = "PushFloError";
221
+ this.code = code;
222
+ this.retryable = options.retryable ?? false;
223
+ this.cause = options.cause;
224
+ if (Error.captureStackTrace) {
225
+ Error.captureStackTrace(this, this.constructor);
226
+ }
227
+ }
228
+ /**
229
+ * Convert error to JSON-serializable object
230
+ */
231
+ toJSON() {
232
+ return {
233
+ name: this.name,
234
+ message: this.message,
235
+ code: this.code,
236
+ retryable: this.retryable,
237
+ stack: this.stack,
238
+ cause: this.cause?.message
239
+ };
240
+ }
241
+ /**
242
+ * Create a string representation
243
+ */
244
+ toString() {
245
+ return `${this.name} [${this.code}]: ${this.message}`;
246
+ }
247
+ };
248
+
249
+ // src/errors/AuthenticationError.ts
250
+ var AuthenticationError = class _AuthenticationError extends PushFloError {
251
+ constructor(message, code = ERROR_CODES.UNAUTHORIZED, options = {}) {
252
+ super(message, code, { retryable: false, ...options });
253
+ this.name = "AuthenticationError";
254
+ }
255
+ /**
256
+ * Create an invalid API key error
257
+ */
258
+ static invalidKey(keyType) {
259
+ const message = keyType ? `Invalid ${keyType} API key` : "Invalid API key";
260
+ return new _AuthenticationError(message, ERROR_CODES.INVALID_API_KEY);
261
+ }
262
+ /**
263
+ * Create an unauthorized error
264
+ */
265
+ static unauthorized(reason) {
266
+ return new _AuthenticationError(
267
+ reason ?? "Unauthorized - check your API key",
268
+ ERROR_CODES.UNAUTHORIZED
269
+ );
270
+ }
271
+ /**
272
+ * Create a forbidden error
273
+ */
274
+ static forbidden(action) {
275
+ const message = action ? `Access forbidden: insufficient permissions for ${action}` : "Access forbidden: insufficient permissions";
276
+ return new _AuthenticationError(message, ERROR_CODES.FORBIDDEN);
277
+ }
278
+ };
279
+
280
+ // src/utils/retry.ts
281
+ function calculateBackoff(attempt, options = {}) {
282
+ const {
283
+ initialDelay = DEFAULTS.RECONNECT_DELAY,
284
+ maxDelay = DEFAULTS.MAX_RECONNECT_DELAY,
285
+ multiplier = DEFAULTS.RECONNECT_MULTIPLIER
286
+ } = options;
287
+ const exponentialDelay = initialDelay * Math.pow(multiplier, attempt);
288
+ const cappedDelay = Math.min(exponentialDelay, maxDelay);
289
+ const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
290
+ return Math.floor(cappedDelay + jitter);
291
+ }
292
+
293
+ // src/errors/ConnectionError.ts
294
+ var ConnectionError = class _ConnectionError extends PushFloError {
295
+ constructor(message, code = ERROR_CODES.CONNECTION_FAILED, options = {}) {
296
+ super(message, code, { retryable: true, ...options });
297
+ this.name = "ConnectionError";
298
+ }
299
+ /**
300
+ * Create a connection timeout error
301
+ */
302
+ static timeout(timeoutMs) {
303
+ return new _ConnectionError(
304
+ `Connection timed out after ${timeoutMs}ms`,
305
+ ERROR_CODES.CONNECTION_TIMEOUT,
306
+ { retryable: true }
307
+ );
308
+ }
309
+ /**
310
+ * Create a connection closed error
311
+ */
312
+ static closed(reason) {
313
+ return new _ConnectionError(
314
+ reason ? `Connection closed: ${reason}` : "Connection closed unexpectedly",
315
+ ERROR_CODES.CONNECTION_CLOSED,
316
+ { retryable: true }
317
+ );
318
+ }
319
+ /**
320
+ * Create a connection failed error
321
+ */
322
+ static failed(reason, cause) {
323
+ return new _ConnectionError(
324
+ reason ? `Connection failed: ${reason}` : "Failed to connect",
325
+ ERROR_CODES.CONNECTION_FAILED,
326
+ { retryable: true, cause }
327
+ );
328
+ }
329
+ };
330
+
331
+ // src/client/ConnectionStateMachine.ts
332
+ var VALID_TRANSITIONS = [
333
+ { from: "disconnected", to: "connecting" },
334
+ { from: "connecting", to: "connected" },
335
+ { from: "connecting", to: "disconnected" },
336
+ { from: "connecting", to: "error" },
337
+ { from: "connected", to: "disconnected" },
338
+ { from: "connected", to: "error" },
339
+ { from: "error", to: "connecting" },
340
+ { from: "error", to: "disconnected" }
341
+ ];
342
+ var ConnectionStateMachine = class {
343
+ _state = "disconnected";
344
+ listeners = /* @__PURE__ */ new Set();
345
+ /**
346
+ * Get current connection state
347
+ */
348
+ get state() {
349
+ return this._state;
350
+ }
351
+ /**
352
+ * Check if currently in a specific state
353
+ */
354
+ is(state) {
355
+ return this._state === state;
356
+ }
357
+ /**
358
+ * Check if connected
359
+ */
360
+ get isConnected() {
361
+ return this._state === "connected";
362
+ }
363
+ /**
364
+ * Check if connecting
365
+ */
366
+ get isConnecting() {
367
+ return this._state === "connecting";
368
+ }
369
+ /**
370
+ * Check if disconnected
371
+ */
372
+ get isDisconnected() {
373
+ return this._state === "disconnected";
374
+ }
375
+ /**
376
+ * Check if in error state
377
+ */
378
+ get isError() {
379
+ return this._state === "error";
380
+ }
381
+ /**
382
+ * Transition to a new state
383
+ * @returns true if transition was successful
384
+ */
385
+ transition(to) {
386
+ if (this._state === to) {
387
+ return true;
388
+ }
389
+ const isValid = VALID_TRANSITIONS.some((t) => {
390
+ const fromStates = Array.isArray(t.from) ? t.from : [t.from];
391
+ return fromStates.includes(this._state) && t.to === to;
392
+ });
393
+ if (!isValid) {
394
+ console.warn(`Invalid state transition: ${this._state} -> ${to}`);
395
+ return false;
396
+ }
397
+ this._state = to;
398
+ this.listeners.forEach((listener) => {
399
+ try {
400
+ listener(to);
401
+ } catch (error) {
402
+ console.error("Error in connection state listener:", error);
403
+ }
404
+ });
405
+ return true;
406
+ }
407
+ /**
408
+ * Force transition to a state (bypasses validation)
409
+ */
410
+ forceTransition(to) {
411
+ if (this._state === to) {
412
+ return;
413
+ }
414
+ this._state = to;
415
+ this.listeners.forEach((listener) => {
416
+ try {
417
+ listener(to);
418
+ } catch (error) {
419
+ console.error("Error in connection state listener:", error);
420
+ }
421
+ });
422
+ }
423
+ /**
424
+ * Reset to disconnected state
425
+ */
426
+ reset() {
427
+ this.forceTransition("disconnected");
428
+ }
429
+ /**
430
+ * Subscribe to state changes
431
+ */
432
+ onChange(listener) {
433
+ this.listeners.add(listener);
434
+ return () => this.listeners.delete(listener);
435
+ }
436
+ /**
437
+ * Remove all listeners
438
+ */
439
+ removeAllListeners() {
440
+ this.listeners.clear();
441
+ }
442
+ };
443
+
444
+ // src/client/Heartbeat.ts
445
+ var Heartbeat = class {
446
+ intervalId = null;
447
+ timeoutId = null;
448
+ interval;
449
+ pongTimeout;
450
+ onPing;
451
+ onTimeout;
452
+ running = false;
453
+ constructor(options) {
454
+ this.interval = options.interval ?? DEFAULTS.HEARTBEAT_INTERVAL;
455
+ this.pongTimeout = options.pongTimeout ?? this.interval * 2;
456
+ this.onPing = options.onPing;
457
+ this.onTimeout = options.onTimeout;
458
+ }
459
+ /**
460
+ * Start the heartbeat
461
+ */
462
+ start() {
463
+ if (this.running) {
464
+ return;
465
+ }
466
+ this.running = true;
467
+ this.scheduleNextPing();
468
+ }
469
+ /**
470
+ * Stop the heartbeat
471
+ */
472
+ stop() {
473
+ this.running = false;
474
+ this.clearTimers();
475
+ }
476
+ /**
477
+ * Called when a pong is received
478
+ */
479
+ receivedPong() {
480
+ if (!this.running) {
481
+ return;
482
+ }
483
+ if (this.timeoutId !== null) {
484
+ clearTimeout(this.timeoutId);
485
+ this.timeoutId = null;
486
+ }
487
+ this.scheduleNextPing();
488
+ }
489
+ /**
490
+ * Reset the heartbeat (e.g., after any activity)
491
+ */
492
+ reset() {
493
+ if (!this.running) {
494
+ return;
495
+ }
496
+ this.clearTimers();
497
+ this.scheduleNextPing();
498
+ }
499
+ scheduleNextPing() {
500
+ if (!this.running) {
501
+ return;
502
+ }
503
+ this.intervalId = setTimeout(() => {
504
+ if (!this.running) {
505
+ return;
506
+ }
507
+ this.onPing();
508
+ this.timeoutId = setTimeout(() => {
509
+ if (this.running && this.onTimeout) {
510
+ this.onTimeout();
511
+ }
512
+ }, this.pongTimeout);
513
+ }, this.interval);
514
+ }
515
+ clearTimers() {
516
+ if (this.intervalId !== null) {
517
+ clearTimeout(this.intervalId);
518
+ this.intervalId = null;
519
+ }
520
+ if (this.timeoutId !== null) {
521
+ clearTimeout(this.timeoutId);
522
+ this.timeoutId = null;
523
+ }
524
+ }
525
+ };
526
+
527
+ // src/client/WebSocketManager.ts
528
+ var WebSocketManager = class extends TypedEventEmitter {
529
+ options;
530
+ logger;
531
+ stateMachine;
532
+ heartbeat;
533
+ ws = null;
534
+ connectionTimeoutId = null;
535
+ reconnectAttempt = 0;
536
+ reconnectTimeoutId = null;
537
+ intentionalDisconnect = false;
538
+ clientId = null;
539
+ constructor(options) {
540
+ super();
541
+ this.options = {
542
+ apiKey: options.apiKey,
543
+ baseUrl: (options.baseUrl ?? DEFAULTS.BASE_URL).replace(/\/$/, ""),
544
+ connectionTimeout: options.connectionTimeout ?? DEFAULTS.CONNECTION_TIMEOUT,
545
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.HEARTBEAT_INTERVAL,
546
+ autoReconnect: options.autoReconnect ?? true,
547
+ maxReconnectAttempts: options.maxReconnectAttempts ?? DEFAULTS.MAX_RECONNECT_ATTEMPTS,
548
+ reconnectDelay: options.reconnectDelay ?? DEFAULTS.RECONNECT_DELAY,
549
+ maxReconnectDelay: options.maxReconnectDelay ?? DEFAULTS.MAX_RECONNECT_DELAY
550
+ };
551
+ this.logger = createLogger({ debug: options.debug, prefix: "[PushFlo WS]" });
552
+ this.stateMachine = new ConnectionStateMachine();
553
+ this.heartbeat = new Heartbeat({
554
+ interval: this.options.heartbeatInterval,
555
+ onPing: () => this.sendPing(),
556
+ onTimeout: () => this.handleHeartbeatTimeout()
557
+ });
558
+ }
559
+ /**
560
+ * Get current connection state
561
+ */
562
+ get state() {
563
+ return this.stateMachine.state;
564
+ }
565
+ /**
566
+ * Get client ID (available after connected)
567
+ */
568
+ getClientId() {
569
+ return this.clientId;
570
+ }
571
+ /**
572
+ * Subscribe to state changes
573
+ */
574
+ onStateChange(listener) {
575
+ return this.stateMachine.onChange(listener);
576
+ }
577
+ /**
578
+ * Connect to WebSocket server
579
+ */
580
+ async connect() {
581
+ if (this.stateMachine.isConnected) {
582
+ throw new ConnectionError(
583
+ "Already connected",
584
+ "ALREADY_CONNECTED",
585
+ { retryable: false }
586
+ );
587
+ }
588
+ if (this.stateMachine.isConnecting) {
589
+ throw new ConnectionError(
590
+ "Connection in progress",
591
+ "CONNECTION_IN_PROGRESS",
592
+ { retryable: false }
593
+ );
594
+ }
595
+ this.intentionalDisconnect = false;
596
+ this.clearReconnectTimeout();
597
+ return this.establishConnection();
598
+ }
599
+ /**
600
+ * Disconnect from WebSocket server
601
+ */
602
+ disconnect() {
603
+ this.intentionalDisconnect = true;
604
+ this.cleanup();
605
+ this.stateMachine.transition("disconnected");
606
+ this.emit("disconnected", "Disconnected by client");
607
+ }
608
+ /**
609
+ * Send a message to the server
610
+ */
611
+ send(message) {
612
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
613
+ return false;
614
+ }
615
+ try {
616
+ this.ws.send(JSON.stringify(message));
617
+ this.logger.debug("Sent message:", message);
618
+ return true;
619
+ } catch (error) {
620
+ this.logger.error("Failed to send message:", error);
621
+ return false;
622
+ }
623
+ }
624
+ /**
625
+ * Subscribe to a channel
626
+ */
627
+ subscribe(channel) {
628
+ return this.send({
629
+ type: WS_CLIENT_MESSAGES.SUBSCRIBE,
630
+ channel
631
+ });
632
+ }
633
+ /**
634
+ * Unsubscribe from a channel
635
+ */
636
+ unsubscribe(channel) {
637
+ return this.send({
638
+ type: WS_CLIENT_MESSAGES.UNSUBSCRIBE,
639
+ channel
640
+ });
641
+ }
642
+ /**
643
+ * Clean up resources
644
+ */
645
+ destroy() {
646
+ this.intentionalDisconnect = true;
647
+ this.cleanup();
648
+ this.removeAllListeners();
649
+ this.stateMachine.removeAllListeners();
650
+ }
651
+ async establishConnection() {
652
+ this.stateMachine.transition("connecting");
653
+ this.logger.debug("Connecting...");
654
+ return new Promise((resolve, reject) => {
655
+ try {
656
+ const wsUrl = this.buildWsUrl();
657
+ this.logger.debug("WebSocket URL:", wsUrl);
658
+ this.ws = new WebSocket(wsUrl);
659
+ this.connectionTimeoutId = setTimeout(() => {
660
+ if (this.stateMachine.isConnecting) {
661
+ const error = ConnectionError.timeout(this.options.connectionTimeout);
662
+ this.cleanup();
663
+ this.stateMachine.transition("error");
664
+ this.emit("error", error);
665
+ reject(error);
666
+ }
667
+ }, this.options.connectionTimeout);
668
+ this.ws.onopen = () => {
669
+ this.logger.debug("WebSocket opened, waiting for connected message...");
670
+ };
671
+ this.ws.onclose = (event) => {
672
+ this.handleClose(event, reject);
673
+ };
674
+ this.ws.onerror = (event) => {
675
+ this.logger.error("WebSocket error:", event);
676
+ };
677
+ this.ws.onmessage = (event) => {
678
+ this.handleMessage(event, resolve, reject);
679
+ };
680
+ } catch (error) {
681
+ this.cleanup();
682
+ this.stateMachine.transition("error");
683
+ const connError = ConnectionError.failed(
684
+ error instanceof Error ? error.message : "Unknown error",
685
+ error instanceof Error ? error : void 0
686
+ );
687
+ this.emit("error", connError);
688
+ reject(connError);
689
+ }
690
+ });
691
+ }
692
+ buildWsUrl() {
693
+ const baseUrl = this.options.baseUrl;
694
+ const protocol = baseUrl.startsWith("https") ? "wss" : "ws";
695
+ const host = baseUrl.replace(/^https?:\/\//, "");
696
+ return `${protocol}://${host}${DEFAULTS.WS_PATH}?token=${encodeURIComponent(this.options.apiKey)}`;
697
+ }
698
+ handleMessage(event, onConnect, onConnectError) {
699
+ try {
700
+ const message = JSON.parse(event.data);
701
+ this.logger.debug("Received message:", message);
702
+ switch (message.type) {
703
+ case WS_SERVER_MESSAGES.CONNECTED:
704
+ this.handleConnected(message, onConnect);
705
+ break;
706
+ case WS_SERVER_MESSAGES.PONG:
707
+ this.heartbeat.receivedPong();
708
+ break;
709
+ case WS_SERVER_MESSAGES.ERROR:
710
+ this.handleErrorMessage(message, onConnectError);
711
+ break;
712
+ default:
713
+ this.emit("message", message);
714
+ }
715
+ } catch (error) {
716
+ this.logger.error("Failed to parse message:", error);
717
+ }
718
+ }
719
+ handleConnected(message, onConnect) {
720
+ this.clearConnectionTimeout();
721
+ if (message.clientId) {
722
+ this.clientId = message.clientId;
723
+ }
724
+ const connectionInfo = {
725
+ clientId: message.clientId ?? "",
726
+ timestamp: message.timestamp ?? Date.now()
727
+ };
728
+ this.reconnectAttempt = 0;
729
+ this.stateMachine.transition("connected");
730
+ this.heartbeat.start();
731
+ this.logger.debug("Connected:", connectionInfo);
732
+ this.emit("connected", connectionInfo);
733
+ onConnect?.(connectionInfo);
734
+ }
735
+ handleErrorMessage(message, onConnectError) {
736
+ const errorMsg = message.error ?? "Unknown error";
737
+ const code = message.code;
738
+ let error;
739
+ if (code === "UNAUTHORIZED" || code === "INVALID_TOKEN") {
740
+ error = AuthenticationError.unauthorized(errorMsg);
741
+ } else {
742
+ error = new ConnectionError(errorMsg, code ?? "UNKNOWN");
743
+ }
744
+ this.emit("error", error);
745
+ if (this.stateMachine.isConnecting) {
746
+ this.cleanup();
747
+ this.stateMachine.transition("error");
748
+ onConnectError?.(error);
749
+ }
750
+ }
751
+ handleClose(event, onConnectError) {
752
+ this.logger.debug("WebSocket closed:", event.code, event.reason);
753
+ const wasConnected = this.stateMachine.isConnected;
754
+ this.cleanup();
755
+ if (this.intentionalDisconnect) {
756
+ this.stateMachine.transition("disconnected");
757
+ return;
758
+ }
759
+ if (this.stateMachine.isConnecting) {
760
+ this.stateMachine.transition("error");
761
+ const error = ConnectionError.failed(event.reason || "Connection closed");
762
+ this.emit("error", error);
763
+ onConnectError?.(error);
764
+ return;
765
+ }
766
+ this.stateMachine.transition("disconnected");
767
+ this.emit("disconnected", event.reason || void 0);
768
+ if (wasConnected && this.options.autoReconnect) {
769
+ this.scheduleReconnect();
770
+ }
771
+ }
772
+ handleHeartbeatTimeout() {
773
+ this.logger.warn("Heartbeat timeout, reconnecting...");
774
+ this.cleanup();
775
+ this.stateMachine.transition("disconnected");
776
+ this.emit("disconnected", "Heartbeat timeout");
777
+ if (this.options.autoReconnect) {
778
+ this.scheduleReconnect();
779
+ }
780
+ }
781
+ scheduleReconnect() {
782
+ if (this.intentionalDisconnect) {
783
+ return;
784
+ }
785
+ const { maxReconnectAttempts } = this.options;
786
+ if (maxReconnectAttempts > 0 && this.reconnectAttempt >= maxReconnectAttempts) {
787
+ this.logger.error("Max reconnect attempts reached");
788
+ this.stateMachine.transition("error");
789
+ this.emit("error", new ConnectionError(
790
+ "Max reconnection attempts exceeded",
791
+ "MAX_RECONNECT_ATTEMPTS",
792
+ { retryable: false }
793
+ ));
794
+ return;
795
+ }
796
+ const delay = calculateBackoff(this.reconnectAttempt, {
797
+ initialDelay: this.options.reconnectDelay,
798
+ maxDelay: this.options.maxReconnectDelay
799
+ });
800
+ this.logger.debug(`Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`);
801
+ this.reconnectTimeoutId = setTimeout(() => {
802
+ this.reconnectAttempt++;
803
+ this.establishConnection().catch((error) => {
804
+ this.logger.error("Reconnect failed:", error);
805
+ this.scheduleReconnect();
806
+ });
807
+ }, delay);
808
+ }
809
+ sendPing() {
810
+ this.send({ type: WS_CLIENT_MESSAGES.PING });
811
+ }
812
+ cleanup() {
813
+ this.clearConnectionTimeout();
814
+ this.heartbeat.stop();
815
+ if (this.ws) {
816
+ this.ws.onopen = null;
817
+ this.ws.onclose = null;
818
+ this.ws.onerror = null;
819
+ this.ws.onmessage = null;
820
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
821
+ this.ws.close();
822
+ }
823
+ this.ws = null;
824
+ }
825
+ }
826
+ clearConnectionTimeout() {
827
+ if (this.connectionTimeoutId !== null) {
828
+ clearTimeout(this.connectionTimeoutId);
829
+ this.connectionTimeoutId = null;
830
+ }
831
+ }
832
+ clearReconnectTimeout() {
833
+ if (this.reconnectTimeoutId !== null) {
834
+ clearTimeout(this.reconnectTimeoutId);
835
+ this.reconnectTimeoutId = null;
836
+ }
837
+ }
838
+ };
839
+
840
+ // src/client/SubscriptionManager.ts
841
+ var SubscriptionManager = class {
842
+ subscriptions = /* @__PURE__ */ new Map();
843
+ /**
844
+ * Add a subscription
845
+ */
846
+ add(channel, options = {}) {
847
+ this.subscriptions.set(channel, {
848
+ channel,
849
+ options,
850
+ confirmed: false
851
+ });
852
+ }
853
+ /**
854
+ * Remove a subscription
855
+ */
856
+ remove(channel) {
857
+ const entry = this.subscriptions.get(channel);
858
+ if (entry) {
859
+ this.subscriptions.delete(channel);
860
+ entry.options.onUnsubscribed?.();
861
+ return true;
862
+ }
863
+ return false;
864
+ }
865
+ /**
866
+ * Check if subscribed to a channel
867
+ */
868
+ has(channel) {
869
+ return this.subscriptions.has(channel);
870
+ }
871
+ /**
872
+ * Get subscription options for a channel
873
+ */
874
+ get(channel) {
875
+ return this.subscriptions.get(channel)?.options;
876
+ }
877
+ /**
878
+ * Mark subscription as confirmed
879
+ */
880
+ confirm(channel) {
881
+ const entry = this.subscriptions.get(channel);
882
+ if (entry) {
883
+ entry.confirmed = true;
884
+ entry.options.onSubscribed?.();
885
+ }
886
+ }
887
+ /**
888
+ * Check if subscription is confirmed
889
+ */
890
+ isConfirmed(channel) {
891
+ return this.subscriptions.get(channel)?.confirmed ?? false;
892
+ }
893
+ /**
894
+ * Handle incoming message
895
+ */
896
+ handleMessage(message) {
897
+ const entry = this.subscriptions.get(message.channel);
898
+ if (entry) {
899
+ entry.options.onMessage?.(message);
900
+ }
901
+ }
902
+ /**
903
+ * Handle subscription error
904
+ */
905
+ handleError(channel, error) {
906
+ const entry = this.subscriptions.get(channel);
907
+ if (entry) {
908
+ entry.options.onError?.(error);
909
+ }
910
+ }
911
+ /**
912
+ * Get all subscribed channel names
913
+ */
914
+ getChannels() {
915
+ return Array.from(this.subscriptions.keys());
916
+ }
917
+ /**
918
+ * Get count of subscriptions
919
+ */
920
+ get size() {
921
+ return this.subscriptions.size;
922
+ }
923
+ /**
924
+ * Clear all subscriptions
925
+ */
926
+ clear() {
927
+ this.subscriptions.forEach((entry) => {
928
+ entry.options.onUnsubscribed?.();
929
+ });
930
+ this.subscriptions.clear();
931
+ }
932
+ /**
933
+ * Reset confirmation status for all subscriptions (e.g., on reconnect)
934
+ */
935
+ resetConfirmations() {
936
+ this.subscriptions.forEach((entry) => {
937
+ entry.confirmed = false;
938
+ });
939
+ }
940
+ };
941
+
942
+ // src/client/PushFloClient.ts
943
+ var PushFloClient = class extends TypedEventEmitter {
944
+ wsManager;
945
+ subscriptions;
946
+ logger;
947
+ connectionChangeListeners = /* @__PURE__ */ new Set();
948
+ constructor(options) {
949
+ super();
950
+ if (!options.publishKey) {
951
+ throw new AuthenticationError(
952
+ "Publish key is required",
953
+ "MISSING_PUBLISH_KEY"
954
+ );
955
+ }
956
+ if (!options.publishKey.startsWith("pub_") && !options.publishKey.startsWith("sec_") && !options.publishKey.startsWith("mgmt_")) {
957
+ throw AuthenticationError.invalidKey("publish");
958
+ }
959
+ this.logger = createLogger({ debug: options.debug, prefix: "[PushFlo]" });
960
+ this.subscriptions = new SubscriptionManager();
961
+ this.wsManager = new WebSocketManager({
962
+ apiKey: options.publishKey,
963
+ baseUrl: options.baseUrl ?? DEFAULTS.BASE_URL,
964
+ connectionTimeout: options.connectionTimeout,
965
+ heartbeatInterval: options.heartbeatInterval,
966
+ autoReconnect: options.autoReconnect,
967
+ maxReconnectAttempts: options.maxReconnectAttempts,
968
+ reconnectDelay: options.reconnectDelay,
969
+ maxReconnectDelay: options.maxReconnectDelay,
970
+ debug: options.debug
971
+ });
972
+ this.setupEventHandlers();
973
+ if (options.autoConnect) {
974
+ this.connect().catch((error) => {
975
+ this.logger.error("Auto-connect failed:", error);
976
+ });
977
+ }
978
+ }
979
+ /**
980
+ * Get current connection state
981
+ */
982
+ get connectionState() {
983
+ return this.wsManager.state;
984
+ }
985
+ /**
986
+ * Get client ID (available after connected)
987
+ */
988
+ get clientId() {
989
+ return this.wsManager.getClientId();
990
+ }
991
+ /**
992
+ * Connect to PushFlo
993
+ */
994
+ async connect() {
995
+ this.logger.debug("Connecting...");
996
+ await this.wsManager.connect();
997
+ const channels = this.subscriptions.getChannels();
998
+ if (channels.length > 0) {
999
+ this.logger.debug("Re-subscribing to channels:", channels);
1000
+ this.subscriptions.resetConfirmations();
1001
+ channels.forEach((channel) => {
1002
+ this.wsManager.subscribe(channel);
1003
+ });
1004
+ }
1005
+ }
1006
+ /**
1007
+ * Disconnect from PushFlo
1008
+ */
1009
+ disconnect() {
1010
+ this.logger.debug("Disconnecting...");
1011
+ this.wsManager.disconnect();
1012
+ }
1013
+ /**
1014
+ * Clean up all resources
1015
+ */
1016
+ destroy() {
1017
+ this.logger.debug("Destroying client...");
1018
+ this.subscriptions.clear();
1019
+ this.wsManager.destroy();
1020
+ this.connectionChangeListeners.clear();
1021
+ this.removeAllListeners();
1022
+ }
1023
+ /**
1024
+ * Subscribe to a channel
1025
+ */
1026
+ subscribe(channel, options = {}) {
1027
+ if (!channel) {
1028
+ throw new PushFloError("Channel is required", "INVALID_CHANNEL", { retryable: false });
1029
+ }
1030
+ this.logger.debug("Subscribing to channel:", channel);
1031
+ this.subscriptions.add(channel, options);
1032
+ if (this.wsManager.state === "connected") {
1033
+ this.wsManager.subscribe(channel);
1034
+ }
1035
+ return {
1036
+ channel,
1037
+ unsubscribe: () => this.unsubscribe(channel)
1038
+ };
1039
+ }
1040
+ /**
1041
+ * Unsubscribe from a channel
1042
+ */
1043
+ unsubscribe(channel) {
1044
+ this.logger.debug("Unsubscribing from channel:", channel);
1045
+ this.subscriptions.remove(channel);
1046
+ if (this.wsManager.state === "connected") {
1047
+ this.wsManager.unsubscribe(channel);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Register a connection state change listener
1052
+ */
1053
+ onConnectionChange(listener) {
1054
+ this.connectionChangeListeners.add(listener);
1055
+ return () => this.connectionChangeListeners.delete(listener);
1056
+ }
1057
+ /**
1058
+ * Get list of subscribed channels
1059
+ */
1060
+ getSubscribedChannels() {
1061
+ return this.subscriptions.getChannels();
1062
+ }
1063
+ /**
1064
+ * Check if subscribed to a channel
1065
+ */
1066
+ isSubscribed(channel) {
1067
+ return this.subscriptions.has(channel);
1068
+ }
1069
+ setupEventHandlers() {
1070
+ this.wsManager.onStateChange((state) => {
1071
+ this.connectionChangeListeners.forEach((listener) => {
1072
+ try {
1073
+ listener(state);
1074
+ } catch (error) {
1075
+ this.logger.error("Error in connection change listener:", error);
1076
+ }
1077
+ });
1078
+ });
1079
+ this.wsManager.on("connected", (info) => {
1080
+ this.emit("connected", info);
1081
+ });
1082
+ this.wsManager.on("disconnected", (reason) => {
1083
+ this.emit("disconnected", reason);
1084
+ });
1085
+ this.wsManager.on("error", (error) => {
1086
+ this.emit("error", error);
1087
+ });
1088
+ this.wsManager.on("message", (message) => {
1089
+ this.handleServerMessage(message);
1090
+ });
1091
+ }
1092
+ handleServerMessage(message) {
1093
+ switch (message.type) {
1094
+ case WS_SERVER_MESSAGES.SUBSCRIBED:
1095
+ if (message.channel) {
1096
+ this.subscriptions.confirm(message.channel);
1097
+ this.logger.debug("Subscribed to channel:", message.channel);
1098
+ }
1099
+ break;
1100
+ case WS_SERVER_MESSAGES.UNSUBSCRIBED:
1101
+ if (message.channel) {
1102
+ this.logger.debug("Unsubscribed from channel:", message.channel);
1103
+ }
1104
+ break;
1105
+ case WS_SERVER_MESSAGES.MESSAGE:
1106
+ if (message.channel && message.message) {
1107
+ const fullMessage = {
1108
+ id: message.message.id,
1109
+ channel: message.channel,
1110
+ eventType: message.message.eventType,
1111
+ clientId: message.message.clientId,
1112
+ content: message.message.content,
1113
+ timestamp: message.message.timestamp
1114
+ };
1115
+ this.subscriptions.handleMessage(fullMessage);
1116
+ this.emit("message", fullMessage);
1117
+ }
1118
+ break;
1119
+ case WS_SERVER_MESSAGES.ERROR:
1120
+ if (message.channel) {
1121
+ const error = new PushFloError(
1122
+ message.error ?? "Unknown error",
1123
+ message.code ?? ERROR_CODES.SERVER_ERROR
1124
+ );
1125
+ this.subscriptions.handleError(message.channel, error);
1126
+ }
1127
+ break;
1128
+ }
1129
+ }
1130
+ };
1131
+
1132
+ // src/errors/NetworkError.ts
1133
+ var NetworkError = class _NetworkError extends PushFloError {
1134
+ /** HTTP status code, if applicable */
1135
+ statusCode;
1136
+ constructor(message, code = ERROR_CODES.NETWORK_ERROR, options = {}) {
1137
+ super(message, code, { retryable: true, ...options });
1138
+ this.name = "NetworkError";
1139
+ this.statusCode = options.statusCode;
1140
+ }
1141
+ /**
1142
+ * Create a network error from fetch failure
1143
+ */
1144
+ static fromFetch(cause) {
1145
+ return new _NetworkError(
1146
+ `Network request failed: ${cause.message}`,
1147
+ ERROR_CODES.NETWORK_ERROR,
1148
+ { retryable: true, cause }
1149
+ );
1150
+ }
1151
+ /**
1152
+ * Create a request timeout error
1153
+ */
1154
+ static timeout(timeoutMs) {
1155
+ return new _NetworkError(
1156
+ `Request timed out after ${timeoutMs}ms`,
1157
+ ERROR_CODES.REQUEST_TIMEOUT,
1158
+ { retryable: true }
1159
+ );
1160
+ }
1161
+ /**
1162
+ * Create an error from HTTP status code
1163
+ */
1164
+ static fromStatus(statusCode, message) {
1165
+ const defaultMessage = _NetworkError.getStatusMessage(statusCode);
1166
+ const retryable = statusCode >= 500 || statusCode === 429;
1167
+ let code = ERROR_CODES.SERVER_ERROR;
1168
+ if (statusCode === 404) {
1169
+ code = ERROR_CODES.NOT_FOUND;
1170
+ } else if (statusCode === 422 || statusCode === 400) {
1171
+ code = ERROR_CODES.VALIDATION_ERROR;
1172
+ } else if (statusCode === 429) {
1173
+ code = ERROR_CODES.RATE_LIMITED;
1174
+ }
1175
+ return new _NetworkError(
1176
+ message ?? defaultMessage,
1177
+ code,
1178
+ { retryable, statusCode }
1179
+ );
1180
+ }
1181
+ static getStatusMessage(statusCode) {
1182
+ const messages = {
1183
+ 400: "Bad request",
1184
+ 404: "Resource not found",
1185
+ 422: "Validation error",
1186
+ 429: "Rate limit exceeded",
1187
+ 500: "Internal server error",
1188
+ 502: "Bad gateway",
1189
+ 503: "Service unavailable",
1190
+ 504: "Gateway timeout"
1191
+ };
1192
+ return messages[statusCode] ?? `HTTP error ${statusCode}`;
1193
+ }
1194
+ toJSON() {
1195
+ return {
1196
+ ...super.toJSON(),
1197
+ statusCode: this.statusCode
1198
+ };
1199
+ }
1200
+ };
1201
+
1202
+ export { AuthenticationError, ConnectionError, NetworkError, PushFloClient, PushFloError };
1203
+ //# sourceMappingURL=index.js.map
1204
+ //# sourceMappingURL=index.js.map