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.
- package/lib/state_machines/AnearEventMachine.js +159 -61
- package/lib/state_machines/AnearParticipantMachine.js +36 -41
- package/lib/utils/AppMachineTransition.js +174 -158
- 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
|
|
@@ -2335,9 +2420,11 @@ const AnearEventMachineFunctions = ({
|
|
|
2335
2420
|
}
|
|
2336
2421
|
|
|
2337
2422
|
// Observe AppM transitions with full event payloads.
|
|
2338
|
-
//
|
|
2339
|
-
// the
|
|
2340
|
-
//
|
|
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
|
|
2352
|
-
let
|
|
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
|
|
2382
|
-
//
|
|
2383
|
-
if (event &&
|
|
2384
|
-
|
|
2385
|
-
logger.debug('[AEM][inspect]
|
|
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
|
-
//
|
|
2397
|
-
//
|
|
2398
|
-
//
|
|
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
|
-
|
|
2410
|
-
lastRefreshHandledBySnapshot = false
|
|
2500
|
+
}
|
|
2411
2501
|
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2502
|
+
defer(() => {
|
|
2503
|
+
if (!appActor) return
|
|
2504
|
+
if (lastDeferredEvent !== ev) return
|
|
2505
|
+
if (lastDeferredHandledBySnapshot) return
|
|
2416
2506
|
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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
|
-
//
|
|
2585
|
-
|
|
2586
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
285
|
-
//
|
|
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
|
-
//
|
|
289
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
626
|
-
const
|
|
623
|
+
logBlockedRenderDuringPublish: ({ context, event }) => {
|
|
624
|
+
const participantId = context?.anearParticipant?.id
|
|
627
625
|
const timeout = event?.timeout ?? event?.data?.timeout
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
99
|
+
const source = invocationContext.source || 'unknown'
|
|
87
100
|
|
|
88
|
-
logger.info(`[AppMachineTransition]
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
//
|
|
145
|
-
if (meta.allParticipants &&
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
225
|
-
null,
|
|
226
|
-
props
|
|
205
|
+
null
|
|
227
206
|
)
|
|
228
|
-
displayEvents.push(displayEvent)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
207
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
+
renderContext,
|
|
240
240
|
viewer,
|
|
241
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
)
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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)
|