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