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.
- package/EACH_PARTICIPANT_LIFECYCLE.md +3 -1
- package/README.md +12 -19
- package/lib/AnearService.js +3 -3
- package/lib/ci/registerAppVersion.js +7 -1
- package/lib/models/AnearEvent.js +2 -4
- package/lib/state_machines/AnearCoreServiceMachine.js +33 -6
- package/lib/state_machines/AnearEventMachine.js +195 -360
- package/lib/utils/AssetFileCollector.js +0 -2
- package/lib/utils/DisplayEventProcessor.js +21 -40
- package/package.json +1 -1
- package/tests/DisplayEventProcessor.test.js +39 -26
- package/lib/state_machines/AnearParticipantMachine.js +0 -765
|
@@ -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
|