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.
@@ -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
@@ -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
- // This service handles the transition of the event to 'canceled' via AnearApi
2589
- // and the publishing of the 'EVENT_TRANSITION' message to ABRs.
2590
- // 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.
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
- if (baseSignature === lastBaseSignature) {
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
- '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.2",
3
+ "version": "1.6.2",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {