anear-js-api 1.5.1 → 1.6.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.
@@ -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: 'savingContextMaybe',
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
- savingContextMaybe: {
913
- always: [
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
- context.actionsChannel,
1491
- self,
1492
- 'ACTION'
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
- // Events like PAUSE/SAVE are sent as send('PAUSE', { appmContext: {...} })
2285
- // event.appmContext -> { context, resumeEvent }
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
@@ -2335,9 +2420,11 @@ const AnearEventMachineFunctions = ({
2335
2420
  }
2336
2421
 
2337
2422
  // Observe AppM transitions with full event payloads.
2338
- // v5 note: `subscribe()` only provides snapshots, which often do not include
2339
- // the triggering event. We use `inspect` so AppMachineTransition always gets
2340
- // (snapshot, event) for meta.eachParticipant(ctx, event) and timeout payloads.
2423
+ // inspect() is the v5 replacement for v4 onTransition for meta/view and RENDER_DISPLAY:
2424
+ // - @xstate.snapshot: run when the AppM produces a new snapshot (typical state transitions).
2425
+ // - @xstate.event: we defer a run for every event so internal/no-op transitions and
2426
+ // presence events (REFRESH_EVENT_TYPES) that may not emit a snapshot still trigger
2427
+ // meta scan; if a snapshot arrives for the same event, we skip the deferred run.
2341
2428
  const appMachineTransition = AppMachineTransition(context.anearEvent)
2342
2429
 
2343
2430
  const REFRESH_EVENT_TYPES = AppMachineTransition.REFRESH_EVENT_TYPES
@@ -2348,8 +2435,8 @@ const AnearEventMachineFunctions = ({
2348
2435
  : (fn) => Promise.resolve().then(fn)
2349
2436
 
2350
2437
  let appActor
2351
- let lastRefreshEvent = null
2352
- let lastRefreshHandledBySnapshot = false
2438
+ let lastDeferredEvent = null
2439
+ let lastDeferredHandledBySnapshot = false
2353
2440
  let sawAnySnapshot = false
2354
2441
 
2355
2442
  const inspect = (inspectionEvent) => {
@@ -2373,16 +2460,16 @@ const AnearEventMachineFunctions = ({
2373
2460
  // logging should never interrupt state processing
2374
2461
  }
2375
2462
  try {
2376
- appMachineTransition(snapshot, event)
2463
+ appMachineTransition(snapshot, event, { source: 'inspect.snapshot' })
2377
2464
  } catch (e) {
2378
2465
  logger.warn('[AEM] AppMachineTransition failed during inspect snapshot', e)
2379
2466
  }
2380
2467
 
2381
- // If we deferred a "refresh render" for this exact event, mark it as handled
2382
- // so the fallback won't run and double-render.
2383
- if (event && lastRefreshEvent && event === lastRefreshEvent) {
2384
- lastRefreshHandledBySnapshot = true
2385
- logger.debug('[AEM][inspect] refresh event handled by snapshot (suppress fallback)', {
2468
+ // If we deferred a run for this exact event, mark it as handled so the
2469
+ // event_deferred callback won't run and double-invoke AppMachineTransition.
2470
+ if (event && lastDeferredEvent && event === lastDeferredEvent) {
2471
+ lastDeferredHandledBySnapshot = true
2472
+ logger.debug('[AEM][inspect] event handled by snapshot (suppress event_deferred)', {
2386
2473
  eventType: event?.type,
2387
2474
  participantId: event?.participantId
2388
2475
  })
@@ -2393,9 +2480,13 @@ const AnearEventMachineFunctions = ({
2393
2480
  const ev = inspectionEvent.event
2394
2481
  if (!ev || !ev.type) return
2395
2482
 
2396
- // Some presence refresh events are internal/no-op transitions, which may not
2397
- // produce a snapshot update. Defer a refresh render and cancel it if a
2398
- // snapshot update for the same event happens in the same tick.
2483
+ // Run transition callback for every event (internal and external). Defer so that
2484
+ // if a snapshot is emitted for this event in the same tick, we use that path and
2485
+ // skip this deferred run to avoid double-render; dedupe in AppMachineTransition
2486
+ // handles any remaining redundancy.
2487
+ lastDeferredEvent = ev
2488
+ lastDeferredHandledBySnapshot = false
2489
+
2399
2490
  if (REFRESH_EVENT_TYPES.has(ev.type)) {
2400
2491
  try {
2401
2492
  logger.debug('[AEM][inspect] @xstate.event (refresh candidate)', {
@@ -2406,25 +2497,23 @@ const AnearEventMachineFunctions = ({
2406
2497
  } catch (_e) {
2407
2498
  // logging should never interrupt state processing
2408
2499
  }
2409
- lastRefreshEvent = ev
2410
- lastRefreshHandledBySnapshot = false
2500
+ }
2411
2501
 
2412
- defer(() => {
2413
- if (!appActor) return
2414
- if (lastRefreshEvent !== ev) return
2415
- if (lastRefreshHandledBySnapshot) return
2502
+ defer(() => {
2503
+ if (!appActor) return
2504
+ if (lastDeferredEvent !== ev) return
2505
+ if (lastDeferredHandledBySnapshot) return
2416
2506
 
2417
- logger.debug('[AEM][inspect] refresh fallback firing (no snapshot update)', {
2418
- eventType: ev?.type,
2419
- participantId: ev?.participantId
2420
- })
2421
- try {
2422
- appMachineTransition(appActor.getSnapshot(), ev)
2423
- } catch (e) {
2424
- logger.warn('[AEM] AppMachineTransition failed during inspect refresh fallback', e)
2425
- }
2507
+ logger.debug('[AEM][inspect] event_deferred firing (no snapshot for this event)', {
2508
+ eventType: ev?.type,
2509
+ participantId: ev?.participantId
2426
2510
  })
2427
- }
2511
+ try {
2512
+ appMachineTransition(appActor.getSnapshot(), ev, { source: 'inspect.event_deferred' })
2513
+ } catch (e) {
2514
+ logger.warn('[AEM] AppMachineTransition failed during inspect event_deferred', e)
2515
+ }
2516
+ })
2428
2517
  }
2429
2518
  }
2430
2519
 
@@ -2441,7 +2530,7 @@ const AnearEventMachineFunctions = ({
2441
2530
  if (sawAnySnapshot) return
2442
2531
  try {
2443
2532
  logger.debug('[AEM][inspect] init snapshot fallback (no @xstate.snapshot observed yet)')
2444
- appMachineTransition(appActor.getSnapshot(), { type: 'xstate.init' })
2533
+ appMachineTransition(appActor.getSnapshot(), { type: 'xstate.init' }, { source: 'inspect.init_fallback' })
2445
2534
  } catch (e) {
2446
2535
  logger.warn('[AEM] AppMachineTransition failed on initial snapshot fallback', e)
2447
2536
  }
@@ -2460,7 +2549,7 @@ const AnearEventMachineFunctions = ({
2460
2549
  appActor.send(resumeEvent)
2461
2550
  }
2462
2551
 
2463
- return { service: appActor, appMachineTransition }
2552
+ return { service: appActor, appMachineTransition, eventStats }
2464
2553
  }),
2465
2554
  renderDisplay: fromPromise(async ({ input }) => {
2466
2555
  const { context, event } = input
@@ -2537,6 +2626,7 @@ const AnearEventMachineFunctions = ({
2537
2626
  // without terminating the event or detaching channels.
2538
2627
  await AnearApi.transitionEvent(context.anearEvent.id, 'restart')
2539
2628
 
2629
+ EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'announce' } })
2540
2630
  const publishPromise = RealtimeMessaging.publish(
2541
2631
  context.eventChannel,
2542
2632
  'EVENT_TRANSITION',
@@ -2559,6 +2649,7 @@ const AnearEventMachineFunctions = ({
2559
2649
  // Transition the event to 'live' via ANAPI and publish an EVENT_TRANSITION
2560
2650
  // message so ABRs can react immediately (e.g., auto-close expanded QR).
2561
2651
  const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'live')
2652
+ EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'live' } })
2562
2653
  const publishPromise = RealtimeMessaging.publish(
2563
2654
  context.eventChannel,
2564
2655
  'EVENT_TRANSITION',
@@ -2575,16 +2666,23 @@ const AnearEventMachineFunctions = ({
2575
2666
  // and the publishing of the 'EVENT_TRANSITION' message to ABRs.
2576
2667
  // It's a promise that resolves when both operations are complete.
2577
2668
  const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'closed')
2669
+ EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'closed' } })
2578
2670
  const publishPromise = RealtimeMessaging.publish(context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'closed' } })
2579
2671
  await Promise.all([transitionPromise, publishPromise])
2580
2672
  return 'done' // Indicate completion
2581
2673
  }),
2582
2674
  eventTransitionCanceled: fromPromise(async ({ input }) => {
2583
2675
  const { context } = input
2584
- // This service handles the transition of the event to 'canceled' via AnearApi
2585
- // and the publishing of the 'EVENT_TRANSITION' message to ABRs.
2586
- // It's a promise that resolves when both operations are complete.
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.
2587
2684
  const transitionPromise = AnearApi.transitionEvent(context.anearEvent.id, 'canceled')
2685
+ EventStats.recordPublish(context.eventStats, context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'canceled' } })
2588
2686
  const publishPromise = RealtimeMessaging.publish(context.eventChannel, 'EVENT_TRANSITION', { content: { state: 'canceled' } })
2589
2687
  await Promise.all([transitionPromise, publishPromise])
2590
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 => ({
@@ -253,9 +255,19 @@ const AnearParticipantMachineConfig = participantId => ({
253
255
  on: {
254
256
  // Buffer the latest render request if we are currently publishing.
255
257
  // (Otherwise, RENDER_DISPLAY is unhandled in this state and gets dropped.)
256
- RENDER_DISPLAY: {
257
- actions: 'queueRenderDisplay'
258
- },
258
+ RENDER_DISPLAY: [
259
+ {
260
+ // If a display arrives while we're already publishing another display,
261
+ // it's safe to buffer *visual-only* refreshes (no timeout). However,
262
+ // buffering timed prompts can create timer/UI mismatches and indicates
263
+ // an AppM/AEM sequencing bug.
264
+ guard: 'incomingRenderHasTimeout',
265
+ actions: 'logBlockedRenderDuringPublish'
266
+ },
267
+ {
268
+ actions: 'queueRenderDisplay'
269
+ }
270
+ ],
259
271
  PARTICIPANT_EXIT: {
260
272
  actions: 'logExit',
261
273
  target: '#cleanupAndExit'
@@ -281,14 +293,12 @@ const AnearParticipantMachineConfig = participantId => ({
281
293
  initial: 'waiting',
282
294
  states: {
283
295
  waiting: {
284
- // The actual waiting state. RENDER_DISPLAY is blocked here
285
- // to prevent timer conflicts. AppM must cancel timers first.
296
+ // Actual waiting state. RENDER_DISPLAY (including refreshes with timeout) is
297
+ // allowed; updateActionTimeoutIfChanged / timer logic handles stop/reset.
286
298
  },
287
299
  rendering: {
288
- // NOTE: This nested state is currently unreachable as RENDER_DISPLAY
289
- // is blocked during waitParticipantResponse. Kept for potential future use or
290
- // other edge cases. The parent's `after: actionTimeout` timer continues running
291
- // during invoke if this state were to be entered.
300
+ // Refreshes (e.g. PARTICIPANT_DISCONNECT/RECONNECT) are processed here while
301
+ // the parent's `after: actionTimeout` timer continues running.
292
302
  invoke: {
293
303
  src: 'publishPrivateDisplay',
294
304
  input: ({ context, event }) => ({ context, event }),
@@ -315,22 +325,10 @@ const AnearParticipantMachineConfig = participantId => ({
315
325
  }
316
326
  },
317
327
  on: {
318
- RENDER_DISPLAY: [
319
- {
320
- // BLOCKED: RENDER_DISPLAY that carries a NEW timeout while we're already
321
- // waiting on an ACTION is not allowed (it would reset / conflict timers).
322
- // The AppM must first cancel timers (via cancelAllParticipantTimeouts()
323
- // or cancel: "ACTION" in meta) and wait for ACK before issuing new
324
- // RENDER_DISPLAY with timeouts.
325
- guard: 'incomingRenderHasTimeout',
326
- actions: 'logBlockedRenderDuringWait'
327
- },
328
- {
329
- // Allowed: visual-only refresh (no timeout). We publish the display while
330
- // staying inside waitParticipantResponse so the parent after: timer continues.
331
- target: '.rendering'
332
- }
333
- ],
328
+ // Allow all RENDER_DISPLAY (refreshes, timeouts). Timer logic stops/resets as needed.
329
+ RENDER_DISPLAY: {
330
+ target: '.rendering'
331
+ },
334
332
  PARTICIPANT_EXIT: {
335
333
  actions: 'logExit',
336
334
  target: '#cleanupAndExit'
@@ -622,19 +620,13 @@ const AnearParticipantMachineFunctions = {
622
620
  logIgnoringActionCleanup: () => logger.debug('[APM] ignoring ACTION during cleanup'),
623
621
  logIgnoringRenderDisplayDone: () => logger.debug('[APM] ignoring RENDER_DISPLAY in final state'),
624
622
  logIgnoringEventDone: ({ event }) => logger.debug('[APM] ignoring event in final state: ', event.type),
625
- logBlockedRenderDuringWait: ({ context, event }) => {
626
- const eventType = event?.type ?? 'RENDER_DISPLAY'
623
+ logBlockedRenderDuringPublish: ({ context, event }) => {
624
+ const participantId = context?.anearParticipant?.id
627
625
  const timeout = event?.timeout ?? event?.data?.timeout
628
- const participantId = context.anearParticipant.id
629
- const remainingMsecs = context.actionTimeoutMsecs
630
- const timeoutInfo = timeout ? ` (new timeout: ${timeout}ms)` : ''
631
-
632
- logger.error(`[APM] ⚠️ BLOCKED: ${eventType} received during active timeout wait for participant ${participantId}. Current timer: ${remainingMsecs}ms remaining.${timeoutInfo}`)
633
- logger.error(`[APM] ⚠️ The AppM must first cancel all participant timers using one of these methods:`)
634
- logger.error(`[APM] ⚠️ 1. Declarative: Add cancel: "ACTION_NAME" to meta.eachParticipant or meta.allParticipants`)
635
- logger.error(`[APM] ⚠️ 2. Manual: Call anearEvent.cancelAllParticipantTimeouts() and wait for ACK`)
636
- logger.error(`[APM] ⚠️ Only after timers are cancelled (APM transitions to idle) can new RENDER_DISPLAY with timeouts be issued.`)
637
- logger.error(`[APM] ⚠️ This ${eventType} event is being IGNORED to prevent timer conflicts.`)
626
+ logger.error(
627
+ `[APM] ⚠️ BLOCKED: RENDER_DISPLAY (timeout=${timeout}ms) received while already publishing PRIVATE_DISPLAY for participant ${participantId}. ` +
628
+ `This indicates an AppM/AEM sequencing bug (timed prompts must not overlap in publish).`,
629
+ )
638
630
  }
639
631
  ,
640
632
  queueRenderDisplay: assign(({ context, event }) => {
@@ -663,6 +655,7 @@ const AnearParticipantMachineFunctions = {
663
655
  publishPrivateDisplay: fromPromise(async ({ input }) => {
664
656
  const { context, event } = input
665
657
  const displayMessage = { content: event.content }
658
+ EventStats.recordPublish(context.eventStats, context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
666
659
  await RealtimeMessaging.publish(context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
667
660
  return { timeout: event.timeout }
668
661
  }),
@@ -674,6 +667,7 @@ const AnearParticipantMachineFunctions = {
674
667
  reason: reason || 'You have been removed from the event.'
675
668
  }
676
669
  }
670
+ EventStats.recordPublish(context.eventStats, context.privateChannel, 'FORCE_SHUTDOWN', payload)
677
671
  await RealtimeMessaging.publish(context.privateChannel, 'FORCE_SHUTDOWN', payload)
678
672
  return 'done'
679
673
  }),
@@ -724,7 +718,7 @@ const AnearParticipantMachineFunctions = {
724
718
  // 4. handles activity state, response timeouts, idle state
725
719
  // 5. receives ACTION events relayed by the AnearEventMachine
726
720
  // 6. relays all relevant events to the participant XState Machine for Application-specific handling
727
- const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantMachineFactory }) => {
721
+ const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantMachineFactory, eventStats }) => {
728
722
  const anearParticipantMachine = createMachine(
729
723
  {
730
724
  ...AnearParticipantMachineConfig(anearParticipant.id),
@@ -737,7 +731,8 @@ const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantM
737
731
  const anearParticipantMachineContext = AnearParticipantMachineContext(
738
732
  anearParticipant,
739
733
  anearEvent,
740
- appParticipantMachineFactory
734
+ appParticipantMachineFactory,
735
+ eventStats
741
736
  )
742
737
 
743
738
  const actor = createActor(anearParticipantMachine, { input: anearParticipantMachineContext })
@@ -5,6 +5,9 @@ const RenderContextBuilder = require('./RenderContextBuilder')
5
5
 
6
6
  // Events that should force a "refresh render" even if the AppM did not
7
7
  // transition (no-op/internal transitions, presence refreshes, etc.).
8
+ // These are presence-related events the AEM forwards to the AppM; they may not
9
+ // produce an @xstate.snapshot (no-op on AppM), so the inspect event_deferred
10
+ // path in AnearEventMachine ensures meta is still scanned and RENDER_DISPLAY can run.
8
11
  const REFRESH_EVENT_TYPES = new Set([
9
12
  'SPECTATOR_ENTER',
10
13
  'PARTICIPANT_ENTER',
@@ -62,6 +65,15 @@ const _safeSignatureStringify = (value) => {
62
65
  * - Display events are content-only.
63
66
  * - Emits displayEvents → sends to AnearEventMachine for rendering.
64
67
  */
68
+ const VIEW_META_KEYS = ['allParticipants', 'eachParticipant', 'host', 'spectators']
69
+
70
+ const _hasViewMeta = (metaObjects) => {
71
+ if (!metaObjects || metaObjects.length === 0) return false
72
+ return metaObjects.some(meta =>
73
+ VIEW_META_KEYS.some(key => meta && meta[key] !== undefined && meta[key] !== null)
74
+ )
75
+ }
76
+
65
77
  const AppMachineTransition = (anearEvent) => {
66
78
  // v5 strategy:
67
79
  // - Render meta "once per stable meta state" (dedupe by state+meta+context)
@@ -69,8 +81,9 @@ const AppMachineTransition = (anearEvent) => {
69
81
  // - Never re-render on the RENDERED ack (prevents ping-pong loops)
70
82
  let lastBaseSignature = null
71
83
 
72
- return (appEventMachineState, triggeringEvent = null) => {
73
- // Handle potential XState version differences and missing properties
84
+ return (appEventMachineState, triggeringEvent = null, invocationContext = {}) => {
85
+ // Handle potential XState version differences and missing properties.
86
+ // In XState v5, getMeta() returns merged meta for the active state node (including parent states).
74
87
  const stateObj = appEventMachineState || {}
75
88
  const rawMeta = typeof stateObj.getMeta === 'function' ? stateObj.getMeta() : stateObj.meta
76
89
  const { context: appContext, value } = stateObj
@@ -83,197 +96,200 @@ const AppMachineTransition = (anearEvent) => {
83
96
  ? stateObj.event
84
97
  : { type: 'xstate.init' }
85
98
  const stateName = _stringifiedState(value)
86
- const hasMeta = rawMeta && Object.keys(rawMeta).length > 0;
99
+ const source = invocationContext.source || 'unknown'
87
100
 
88
- logger.info(`[AppMachineTransition] AppM state transition to '${stateName}' event=${event.type}`)
89
- logger.debug(`[AppMachineTransition] Meta detected: ${hasMeta}`)
101
+ logger.info(`[AppMachineTransition] transition callback invoked stateName='${stateName}' event=${event.type} source=${source}`)
90
102
 
91
103
  // Handle unpredictable meta structure
92
104
  const metaObjects = rawMeta ? Object.values(rawMeta) : []
105
+ const hasViewMeta = _hasViewMeta(metaObjects)
106
+
107
+ if (!hasViewMeta) {
108
+ logger.debug(`[AppMachineTransition] no meta view in current state; skipping RENDER_DISPLAY`)
109
+ return
110
+ }
93
111
 
94
112
  // Check if AppM has reached a final state (handle different XState versions)
95
113
  const isDone = stateObj?.status === 'done' || stateObj?.done || stateObj?.value === 'done'
96
114
 
97
- // Process meta FIRST (including exit displays) before sending CLOSE.
98
- // Do not re-process meta on the RENDERED event from AEM to avoid an infinite loop.
99
- if (metaObjects.length > 0 && event.type !== 'RENDERED') {
100
- // Dedupe render emission:
101
- // - By default, only render once per stable meta state (state+meta+context).
102
- // - But if this was triggered by a refresh event (e.g. SPECTATOR_ENTER), allow
103
- // re-rendering even if the AppM state didn't change.
104
- let baseSignature
105
- try {
106
- baseSignature = _safeSignatureStringify({ stateName, meta: rawMeta, context: appContext })
107
- } catch (_e) {
108
- baseSignature = `${stateName}|${rawMeta ? Object.keys(rawMeta).join(',') : ''}`
109
- }
115
+ // Compute signature for dedupe. Re-examine on RENDERED but only send when signature changed.
116
+ let baseSignature
117
+ try {
118
+ baseSignature = _safeSignatureStringify({ stateName, meta: rawMeta, context: appContext })
119
+ } catch (_e) {
120
+ baseSignature = `${stateName}|${rawMeta ? Object.keys(rawMeta).join(',') : ''}`
121
+ }
110
122
 
123
+ if (event.type === 'RENDERED') {
124
+ // Re-examine meta on RENDERED; send RENDER_DISPLAY only when state/context actually changed.
125
+ if (baseSignature === lastBaseSignature) {
126
+ logger.debug(`[AppMachineTransition] RENDERED re-examination: no change, skipping RENDER_DISPLAY stateName='${stateName}'`)
127
+ return
128
+ }
129
+ lastBaseSignature = baseSignature
130
+ } else {
131
+ // Non-RENDERED: dedupe by default; refresh events (e.g. SPECTATOR_ENTER) may re-render even when stable.
111
132
  const isRefresh = REFRESH_EVENT_TYPES.has(event.type)
112
- if (!isRefresh && baseSignature === lastBaseSignature) return
113
-
114
- // Update the "last rendered" marker for stable-state dedupe. Even on refresh,
115
- // we want to consider this state "rendered" so that later no-op snapshots
116
- // (e.g. the RENDERED ack lacking event metadata) don't re-trigger rendering.
133
+ if (!isRefresh && baseSignature === lastBaseSignature) {
134
+ logger.debug(`[AppMachineTransition] meta view present; skipping RENDER_DISPLAY (dedupe, no change) stateName='${stateName}' event=${event.type}`)
135
+ return
136
+ }
117
137
  lastBaseSignature = baseSignature
138
+ }
118
139
 
119
- // For refresh triggers, allow re-rendering even when stable.
120
- // No additional dedupe needed; repeated presence events can legitimately re-render.
121
- if (isRefresh) {
122
- // fallthrough to rendering below
140
+ // Process meta and build display events (shared path for RENDERED with change and non-RENDERED).
141
+ const appStateName = _stringifiedState(value)
142
+ const isRefresh = event.type !== 'RENDERED' && REFRESH_EVENT_TYPES.has(event.type)
143
+
144
+ const displayEvents = []
145
+ let cancelActionType = null // Track cancel property from meta configuration
146
+
147
+ // Process all meta objects to handle unpredictable AppM structures
148
+ metaObjects.forEach(meta => {
149
+ let viewer
150
+ let timeoutFn
151
+ let displayEvent
152
+
153
+ // Extract cancel from meta level (for function-based eachParticipant or top-level cancel)
154
+ // Supports both "cancel" (developer-friendly) and "cancelActionType" (internal)
155
+ if (meta.cancel && !cancelActionType) {
156
+ cancelActionType = meta.cancel
157
+ } else if (meta.cancelActionType && !cancelActionType) {
158
+ cancelActionType = meta.cancelActionType
123
159
  }
124
160
 
125
- const appStateName = _stringifiedState(value)
126
-
127
- const displayEvents = []
128
- let cancelActionType = null // Track cancel property from meta configuration
129
-
130
- // Process all meta objects to handle unpredictable AppM structures
131
- metaObjects.forEach(meta => {
132
- let viewer
133
- let timeoutFn
134
- let displayEvent
161
+ // Validate that participants: and participant: are not both defined
162
+ if (meta.allParticipants && meta.eachParticipant) {
163
+ logger.error(`[AppMachineTransition] Invalid meta configuration: both 'allParticipants' and 'eachParticipant' are defined. Only one can be used per state.`)
164
+ return // Skip processing this meta object
165
+ }
135
166
 
136
- // Extract cancel from meta level (for function-based eachParticipant or top-level cancel)
137
- // Supports both "cancel" (developer-friendly) and "cancelActionType" (internal)
138
- if (meta.cancel && !cancelActionType) {
139
- cancelActionType = meta.cancel
140
- } else if (meta.cancelActionType && !cancelActionType) {
141
- cancelActionType = meta.cancelActionType
142
- }
167
+ if (meta.allParticipants) {
168
+ viewer = 'allParticipants'
169
+ const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
170
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
143
171
 
144
- // Validate that participants: and participant: are not both defined
145
- if (meta.allParticipants && meta.eachParticipant) {
146
- logger.error(`[AppMachineTransition] Invalid meta configuration: both 'allParticipants' and 'eachParticipant' are defined. Only one can be used per state.`)
147
- return // Skip processing this meta object
172
+ // Extract cancel property if present
173
+ if (meta.allParticipants.cancel && !cancelActionType) {
174
+ cancelActionType = meta.allParticipants.cancel
148
175
  }
149
176
 
150
- if (meta.allParticipants) {
151
- viewer = 'allParticipants'
152
- const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
153
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
154
-
155
- // Extract cancel property if present
156
- if (meta.allParticipants.cancel && !cancelActionType) {
157
- cancelActionType = meta.allParticipants.cancel
158
- }
159
-
160
- displayEvent = RenderContextBuilder.buildDisplayEvent(
161
- viewPath,
162
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
163
- viewer,
164
- null,
165
- null,
166
- props
167
- )
168
- displayEvents.push(displayEvent)
169
- }
177
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
178
+ viewPath,
179
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
180
+ viewer,
181
+ null,
182
+ null,
183
+ props
184
+ )
185
+ displayEvents.push(displayEvent)
186
+ }
170
187
 
171
- if (meta.eachParticipant) {
172
- // Check if participant is a function (new selective rendering format)
173
- viewer = 'eachParticipant'
174
- if (typeof meta.eachParticipant === 'function') {
175
- // New selective participant rendering - cancel property is not supported in function format
176
- // (would need to be in the object format, see legacy handling below)
177
- const participantRenderFunc = meta.eachParticipant
178
- const participantDisplays = participantRenderFunc(appContext, event)
179
-
180
- if (Array.isArray(participantDisplays)) {
181
- // Build the base render context once and reuse it for all participant displays
182
- // This avoids redundant context building when multiple participants receive different views
183
- const baseAppRenderContext = RenderContextBuilder.buildAppRenderContext(
184
- appContext,
185
- appStateName,
186
- event.type,
187
- viewer,
188
- null
189
- )
190
-
191
- participantDisplays.forEach(participantDisplay => {
192
- if (participantDisplay && participantDisplay.participantId && participantDisplay.view) {
193
- // For selective rendering, timeout is handled directly in the participant display object
194
- const timeout = participantDisplay.timeout || null
195
- const props = participantDisplay.props || {}
196
-
197
- displayEvent = RenderContextBuilder.buildDisplayEvent(
198
- participantDisplay.view,
199
- baseAppRenderContext, // Reuse the same base context
200
- viewer,
201
- participantDisplay.participantId,
202
- timeout,
203
- props
204
- )
205
- displayEvents.push(displayEvent)
206
- }
207
- })
208
- }
209
- } else {
210
- // Legacy participant rendering - normalize to selective format
211
- const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
212
- const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
213
-
214
- // Extract cancel property if present (for legacy eachParticipant format)
215
- if (meta.eachParticipant.cancel && !cancelActionType) {
216
- cancelActionType = meta.eachParticipant.cancel
217
- }
218
-
219
- const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn)
220
- displayEvent = RenderContextBuilder.buildDisplayEvent(
221
- viewPath,
222
- renderContext,
188
+ if (meta.eachParticipant) {
189
+ // Check if participant is a function (new selective rendering format)
190
+ viewer = 'eachParticipant'
191
+ if (typeof meta.eachParticipant === 'function') {
192
+ // New selective participant rendering - cancel property is not supported in function format
193
+ // (would need to be in the object format, see legacy handling below)
194
+ const participantRenderFunc = meta.eachParticipant
195
+ const participantDisplays = participantRenderFunc(appContext, event)
196
+
197
+ if (Array.isArray(participantDisplays)) {
198
+ // Build the base render context once and reuse it for all participant displays
199
+ // This avoids redundant context building when multiple participants receive different views
200
+ const baseAppRenderContext = RenderContextBuilder.buildAppRenderContext(
201
+ appContext,
202
+ appStateName,
203
+ event.type,
223
204
  viewer,
224
- 'ALL_PARTICIPANTS', // Special marker for "all participants"
225
- null,
226
- props
205
+ null
227
206
  )
228
- displayEvents.push(displayEvent)
229
- }
230
- }
231
207
 
232
- if (meta.host) {
233
- viewer = 'host'
234
- const { viewPath, props } = _extractViewAndProps(meta.host)
235
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.host.timeout)
208
+ participantDisplays.forEach(participantDisplay => {
209
+ if (participantDisplay && participantDisplay.participantId && participantDisplay.view) {
210
+ // For selective rendering, timeout is handled directly in the participant display object
211
+ const timeout = participantDisplay.timeout || null
212
+ const props = participantDisplay.props || {}
213
+
214
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
215
+ participantDisplay.view,
216
+ baseAppRenderContext, // Reuse the same base context
217
+ viewer,
218
+ participantDisplay.participantId,
219
+ timeout,
220
+ props
221
+ )
222
+ displayEvents.push(displayEvent)
223
+ }
224
+ })
225
+ }
226
+ } else {
227
+ // Legacy participant rendering - normalize to selective format
228
+ const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
229
+ const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
230
+
231
+ // Extract cancel property if present (for legacy eachParticipant format)
232
+ if (meta.eachParticipant.cancel && !cancelActionType) {
233
+ cancelActionType = meta.eachParticipant.cancel
234
+ }
236
235
 
236
+ const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn)
237
237
  displayEvent = RenderContextBuilder.buildDisplayEvent(
238
238
  viewPath,
239
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
239
+ renderContext,
240
240
  viewer,
241
- null,
241
+ 'ALL_PARTICIPANTS', // Special marker for "all participants"
242
242
  null,
243
243
  props
244
244
  )
245
245
  displayEvents.push(displayEvent)
246
246
  }
247
+ }
247
248
 
248
- if (meta.spectators) {
249
- viewer = 'spectators'
250
- const { viewPath, props } = _extractViewAndProps(meta.spectators)
251
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.spectators.timeout)
249
+ if (meta.host) {
250
+ viewer = 'host'
251
+ const { viewPath, props } = _extractViewAndProps(meta.host)
252
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.host.timeout)
253
+
254
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
255
+ viewPath,
256
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
257
+ viewer,
258
+ null,
259
+ null,
260
+ props
261
+ )
262
+ displayEvents.push(displayEvent)
263
+ }
252
264
 
253
- displayEvent = RenderContextBuilder.buildDisplayEvent(
254
- viewPath,
255
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
256
- viewer,
257
- null,
258
- null,
259
- props
260
- )
261
- displayEvents.push(displayEvent)
262
- }
263
- })
265
+ if (meta.spectators) {
266
+ viewer = 'spectators'
267
+ const { viewPath, props } = _extractViewAndProps(meta.spectators)
268
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.spectators.timeout)
269
+
270
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
271
+ viewPath,
272
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
273
+ viewer,
274
+ null,
275
+ null,
276
+ props
277
+ )
278
+ displayEvents.push(displayEvent)
279
+ }
280
+ })
264
281
 
265
- if (displayEvents.length > 0) {
266
- logger.debug(`[AppMachineTransition] sending RENDER_DISPLAY with ${displayEvents.length} displayEvents`)
267
- const renderDisplayPayload = {
268
- type: 'RENDER_DISPLAY',
269
- displayEvents
270
- }
271
- // Include cancel property only if defined
272
- if (cancelActionType) {
273
- renderDisplayPayload.cancelActionType = cancelActionType
274
- }
275
- anearEvent.send(renderDisplayPayload)
282
+ if (displayEvents.length > 0) {
283
+ logger.info(`[AppMachineTransition] sending RENDER_DISPLAY stateName='${stateName}' event=${event.type} isRefresh=${isRefresh} displayEventsCount=${displayEvents.length}`)
284
+ const renderDisplayPayload = {
285
+ type: 'RENDER_DISPLAY',
286
+ displayEvents
287
+ }
288
+ // Include cancel property only if defined
289
+ if (cancelActionType) {
290
+ renderDisplayPayload.cancelActionType = cancelActionType
276
291
  }
292
+ anearEvent.send(renderDisplayPayload)
277
293
  }
278
294
 
279
295
  // Note: When AppM reaches a final state, the service.onDone() callback
@@ -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
- 'SPECTATORS_DISPLAY',
217
- formattedDisplayMessage()
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
- subscribe(channel, actor, eventName = null) {
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {