anear-js-api 1.5.2 → 1.6.2
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/lib/state_machines/AnearEventMachine.js +124 -30
- package/lib/state_machines/AnearParticipantMachine.js +9 -4
- package/lib/utils/AppMachineTransition.js +6 -1
- package/lib/utils/DisplayEventProcessor.js +7 -3
- package/lib/utils/EventStats.js +119 -0
- package/lib/utils/RealtimeMessaging.js +10 -1
- package/package.json +1 -1
|
@@ -134,6 +134,7 @@ const getPresenceEventName = (participant, presenceAction) => {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
const RealtimeMessaging = require('../utils/RealtimeMessaging')
|
|
137
|
+
const EventStats = require('../utils/EventStats')
|
|
137
138
|
const AppMachineTransition = require('../utils/AppMachineTransition')
|
|
138
139
|
const DisplayEventProcessor = require('../utils/DisplayEventProcessor')
|
|
139
140
|
const PugHelpers = require('../utils/PugHelpers')
|
|
@@ -144,6 +145,34 @@ const AnearParticipant = require('../models/AnearParticipant')
|
|
|
144
145
|
|
|
145
146
|
const CurrentDateTimestamp = _ => new Date().getTime()
|
|
146
147
|
|
|
148
|
+
// Event stats: AEM-only, for metering. Never passed to AppM. Shape matches persisted event_stats.
|
|
149
|
+
// event channel: we publish and may receive → publishCount, bytesPublished, receivedCount, bytesReceived.
|
|
150
|
+
// actions channel: we receive ACTIONS from clients → receivedCount, bytesReceived.
|
|
151
|
+
// Other channels: we publish only → publishCount, bytesPublished.
|
|
152
|
+
const CHANNEL_NAMES_PUBLISH_ONLY = ['participants', 'spectators', 'private']
|
|
153
|
+
function createInitialEventStats() {
|
|
154
|
+
const channels = {}
|
|
155
|
+
CHANNEL_NAMES_PUBLISH_ONLY.forEach(name => {
|
|
156
|
+
channels[name] = { publishCount: 0, bytesPublished: 0 }
|
|
157
|
+
})
|
|
158
|
+
channels.event = { publishCount: 0, bytesPublished: 0, receivedCount: 0, bytesReceived: 0 }
|
|
159
|
+
channels.actions = { receivedCount: 0, bytesReceived: 0 }
|
|
160
|
+
return {
|
|
161
|
+
liveDurationMs: 0,
|
|
162
|
+
liveStartedAt: null,
|
|
163
|
+
channels
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Serializable snapshot for persistence (omit liveStartedAt).
|
|
168
|
+
function serializeEventStats(eventStats) {
|
|
169
|
+
if (!eventStats || !eventStats.channels) return null
|
|
170
|
+
return {
|
|
171
|
+
liveDurationMs: eventStats.liveDurationMs ?? 0,
|
|
172
|
+
channels: { ...eventStats.channels }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
147
176
|
const AnearEventMachineContext = (
|
|
148
177
|
anearEvent,
|
|
149
178
|
coreServiceMachine,
|
|
@@ -174,7 +203,8 @@ const AnearEventMachineContext = (
|
|
|
174
203
|
pendingCancelConfirmations: null, // Set of participantIds waiting for TIMER_CANCELED confirmation
|
|
175
204
|
pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
|
|
176
205
|
consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
|
|
177
|
-
consecutiveAllParticipantsTimeoutCount: 0 // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
|
|
206
|
+
consecutiveAllParticipantsTimeoutCount: 0, // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
|
|
207
|
+
eventStats: null // Set on createAppEventMachine onDone (initial or rehydrated); createInitialEventStats() shape
|
|
178
208
|
})
|
|
179
209
|
|
|
180
210
|
const ActiveEventGlobalEvents = {
|
|
@@ -189,11 +219,12 @@ const ActiveEventGlobalEvents = {
|
|
|
189
219
|
CLOSE: {
|
|
190
220
|
// AppM has reached a final state or explicitly called closeEvent()
|
|
191
221
|
// initiate orderly shutdown
|
|
222
|
+
actions: ['addLiveDurationSegment'],
|
|
192
223
|
target: '#activeEvent.closeEvent'
|
|
193
224
|
},
|
|
194
225
|
CANCEL: {
|
|
195
226
|
// appM does an abrupt shutdown of the event
|
|
196
|
-
actions: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} CANCEL received`),
|
|
227
|
+
actions: ['addLiveDurationSegment', ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} CANCEL received`)],
|
|
197
228
|
target: '#canceled'
|
|
198
229
|
},
|
|
199
230
|
APPM_FINAL: {
|
|
@@ -341,7 +372,7 @@ const ActiveEventStatesConfig = {
|
|
|
341
372
|
id: 'waitingAnnounce',
|
|
342
373
|
after: {
|
|
343
374
|
timeoutEventAnnounce: {
|
|
344
|
-
actions: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} TIMED OUT waiting for ANNOUNCE`),
|
|
375
|
+
actions: ['addLiveDurationSegment', ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} TIMED OUT waiting for ANNOUNCE`)],
|
|
345
376
|
target: '#canceled'
|
|
346
377
|
}
|
|
347
378
|
},
|
|
@@ -350,12 +381,15 @@ const ActiveEventStatesConfig = {
|
|
|
350
381
|
target: '#activeEvent.announce'
|
|
351
382
|
},
|
|
352
383
|
START: {
|
|
384
|
+
actions: ['startLiveDuration'],
|
|
353
385
|
target: '#activeEvent.live'
|
|
354
386
|
},
|
|
355
387
|
PAUSE: {
|
|
388
|
+
actions: ['addLiveDurationSegment'],
|
|
356
389
|
target: '#pausingEvent'
|
|
357
390
|
},
|
|
358
391
|
SAVE: {
|
|
392
|
+
actions: ['addLiveDurationSegment'],
|
|
359
393
|
target: 'savingAppEventContext'
|
|
360
394
|
}
|
|
361
395
|
}
|
|
@@ -528,13 +562,13 @@ const ActiveEventStatesConfig = {
|
|
|
528
562
|
entry: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} created → announce`),
|
|
529
563
|
after: {
|
|
530
564
|
timeoutEventStart: {
|
|
531
|
-
actions: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} TIMED OUT waiting for START`),
|
|
565
|
+
actions: ['addLiveDurationSegment', ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} TIMED OUT waiting for START`)],
|
|
532
566
|
target: '#canceled'
|
|
533
567
|
}
|
|
534
568
|
},
|
|
535
569
|
on: {
|
|
536
570
|
START: {
|
|
537
|
-
actions: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} announce → live`),
|
|
571
|
+
actions: ['startLiveDuration', ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} announce → live`)],
|
|
538
572
|
target: '#activeEvent.live'
|
|
539
573
|
}
|
|
540
574
|
}
|
|
@@ -902,25 +936,15 @@ const ActiveEventStatesConfig = {
|
|
|
902
936
|
closeEvent: {
|
|
903
937
|
id: 'closeEvent',
|
|
904
938
|
entry: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} live → closed`),
|
|
905
|
-
initial: '
|
|
939
|
+
initial: 'savingAppEventContext',
|
|
906
940
|
on: {
|
|
907
941
|
APPM_FINAL: {
|
|
908
942
|
actions: () => logger.debug('[AEM] Ignoring APPM_FINAL during closeEvent.')
|
|
909
943
|
}
|
|
910
944
|
},
|
|
911
945
|
states: {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
{
|
|
915
|
-
guard: ({ event }) => {
|
|
916
|
-
const c = event?.appmContext?.context
|
|
917
|
-
return !!(c && typeof c === 'object')
|
|
918
|
-
},
|
|
919
|
-
target: 'savingAppEventContext'
|
|
920
|
-
},
|
|
921
|
-
{ target: 'notifyingParticipants' }
|
|
922
|
-
]
|
|
923
|
-
},
|
|
946
|
+
// Always persist event_stats on close (with full or empty app context).
|
|
947
|
+
// saveAppEventContext includes event_stats; appmContext may be {} or { context, resumeEvent }.
|
|
924
948
|
savingAppEventContext: {
|
|
925
949
|
invoke: {
|
|
926
950
|
src: 'saveAppEventContext',
|
|
@@ -1428,6 +1452,26 @@ const AnearEventMachineFunctions = ({
|
|
|
1428
1452
|
},
|
|
1429
1453
|
appMachineTransition: ({ event }) => {
|
|
1430
1454
|
return event?.output?.appMachineTransition ?? event?.data?.appMachineTransition ?? null
|
|
1455
|
+
},
|
|
1456
|
+
eventStats: ({ event, context }) => {
|
|
1457
|
+
const fromOutput = event?.output?.eventStats ?? event?.data?.eventStats
|
|
1458
|
+
return fromOutput ?? context.eventStats ?? createInitialEventStats()
|
|
1459
|
+
}
|
|
1460
|
+
}),
|
|
1461
|
+
startLiveDuration: assign({
|
|
1462
|
+
eventStats: ({ context }) => {
|
|
1463
|
+
const s = context.eventStats
|
|
1464
|
+
if (!s || !s.channels) return s
|
|
1465
|
+
return { ...s, liveStartedAt: Date.now() }
|
|
1466
|
+
}
|
|
1467
|
+
}),
|
|
1468
|
+
addLiveDurationSegment: assign({
|
|
1469
|
+
eventStats: ({ context }) => {
|
|
1470
|
+
const s = context.eventStats
|
|
1471
|
+
if (!s || !s.channels) return s
|
|
1472
|
+
const now = Date.now()
|
|
1473
|
+
const add = (s.liveStartedAt != null) ? (now - s.liveStartedAt) : 0
|
|
1474
|
+
return { ...s, liveDurationMs: (s.liveDurationMs || 0) + add, liveStartedAt: null }
|
|
1431
1475
|
}
|
|
1432
1476
|
}),
|
|
1433
1477
|
notifyAppMachineRendered: ({ context }) => {
|
|
@@ -1486,13 +1530,21 @@ const AnearEventMachineFunctions = ({
|
|
|
1486
1530
|
)
|
|
1487
1531
|
},
|
|
1488
1532
|
subscribeToActionMessages: ({ context, self }) => {
|
|
1489
|
-
RealtimeMessaging.subscribe(
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1533
|
+
RealtimeMessaging.subscribe(context.actionsChannel, self, 'ACTION', {
|
|
1534
|
+
onMessage: (message, actor) => {
|
|
1535
|
+
const ctx = actor.getSnapshot().context
|
|
1536
|
+
EventStats.recordReceived(ctx.eventStats, ctx.actionsChannel, message.data)
|
|
1537
|
+
}
|
|
1538
|
+
})
|
|
1539
|
+
},
|
|
1540
|
+
subscribeToEventMessages: ({ context, self }) => {
|
|
1541
|
+
RealtimeMessaging.subscribe(context.eventChannel, self, null, {
|
|
1542
|
+
onMessage: (message, actor) => {
|
|
1543
|
+
const ctx = actor.getSnapshot().context
|
|
1544
|
+
EventStats.recordReceived(ctx.eventStats, ctx.eventChannel, message.data)
|
|
1545
|
+
}
|
|
1546
|
+
})
|
|
1494
1547
|
},
|
|
1495
|
-
subscribeToEventMessages: ({ context, self }) => RealtimeMessaging.subscribe(context.eventChannel, self),
|
|
1496
1548
|
createParticipantsDisplayChannel: assign({
|
|
1497
1549
|
participantsDisplayChannel: ({ context, self }) => RealtimeMessaging.getChannel(
|
|
1498
1550
|
context.anearEvent.participantsChannelName,
|
|
@@ -2281,14 +2333,17 @@ const AnearEventMachineFunctions = ({
|
|
|
2281
2333
|
actors: {
|
|
2282
2334
|
saveAppEventContext: fromPromise(async ({ input }) => {
|
|
2283
2335
|
const { context, event } = input
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2336
|
+
// PAUSE/SAVE/CLOSE may include appmContext: { context, resumeEvent }
|
|
2337
|
+
// Always include event_stats when present (AEM flushes metering on every save)
|
|
2286
2338
|
const appmContext = event?.appmContext || {}
|
|
2287
2339
|
const payload = {
|
|
2288
2340
|
eventId: context.anearEvent.id,
|
|
2289
2341
|
savedAt: new Date().toISOString(),
|
|
2290
2342
|
...appmContext
|
|
2291
2343
|
}
|
|
2344
|
+
if (context.eventStats) {
|
|
2345
|
+
payload.event_stats = serializeEventStats(context.eventStats)
|
|
2346
|
+
}
|
|
2292
2347
|
await AnearApi.saveAppEventContext(context.anearEvent.id, payload)
|
|
2293
2348
|
return 'done'
|
|
2294
2349
|
}),
|
|
@@ -2306,6 +2361,7 @@ const AnearEventMachineFunctions = ({
|
|
|
2306
2361
|
let machineToStart = baseMachine
|
|
2307
2362
|
let actorInput = undefined
|
|
2308
2363
|
let resumeEvent = null
|
|
2364
|
+
let eventStats = createInitialEventStats()
|
|
2309
2365
|
|
|
2310
2366
|
if (context.rehydrate) {
|
|
2311
2367
|
try {
|
|
@@ -2318,6 +2374,35 @@ const AnearEventMachineFunctions = ({
|
|
|
2318
2374
|
// (AppM machine config should define `context: ({ input }) => ...`).
|
|
2319
2375
|
actorInput = savedContext
|
|
2320
2376
|
}
|
|
2377
|
+
// AEM-only: restore event_stats for metering; never pass to AppM
|
|
2378
|
+
if (appmContext.event_stats && typeof appmContext.event_stats === 'object') {
|
|
2379
|
+
const raw = appmContext.event_stats
|
|
2380
|
+
eventStats = {
|
|
2381
|
+
liveDurationMs: typeof raw.liveDurationMs === 'number' ? raw.liveDurationMs : 0,
|
|
2382
|
+
liveStartedAt: null,
|
|
2383
|
+
channels: { ...createInitialEventStats().channels }
|
|
2384
|
+
}
|
|
2385
|
+
if (raw.channels && typeof raw.channels === 'object') {
|
|
2386
|
+
Object.keys(raw.channels).forEach(key => {
|
|
2387
|
+
const c = raw.channels[key]
|
|
2388
|
+
if (!c) return
|
|
2389
|
+
if (key === 'actions' && typeof c.receivedCount === 'number' && typeof c.bytesReceived === 'number') {
|
|
2390
|
+
eventStats.channels[key] = { receivedCount: c.receivedCount, bytesReceived: c.bytesReceived }
|
|
2391
|
+
} else if (key === 'event') {
|
|
2392
|
+
const pub = typeof c.publishCount === 'number' && typeof c.bytesPublished === 'number'
|
|
2393
|
+
const rec = typeof c.receivedCount === 'number' && typeof c.bytesReceived === 'number'
|
|
2394
|
+
eventStats.channels[key] = {
|
|
2395
|
+
publishCount: pub ? c.publishCount : 0,
|
|
2396
|
+
bytesPublished: pub ? c.bytesPublished : 0,
|
|
2397
|
+
receivedCount: rec ? c.receivedCount : 0,
|
|
2398
|
+
bytesReceived: rec ? c.bytesReceived : 0
|
|
2399
|
+
}
|
|
2400
|
+
} else if (typeof c.publishCount === 'number' && typeof c.bytesPublished === 'number') {
|
|
2401
|
+
eventStats.channels[key] = { publishCount: c.publishCount, bytesPublished: c.bytesPublished }
|
|
2402
|
+
}
|
|
2403
|
+
})
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2321
2406
|
}
|
|
2322
2407
|
} catch (e) {
|
|
2323
2408
|
// Log and proceed without rehydration
|
|
@@ -2464,7 +2549,7 @@ const AnearEventMachineFunctions = ({
|
|
|
2464
2549
|
appActor.send(resumeEvent)
|
|
2465
2550
|
}
|
|
2466
2551
|
|
|
2467
|
-
return { service: appActor, appMachineTransition }
|
|
2552
|
+
return { service: appActor, appMachineTransition, eventStats }
|
|
2468
2553
|
}),
|
|
2469
2554
|
renderDisplay: fromPromise(async ({ input }) => {
|
|
2470
2555
|
const { context, event } = input
|
|
@@ -2541,6 +2626,7 @@ const AnearEventMachineFunctions = ({
|
|
|
2541
2626
|
// without terminating the event or detaching channels.
|
|
2542
2627
|
await AnearApi.transitionEvent(context.anearEvent.id, 'restart')
|
|
2543
2628
|
|
|
2629
|
+
EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'announce' } })
|
|
2544
2630
|
const publishPromise = RealtimeMessaging.publish(
|
|
2545
2631
|
context.eventChannel,
|
|
2546
2632
|
'EVENT_TRANSITION',
|
|
@@ -2563,6 +2649,7 @@ const AnearEventMachineFunctions = ({
|
|
|
2563
2649
|
// Transition the event to 'live' via ANAPI and publish an EVENT_TRANSITION
|
|
2564
2650
|
// message so ABRs can react immediately (e.g., auto-close expanded QR).
|
|
2565
2651
|
const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'live')
|
|
2652
|
+
EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'live' } })
|
|
2566
2653
|
const publishPromise = RealtimeMessaging.publish(
|
|
2567
2654
|
context.eventChannel,
|
|
2568
2655
|
'EVENT_TRANSITION',
|
|
@@ -2579,16 +2666,23 @@ const AnearEventMachineFunctions = ({
|
|
|
2579
2666
|
// and the publishing of the 'EVENT_TRANSITION' message to ABRs.
|
|
2580
2667
|
// It's a promise that resolves when both operations are complete.
|
|
2581
2668
|
const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'closed')
|
|
2669
|
+
EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'closed' } })
|
|
2582
2670
|
const publishPromise = RealtimeMessaging.publish(context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'closed' } })
|
|
2583
2671
|
await Promise.all([transitionPromise, publishPromise])
|
|
2584
2672
|
return 'done' // Indicate completion
|
|
2585
2673
|
}),
|
|
2586
2674
|
eventTransitionCanceled: fromPromise(async ({ input }) => {
|
|
2587
2675
|
const { context } = input
|
|
2588
|
-
//
|
|
2589
|
-
|
|
2590
|
-
|
|
2676
|
+
// Persist event_stats once on cancel (no app context to save).
|
|
2677
|
+
const statsPayload = context.eventStats
|
|
2678
|
+
? { eventId: context.anearEvent.id, savedAt: new Date().toISOString(), event_stats: serializeEventStats(context.eventStats) }
|
|
2679
|
+
: null
|
|
2680
|
+
if (statsPayload) {
|
|
2681
|
+
await AnearApi.saveAppEventContext(context.anearEvent.id, statsPayload)
|
|
2682
|
+
}
|
|
2683
|
+
// Transition the event to 'canceled' via AnearApi and publish EVENT_TRANSITION to ABRs.
|
|
2591
2684
|
const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'canceled')
|
|
2685
|
+
EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'canceled' } })
|
|
2592
2686
|
const publishPromise = RealtimeMessaging.publish(context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'canceled' } })
|
|
2593
2687
|
await Promise.all([transitionPromise, publishPromise])
|
|
2594
2688
|
return 'done' // Indicate completion
|
|
@@ -99,10 +99,11 @@ const { assign, createMachine, createActor, fromPromise } = require('xstate')
|
|
|
99
99
|
const C = require('../utils/Constants')
|
|
100
100
|
|
|
101
101
|
const RealtimeMessaging = require('../utils/RealtimeMessaging')
|
|
102
|
+
const EventStats = require('../utils/EventStats')
|
|
102
103
|
|
|
103
104
|
const CurrentDateTimestamp = _ => new Date().getTime()
|
|
104
105
|
|
|
105
|
-
const AnearParticipantMachineContext = (anearParticipant, anearEvent, appParticipantMachineFactory) => ({
|
|
106
|
+
const AnearParticipantMachineContext = (anearParticipant, anearEvent, appParticipantMachineFactory, eventStats = null) => ({
|
|
106
107
|
anearEvent,
|
|
107
108
|
anearParticipant,
|
|
108
109
|
privateChannel: null,
|
|
@@ -117,7 +118,8 @@ const AnearParticipantMachineContext = (anearParticipant, anearEvent, appPartici
|
|
|
117
118
|
// would be dropped (no handler in that state). We keep the latest and flush it
|
|
118
119
|
// immediately after the current publish completes.
|
|
119
120
|
queuedRenderDisplay: null,
|
|
120
|
-
lastSeen: CurrentDateTimestamp()
|
|
121
|
+
lastSeen: CurrentDateTimestamp(),
|
|
122
|
+
eventStats // AEM-only metering; optional for EventStats.recordPublish before publish
|
|
121
123
|
})
|
|
122
124
|
|
|
123
125
|
const AnearParticipantMachineConfig = participantId => ({
|
|
@@ -653,6 +655,7 @@ const AnearParticipantMachineFunctions = {
|
|
|
653
655
|
publishPrivateDisplay: fromPromise(async ({ input }) => {
|
|
654
656
|
const { context, event } = input
|
|
655
657
|
const displayMessage = { content: event.content }
|
|
658
|
+
EventStats.recordPublish(context.eventStats, context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
|
|
656
659
|
await RealtimeMessaging.publish(context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
|
|
657
660
|
return { timeout: event.timeout }
|
|
658
661
|
}),
|
|
@@ -664,6 +667,7 @@ const AnearParticipantMachineFunctions = {
|
|
|
664
667
|
reason: reason || 'You have been removed from the event.'
|
|
665
668
|
}
|
|
666
669
|
}
|
|
670
|
+
EventStats.recordPublish(context.eventStats, context.privateChannel, 'FORCE_SHUTDOWN', payload)
|
|
667
671
|
await RealtimeMessaging.publish(context.privateChannel, 'FORCE_SHUTDOWN', payload)
|
|
668
672
|
return 'done'
|
|
669
673
|
}),
|
|
@@ -714,7 +718,7 @@ const AnearParticipantMachineFunctions = {
|
|
|
714
718
|
// 4. handles activity state, response timeouts, idle state
|
|
715
719
|
// 5. receives ACTION events relayed by the AnearEventMachine
|
|
716
720
|
// 6. relays all relevant events to the participant XState Machine for Application-specific handling
|
|
717
|
-
const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantMachineFactory }) => {
|
|
721
|
+
const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantMachineFactory, eventStats }) => {
|
|
718
722
|
const anearParticipantMachine = createMachine(
|
|
719
723
|
{
|
|
720
724
|
...AnearParticipantMachineConfig(anearParticipant.id),
|
|
@@ -727,7 +731,8 @@ const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantM
|
|
|
727
731
|
const anearParticipantMachineContext = AnearParticipantMachineContext(
|
|
728
732
|
anearParticipant,
|
|
729
733
|
anearEvent,
|
|
730
|
-
appParticipantMachineFactory
|
|
734
|
+
appParticipantMachineFactory,
|
|
735
|
+
eventStats
|
|
731
736
|
)
|
|
732
737
|
|
|
733
738
|
const actor = createActor(anearParticipantMachine, { input: anearParticipantMachineContext })
|
|
@@ -122,7 +122,12 @@ const AppMachineTransition = (anearEvent) => {
|
|
|
122
122
|
|
|
123
123
|
if (event.type === 'RENDERED') {
|
|
124
124
|
// Re-examine meta on RENDERED; send RENDER_DISPLAY only when state/context actually changed.
|
|
125
|
-
|
|
125
|
+
// Exception: eachParticipant states re-send once on RENDERED so participants who had a
|
|
126
|
+
// display queued (e.g. joined while another was publishing) still get the view. No opt-in.
|
|
127
|
+
const hasEachParticipant = (rawMeta && typeof rawMeta === 'object' && rawMeta.eachParticipant != null) ||
|
|
128
|
+
metaObjects.some(m => m && typeof m === 'object' && m.eachParticipant != null)
|
|
129
|
+
const sameSignature = baseSignature === lastBaseSignature
|
|
130
|
+
if (sameSignature && !hasEachParticipant) {
|
|
126
131
|
logger.debug(`[AppMachineTransition] RENDERED re-examination: no change, skipping RENDER_DISPLAY stateName='${stateName}'`)
|
|
127
132
|
return
|
|
128
133
|
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
const logger = require('./Logger')
|
|
42
42
|
const RealtimeMessaging = require('./RealtimeMessaging')
|
|
43
|
+
const EventStats = require('./EventStats')
|
|
43
44
|
const C = require('./Constants')
|
|
44
45
|
|
|
45
46
|
class DisplayEventProcessor {
|
|
@@ -54,6 +55,7 @@ class DisplayEventProcessor {
|
|
|
54
55
|
this.spectatorsDisplayChannel = anearEventMachineContext.spectatorsDisplayChannel
|
|
55
56
|
this.participants = anearEventMachineContext.participants
|
|
56
57
|
this.participantsIndex = this._buildParticipantsIndex(anearEventMachineContext.participants)
|
|
58
|
+
this.eventStats = anearEventMachineContext.eventStats ?? null
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
processAndPublish(displayEvents, cancelActionType = null) {
|
|
@@ -168,6 +170,7 @@ class DisplayEventProcessor {
|
|
|
168
170
|
logger.debug(`[DisplayEventProcessor] no allParticipants timeout resolved`)
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
EventStats.recordPublish(this.eventStats, this.participantsDisplayChannel, 'PARTICIPANTS_DISPLAY', formattedDisplayMessage())
|
|
171
174
|
publishPromise = RealtimeMessaging.publish(
|
|
172
175
|
this.participantsDisplayChannel,
|
|
173
176
|
'PARTICIPANTS_DISPLAY',
|
|
@@ -211,11 +214,12 @@ class DisplayEventProcessor {
|
|
|
211
214
|
logger.debug(`[DisplayEventProcessor] spectators visual timeout set to msecs=${specTimeout.msecs}, remaining=${specTimeout.remainingMsecs}`)
|
|
212
215
|
}
|
|
213
216
|
|
|
217
|
+
EventStats.recordPublish(this.eventStats, this.spectatorsDisplayChannel, 'SPECTATORS_DISPLAY', formattedDisplayMessage())
|
|
214
218
|
publishPromise = RealtimeMessaging.publish(
|
|
215
219
|
this.spectatorsDisplayChannel,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
'SPECTATORS_DISPLAY',
|
|
221
|
+
formattedDisplayMessage()
|
|
222
|
+
)
|
|
219
223
|
break
|
|
220
224
|
|
|
221
225
|
case 'eachParticipant':
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event stats metering for realtime publishes. Invoke recordPublish before each
|
|
5
|
+
* RealtimeMessaging.publish so the event's stats (per-channel publish count and
|
|
6
|
+
* bytes) are updated. RealtimeMessaging does not know about event stats.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts a short channel name from the full channel name (same mapping as RTM).
|
|
11
|
+
* @param {string} channelName - Full channel name (e.g. "anear:appId:e:eventId:actions")
|
|
12
|
+
* @returns {string} - Short key (e.g. "actions", "private", "participants", "spectators", "event")
|
|
13
|
+
*/
|
|
14
|
+
function getChannelShortName(channelName) {
|
|
15
|
+
if (!channelName) return 'unknown'
|
|
16
|
+
|
|
17
|
+
const parts = channelName.split(':')
|
|
18
|
+
|
|
19
|
+
if (parts.length >= 4 && parts[2] === 'e') {
|
|
20
|
+
if (parts.length === 5) return parts[4]
|
|
21
|
+
if (parts.length === 6 && parts[4] === 'private') return 'private'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parts.length === 3 && parts[2] === 'e') return 'events'
|
|
25
|
+
|
|
26
|
+
return parts[parts.length - 1] || channelName
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ACTIONS_CHANNEL_KEY = 'actions'
|
|
30
|
+
const EVENT_CHANNEL_KEY = 'event'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default shape for a channel that receives messages (e.g. actions channel).
|
|
34
|
+
* Server receives ACTIONS from clients on the actions channel; we track receivedCount/bytesReceived.
|
|
35
|
+
*/
|
|
36
|
+
function defaultReceivedChannelShape() {
|
|
37
|
+
return { receivedCount: 0, bytesReceived: 0 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default shape for a channel we publish to (participants, spectators, private).
|
|
42
|
+
*/
|
|
43
|
+
function defaultPublishChannelShape() {
|
|
44
|
+
return { publishCount: 0, bytesPublished: 0 }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default shape for a channel that both publishes and receives (e.g. event channel).
|
|
49
|
+
*/
|
|
50
|
+
function defaultDualChannelShape() {
|
|
51
|
+
return { publishCount: 0, bytesPublished: 0, receivedCount: 0, bytesReceived: 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record one publish for event stats. Call before RealtimeMessaging.publish.
|
|
56
|
+
* No-op if eventStats is null/undefined or missing channels.
|
|
57
|
+
* Not used for the actions channel (server does not publish to actions).
|
|
58
|
+
*
|
|
59
|
+
* @param {{ channels: Record<string, { publishCount: number, bytesPublished: number } | { receivedCount: number, bytesReceived: number }> } | null | undefined} eventStats - AEM context.eventStats
|
|
60
|
+
* @param {{ name: string } | string} channel - Ably channel (with .name) or channel name string
|
|
61
|
+
* @param {string} msgType - Message type (for consistency; bytes are from message payload)
|
|
62
|
+
* @param {object} message - The message object being published (stringified for byte count)
|
|
63
|
+
*/
|
|
64
|
+
function recordPublish(eventStats, channel, msgType, message) {
|
|
65
|
+
if (!eventStats || !eventStats.channels || typeof eventStats.channels !== 'object') return
|
|
66
|
+
|
|
67
|
+
const channelName = typeof channel === 'string' ? channel : (channel && channel.name)
|
|
68
|
+
const shortName = getChannelShortName(channelName)
|
|
69
|
+
|
|
70
|
+
if (!eventStats.channels[shortName]) {
|
|
71
|
+
eventStats.channels[shortName] = shortName === EVENT_CHANNEL_KEY ? defaultDualChannelShape() : defaultPublishChannelShape()
|
|
72
|
+
}
|
|
73
|
+
const ch = eventStats.channels[shortName]
|
|
74
|
+
if ('publishCount' in ch && 'bytesPublished' in ch) {
|
|
75
|
+
ch.publishCount += 1
|
|
76
|
+
const payload = typeof message === 'string' ? message : JSON.stringify(message)
|
|
77
|
+
const bytes = typeof Buffer !== 'undefined' ? Buffer.byteLength(payload, 'utf8') : new TextEncoder().encode(payload).length
|
|
78
|
+
ch.bytesPublished += bytes
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Record one received message on a channel. Used for actions channel (ACTION from clients) and
|
|
84
|
+
* event channel (any message received on the event channel). No-op if eventStats is null/undefined.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ channels: Record<string, object> } | null | undefined} eventStats - AEM context.eventStats
|
|
87
|
+
* @param {{ name: string } | string} channel - Ably channel (or channel short name)
|
|
88
|
+
* @param {object} message - The received message payload (e.g. event.data or message.data)
|
|
89
|
+
*/
|
|
90
|
+
function recordReceived(eventStats, channel, message) {
|
|
91
|
+
if (!eventStats || !eventStats.channels || typeof eventStats.channels !== 'object') return
|
|
92
|
+
|
|
93
|
+
const channelName = typeof channel === 'string' ? channel : (channel && channel.name)
|
|
94
|
+
const shortName = getChannelShortName(channelName)
|
|
95
|
+
// Only actions and event channels track receives (event can have both publish and receive).
|
|
96
|
+
if (shortName !== ACTIONS_CHANNEL_KEY && shortName !== EVENT_CHANNEL_KEY) return
|
|
97
|
+
|
|
98
|
+
if (!eventStats.channels[shortName]) {
|
|
99
|
+
eventStats.channels[shortName] = shortName === EVENT_CHANNEL_KEY ? defaultDualChannelShape() : defaultReceivedChannelShape()
|
|
100
|
+
}
|
|
101
|
+
const ch = eventStats.channels[shortName]
|
|
102
|
+
if (!('receivedCount' in ch)) ch.receivedCount = 0
|
|
103
|
+
if (!('bytesReceived' in ch)) ch.bytesReceived = 0
|
|
104
|
+
ch.receivedCount += 1
|
|
105
|
+
const payload = typeof message === 'string' ? message : JSON.stringify(message)
|
|
106
|
+
const bytes = typeof Buffer !== 'undefined' ? Buffer.byteLength(payload, 'utf8') : new TextEncoder().encode(payload).length
|
|
107
|
+
ch.bytesReceived += bytes
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
ACTIONS_CHANNEL_KEY,
|
|
112
|
+
EVENT_CHANNEL_KEY,
|
|
113
|
+
getChannelShortName,
|
|
114
|
+
recordPublish,
|
|
115
|
+
recordReceived,
|
|
116
|
+
defaultReceivedChannelShape,
|
|
117
|
+
defaultPublishChannelShape,
|
|
118
|
+
defaultDualChannelShape
|
|
119
|
+
}
|
|
@@ -255,17 +255,26 @@ class RealtimeMessaging {
|
|
|
255
255
|
await Promise.all(detachPromises)
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
|
|
258
|
+
/**
|
|
259
|
+
* @param {Channel} channel
|
|
260
|
+
* @param {Actor} actor
|
|
261
|
+
* @param {string | null} eventName - If set, only subscribe to this message name
|
|
262
|
+
* @param {{ onMessage?: (message: object, actor: Actor) => void }} [options] - Optional; onMessage(message, actor) is called before forwarding to actor (e.g. for receive stats)
|
|
263
|
+
*/
|
|
264
|
+
subscribe(channel, actor, eventName = null, options = null) {
|
|
259
265
|
// Note: subscribing to an Ably channel will implicitly attach()
|
|
260
266
|
const key = `${actor.id}::${channel.name}::subscribe::${eventName || '*'}`
|
|
261
267
|
if (this._messageListenerKeys.has(key)) return
|
|
262
268
|
this._messageListenerKeys.add(key)
|
|
263
269
|
|
|
270
|
+
const onMessage = options?.onMessage
|
|
271
|
+
|
|
264
272
|
const args = []
|
|
265
273
|
|
|
266
274
|
if (eventName) args.push(eventName)
|
|
267
275
|
args.push(
|
|
268
276
|
message => {
|
|
277
|
+
if (typeof onMessage === 'function') onMessage(message, actor)
|
|
269
278
|
const participantId = message.data?.participantId || null
|
|
270
279
|
const participantIdStr = participantId ? ` participantId=${participantId}` : ''
|
|
271
280
|
const channelShortName = getChannelShortName(channel.name)
|