@codingfactory/socialkit-vue 0.3.0 → 0.4.0

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.
@@ -0,0 +1,1072 @@
1
+ /**
2
+ * Configurable Echo/Reverb client for SocialKit-powered frontends.
3
+ */
4
+
5
+ import type { EchoConfig } from '../types/api.js'
6
+ import type {
7
+ ConnectionStatus,
8
+ LiveCircleCountUpdatedEvent,
9
+ PresenceStatus,
10
+ PresenceStatusChangedEvent,
11
+ ProfileActivityCreatedEvent,
12
+ ProfileStatsUpdatedEvent,
13
+ StoryTrayUpdatedEvent,
14
+ TutorialCommentAddedEvent,
15
+ TutorialProgressUpdatedEvent
16
+ } from '../types/realtime.js'
17
+
18
+ export interface EchoChannelLike {
19
+ listen(event: string, callback: (data: unknown) => void): EchoChannelLike
20
+ }
21
+
22
+ export interface EchoInstanceLike {
23
+ connector?: {
24
+ pusher?: {
25
+ connection?: PusherConnectionBindings
26
+ }
27
+ }
28
+ private(channel: string): EchoChannelLike
29
+ join(channel: string): EchoChannelLike
30
+ leave(channel: string): void
31
+ disconnect(): void
32
+ }
33
+
34
+ export interface EchoConstructor<TInstance extends EchoInstanceLike = EchoInstanceLike> {
35
+ new (...args: any[]): TInstance
36
+ }
37
+
38
+ type UnknownRecord = Record<string, unknown>
39
+ type EchoWindow = Window & {
40
+ Echo?: EchoInstanceLike | null
41
+ Pusher?: unknown
42
+ }
43
+
44
+ interface PusherConnectionEventHandler {
45
+ (data?: unknown): void
46
+ }
47
+
48
+ interface PusherConnectionBindings {
49
+ bind(event: string, handler: PusherConnectionEventHandler): void
50
+ state?: string
51
+ }
52
+
53
+ interface PusherConnectorInterface {
54
+ pusher: {
55
+ connection: PusherConnectionBindings
56
+ }
57
+ }
58
+
59
+ export interface EchoServiceLogger {
60
+ debug(message: string, context?: unknown): void
61
+ warn(message: string, context?: unknown): void
62
+ error(message: string, context?: unknown): void
63
+ }
64
+
65
+ export interface EchoConnectionConfig extends EchoConfig {
66
+ wsPath?: string
67
+ }
68
+
69
+ export interface EchoDisconnectOptions {
70
+ preventReconnect?: boolean
71
+ skipStatusUpdate?: boolean
72
+ }
73
+
74
+ export interface EchoReconnectOptions {
75
+ resetBackoff?: boolean
76
+ }
77
+
78
+ export interface ProfileUpdatesCallbacks {
79
+ onStatsUpdated?: (data: ProfileStatsUpdatedEvent) => void
80
+ onActivityCreated?: (data: ProfileActivityCreatedEvent) => void
81
+ }
82
+
83
+ export interface StoryTrayCallbacks {
84
+ onStoryTrayUpdated?: (data: StoryTrayUpdatedEvent) => void
85
+ }
86
+
87
+ export interface EchoServiceConfig {
88
+ Echo: EchoConstructor
89
+ pusher?: unknown
90
+ getToken: () => string | null
91
+ resolveConnectionConfig: (token: string) => EchoConnectionConfig
92
+ resolveTenantScope?: () => string
93
+ initializedEvent?: string
94
+ logger?: Partial<EchoServiceLogger>
95
+ }
96
+
97
+ export interface EchoServiceInstance<TInstance extends EchoInstanceLike = EchoInstanceLike> {
98
+ initializeEcho(): TInstance | null
99
+ getEcho(): TInstance | null
100
+ disconnectEcho(options?: EchoDisconnectOptions): void
101
+ reconnectEcho(options?: EchoReconnectOptions): void
102
+ getConnectionStatus(): ConnectionStatus
103
+ onConnectionStatusChange(callback: (status: ConnectionStatus) => void): () => void
104
+ onEchoReconnected(callback: () => void): () => void
105
+ subscribeToPresenceStatus(callback: (event: PresenceStatusChangedEvent) => void): void
106
+ unsubscribeFromPresenceStatus(): void
107
+ subscribeToLiveCircleCount(
108
+ circleId: string,
109
+ callback: (event: LiveCircleCountUpdatedEvent) => void
110
+ ): () => void
111
+ subscribeToTutorialComments(
112
+ tutorialId: string,
113
+ callback: (event: TutorialCommentAddedEvent) => void
114
+ ): () => void
115
+ subscribeToTutorialProgress(
116
+ tutorialId: string,
117
+ callback: (event: TutorialProgressUpdatedEvent) => void
118
+ ): () => void
119
+ subscribeToProfileUpdates(userId: string, callbacks: ProfileUpdatesCallbacks): void
120
+ unsubscribeFromProfileUpdates(userId: string): void
121
+ subscribeToStoryTray(callbacks: StoryTrayCallbacks): boolean
122
+ unsubscribeFromStoryTray(): void
123
+ }
124
+
125
+ const TRANSIENT_PUSHER_CLOSE_CODE = 1006
126
+ const CONNECTION_STATE_RECONCILE_DELAYS_MS = [0, 50, 250, 1000, 5000] as const
127
+ const FAST_RECONNECT_RETRY_COUNT = 10
128
+ const MAX_RECONNECT_DELAY_MS = 30000
129
+ const STEADY_RECONNECT_DELAY_MS = 60000
130
+ const HEALTH_CHECK_INTERVAL_MS = 45000
131
+ const DISCONNECT_SUPPRESSION_RESET_MS = 2000
132
+
133
+ const noopLogger: EchoServiceLogger = {
134
+ debug: () => undefined,
135
+ warn: () => undefined,
136
+ error: () => undefined
137
+ }
138
+
139
+ function getBrowserWindow(): EchoWindow | null {
140
+ return typeof window === 'undefined' ? null : window as unknown as EchoWindow
141
+ }
142
+
143
+ function getBrowserDocument(): Document | null {
144
+ return typeof document === 'undefined' ? null : document
145
+ }
146
+
147
+ function ensurePusherGlobal(pusher?: unknown): void {
148
+ const liveWindow = getBrowserWindow()
149
+ if (liveWindow && pusher !== undefined) {
150
+ ;(liveWindow as { Pusher?: unknown }).Pusher = pusher
151
+ }
152
+ }
153
+
154
+ function isRecord(value: unknown): value is UnknownRecord {
155
+ return typeof value === 'object' && value !== null
156
+ }
157
+
158
+ function hasString(obj: UnknownRecord, key: string): boolean {
159
+ return typeof obj[key] === 'string'
160
+ }
161
+
162
+ function getPresenceField(payload: UnknownRecord, snakeKey: string, camelKey: string): string | null {
163
+ const snakeValue = payload[snakeKey]
164
+ if (typeof snakeValue === 'string' && snakeValue.trim().length > 0) {
165
+ return snakeValue
166
+ }
167
+
168
+ const camelValue = payload[camelKey]
169
+ if (typeof camelValue === 'string' && camelValue.trim().length > 0) {
170
+ return camelValue
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ function normalizePresenceStatus(rawStatus: unknown): PresenceStatus | null {
177
+ if (typeof rawStatus !== 'string') {
178
+ return null
179
+ }
180
+
181
+ const normalizedStatus = rawStatus.trim().toLowerCase()
182
+ if (
183
+ normalizedStatus === 'online'
184
+ || normalizedStatus === 'idle'
185
+ || normalizedStatus === 'dnd'
186
+ || normalizedStatus === 'offline'
187
+ ) {
188
+ return normalizedStatus
189
+ }
190
+
191
+ return null
192
+ }
193
+
194
+ function normalizePresenceStatusChangedPayload(
195
+ data: unknown,
196
+ resolveTenantScope: () => string
197
+ ): PresenceStatusChangedEvent | null {
198
+ if (!isRecord(data)) {
199
+ return null
200
+ }
201
+
202
+ const userId = getPresenceField(data, 'user_id', 'userId')
203
+ const status = normalizePresenceStatus(data.status)
204
+ if (!userId || !status) {
205
+ return null
206
+ }
207
+
208
+ const payload: PresenceStatusChangedEvent = {
209
+ user_id: userId,
210
+ tenant_scope: getPresenceField(data, 'tenant_scope', 'tenantScope') ?? resolveTenantScope(),
211
+ status,
212
+ ts: getPresenceField(data, 'ts', 'timestamp') ?? new Date().toISOString()
213
+ }
214
+
215
+ const tenantIdValue = data.tenant_id ?? data.tenantId
216
+ if (typeof tenantIdValue === 'string') {
217
+ payload.tenant_id = tenantIdValue
218
+ } else if (tenantIdValue === null) {
219
+ payload.tenant_id = null
220
+ }
221
+
222
+ const lastSeenValue = data.last_seen_at ?? data.lastSeenAt
223
+ if (typeof lastSeenValue === 'string') {
224
+ payload.last_seen_at = lastSeenValue
225
+ } else if (lastSeenValue === null) {
226
+ payload.last_seen_at = null
227
+ }
228
+
229
+ return payload
230
+ }
231
+
232
+ export function isValidPresenceStatusChangedEvent(data: unknown): data is PresenceStatusChangedEvent {
233
+ if (!isRecord(data)) {
234
+ return false
235
+ }
236
+
237
+ return hasString(data, 'user_id')
238
+ && (data.status === 'online' || data.status === 'idle' || data.status === 'dnd' || data.status === 'offline')
239
+ }
240
+
241
+ export function isValidLiveCircleCountUpdatedEvent(data: unknown): data is LiveCircleCountUpdatedEvent {
242
+ if (!isRecord(data)) {
243
+ return false
244
+ }
245
+
246
+ return hasString(data, 'circle_id') && typeof data.count === 'number'
247
+ }
248
+
249
+ export function isValidTutorialCommentAddedEvent(data: unknown): data is TutorialCommentAddedEvent {
250
+ if (!isRecord(data) || !hasString(data, 'tutorial_id') || !isRecord(data.comment)) {
251
+ return false
252
+ }
253
+
254
+ const comment = data.comment
255
+ return hasString(comment, 'id')
256
+ && hasString(comment, 'author_id')
257
+ && hasString(comment, 'author_name')
258
+ && hasString(comment, 'body')
259
+ && hasString(comment, 'created_at')
260
+ }
261
+
262
+ export function isValidTutorialProgressUpdatedEvent(data: unknown): data is TutorialProgressUpdatedEvent {
263
+ if (!isRecord(data)) {
264
+ return false
265
+ }
266
+
267
+ if (
268
+ !hasString(data, 'tutorial_id')
269
+ || !hasString(data, 'user_id')
270
+ || !hasString(data, 'current_step_id')
271
+ || typeof data.progress_percent !== 'number'
272
+ || typeof data.is_completed !== 'boolean'
273
+ || !hasString(data, 'updated_at')
274
+ ) {
275
+ return false
276
+ }
277
+
278
+ if (!Array.isArray(data.completed_steps)) {
279
+ return false
280
+ }
281
+
282
+ return data.completed_steps.every((stepId: unknown) => typeof stepId === 'string')
283
+ }
284
+
285
+ export function isValidProfileStatsUpdatedEvent(data: unknown): data is ProfileStatsUpdatedEvent {
286
+ if (!isRecord(data) || !isRecord(data.stats)) {
287
+ return false
288
+ }
289
+
290
+ const stats = data.stats
291
+ return typeof stats.followers_count === 'number' && typeof stats.following_count === 'number'
292
+ }
293
+
294
+ export function isValidProfileActivityCreatedEvent(data: unknown): data is ProfileActivityCreatedEvent {
295
+ if (!isRecord(data) || !isRecord(data.activity)) {
296
+ return false
297
+ }
298
+
299
+ const activity = data.activity
300
+ return hasString(activity, 'id') && hasString(activity, 'activity_type') && hasString(activity, 'created_at')
301
+ }
302
+
303
+ export function isValidStoryTrayUpdatedEvent(data: unknown): data is StoryTrayUpdatedEvent {
304
+ if (!isRecord(data)) {
305
+ return false
306
+ }
307
+
308
+ return hasString(data, 'story_id') && hasString(data, 'author_id')
309
+ }
310
+
311
+ function getPusherErrorCode(error: unknown): number | null {
312
+ if (!isRecord(error) || !isRecord(error.data)) {
313
+ return null
314
+ }
315
+
316
+ return typeof error.data.code === 'number' ? error.data.code : null
317
+ }
318
+
319
+ function resolvePusherConnection(echo: EchoInstanceLike | null): PusherConnectionBindings | null {
320
+ const connector = echo?.connector as PusherConnectorInterface | undefined
321
+ return connector?.pusher?.connection ?? null
322
+ }
323
+
324
+ function guardedListen<T>(
325
+ channel: EchoChannelLike,
326
+ event: string,
327
+ validator: (data: unknown) => data is T,
328
+ callback: (data: T) => void,
329
+ label: string,
330
+ logger: EchoServiceLogger
331
+ ): (data: unknown) => void {
332
+ const handler = (data: unknown): void => {
333
+ if (!validator(data)) {
334
+ logger.warn(`Dropping malformed ${label} event`, data)
335
+ return
336
+ }
337
+
338
+ try {
339
+ callback(data)
340
+ } catch (error) {
341
+ logger.error(`Error handling ${label} event`, error)
342
+ }
343
+ }
344
+
345
+ channel.listen(event, handler)
346
+ return handler
347
+ }
348
+
349
+ export function createEchoService<TInstance extends EchoInstanceLike = EchoInstanceLike>(
350
+ config: EchoServiceConfig & {
351
+ Echo: EchoConstructor<TInstance>
352
+ }
353
+ ): EchoServiceInstance<TInstance> {
354
+ const logger: EchoServiceLogger = {
355
+ ...noopLogger,
356
+ ...config.logger
357
+ }
358
+
359
+ const resolveTenantScope = (): string => {
360
+ const tenantScope = config.resolveTenantScope?.()
361
+ return typeof tenantScope === 'string' && tenantScope.length > 0 ? tenantScope : 'global'
362
+ }
363
+
364
+ let echoInstance: TInstance | null = null
365
+ let suppressNextDisconnectEvent = false
366
+ let suppressDisconnectResetTimeoutId: ReturnType<typeof setTimeout> | null = null
367
+ let connectionStateReconcileTimeoutId: ReturnType<typeof setTimeout> | null = null
368
+ let connectionStateReconcileAttempt = 0
369
+ let connectionStatus: ConnectionStatus = 'disconnected'
370
+ const connectionCallbacks = new Set<(status: ConnectionStatus) => void>()
371
+ const reconnectCallbacks = new Set<() => void>()
372
+ let hasConnectedAtLeastOnce = false
373
+ let reconnectAttempts = 0
374
+ let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
375
+ let healthCheckIntervalId: ReturnType<typeof setInterval> | null = null
376
+ let connectionGeneration = 0
377
+
378
+ function clearConnectionStateReconcile(): void {
379
+ if (connectionStateReconcileTimeoutId) {
380
+ clearTimeout(connectionStateReconcileTimeoutId)
381
+ connectionStateReconcileTimeoutId = null
382
+ }
383
+
384
+ connectionStateReconcileAttempt = 0
385
+ }
386
+
387
+ function updateConnectionStatus(status: ConnectionStatus): void {
388
+ if (connectionStatus === status) {
389
+ return
390
+ }
391
+
392
+ const previousStatus = connectionStatus
393
+ connectionStatus = status
394
+ logger.debug(`Connection status changed to ${status}`)
395
+
396
+ for (const callback of connectionCallbacks) {
397
+ callback(status)
398
+ }
399
+
400
+ if (status !== 'connected') {
401
+ return
402
+ }
403
+
404
+ const recoveredConnection = hasConnectedAtLeastOnce && previousStatus !== 'connected'
405
+ hasConnectedAtLeastOnce = true
406
+
407
+ if (!recoveredConnection) {
408
+ return
409
+ }
410
+
411
+ for (const callback of reconnectCallbacks) {
412
+ callback()
413
+ }
414
+ }
415
+
416
+ function scheduleConnectionStateReconcile(connection: PusherConnectionBindings): void {
417
+ clearConnectionStateReconcile()
418
+
419
+ const reconcile = (): void => {
420
+ connectionStateReconcileTimeoutId = null
421
+
422
+ if (getConnectionStatus() !== 'connecting') {
423
+ clearConnectionStateReconcile()
424
+ return
425
+ }
426
+
427
+ if (connection.state === 'connected') {
428
+ updateConnectionStatus('connected')
429
+ clearConnectionStateReconcile()
430
+ return
431
+ }
432
+
433
+ connectionStateReconcileAttempt += 1
434
+ const nextDelay = CONNECTION_STATE_RECONCILE_DELAYS_MS[connectionStateReconcileAttempt]
435
+ if (typeof nextDelay !== 'number') {
436
+ clearConnectionStateReconcile()
437
+ return
438
+ }
439
+
440
+ connectionStateReconcileTimeoutId = setTimeout(reconcile, nextDelay)
441
+ }
442
+
443
+ connectionStateReconcileTimeoutId = setTimeout(reconcile, CONNECTION_STATE_RECONCILE_DELAYS_MS[0])
444
+ }
445
+
446
+ function stopHealthCheck(): void {
447
+ if (healthCheckIntervalId) {
448
+ clearInterval(healthCheckIntervalId)
449
+ healthCheckIntervalId = null
450
+ }
451
+ }
452
+
453
+ function startHealthCheck(): void {
454
+ if (healthCheckIntervalId) {
455
+ return
456
+ }
457
+
458
+ healthCheckIntervalId = setInterval(() => {
459
+ const liveDocument = getBrowserDocument()
460
+ if (liveDocument?.hidden) {
461
+ return
462
+ }
463
+
464
+ const status = getConnectionStatus()
465
+ if (status === 'disconnected' || status === 'error') {
466
+ logger.debug('Health check: connection is down, triggering reconnect')
467
+ reconnectAttempts = 0
468
+ scheduleReconnect()
469
+ }
470
+ }, HEALTH_CHECK_INTERVAL_MS)
471
+ }
472
+
473
+ function scheduleReconnect(): void {
474
+ const delay = reconnectAttempts < FAST_RECONNECT_RETRY_COUNT
475
+ ? Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS)
476
+ : STEADY_RECONNECT_DELAY_MS
477
+
478
+ reconnectAttempts += 1
479
+ logger.debug(`Scheduling reconnect attempt ${reconnectAttempts} in ${delay}ms`)
480
+
481
+ if (reconnectTimeoutId) {
482
+ clearTimeout(reconnectTimeoutId)
483
+ }
484
+
485
+ reconnectTimeoutId = setTimeout(() => {
486
+ logger.debug(`Reconnect attempt ${reconnectAttempts}`)
487
+ reconnectEcho()
488
+ }, delay)
489
+ }
490
+
491
+ function initializeEcho(): TInstance | null {
492
+ connectionGeneration += 1
493
+ const thisGeneration = connectionGeneration
494
+
495
+ if (suppressDisconnectResetTimeoutId) {
496
+ clearTimeout(suppressDisconnectResetTimeoutId)
497
+ suppressDisconnectResetTimeoutId = null
498
+ }
499
+
500
+ clearConnectionStateReconcile()
501
+
502
+ const token = config.getToken()
503
+ if (!token) {
504
+ logger.warn('No auth token available, skipping initialization')
505
+ return null
506
+ }
507
+
508
+ if (echoInstance) {
509
+ logger.debug('Already initialized')
510
+ return echoInstance
511
+ }
512
+
513
+ ensurePusherGlobal(config.pusher)
514
+
515
+ logger.debug('Initializing connection to Reverb...')
516
+
517
+ const echoOptions = config.resolveConnectionConfig(token)
518
+ logger.debug('Resolved config', {
519
+ host: echoOptions.wsHost,
520
+ port: echoOptions.wsPort,
521
+ tls: echoOptions.forceTLS,
522
+ path: echoOptions.wsPath ?? '(default)',
523
+ authEndpoint: echoOptions.authEndpoint
524
+ })
525
+
526
+ echoInstance = new config.Echo(echoOptions) as TInstance
527
+
528
+ const liveWindow = getBrowserWindow()
529
+ if (liveWindow) {
530
+ ;(liveWindow as unknown as { Echo?: TInstance | null }).Echo = echoInstance
531
+ if (config.initializedEvent) {
532
+ liveWindow.dispatchEvent(new CustomEvent(config.initializedEvent))
533
+ }
534
+ }
535
+
536
+ const connection = resolvePusherConnection(echoInstance)
537
+ if (!connection) {
538
+ logger.error('Unable to access Pusher connection')
539
+ return echoInstance
540
+ }
541
+
542
+ connection.bind('connected', () => {
543
+ if (thisGeneration !== connectionGeneration) {
544
+ logger.debug('Ignoring stale connected event from old connection')
545
+ return
546
+ }
547
+
548
+ logger.debug('Connected to Reverb WebSocket server')
549
+ clearConnectionStateReconcile()
550
+ suppressNextDisconnectEvent = false
551
+
552
+ if (suppressDisconnectResetTimeoutId) {
553
+ clearTimeout(suppressDisconnectResetTimeoutId)
554
+ suppressDisconnectResetTimeoutId = null
555
+ }
556
+
557
+ updateConnectionStatus('connected')
558
+ reconnectAttempts = 0
559
+
560
+ if (reconnectTimeoutId) {
561
+ clearTimeout(reconnectTimeoutId)
562
+ reconnectTimeoutId = null
563
+ }
564
+
565
+ startHealthCheck()
566
+ })
567
+
568
+ connection.bind('disconnected', () => {
569
+ if (thisGeneration !== connectionGeneration) {
570
+ logger.debug('Ignoring stale disconnected event from old connection')
571
+ return
572
+ }
573
+
574
+ if (suppressNextDisconnectEvent) {
575
+ logger.debug('Skipping reconnect for intentional disconnect')
576
+ suppressNextDisconnectEvent = false
577
+
578
+ if (suppressDisconnectResetTimeoutId) {
579
+ clearTimeout(suppressDisconnectResetTimeoutId)
580
+ suppressDisconnectResetTimeoutId = null
581
+ }
582
+
583
+ return
584
+ }
585
+
586
+ logger.warn('Disconnected from Reverb WebSocket server')
587
+ updateConnectionStatus('disconnected')
588
+ scheduleReconnect()
589
+ })
590
+
591
+ connection.bind('error', (error?: unknown) => {
592
+ if (thisGeneration !== connectionGeneration) {
593
+ logger.debug('Ignoring stale error event from old connection')
594
+ return
595
+ }
596
+
597
+ const errorCode = getPusherErrorCode(error)
598
+ if (errorCode === TRANSIENT_PUSHER_CLOSE_CODE) {
599
+ if (suppressNextDisconnectEvent) {
600
+ logger.debug('Ignoring PusherError 1006 during intentional disconnect')
601
+ return
602
+ }
603
+
604
+ logger.debug('Received PusherError 1006; treating as disconnected')
605
+ updateConnectionStatus('disconnected')
606
+ scheduleReconnect()
607
+ return
608
+ }
609
+
610
+ logger.error('Connection error', error)
611
+ updateConnectionStatus('error')
612
+ scheduleReconnect()
613
+ })
614
+
615
+ connection.bind('unavailable', () => {
616
+ if (thisGeneration !== connectionGeneration) {
617
+ logger.debug('Ignoring stale unavailable event from old connection')
618
+ return
619
+ }
620
+
621
+ if (suppressNextDisconnectEvent) {
622
+ logger.debug('Ignoring unavailable event during intentional disconnect')
623
+ return
624
+ }
625
+
626
+ logger.debug('Connection unavailable; scheduling reconnect')
627
+ updateConnectionStatus('disconnected')
628
+ scheduleReconnect()
629
+ })
630
+
631
+ connection.bind('connecting', () => {
632
+ if (thisGeneration !== connectionGeneration) {
633
+ logger.debug('Ignoring stale connecting event from old connection')
634
+ return
635
+ }
636
+
637
+ logger.debug('Connecting to Reverb WebSocket server')
638
+ updateConnectionStatus('connecting')
639
+ scheduleConnectionStateReconcile(connection)
640
+ })
641
+
642
+ updateConnectionStatus('connecting')
643
+
644
+ if (connection.state === 'connected') {
645
+ updateConnectionStatus('connected')
646
+ }
647
+
648
+ scheduleConnectionStateReconcile(connection)
649
+
650
+ return echoInstance
651
+ }
652
+
653
+ function getEcho(): TInstance | null {
654
+ const liveWindow = getBrowserWindow()
655
+
656
+ if (!echoInstance && liveWindow?.Echo) {
657
+ echoInstance = liveWindow.Echo as TInstance
658
+
659
+ const liveConnection = resolvePusherConnection(echoInstance)
660
+ if (liveConnection?.state === 'connected') {
661
+ updateConnectionStatus('connected')
662
+ }
663
+ }
664
+
665
+ return echoInstance
666
+ }
667
+
668
+ function disconnectEcho(options: EchoDisconnectOptions = {}): void {
669
+ const preventReconnect = options.preventReconnect ?? true
670
+
671
+ if (!preventReconnect) {
672
+ suppressNextDisconnectEvent = false
673
+ if (suppressDisconnectResetTimeoutId) {
674
+ clearTimeout(suppressDisconnectResetTimeoutId)
675
+ suppressDisconnectResetTimeoutId = null
676
+ }
677
+ }
678
+
679
+ clearConnectionStateReconcile()
680
+ stopHealthCheck()
681
+
682
+ if (echoInstance) {
683
+ if (preventReconnect) {
684
+ suppressNextDisconnectEvent = true
685
+
686
+ if (suppressDisconnectResetTimeoutId) {
687
+ clearTimeout(suppressDisconnectResetTimeoutId)
688
+ }
689
+
690
+ suppressDisconnectResetTimeoutId = setTimeout(() => {
691
+ suppressNextDisconnectEvent = false
692
+ suppressDisconnectResetTimeoutId = null
693
+ }, DISCONNECT_SUPPRESSION_RESET_MS)
694
+
695
+ if (reconnectTimeoutId) {
696
+ clearTimeout(reconnectTimeoutId)
697
+ reconnectTimeoutId = null
698
+ }
699
+ }
700
+
701
+ logger.debug('Disconnecting...')
702
+ echoInstance.disconnect()
703
+ echoInstance = null
704
+
705
+ const liveWindow = getBrowserWindow()
706
+ if (liveWindow) {
707
+ delete liveWindow.Echo
708
+ }
709
+ }
710
+
711
+ if (preventReconnect && !options.skipStatusUpdate) {
712
+ updateConnectionStatus('disconnected')
713
+ }
714
+ }
715
+
716
+ function getConnectionStatus(): ConnectionStatus {
717
+ const liveState = resolvePusherConnection(getBrowserWindow()?.Echo ?? null)?.state
718
+ if (liveState === 'connected' && connectionStatus !== 'connected') {
719
+ updateConnectionStatus('connected')
720
+ }
721
+
722
+ return connectionStatus
723
+ }
724
+
725
+ function onConnectionStatusChange(callback: (status: ConnectionStatus) => void): () => void {
726
+ connectionCallbacks.add(callback)
727
+
728
+ return () => {
729
+ connectionCallbacks.delete(callback)
730
+ }
731
+ }
732
+
733
+ function onEchoReconnected(callback: () => void): () => void {
734
+ reconnectCallbacks.add(callback)
735
+
736
+ return () => {
737
+ reconnectCallbacks.delete(callback)
738
+ }
739
+ }
740
+
741
+ function subscribeToPresenceStatus(callback: (event: PresenceStatusChangedEvent) => void): void {
742
+ const echo = getEcho()
743
+ if (!echo) {
744
+ logger.warn('Cannot subscribe to presence status, not connected')
745
+ return
746
+ }
747
+
748
+ const channelName = `online-users.${resolveTenantScope()}`
749
+ logger.debug(`Subscribing to ${channelName}`)
750
+
751
+ const channel = echo.join(channelName) as EchoChannelLike
752
+ const presenceEventNames = [
753
+ '.PresenceStatusChanged',
754
+ 'PresenceStatusChanged',
755
+ '.presence.status.changed',
756
+ 'presence.status.changed',
757
+ '.Presence.StatusChanged',
758
+ 'Presence.StatusChanged',
759
+ '.Modules\\Presence\\Events\\UserPresenceStatusChanged',
760
+ 'Modules\\Presence\\Events\\UserPresenceStatusChanged'
761
+ ]
762
+
763
+ for (const eventName of presenceEventNames) {
764
+ const validator = (rawEvent: unknown): rawEvent is UnknownRecord => isRecord(rawEvent)
765
+ guardedListen(channel, eventName, validator, (rawEvent) => {
766
+ const event = normalizePresenceStatusChangedPayload(rawEvent, resolveTenantScope)
767
+ if (!event) {
768
+ logger.warn('Dropping malformed PresenceStatusChanged event', rawEvent)
769
+ return
770
+ }
771
+
772
+ logger.debug('Presence status event received', event)
773
+ callback(event)
774
+ }, 'PresenceStatusChanged', logger)
775
+ }
776
+ }
777
+
778
+ function unsubscribeFromPresenceStatus(): void {
779
+ const echo = getEcho()
780
+ if (!echo) {
781
+ return
782
+ }
783
+
784
+ const channelName = `online-users.${resolveTenantScope()}`
785
+ logger.debug(`Unsubscribing from ${channelName}`)
786
+ echo.leave(channelName)
787
+ }
788
+
789
+ function subscribeToLiveCircleCount(
790
+ circleId: string,
791
+ callback: (event: LiveCircleCountUpdatedEvent) => void
792
+ ): () => void {
793
+ const echo = getEcho()
794
+ if (!echo) {
795
+ logger.warn('Cannot subscribe to live circle count, not connected')
796
+ return () => undefined
797
+ }
798
+
799
+ const channelName = `live-circle.${circleId}`
800
+ logger.debug(`Subscribing to ${channelName}`)
801
+
802
+ const channel = echo.private(channelName) as EchoChannelLike
803
+ const handler = guardedListen(
804
+ channel,
805
+ '.LiveCircleCountUpdated',
806
+ isValidLiveCircleCountUpdatedEvent,
807
+ (data) => {
808
+ logger.debug('Live circle count updated event received', data)
809
+ callback(data)
810
+ },
811
+ 'LiveCircleCountUpdated',
812
+ logger
813
+ )
814
+
815
+ let cleanedUp = false
816
+ return () => {
817
+ if (cleanedUp) {
818
+ return
819
+ }
820
+
821
+ cleanedUp = true
822
+ const stoppableChannel = channel as EchoChannelLike & {
823
+ stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
824
+ }
825
+
826
+ if (typeof stoppableChannel.stopListening !== 'function') {
827
+ return
828
+ }
829
+
830
+ stoppableChannel.stopListening('.LiveCircleCountUpdated', handler)
831
+ }
832
+ }
833
+
834
+ function subscribeToTutorialComments(
835
+ tutorialId: string,
836
+ callback: (event: TutorialCommentAddedEvent) => void
837
+ ): () => void {
838
+ const echo = getEcho() ?? initializeEcho()
839
+ if (!echo) {
840
+ logger.warn('Cannot subscribe to tutorial comments, not connected')
841
+ return () => undefined
842
+ }
843
+
844
+ const channelName = `tutorial.${tutorialId}.comments`
845
+ logger.debug(`Subscribing to ${channelName}`)
846
+
847
+ const channel = echo.private(channelName) as EchoChannelLike
848
+ const handler = guardedListen(
849
+ channel,
850
+ '.comment.added',
851
+ isValidTutorialCommentAddedEvent,
852
+ (data) => {
853
+ logger.debug('Tutorial comment added event received', data)
854
+ callback(data)
855
+ },
856
+ 'comment.added',
857
+ logger
858
+ )
859
+
860
+ let cleanedUp = false
861
+ return () => {
862
+ if (cleanedUp) {
863
+ return
864
+ }
865
+
866
+ cleanedUp = true
867
+ const stoppableChannel = channel as EchoChannelLike & {
868
+ stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
869
+ }
870
+
871
+ if (typeof stoppableChannel.stopListening !== 'function') {
872
+ return
873
+ }
874
+
875
+ stoppableChannel.stopListening('.comment.added', handler)
876
+ }
877
+ }
878
+
879
+ function subscribeToTutorialProgress(
880
+ tutorialId: string,
881
+ callback: (event: TutorialProgressUpdatedEvent) => void
882
+ ): () => void {
883
+ const echo = getEcho() ?? initializeEcho()
884
+ if (!echo) {
885
+ logger.warn('Cannot subscribe to tutorial progress, not connected')
886
+ return () => undefined
887
+ }
888
+
889
+ const channelName = `tutorial.${tutorialId}.progress`
890
+ logger.debug(`Subscribing to ${channelName}`)
891
+
892
+ const channel = echo.private(channelName) as EchoChannelLike
893
+ channel.listen('pusher:subscription_error', (error: unknown) => {
894
+ logger.warn(`Channel subscription failed for ${channelName}`, error)
895
+ })
896
+
897
+ const handler = guardedListen(
898
+ channel,
899
+ '.progress.updated',
900
+ isValidTutorialProgressUpdatedEvent,
901
+ (data) => {
902
+ logger.debug('Tutorial progress updated event received', data)
903
+ callback(data)
904
+ },
905
+ 'progress.updated',
906
+ logger
907
+ )
908
+
909
+ let cleanedUp = false
910
+ return () => {
911
+ if (cleanedUp) {
912
+ return
913
+ }
914
+
915
+ cleanedUp = true
916
+ const stoppableChannel = channel as EchoChannelLike & {
917
+ stopListening?: (event: string, callback?: (data: unknown) => void) => unknown
918
+ }
919
+
920
+ if (typeof stoppableChannel.stopListening !== 'function') {
921
+ return
922
+ }
923
+
924
+ stoppableChannel.stopListening('.progress.updated', handler)
925
+ }
926
+ }
927
+
928
+ function subscribeToProfileUpdates(userId: string, callbacks: ProfileUpdatesCallbacks): void {
929
+ const echo = getEcho()
930
+ if (!echo) {
931
+ logger.warn('Cannot subscribe to profile updates, not connected')
932
+ return
933
+ }
934
+
935
+ const channelName = `profiles.${userId}`
936
+ logger.debug(`Subscribing to ${channelName}`)
937
+
938
+ const channel = echo.private(channelName) as EchoChannelLike
939
+
940
+ if (callbacks.onStatsUpdated) {
941
+ const onStatsUpdated = callbacks.onStatsUpdated
942
+ guardedListen(
943
+ channel,
944
+ '.Profiles.StatsUpdated',
945
+ isValidProfileStatsUpdatedEvent,
946
+ (data) => {
947
+ logger.debug('Profile stats updated event received', data)
948
+ onStatsUpdated(data)
949
+ },
950
+ 'Profiles.StatsUpdated',
951
+ logger
952
+ )
953
+ }
954
+
955
+ if (callbacks.onActivityCreated) {
956
+ const onActivityCreated = callbacks.onActivityCreated
957
+ guardedListen(
958
+ channel,
959
+ '.Profiles.ActivityCreated',
960
+ isValidProfileActivityCreatedEvent,
961
+ (data) => {
962
+ logger.debug('Profile activity created event received', data)
963
+ onActivityCreated(data)
964
+ },
965
+ 'Profiles.ActivityCreated',
966
+ logger
967
+ )
968
+ }
969
+ }
970
+
971
+ function unsubscribeFromProfileUpdates(userId: string): void {
972
+ const echo = getEcho()
973
+ if (!echo) {
974
+ return
975
+ }
976
+
977
+ const channelName = `profiles.${userId}`
978
+ logger.debug(`Unsubscribing from ${channelName}`)
979
+ echo.leave(channelName)
980
+ }
981
+
982
+ function subscribeToStoryTray(callbacks: StoryTrayCallbacks): boolean {
983
+ const echo = getEcho() ?? initializeEcho()
984
+ if (!echo) {
985
+ logger.warn('Cannot subscribe to story tray channel, not connected')
986
+ return false
987
+ }
988
+
989
+ const channelName = `story-tray.${resolveTenantScope()}`
990
+ logger.debug(`Subscribing to ${channelName}`)
991
+
992
+ const channel = echo.private(channelName) as EchoChannelLike
993
+
994
+ if (callbacks.onStoryTrayUpdated) {
995
+ const onStoryTrayUpdated = callbacks.onStoryTrayUpdated
996
+ guardedListen(
997
+ channel,
998
+ '.StoryTrayUpdated',
999
+ isValidStoryTrayUpdatedEvent,
1000
+ (data) => {
1001
+ logger.debug('Story tray updated event received', data)
1002
+ onStoryTrayUpdated(data)
1003
+ },
1004
+ 'StoryTrayUpdated',
1005
+ logger
1006
+ )
1007
+ }
1008
+
1009
+ return true
1010
+ }
1011
+
1012
+ function unsubscribeFromStoryTray(): void {
1013
+ const echo = getEcho()
1014
+ if (!echo) {
1015
+ return
1016
+ }
1017
+
1018
+ const channelName = `story-tray.${resolveTenantScope()}`
1019
+ logger.debug(`Unsubscribing from ${channelName}`)
1020
+ echo.leave(channelName)
1021
+ }
1022
+
1023
+ function reconnectEcho(options: EchoReconnectOptions = {}): void {
1024
+ if (options.resetBackoff) {
1025
+ reconnectAttempts = 0
1026
+ }
1027
+
1028
+ if (reconnectTimeoutId) {
1029
+ clearTimeout(reconnectTimeoutId)
1030
+ reconnectTimeoutId = null
1031
+ }
1032
+
1033
+ updateConnectionStatus('connecting')
1034
+
1035
+ if (echoInstance) {
1036
+ logger.debug('Disconnecting before reconnect')
1037
+ disconnectEcho({ preventReconnect: true, skipStatusUpdate: true })
1038
+ }
1039
+
1040
+ try {
1041
+ echoInstance = initializeEcho()
1042
+ if (!echoInstance) {
1043
+ logger.error('Reconnect failed: initializeEcho returned null')
1044
+ updateConnectionStatus('error')
1045
+ scheduleReconnect()
1046
+ }
1047
+ } catch (error) {
1048
+ logger.error('Reconnect failed', error)
1049
+ updateConnectionStatus('error')
1050
+ scheduleReconnect()
1051
+ }
1052
+ }
1053
+
1054
+ return {
1055
+ initializeEcho,
1056
+ getEcho,
1057
+ disconnectEcho,
1058
+ reconnectEcho,
1059
+ getConnectionStatus,
1060
+ onConnectionStatusChange,
1061
+ onEchoReconnected,
1062
+ subscribeToPresenceStatus,
1063
+ unsubscribeFromPresenceStatus,
1064
+ subscribeToLiveCircleCount,
1065
+ subscribeToTutorialComments,
1066
+ subscribeToTutorialProgress,
1067
+ subscribeToProfileUpdates,
1068
+ unsubscribeFromProfileUpdates,
1069
+ subscribeToStoryTray,
1070
+ unsubscribeFromStoryTray
1071
+ }
1072
+ }