anear-js-api 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,765 +0,0 @@
1
- "use strict"
2
- /**
3
- * AnearParticipantMachine (APM)
4
- *
5
- * One AnearParticipantMachine instance is created per participant in a given
6
- * Anear Event. It is owned by the AnearEventMachine (AEM) and represents the
7
- * server-side lifecycle and realtime plumbing for a single participant.
8
- *
9
- * High-level responsibilities:
10
- *
11
- * - Manage the participant’s event-scoped private Ably channel:
12
- * - Creates and attaches to a participant-specific private channel
13
- * (e.g. for per-player UI updates, prompts, and boot/shutdown messages).
14
- * - Publishes PRIVATE_DISPLAY / FORCE_SHUTDOWN payloads to the client.
15
- *
16
- * - Bridge between the AnearEventMachine and the app’s optional
17
- * appParticipantMachine:
18
- * - Receives ACTION / display-related events and presence events from the AEM.
19
- * - Forwards ACTION and timeout signals into the appParticipantMachine
20
- * when present, so the app can implement per-participant logic.
21
- * - Notifies the AEM when this participant’s machine has completed
22
- * (PARTICIPANT_MACHINE_EXIT).
23
- *
24
- * - Enforce participant-level timeouts and activity rules:
25
- * - Action timeouts:
26
- * - After a RENDER_DISPLAY that expects input,
27
- * starts a per-participant response timer.
28
- * - If the timer fires before an ACTION arrives, emits PARTICIPANT_TIMEOUT
29
- * back to the AEM and the appParticipantMachine.
30
- * - Disconnect / reconnect:
31
- * - On PARTICIPANT_DISCONNECT, enters a reconnect-grace window.
32
- * - If the participant reconnects (PARTICIPANT_RECONNECT) in time,
33
- * resumes either idle or the pending response state.
34
- * - If not, either times out the current turn or exits the participant
35
- * from the event.
36
- * - Idle / cleanup:
37
- * - Tracks lastSeen and can be used by the AEM/app logic to remove
38
- * idle/non-responsive participants and keep the event “clean”.
39
- *
40
- * - Handle participant exit / removal:
41
- * - PARTICIPANT_EXIT:
42
- * - Voluntary exit from the participant (e.g. closing the app, leaving).
43
- * - Drives cleanup of the private channel and transitions to a final state.
44
- * - BOOT_PARTICIPANT:
45
- * - Involuntary removal (e.g. host kicks the player).
46
- * - Publishes a shutdown/boot message and then proceeds to cleanup.
47
- *
48
- * Relationship to other Anear components:
49
- *
50
- * - AnearEventMachine (AEM):
51
- * - The AEM creates one APM per participant and passes:
52
- * - `anearEvent` → the parent event/state machine actor
53
- * - `anearParticipant` → the participant model (ids, privateChannelName, etc.)
54
- * - `appParticipantMachineFactory` (optional)
55
- * - APM reports its termination back to the AEM via
56
- * `PARTICIPANT_MACHINE_EXIT` so the AEM can clean up references.
57
- *
58
- * - App-level participant state machine (appParticipantMachine):
59
- * - Created only if `appParticipantMachineFactory` is provided.
60
- * - Encapsulates app-specific per-participant state (roles, inventory,
61
- * per-player storyline, etc.)
62
- * - The APM routes ACTION and timeout-related events into this machine so
63
- * the app developer can implement custom behavior without worrying about
64
- * Ably wiring or lifecycle plumbing.
65
- *
66
- * Incoming events (from AEM / system) – high level:
67
- * - RENDER_DISPLAY:
68
- * - Prompt the participant with a new view or interaction on their private channel.
69
- * - Optionally include an action timeout; APM transitions into a wait state.
70
- *
71
- * - ACTION:
72
- * - Participant’s response to a prompt (button click, answer, move, etc.).
73
- * - APM clears any pending timeout, updates `lastSeen`, and forwards the event
74
- * into the appParticipantMachine (if defined).
75
- *
76
- * - PARTICIPANT_DISCONNECT / PARTICIPANT_RECONNECT:
77
- * - Presence events indicating temporary loss or restoration of connection.
78
- * - APM pauses/resumes any active action timeout and decides whether to
79
- * time out the participant or allow them to continue.
80
- *
81
- * - PARTICIPANT_EXIT / BOOT_PARTICIPANT:
82
- * - Signals that the participant is done with the event (voluntarily or not).
83
- * - Triggers cleanup, private channel detach, and notification back to the AEM.
84
- *
85
- * Lifecycle (condensed):
86
- * 1. APM is created by the AEM for a specific participant.
87
- * 2. It creates and attaches to the participant’s private Ably channel, then
88
- * signals PARTICIPANT_MACHINE_READY back to the AEM.
89
- * 3. It enters the live state, handling:
90
- * - RENDER_DISPLAY → publish UI, optionally start timeout.
91
- * - ACTION → forward to appParticipantMachine, clear timeout, go idle.
92
- * - DISCONNECT / RECONNECT → manage reconnect windows and possible timeouts.
93
- * - EXIT / BOOT → send shutdown messaging and clean up.
94
- * 4. On final cleanup, it detaches from the private channel and notifies the AEM
95
- * via PARTICIPANT_MACHINE_EXIT, then ignores any further incoming events.
96
- */
97
- const logger = require('../utils/Logger')
98
- const { assign, createMachine, createActor, fromPromise } = require('xstate')
99
- const C = require('../utils/Constants')
100
-
101
- const RealtimeMessaging = require('../utils/RealtimeMessaging')
102
- const EventStats = require('../utils/EventStats')
103
-
104
- const CurrentDateTimestamp = _ => new Date().getTime()
105
-
106
- const AnearParticipantMachineContext = (anearParticipant, anearEvent, appParticipantMachineFactory, eventStats = null) => ({
107
- anearEvent,
108
- anearParticipant,
109
- privateChannel: null,
110
- appParticipantMachineFactory,
111
- appParticipantMachine: null,
112
- actionTimeoutMsecs: null,
113
- actionTimeoutStart: null,
114
- capturedTimeoutMsecs: null, // Captured timeout value for logging before clearing
115
- // If the AppM emits rapid successive RENDER_DISPLAY events (e.g. dealing animation),
116
- // a participant may still be in the middle of publishing the prior PRIVATE_DISPLAY.
117
- // Without buffering, any new RENDER_DISPLAY arriving while in `live.renderDisplay`
118
- // would be dropped (no handler in that state). We keep the latest and flush it
119
- // immediately after the current publish completes.
120
- queuedRenderDisplay: null,
121
- lastSeen: CurrentDateTimestamp(),
122
- eventStats // AEM-only metering; optional for EventStats.recordPublish before publish
123
- })
124
-
125
- const AnearParticipantMachineConfig = participantId => ({
126
- id: `AnearParticipantMachine_${participantId}`,
127
- initial: 'active',
128
- states: {
129
- active: {
130
- id: 'active',
131
- initial: 'setup',
132
- states: {
133
- setup: {
134
- id: 'setup',
135
- initial: 'setupPrivateChannel',
136
- states: {
137
- setupPrivateChannel: {
138
- entry: ['createPrivateChannel'],
139
- invoke: {
140
- src: 'attachToPrivateChannel',
141
- input: ({ context, event }) => ({ context, event }),
142
- onDone: {
143
- target: '.',
144
- // v5 note: internal transitions are the default (v4 had `internal: true`)
145
- },
146
- onError: {
147
- target: '#error'
148
- }
149
- },
150
- on: {
151
- ATTACHED: {
152
- actions: [
153
- 'logAttached',
154
- 'sendParticipantReady'
155
- ],
156
- target: '#setupAppMachine'
157
- }
158
- }
159
- },
160
- setupAppMachine: {
161
- id: 'setupAppMachine',
162
- entry: 'createAnyAppParticipantMachine',
163
- always: '#live'
164
- }
165
- }
166
- },
167
- live: {
168
- id: 'live',
169
- entry: 'logLive',
170
- initial: 'idle',
171
- states: {
172
- idle: {
173
- entry: ['ensureTimerCleared'],
174
- on: {
175
- RENDER_DISPLAY: {
176
- target: '#renderDisplay'
177
- },
178
- CANCEL_TIMEOUT: {
179
- // Acknowledge CANCEL_TIMEOUT even when idle (no active timer).
180
- // This ensures the AEM's cancellation sequence completes for all participants.
181
- actions: ['sendTimerCanceled'],
182
- // v5 note: internal transitions are the default (v4 had `internal: true`)
183
- },
184
- PARTICIPANT_DISCONNECT: {
185
- target: 'waitReconnect'
186
- },
187
- PARTICIPANT_EXIT: {
188
- actions: 'logExit',
189
- target: '#cleanupAndExit'
190
- },
191
- PARTICIPANT_RECONNECT: {
192
- actions: 'logReconnected',
193
- // v5 note: internal transitions are the default (v4 had `internal: true`)
194
- },
195
- BOOT_PARTICIPANT: {
196
- actions: 'logBooted',
197
- target: '#cleanupAndExit'
198
- }
199
- }
200
- },
201
- waitReconnect: {
202
- entry: 'logDisconnected',
203
- after: {
204
- dynamicReconnectTimeout: [
205
- {
206
- guard: 'wasMidTurnOnDisconnect',
207
- actions: 'logActionTimeoutWhileDisconnected',
208
- target: '#participantTimedOut'
209
- },
210
- {
211
- actions: 'logNeverReconnected',
212
- target: '#cleanupAndExit'
213
- }
214
- ]
215
- },
216
- on: {
217
- // Even while we're waiting for a reconnect, the AppM may
218
- // re-trigger a display (e.g. a refreshed PlayableGameBoard).
219
- // In that case, re-publish the view but keep the timeout
220
- // semantics governed by the stored remaining time.
221
- RENDER_DISPLAY: {
222
- target: '#renderDisplay'
223
- },
224
- PARTICIPANT_EXIT: {
225
- target: '#cleanupAndExit'
226
- },
227
- PARTICIPANT_RECONNECT: [
228
- {
229
- guard: 'wasMidTurnOnDisconnect',
230
- actions: 'logReconnected',
231
- target: 'waitParticipantResponse'
232
- },
233
- {
234
- actions: 'logReconnected',
235
- target: 'idle'
236
- }
237
- ]
238
- }
239
- },
240
- renderDisplay: {
241
- id: 'renderDisplay',
242
- invoke: {
243
- src: 'publishPrivateDisplay',
244
- input: ({ context, event }) => ({ context, event }),
245
- onDone: [
246
- { guard: 'hasActionTimeout', actions: ['updateActionTimeout', 'sendQueuedRenderDisplayIfAny', 'clearQueuedRenderDisplay'], target: 'waitParticipantResponse' },
247
- // If this display does not configure a timeout, explicitly clear any
248
- // previously-running action timeout.
249
- { actions: ['nullActionTimeout', 'sendQueuedRenderDisplayIfAny', 'clearQueuedRenderDisplay'], target: 'idle' }
250
- ],
251
- onError: {
252
- target: '#error'
253
- }
254
- },
255
- on: {
256
- // Buffer the latest render request if we are currently publishing.
257
- // (Otherwise, RENDER_DISPLAY is unhandled in this state and gets dropped.)
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
- ],
271
- PARTICIPANT_EXIT: {
272
- actions: 'logExit',
273
- target: '#cleanupAndExit'
274
- }
275
- }
276
- },
277
- waitParticipantResponse: {
278
- entry: 'logWaitParticipantResponseEntry',
279
- always: {
280
- guard: 'isTimeoutImmediate',
281
- actions: 'logImmediateTimeout',
282
- target: '#participantTimedOut'
283
- },
284
- after: {
285
- // Note: This timer continues running even when in nested .rendering state.
286
- // XState v5 handles this correctly - the timer fires from the parent state
287
- // and can transition even during invoke processing.
288
- actionTimeout: {
289
- actions: ['captureTimeoutForLogging', 'nullActionTimeout'],
290
- target: '#participantTimedOut'
291
- }
292
- },
293
- initial: 'waiting',
294
- states: {
295
- waiting: {
296
- // Actual waiting state. RENDER_DISPLAY (including refreshes with timeout) is
297
- // allowed; updateActionTimeoutIfChanged / timer logic handles stop/reset.
298
- },
299
- rendering: {
300
- // Refreshes (e.g. PARTICIPANT_DISCONNECT/RECONNECT) are processed here while
301
- // the parent's `after: actionTimeout` timer continues running.
302
- invoke: {
303
- src: 'publishPrivateDisplay',
304
- input: ({ context, event }) => ({ context, event }),
305
- onDone: [
306
- {
307
- guard: 'hasActionTimeout',
308
- actions: 'updateActionTimeoutIfChanged',
309
- target: 'waiting'
310
- },
311
- {
312
- target: 'waiting'
313
- }
314
- ],
315
- onError: {
316
- target: '#error'
317
- }
318
- },
319
- on: {
320
- PARTICIPANT_EXIT: {
321
- actions: 'logExit',
322
- target: '#cleanupAndExit'
323
- }
324
- }
325
- }
326
- },
327
- on: {
328
- // Allow all RENDER_DISPLAY (refreshes, timeouts). Timer logic stops/resets as needed.
329
- RENDER_DISPLAY: {
330
- target: '.rendering'
331
- },
332
- PARTICIPANT_EXIT: {
333
- actions: 'logExit',
334
- target: '#cleanupAndExit'
335
- },
336
- ACTION: {
337
- actions: [
338
- 'updateLastSeen',
339
- 'sendActionToAppParticipantMachine',
340
- 'nullActionTimeout'
341
- ],
342
- target: 'idle'
343
- },
344
- CANCEL_TIMEOUT: {
345
- // Cancel the active timeout and transition to idle. This is used
346
- // when the AppM wants to immediately cancel all participant timeouts
347
- // (e.g., when a LIAR call interrupts the wait state).
348
- // Always send TIMER_CANCELED to acknowledge, even if no timer was active.
349
- actions: ['nullActionTimeout', 'sendTimerCanceled'],
350
- target: 'idle'
351
- },
352
- PARTICIPANT_DISCONNECT: {
353
- actions: 'updateRemainingTimeoutOnDisconnect',
354
- target: 'waitReconnect'
355
- },
356
- PARTICIPANT_RECONNECT: {
357
- actions: 'logReconnected',
358
- // v5 note: internal transitions are the default (v4 had `internal: true`)
359
- }
360
- }
361
- },
362
- participantTimedOut: {
363
- id: 'participantTimedOut',
364
- entry: ['logParticipantTimedOut', 'sendTimeoutEvents'],
365
- always: 'idle'
366
- },
367
- cleanupAndExit: {
368
- id: 'cleanupAndExit',
369
- entry: 'nullActionTimeout',
370
- // Ignore most events during cleanup, but allow exit displays
371
- on: {
372
- RENDER_DISPLAY: { actions: 'logIgnoringRenderDisplayCleanup' },
373
- PARTICIPANT_EXIT: {
374
- actions: 'logIgnoringRedundantExit'
375
- },
376
- PARTICIPANT_RECONNECT: {
377
- actions: 'logIgnoringReconnectCleanup'
378
- },
379
- ACTION: {
380
- actions: 'logIgnoringActionCleanup'
381
- }
382
- },
383
- invoke: {
384
- src: 'detachPrivateChannel',
385
- input: ({ context, event }) => ({ context, event }),
386
- onDone: {
387
- actions: assign(() => ({
388
- privateChannel: null
389
- })),
390
- target: '#done'
391
- },
392
- onError: {
393
- target: '#error'
394
- }
395
- }
396
- },
397
- booting: {
398
- initial: 'publishingShutdown',
399
- states: {
400
- publishingShutdown: {
401
- invoke: {
402
- id: 'publishBootMessage',
403
- src: 'publishBootMessageService',
404
- input: ({ context, event }) => ({ context, event }),
405
- onDone: 'waitingForClientExit',
406
- onError: '#cleanupAndExit'
407
- }
408
- },
409
- waitingForClientExit: {
410
- after: {
411
- bootCleanupTimeout: {
412
- target: '#cleanupAndExit'
413
- }
414
- },
415
- on: {
416
- PARTICIPANT_EXIT: '#cleanupAndExit',
417
- ACTION: {
418
- // v5 note: internal transitions are the default (v4 had `internal: true`)
419
- },
420
- PARTICIPANT_DISCONNECT: {
421
- // v5 note: internal transitions are the default (v4 had `internal: true`)
422
- }
423
- }
424
- }
425
- }
426
- },
427
- done: {
428
- id: 'done',
429
- entry: ['notifyEventMachineExit'],
430
- // Final state - ignore all events except exit displays
431
- on: {
432
- RENDER_DISPLAY: { actions: 'logIgnoringRenderDisplayDone' },
433
- '*': {
434
- actions: 'logIgnoringEventDone'
435
- }
436
- },
437
- type: 'final'
438
- }
439
- } // end live states
440
- } // end live
441
- } // end active states
442
- },
443
- error: {
444
- id: 'error',
445
- entry: ['logErrorDetails', 'notifyEventMachineExit'],
446
- type: 'final'
447
- }
448
- }
449
- })
450
-
451
- const AnearParticipantMachineFunctions = {
452
- actions: {
453
- createPrivateChannel: assign(({ context, self }) => {
454
- const privateChannel = RealtimeMessaging.getChannel(
455
- context.anearParticipant.privateChannelName,
456
- self
457
- )
458
- return { privateChannel }
459
- }),
460
- sendParticipantReady: ({ context }) => {
461
- context.anearEvent.send({
462
- type: 'PARTICIPANT_MACHINE_READY',
463
- data: { anearParticipant: context.anearParticipant }
464
- })
465
- },
466
- createAnyAppParticipantMachine: assign({
467
- appParticipantMachine: ({ context }) => {
468
- if (!context.appParticipantMachineFactory) return null
469
-
470
- const machineToStart = context.appParticipantMachineFactory(context.anearParticipant)
471
- const actor = createActor(machineToStart)
472
- actor.start()
473
- return actor
474
- }
475
- }),
476
- captureTimeoutForLogging: assign(({ context }) => {
477
- // Capture timeout value before it's cleared by nullActionTimeout
478
- return {
479
- capturedTimeoutMsecs: context.actionTimeoutMsecs
480
- }
481
- }),
482
- sendTimeoutEvents: ({ context }) => {
483
- const timeoutMsecs = context.capturedTimeoutMsecs ?? context.actionTimeoutMsecs ?? 'unknown'
484
- logger.info(`[APM] Timer expired participantId=${context.anearParticipant.id} timeout=${timeoutMsecs}ms, sending PARTICIPANT_TIMEOUT to AEM`)
485
- // AEM currently expects `event.participantId` (not nested under `data`).
486
- context.anearEvent.send({
487
- type: 'PARTICIPANT_TIMEOUT',
488
- participantId: context.anearParticipant.id
489
- })
490
- if (context.appParticipantMachine) context.appParticipantMachine.send({ type: 'PARTICIPANT_TIMEOUT' })
491
- },
492
- sendTimerCanceled: ({ context }) => {
493
- logger.debug(`[APM] Sending TIMER_CANCELED to AEM for ${context.anearParticipant.id}`)
494
- context.anearEvent.send({
495
- type: 'TIMER_CANCELED',
496
- participantId: context.anearParticipant.id
497
- })
498
- },
499
- sendActionToAppParticipantMachine: ({ context, event }) => {
500
- if (context.appParticipantMachine) context.appParticipantMachine.send(event)
501
- },
502
- updateLastSeen: assign({
503
- lastSeen: (_args) => CurrentDateTimestamp()
504
- }),
505
- updateActionTimeout: assign(({ context, event }) => {
506
- const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
507
- const now = CurrentDateTimestamp()
508
-
509
- // Start a new timer. This is only called when transitioning from idle to waitParticipantResponse.
510
- // When RENDER_DISPLAY arrives during waitParticipantResponse, we transition to the nested
511
- // .rendering state, which preserves the parent's after: timer (no resume needed).
512
- const existingMsecs = context.actionTimeoutMsecs
513
- const existingStart = context.actionTimeoutStart
514
- if (existingMsecs != null || existingStart != null) {
515
- logger.debug(`[APM] WARNING: Starting new timer for ${context.anearParticipant.id} but existing timer state found: msecs=${existingMsecs}, start=${existingStart}`)
516
- }
517
- logger.info(`[APM] Timer started participantId=${context.anearParticipant.id} timeout=${timeoutFromEvent}ms`)
518
- return {
519
- actionTimeoutMsecs: timeoutFromEvent,
520
- actionTimeoutStart: now
521
- }
522
- }),
523
- updateActionTimeoutIfChanged: assign(({ context, event }) => {
524
- const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
525
- const now = CurrentDateTimestamp()
526
-
527
- // When RENDER_DISPLAY arrives during waitParticipantResponse, check if the timeout has changed.
528
- // If it has, update the timer. This handles state transitions where a new timeout is needed.
529
- const existingMsecs = context.actionTimeoutMsecs
530
- const existingStart = context.actionTimeoutStart
531
-
532
- if (timeoutFromEvent == null || timeoutFromEvent <= 0) {
533
- // New display has no timeout - preserve existing timer (this is a visual status update)
534
- // Don't clear it, as we're still waiting for the original ACTION
535
- logger.debug(`[APM] Preserving existing timer for ${context.anearParticipant.id} (new display has no timeout, visual status update)`)
536
- return {}
537
- }
538
-
539
- // If we already have an active timer and receive a new timeout, this is a bug (detected above).
540
- // Preserve the existing timer to avoid conflicts. The AppM should cancel timers first.
541
- if (existingMsecs != null && existingStart != null) {
542
- logger.warn(`[APM] AppM BUG: Received timeout=${timeoutFromEvent}ms while timer already active (${existingMsecs}ms) for ${context.anearParticipant.id}. Preserving existing timer. AppM should cancel timers first.`)
543
- return {}
544
- }
545
-
546
- // No existing timer - start a new one (shouldn't happen in waitParticipantResponse, but handle gracefully)
547
- logger.info(`[APM] Timer started participantId=${context.anearParticipant.id} timeout=${timeoutFromEvent}ms`)
548
- return {
549
- actionTimeoutMsecs: timeoutFromEvent,
550
- actionTimeoutStart: now
551
- }
552
- }),
553
- nullActionTimeout: assign(({ context }) => {
554
- if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
555
- logger.debug(`[APM] Cancelling timer for ${context.anearParticipant.id} (ACTION received or timeout cleared)`)
556
- }
557
- return {
558
- actionTimeoutMsecs: null,
559
- actionTimeoutStart: null
560
- }
561
- }),
562
- ensureTimerCleared: assign(({ context }) => {
563
- // Defensive: Ensure timer state is cleared when entering idle state.
564
- // This prevents stale timer state from affecting new displays, especially
565
- // if a RENDER_DISPLAY arrives before timeout cleanup completes.
566
- if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
567
- logger.debug(`[APM] Clearing stale timer state for ${context.anearParticipant.id} on idle entry (msecs=${context.actionTimeoutMsecs}, start=${context.actionTimeoutStart})`)
568
- return {
569
- actionTimeoutMsecs: null,
570
- actionTimeoutStart: null
571
- }
572
- }
573
- return {}
574
- }),
575
- updateRemainingTimeoutOnDisconnect: assign(({ context }) => {
576
- if (context.actionTimeoutStart) {
577
- const now = CurrentDateTimestamp()
578
- const elapsed = now - context.actionTimeoutStart
579
- const remaining = context.actionTimeoutMsecs - elapsed
580
- const remainingMsecs = remaining > 0 ? remaining : 0
581
- logger.debug(`[APM] Participant disconnected mid-turn. Storing remaining timeout of ${remainingMsecs}ms`)
582
- return {
583
- actionTimeoutMsecs: remainingMsecs,
584
- // Reset the start baseline to "now" so that subsequent resumptions
585
- // measure elapsed time from the disconnect point forward.
586
- actionTimeoutStart: now
587
- }
588
- }
589
- return {}
590
- }),
591
- notifyEventMachineExit: ({ context }) => {
592
- // AEM cleanup currently expects `event.participantId` (not nested under `data`).
593
- context.anearEvent.send({
594
- type: 'PARTICIPANT_MACHINE_EXIT',
595
- participantId: context.anearParticipant.id
596
- })
597
- },
598
- logErrorDetails: ({ event }) => {
599
- // Log the entire error object.
600
- const err = event?.error ?? event?.data
601
- if (err && err.stack) {
602
- logger.error("Stack trace:", err.stack);
603
- } else {
604
- logger.error("Error details:", err);
605
- }
606
- },
607
- logAttached: ({ context }) => logger.debug(`[APM] Got ATTACHED for privateChannel for ${context.anearParticipant.id}`),
608
- logLive: ({ context }) => logger.debug(`[APM] Participant ${context.anearParticipant.id} is LIVE!`),
609
- logWaitParticipantResponseEntry: ({ context }) => logger.debug(`[APM] ENTERING waitParticipantResponse state for ${context.anearParticipant.id} - timer will be (re)started if context provides timeout`),
610
- logParticipantTimedOut: ({ context }) => logger.debug(`[APM] ENTERING participantTimedOut state for ${context.anearParticipant.id} - timeout occurred, clearing timer and sending PARTICIPANT_TIMEOUT`),
611
- logExit: (_args) => logger.debug('[APM] got PARTICIPANT_EXIT. Exiting...'),
612
- logDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has DISCONNECTED`),
613
- logActionTimeoutWhileDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} timed out on action while disconnected.`),
614
- logNeverReconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} never RECONNECTED. Exiting.`),
615
- logReconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has RECONNECTED`),
616
- logImmediateTimeout: ({ context }) => logger.debug(`[APM] Timeout of ${context.actionTimeoutMsecs}ms is immediate for ${context.anearParticipant.id}.`),
617
- logIgnoringRenderDisplayCleanup: (_args) => logger.debug('[APM] ignoring RENDER_DISPLAY during cleanup'),
618
- logIgnoringRedundantExit: () => logger.debug('[APM] ignoring redundant PARTICIPIPANT_EXIT during cleanup'),
619
- logIgnoringReconnectCleanup: () => logger.debug('[APM] ignoring PARTICIPANT_RECONNECT during cleanup - already timed out'),
620
- logIgnoringActionCleanup: () => logger.debug('[APM] ignoring ACTION during cleanup'),
621
- logIgnoringRenderDisplayDone: () => logger.debug('[APM] ignoring RENDER_DISPLAY in final state'),
622
- logIgnoringEventDone: ({ event }) => logger.debug('[APM] ignoring event in final state: ', event.type),
623
- logBlockedRenderDuringPublish: ({ context, event }) => {
624
- const participantId = context?.anearParticipant?.id
625
- const timeout = event?.timeout ?? event?.data?.timeout
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
- )
630
- }
631
- ,
632
- queueRenderDisplay: assign(({ context, event }) => {
633
- // Keep only the latest queued render display.
634
- // Event shape is already { type: 'RENDER_DISPLAY', content, timeout? }.
635
- const participantId = context?.anearParticipant?.id
636
- const replaced = !!context?.queuedRenderDisplay
637
- logger.debug(`[APM] queueing RENDER_DISPLAY for ${participantId}${replaced ? ' (replacing prior queued render)' : ''}`)
638
- return { queuedRenderDisplay: event }
639
- }),
640
- sendQueuedRenderDisplayIfAny: ({ context, self }) => {
641
- const queued = context.queuedRenderDisplay
642
- if (queued && queued.type === 'RENDER_DISPLAY') {
643
- const participantId = context?.anearParticipant?.id
644
- logger.debug(`[APM] flushing queued RENDER_DISPLAY for ${participantId}`)
645
- try {
646
- self.send(queued)
647
- } catch (_e) {
648
- // Best-effort; never interrupt the publish completion path.
649
- }
650
- }
651
- },
652
- clearQueuedRenderDisplay: assign(() => ({ queuedRenderDisplay: null }))
653
- },
654
- actors: {
655
- publishPrivateDisplay: fromPromise(async ({ input }) => {
656
- const { context, event } = input
657
- let displayMessage
658
- if (event.targets != null && typeof event.targets === 'object' && !Array.isArray(event.targets)) {
659
- displayMessage = { targets: event.targets }
660
- } else if (event.anchor != null && event.content !== undefined) {
661
- const val = (event.type === 'replace' || event.type === 'append')
662
- ? { content: event.content, type: event.type }
663
- : event.content
664
- displayMessage = { targets: { [event.anchor]: val } }
665
- } else {
666
- displayMessage = { content: event.content }
667
- }
668
- if (event.timeout != null) {
669
- displayMessage.timeout = event.timeout
670
- }
671
- EventStats.recordPublish(context.eventStats, context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
672
- await RealtimeMessaging.publish(context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
673
- return { timeout: event.timeout }
674
- }),
675
- publishBootMessageService: fromPromise(async ({ input }) => {
676
- const { context, event } = input
677
- const { reason } = event.data
678
- const payload = {
679
- content: {
680
- reason: reason || 'You have been removed from the event.'
681
- }
682
- }
683
- EventStats.recordPublish(context.eventStats, context.privateChannel, 'FORCE_SHUTDOWN', payload)
684
- await RealtimeMessaging.publish(context.privateChannel, 'FORCE_SHUTDOWN', payload)
685
- return 'done'
686
- }),
687
- attachToPrivateChannel: fromPromise(async ({ input }) => {
688
- const { context } = input
689
- return await RealtimeMessaging.attachTo(context.privateChannel)
690
- }),
691
- detachPrivateChannel: fromPromise(async ({ input }) => {
692
- const { context } = input
693
- return context.privateChannel ? await RealtimeMessaging.detachAll([context.privateChannel]) : 'done'
694
- })
695
- },
696
- guards: {
697
- hasAppParticipantMachine: ({ context }) => context.appParticipantMachineFactory !== null,
698
- incomingRenderHasTimeout: ({ event }) => {
699
- // RENDER_DISPLAY events are sent directly as { type, content, timeout? }
700
- // We only block when a *new* timeout is being asserted during an active wait.
701
- const timeout = event?.timeout ?? event?.data?.timeout
702
- return timeout != null && timeout > 0
703
- },
704
- hasActionTimeout: ({ event }) => {
705
- const timeout = event?.output?.timeout ?? event?.data?.timeout
706
- return timeout != null && timeout > 0
707
- },
708
- isTimeoutImmediate: ({ context }) => context.actionTimeoutMsecs !== null && context.actionTimeoutMsecs <= 0,
709
- wasMidTurnOnDisconnect: ({ context }) => context.actionTimeoutMsecs !== null && context.actionTimeoutMsecs > 0
710
- },
711
- delays: {
712
- bootCleanupTimeout: (_args) => C.TIMEOUT_MSECS.BOOT_EXIT,
713
- actionTimeout: ({ context }) => context.actionTimeoutMsecs,
714
- dynamicReconnectTimeout: ({ context }) => {
715
- // If an action timeout is active, use its remaining time.
716
- if (context.actionTimeoutMsecs !== null && context.actionTimeoutMsecs > 0) {
717
- logger.debug(`[APM] Using remaining action timeout for reconnect window: ${context.actionTimeoutMsecs}ms`)
718
- return context.actionTimeoutMsecs
719
- }
720
- // Otherwise, use the standard reconnect timeout.
721
- logger.debug(`[APM] Using standard reconnect timeout: ${C.TIMEOUT_MSECS.RECONNECT}ms`)
722
- return C.TIMEOUT_MSECS.RECONNECT
723
- }
724
- }
725
- }
726
-
727
- // The AnearParticipantMachine:
728
- // 1. maintains the presence and geo-location for a Participant in an Event
729
- // 2. instantiates the XState Machine return by the (optional) appParticipantMachineFactory
730
- // 3. creates a private display ChannelMachine to which any participant displayType messages get published
731
- // 4. handles activity state, response timeouts, idle state
732
- // 5. receives ACTION events relayed by the AnearEventMachine
733
- // 6. relays all relevant events to the participant XState Machine for Application-specific handling
734
- const AnearParticipantMachine = (anearParticipant, { anearEvent, appParticipantMachineFactory, eventStats }) => {
735
- const anearParticipantMachine = createMachine(
736
- {
737
- ...AnearParticipantMachineConfig(anearParticipant.id),
738
- // v5: context is derived from actor `input` (no machine.withContext).
739
- context: ({ input }) => input
740
- },
741
- AnearParticipantMachineFunctions
742
- )
743
-
744
- const anearParticipantMachineContext = AnearParticipantMachineContext(
745
- anearParticipant,
746
- anearEvent,
747
- appParticipantMachineFactory,
748
- eventStats
749
- )
750
-
751
- const actor = createActor(anearParticipantMachine, { input: anearParticipantMachineContext })
752
- actor.start()
753
-
754
- anearParticipant.setMachine(actor)
755
-
756
- actor.subscribe(state => {
757
- logger.debug('─'.repeat(40))
758
- logger.debug(`APM EVENT → ${state.event?.type ?? '(init)'}`)
759
- logger.debug(`APM NEXT STATE → ${JSON.stringify(state.value)}`)
760
- })
761
-
762
- return actor
763
- }
764
-
765
- module.exports = AnearParticipantMachine