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