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