@signaltree/events 7.3.4 → 7.3.6

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/angular.cjs.js ADDED
@@ -0,0 +1,551 @@
1
+ 'use strict';
2
+
3
+ var tslib = require('tslib');
4
+ var core = require('@angular/core');
5
+ var rxjsInterop = require('@angular/core/rxjs-interop');
6
+ var rxjs = require('rxjs');
7
+ var operators = require('rxjs/operators');
8
+ var webSocket = require('rxjs/webSocket');
9
+
10
+ /**
11
+ * Default configuration
12
+ */
13
+ const DEFAULT_CONFIG = {
14
+ reconnect: {
15
+ enabled: true,
16
+ initialDelayMs: 1000,
17
+ maxDelayMs: 30000,
18
+ maxAttempts: 10
19
+ },
20
+ heartbeat: {
21
+ enabled: true,
22
+ intervalMs: 30000,
23
+ timeoutMs: 5000
24
+ }
25
+ };
26
+ /**
27
+ * Base WebSocket service for real-time event synchronization
28
+ *
29
+ * Extend this class in your application and wire it to your SignalTree store.
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * @Injectable({ providedIn: 'root' })
34
+ * export class AppWebSocketService extends WebSocketService {
35
+ * private readonly store = inject(AppStore);
36
+ *
37
+ * constructor() {
38
+ * super({
39
+ * url: environment.wsUrl,
40
+ * getAuthToken: () => this.store.$.session.token(),
41
+ * });
42
+ *
43
+ * // Subscribe to events
44
+ * this.onEvent<TradeProposalCreated>('TradeProposalCreated').subscribe(event => {
45
+ * this.store.$.trades.entities.upsertOne(event.data);
46
+ * });
47
+ * }
48
+ * }
49
+ * ```
50
+ */
51
+ exports.WebSocketService = class WebSocketService {
52
+ destroyRef = core.inject(core.DestroyRef);
53
+ // Signals for reactive state
54
+ _connectionState = core.signal('disconnected');
55
+ _lastError = core.signal(null);
56
+ _reconnectAttempts = core.signal(0);
57
+ _lastMessageTime = core.signal(null);
58
+ // Public readonly signals
59
+ connectionState = this._connectionState.asReadonly();
60
+ lastError = this._lastError.asReadonly();
61
+ isConnected = core.computed(() => this._connectionState() === 'connected');
62
+ isReconnecting = core.computed(() => this._connectionState() === 'reconnecting');
63
+ // WebSocket subject
64
+ socket$;
65
+ messageSubject = new rxjs.Subject();
66
+ // Subscriptions tracking
67
+ subscribedEvents = new Set();
68
+ heartbeatInterval;
69
+ reconnectTimer;
70
+ // Config
71
+ config;
72
+ constructor(config) {
73
+ this.config = {
74
+ ...config,
75
+ reconnect: {
76
+ ...DEFAULT_CONFIG.reconnect,
77
+ ...config.reconnect
78
+ },
79
+ heartbeat: {
80
+ ...DEFAULT_CONFIG.heartbeat,
81
+ ...config.heartbeat
82
+ }
83
+ };
84
+ }
85
+ /**
86
+ * Connect to WebSocket server
87
+ */
88
+ async connect() {
89
+ if (this._connectionState() === 'connected' || this._connectionState() === 'connecting') {
90
+ return;
91
+ }
92
+ this._connectionState.set('connecting');
93
+ this._lastError.set(null);
94
+ try {
95
+ // Get auth token if needed
96
+ let url = this.config.url;
97
+ if (this.config.getAuthToken) {
98
+ const token = await this.config.getAuthToken();
99
+ if (token) {
100
+ url = `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
101
+ }
102
+ }
103
+ // Create WebSocket
104
+ const wsConfig = {
105
+ url,
106
+ openObserver: {
107
+ next: () => this.handleOpen()
108
+ },
109
+ closeObserver: {
110
+ next: event => this.handleClose(event)
111
+ },
112
+ protocol: this.config.protocols
113
+ };
114
+ this.socket$ = webSocket.webSocket(wsConfig);
115
+ // Subscribe to messages
116
+ this.socket$.pipe(rxjsInterop.takeUntilDestroyed(this.destroyRef)).subscribe({
117
+ next: message => this.handleMessage(message),
118
+ error: error => this.handleError(error),
119
+ complete: () => this.handleComplete()
120
+ });
121
+ } catch (error) {
122
+ this._connectionState.set('error');
123
+ this._lastError.set(error instanceof Error ? error : new Error(String(error)));
124
+ throw error;
125
+ }
126
+ }
127
+ /**
128
+ * Disconnect from WebSocket server
129
+ */
130
+ disconnect() {
131
+ this.stopHeartbeat();
132
+ this.clearReconnectTimer();
133
+ if (this.socket$) {
134
+ this.socket$.complete();
135
+ this.socket$ = undefined;
136
+ }
137
+ this._connectionState.set('disconnected');
138
+ this.subscribedEvents.clear();
139
+ }
140
+ /**
141
+ * Send a message
142
+ */
143
+ send(message) {
144
+ if (!this.socket$ || this._connectionState() !== 'connected') {
145
+ console.warn('WebSocket not connected, message not sent:', message);
146
+ return;
147
+ }
148
+ this.socket$.next(message);
149
+ }
150
+ /**
151
+ * Subscribe to a specific event type
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * this.onEvent<TradeProposalCreated>('TradeProposalCreated').subscribe(event => {
156
+ * console.log('Trade created:', event);
157
+ * });
158
+ * ```
159
+ */
160
+ onEvent(eventType) {
161
+ // Track subscription
162
+ if (!this.subscribedEvents.has(eventType)) {
163
+ this.subscribedEvents.add(eventType);
164
+ // Send subscribe message if connected
165
+ if (this._connectionState() === 'connected') {
166
+ this.send({
167
+ type: 'subscribe',
168
+ eventType
169
+ });
170
+ }
171
+ }
172
+ return this.messageSubject.pipe(operators.filter(msg => msg.type === 'event' && msg.eventType === eventType), operators.map(msg => msg.payload));
173
+ }
174
+ /**
175
+ * Unsubscribe from an event type
176
+ */
177
+ offEvent(eventType) {
178
+ if (this.subscribedEvents.has(eventType)) {
179
+ this.subscribedEvents.delete(eventType);
180
+ if (this._connectionState() === 'connected') {
181
+ this.send({
182
+ type: 'unsubscribe',
183
+ eventType
184
+ });
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Get all messages (for debugging/logging)
190
+ */
191
+ get messages$() {
192
+ return this.messageSubject.asObservable();
193
+ }
194
+ /**
195
+ * Wait for connection to be established
196
+ */
197
+ async waitForConnection(timeoutMs = 10000) {
198
+ return new Promise((resolve, reject) => {
199
+ if (this._connectionState() === 'connected') {
200
+ resolve();
201
+ return;
202
+ }
203
+ const startTime = Date.now();
204
+ const checkInterval = setInterval(() => {
205
+ if (this._connectionState() === 'connected') {
206
+ clearInterval(checkInterval);
207
+ resolve();
208
+ } else if (Date.now() - startTime > timeoutMs) {
209
+ clearInterval(checkInterval);
210
+ reject(new Error('Connection timeout'));
211
+ }
212
+ }, 100);
213
+ });
214
+ }
215
+ ngOnDestroy() {
216
+ this.disconnect();
217
+ this.messageSubject.complete();
218
+ }
219
+ // =====================================================================
220
+ // Protected methods for subclass customization
221
+ // =====================================================================
222
+ /**
223
+ * Called when connection is established
224
+ * Override in subclass to perform initialization
225
+ */
226
+ onConnected() {
227
+ // Override in subclass
228
+ }
229
+ /**
230
+ * Called when connection is lost
231
+ * Override in subclass to handle cleanup
232
+ */
233
+ onDisconnected() {
234
+ // Override in subclass
235
+ }
236
+ /**
237
+ * Called when an event is received
238
+ * Override in subclass to dispatch to store
239
+ */
240
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
241
+ onEventReceived(_event) {
242
+ // Override in subclass
243
+ }
244
+ // =====================================================================
245
+ // Private handlers
246
+ // =====================================================================
247
+ handleOpen() {
248
+ this._connectionState.set('connected');
249
+ this._reconnectAttempts.set(0);
250
+ this._lastError.set(null);
251
+ // Re-subscribe to events
252
+ for (const eventType of this.subscribedEvents) {
253
+ this.send({
254
+ type: 'subscribe',
255
+ eventType
256
+ });
257
+ }
258
+ // Start heartbeat
259
+ if (this.config.heartbeat.enabled) {
260
+ this.startHeartbeat();
261
+ }
262
+ this.onConnected();
263
+ }
264
+ handleClose(event) {
265
+ this.stopHeartbeat();
266
+ if (event.wasClean) {
267
+ this._connectionState.set('disconnected');
268
+ } else {
269
+ this._connectionState.set('error');
270
+ this._lastError.set(new Error(`Connection closed: ${event.reason || 'Unknown reason'}`));
271
+ }
272
+ this.onDisconnected();
273
+ // Attempt reconnection if enabled
274
+ if (this.config.reconnect.enabled && !event.wasClean) {
275
+ this.scheduleReconnect();
276
+ }
277
+ }
278
+ handleMessage(message) {
279
+ this._lastMessageTime.set(new Date());
280
+ this.messageSubject.next(message);
281
+ switch (message.type) {
282
+ case 'event':
283
+ if (message.payload) {
284
+ this.onEventReceived(message.payload);
285
+ }
286
+ break;
287
+ case 'pong':
288
+ // Heartbeat response received
289
+ break;
290
+ case 'error':
291
+ console.error('Server error:', message.payload);
292
+ break;
293
+ }
294
+ }
295
+ handleError(error) {
296
+ const err = error instanceof Error ? error : new Error(String(error));
297
+ this._lastError.set(err);
298
+ this._connectionState.set('error');
299
+ console.error('WebSocket error:', err);
300
+ // Attempt reconnection
301
+ if (this.config.reconnect.enabled) {
302
+ this.scheduleReconnect();
303
+ }
304
+ }
305
+ handleComplete() {
306
+ this._connectionState.set('disconnected');
307
+ this.onDisconnected();
308
+ }
309
+ // =====================================================================
310
+ // Reconnection logic
311
+ // =====================================================================
312
+ scheduleReconnect() {
313
+ const attempts = this._reconnectAttempts();
314
+ const maxAttempts = this.config.reconnect.maxAttempts;
315
+ if (attempts >= maxAttempts) {
316
+ console.error(`Max reconnection attempts (${maxAttempts}) reached`);
317
+ this._connectionState.set('error');
318
+ return;
319
+ }
320
+ this._connectionState.set('reconnecting');
321
+ this._reconnectAttempts.set(attempts + 1);
322
+ // Exponential backoff
323
+ const delayMs = Math.min(this.config.reconnect.initialDelayMs * Math.pow(2, attempts), this.config.reconnect.maxDelayMs);
324
+ console.log(`Reconnecting in ${delayMs}ms (attempt ${attempts + 1}/${maxAttempts})`);
325
+ this.reconnectTimer = setTimeout(() => {
326
+ this.connect().catch(error => {
327
+ console.error('Reconnection failed:', error);
328
+ });
329
+ }, delayMs);
330
+ }
331
+ clearReconnectTimer() {
332
+ if (this.reconnectTimer) {
333
+ clearTimeout(this.reconnectTimer);
334
+ this.reconnectTimer = undefined;
335
+ }
336
+ }
337
+ // =====================================================================
338
+ // Heartbeat logic
339
+ // =====================================================================
340
+ startHeartbeat() {
341
+ this.stopHeartbeat();
342
+ this.heartbeatInterval = setInterval(() => {
343
+ if (this._connectionState() === 'connected') {
344
+ this.send({
345
+ type: 'ping'
346
+ });
347
+ }
348
+ }, this.config.heartbeat.intervalMs);
349
+ }
350
+ stopHeartbeat() {
351
+ if (this.heartbeatInterval) {
352
+ clearInterval(this.heartbeatInterval);
353
+ this.heartbeatInterval = undefined;
354
+ }
355
+ }
356
+ };
357
+ exports.WebSocketService = tslib.__decorate([core.Injectable(), tslib.__metadata("design:paramtypes", [Object])], exports.WebSocketService);
358
+
359
+ /**
360
+ * Optimistic Update Manager
361
+ *
362
+ * Tracks optimistic updates and handles confirmation/rollback.
363
+ *
364
+ * @example
365
+ * ```typescript
366
+ * const manager = new OptimisticUpdateManager();
367
+ *
368
+ * // Apply optimistic update
369
+ * manager.apply({
370
+ * id: 'update-1',
371
+ * correlationId: 'corr-123',
372
+ * type: 'UpdateTradeStatus',
373
+ * data: { status: 'accepted' },
374
+ * previousData: { status: 'pending' },
375
+ * timeoutMs: 5000,
376
+ * rollback: () => store.$.trade.status.set('pending'),
377
+ * });
378
+ *
379
+ * // When server confirms
380
+ * manager.confirm('corr-123');
381
+ *
382
+ * // Or when server rejects
383
+ * manager.rollback('corr-123', new Error('Server rejected'));
384
+ * ```
385
+ */
386
+ class OptimisticUpdateManager {
387
+ _updates = core.signal(new Map());
388
+ timeouts = new Map();
389
+ /**
390
+ * Number of pending updates
391
+ */
392
+ pendingCount = core.computed(() => this._updates().size);
393
+ /**
394
+ * Whether there are any pending updates
395
+ */
396
+ hasPending = core.computed(() => this._updates().size > 0);
397
+ /**
398
+ * Get all pending updates
399
+ */
400
+ pending = core.computed(() => Array.from(this._updates().values()));
401
+ /**
402
+ * Apply an optimistic update
403
+ */
404
+ apply(update) {
405
+ // Store the update
406
+ this._updates.update(map => {
407
+ const newMap = new Map(map);
408
+ newMap.set(update.correlationId, update);
409
+ return newMap;
410
+ });
411
+ // Set timeout for automatic rollback
412
+ const timeout = setTimeout(() => {
413
+ this.rollback(update.correlationId, new Error(`Optimistic update timeout after ${update.timeoutMs}ms`));
414
+ }, update.timeoutMs);
415
+ this.timeouts.set(update.correlationId, timeout);
416
+ }
417
+ /**
418
+ * Confirm an optimistic update (server accepted)
419
+ */
420
+ confirm(correlationId) {
421
+ const update = this._updates().get(correlationId);
422
+ if (!update) {
423
+ return false;
424
+ }
425
+ // Clear timeout
426
+ const timeout = this.timeouts.get(correlationId);
427
+ if (timeout) {
428
+ clearTimeout(timeout);
429
+ this.timeouts.delete(correlationId);
430
+ }
431
+ // Remove from pending
432
+ this._updates.update(map => {
433
+ const newMap = new Map(map);
434
+ newMap.delete(correlationId);
435
+ return newMap;
436
+ });
437
+ return true;
438
+ }
439
+ /**
440
+ * Rollback an optimistic update (server rejected or timeout)
441
+ */
442
+ rollback(correlationId, error) {
443
+ const update = this._updates().get(correlationId);
444
+ if (!update) {
445
+ return false;
446
+ }
447
+ // Clear timeout
448
+ const timeout = this.timeouts.get(correlationId);
449
+ if (timeout) {
450
+ clearTimeout(timeout);
451
+ this.timeouts.delete(correlationId);
452
+ }
453
+ // Execute rollback
454
+ try {
455
+ update.rollback();
456
+ } catch (rollbackError) {
457
+ console.error('Rollback failed:', rollbackError);
458
+ }
459
+ // Remove from pending
460
+ this._updates.update(map => {
461
+ const newMap = new Map(map);
462
+ newMap.delete(correlationId);
463
+ return newMap;
464
+ });
465
+ if (error) {
466
+ console.warn(`Optimistic update rolled back: ${error.message}`);
467
+ }
468
+ return true;
469
+ }
470
+ /**
471
+ * Rollback all pending updates
472
+ */
473
+ rollbackAll(error) {
474
+ const updates = Array.from(this._updates().keys());
475
+ let count = 0;
476
+ for (const correlationId of updates) {
477
+ if (this.rollback(correlationId, error)) {
478
+ count++;
479
+ }
480
+ }
481
+ return count;
482
+ }
483
+ /**
484
+ * Get update by correlation ID
485
+ */
486
+ get(correlationId) {
487
+ return this._updates().get(correlationId);
488
+ }
489
+ /**
490
+ * Check if an update is pending
491
+ */
492
+ isPending(correlationId) {
493
+ return this._updates().has(correlationId);
494
+ }
495
+ /**
496
+ * Clear all updates without rollback (use with caution)
497
+ */
498
+ clear() {
499
+ // Clear all timeouts
500
+ for (const timeout of this.timeouts.values()) {
501
+ clearTimeout(timeout);
502
+ }
503
+ this.timeouts.clear();
504
+ // Clear updates
505
+ this._updates.set(new Map());
506
+ }
507
+ /**
508
+ * Dispose the manager
509
+ */
510
+ dispose() {
511
+ this.clear();
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Create a simple event handler
517
+ *
518
+ * @example
519
+ * ```typescript
520
+ * const handler = createEventHandler<TradeProposalCreated>(event => {
521
+ * store.$.trades.entities.upsertOne(event.data);
522
+ * });
523
+ * ```
524
+ */
525
+ function createEventHandler(handler) {
526
+ return handler;
527
+ }
528
+ /**
529
+ * Create a typed event handler with metadata
530
+ *
531
+ * @example
532
+ * ```typescript
533
+ * const handler = createTypedHandler('TradeProposalCreated', {
534
+ * handle: (event) => {
535
+ * store.$.trades.entities.upsertOne(event.data);
536
+ * },
537
+ * priority: 1,
538
+ * });
539
+ * ```
540
+ */
541
+ function createTypedHandler(eventType, options) {
542
+ return {
543
+ eventType,
544
+ handle: options.handle,
545
+ priority: options.priority ?? 10
546
+ };
547
+ }
548
+
549
+ exports.OptimisticUpdateManager = OptimisticUpdateManager;
550
+ exports.createEventHandler = createEventHandler;
551
+ exports.createTypedHandler = createTypedHandler;