anear-js-api 0.5.2 → 0.5.4

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.
@@ -34,6 +34,11 @@ const getAllParticipantIds = (context) => {
34
34
  return Object.keys(context.participants);
35
35
  };
36
36
 
37
+ const getPresenceEventName = (participant, presenceAction) => {
38
+ const role = participant && participant.isHost ? 'HOST' : 'PARTICIPANT';
39
+ return `${role}_${presenceAction}`;
40
+ };
41
+
37
42
  const RealtimeMessaging = require('../utils/RealtimeMessaging')
38
43
  const AppMachineTransition = require('../utils/AppMachineTransition')
39
44
  const DisplayEventProcessor = require('../utils/DisplayEventProcessor')
@@ -51,7 +56,8 @@ const AnearEventMachineContext = (
51
56
  pugTemplates,
52
57
  pugHelpers,
53
58
  appEventMachineFactory,
54
- appParticipantMachineFactory
59
+ appParticipantMachineFactory,
60
+ rehydrate = false
55
61
  ) => ({
56
62
  anearEvent,
57
63
  coreServiceMachine,
@@ -59,6 +65,7 @@ const AnearEventMachineContext = (
59
65
  pugHelpers,
60
66
  appEventMachineFactory,
61
67
  appParticipantMachineFactory,
68
+ rehydrate,
62
69
  appEventMachine: null,
63
70
  eventChannel: null, // event control messages
64
71
  actionsChannel: null, // participant presence/live actions
@@ -72,11 +79,14 @@ const AnearEventMachineContext = (
72
79
  const DeferredStates = [
73
80
  'PARTICIPANT_ENTER',
74
81
  'PARTICIPANT_LEAVE',
82
+ 'PARTICIPANT_UPDATE',
75
83
  'SPECTATOR_ENTER',
76
84
  'PARTICIPANT_TIMEOUT',
77
85
  'ACTION',
78
86
  'CANCEL',
79
- 'CLOSE'
87
+ 'CLOSE',
88
+ 'SAVE',
89
+ 'PAUSE'
80
90
  ]
81
91
 
82
92
  const DeferredStatesPlus = (...additionalStates) => DeferredStates.concat(additionalStates)
@@ -94,6 +104,10 @@ const ActiveEventGlobalEvents = {
94
104
  CANCEL: {
95
105
  // appM does an abrupt shutdown of the event
96
106
  target: '#canceled'
107
+ },
108
+ APPM_FINAL: {
109
+ // AppM reached a root-level final → cleanup only (no ANAPI transition)
110
+ target: '#activeEvent.shutdownOnly'
97
111
  }
98
112
  }
99
113
 
@@ -186,10 +200,6 @@ const ActiveEventStatesConfig = {
186
200
  target: '#createdRendering'
187
201
  },
188
202
  PARTICIPANT_LEAVE: [
189
- {
190
- cond: 'isHostLeaving',
191
- actions: ['sendHostExitToAppMachine', 'sendExitToParticipantMachine']
192
- },
193
203
  {
194
204
  cond: 'isPermanentLeave',
195
205
  actions: ['sendExitToAppMachine', 'sendExitToParticipantMachine']
@@ -198,6 +208,25 @@ const ActiveEventStatesConfig = {
198
208
  actions: ['processDisconnectEvents']
199
209
  }
200
210
  ],
211
+ PARTICIPANT_UPDATE: [
212
+ // In the future, the AnearBrowser may periodically send presence updates to the AEM
213
+ // which will include latitude/longitude direction, etc. for Apps that require this
214
+ // level of approved user location tracking. These types of explicit presence.updates
215
+ // will include a type field in the event payload to indicate the type of update.
216
+ // Otherwise, the Realtime messaging system in the mobile client browser may be
217
+ // sending this presence updated implicityly without a type because it has detected
218
+ // that the user left the event and came back. If we receive this and the participant exists,
219
+ // this is a PARTICIPANT_RECONNECT scenario. The User likely navigated away from the
220
+ // event and came back. We need to update the AppM with a PARTICIPANT_RECONNECT event
221
+ // so they can refresh the view for the returning participant.
222
+ {
223
+ cond: 'isReconnectUpdate',
224
+ actions: 'processReconnectEvents'
225
+ },
226
+ {
227
+ actions: ['updateParticipantGeoLocation', 'processUpdateEvents']
228
+ }
229
+ ],
201
230
  PARTICIPANT_ENTER: [
202
231
  {
203
232
  cond: 'participantExists',
@@ -224,6 +253,42 @@ const ActiveEventStatesConfig = {
224
253
  },
225
254
  START: {
226
255
  target: '#activeEvent.live'
256
+ },
257
+ PAUSE: {
258
+ target: '#pausingEvent'
259
+ },
260
+ SAVE: {
261
+ target: 'savingAppEventContext'
262
+ }
263
+ }
264
+ },
265
+ pausingEvent: {
266
+ id: 'pausingEvent',
267
+ deferred: DeferredStates,
268
+ invoke: {
269
+ src: 'saveAppEventContext',
270
+ onDone: {
271
+ actions: ['sendPausedAckToAppMachine'],
272
+ target: '#waitingAnnounce',
273
+ internal: true
274
+ },
275
+ onError: {
276
+ target: '#activeEvent.failure'
277
+ }
278
+ }
279
+ },
280
+ savingAppEventContext: {
281
+ id: 'savingAppEventContext',
282
+ invoke: {
283
+ src: 'saveAppEventContext',
284
+ onDone: {
285
+ actions: ['sendSavedAckToAppMachine'],
286
+ target: '#waitingAnnounce',
287
+ internal: true
288
+ },
289
+ onError: {
290
+ // If save fails, log and remain in waitingAnnounce; AppM may retry/handle error UI
291
+ target: '#waitingAnnounce'
227
292
  }
228
293
  }
229
294
  },
@@ -265,10 +330,6 @@ const ActiveEventStatesConfig = {
265
330
  target: '#announceRendering'
266
331
  },
267
332
  PARTICIPANT_LEAVE: [
268
- {
269
- cond: 'isHostLeaving',
270
- actions: ['sendHostExitToAppMachine', 'sendExitToParticipantMachine']
271
- },
272
333
  {
273
334
  cond: 'isPermanentLeave',
274
335
  actions: ['sendExitToAppMachine', 'sendExitToParticipantMachine']
@@ -289,6 +350,9 @@ const ActiveEventStatesConfig = {
289
350
  target: '#newParticipantJoining'
290
351
  }
291
352
  ],
353
+ BOOT_PARTICIPANT: {
354
+ actions: 'sendBootEventToParticipantMachine'
355
+ },
292
356
  SPECTATOR_ENTER: {
293
357
  actions: 'sendSpectatorEnterToAppEventMachine'
294
358
  }
@@ -385,10 +449,6 @@ const ActiveEventStatesConfig = {
385
449
  target: '#liveRendering'
386
450
  },
387
451
  PARTICIPANT_LEAVE: [
388
- {
389
- cond: 'isHostLeaving',
390
- actions: ['sendHostExitToAppMachine', 'sendExitToParticipantMachine']
391
- },
392
452
  {
393
453
  cond: 'isPermanentLeave',
394
454
  actions: ['sendExitToAppMachine', 'sendExitToParticipantMachine']
@@ -407,8 +467,8 @@ const ActiveEventStatesConfig = {
407
467
  target: '.participantEntering'
408
468
  }
409
469
  ],
410
- PAUSE: {
411
- target: 'paused'
470
+ BOOT_PARTICIPANT: {
471
+ actions: 'sendBootEventToParticipantMachine'
412
472
  }
413
473
  },
414
474
  states: {
@@ -491,8 +551,8 @@ const ActiveEventStatesConfig = {
491
551
  },
492
552
  PARTICIPANT_LEAVE: [
493
553
  {
494
- cond: 'isHostLeaving',
495
- actions: ['removeLeavingParticipantFromTimeout', 'sendHostExitToAppMachine', 'sendExitToParticipantMachine']
554
+ cond: 'isBooting',
555
+ actions: ['removeLeavingParticipantFromTimeout', 'sendExitToParticipantMachine']
496
556
  },
497
557
  {
498
558
  cond: 'isPermanentLeave',
@@ -539,29 +599,6 @@ const ActiveEventStatesConfig = {
539
599
  }
540
600
  }
541
601
  },
542
- paused: {
543
- initial: 'transitioning',
544
- states: {
545
- transitioning: {
546
- deferred: DeferredStatesPlus('RESUME', 'CLOSE'),
547
- invoke: {
548
- src: 'transitionToPaused',
549
- onDone: {
550
- target: 'waitingForResume'
551
- },
552
- onError: {
553
- target: '#activeEvent.failure'
554
- }
555
- }
556
- },
557
- waitingForResume: {
558
- on: {
559
- RESUME: '#activeEvent.live',
560
- CLOSE: '#activeEvent.closeEvent'
561
- }
562
- }
563
- }
564
- },
565
602
  closeEvent: {
566
603
  id: 'closeEvent',
567
604
  initial: 'notifyingParticipants',
@@ -631,7 +668,7 @@ const ActiveEventStatesConfig = {
631
668
  }
632
669
  },
633
670
  finalizing: {
634
- deferred: DeferredStates,
671
+ // canceled path does ANAPI transition to canceled, then detaches
635
672
  invoke: {
636
673
  src: 'eventTransitionCanceled',
637
674
  onDone: 'detaching',
@@ -652,6 +689,42 @@ const ActiveEventStatesConfig = {
652
689
  }
653
690
  }
654
691
  },
692
+ shutdownOnly: {
693
+ id: 'shutdownOnly',
694
+ initial: 'notifyingParticipants',
695
+ states: {
696
+ notifyingParticipants: {
697
+ entry: 'sendParticipantExitEvents',
698
+ always: 'waitForParticipantsToExit'
699
+ },
700
+ waitForParticipantsToExit: {
701
+ entry: context => logger.debug(`[AEM] Entering waitForParticipantsToExit with ${getAllParticipantIds(context).length} participants`),
702
+ always: [
703
+ {
704
+ cond: context => getAllParticipantIds(context).length === 0,
705
+ target: 'detaching'
706
+ }
707
+ ],
708
+ on: {
709
+ PARTICIPANT_LEAVE: {
710
+ actions: () => logger.debug('[AEM] Ignoring PARTICIPANT_LEAVE during orchestrated shutdown.')
711
+ }
712
+ }
713
+ },
714
+ detaching: {
715
+ deferred: DeferredStates,
716
+ invoke: {
717
+ src: 'detachChannels',
718
+ onDone: {
719
+ target: '#activeEvent.doneExit'
720
+ },
721
+ onError: {
722
+ target: '#activeEvent.failure'
723
+ }
724
+ }
725
+ }
726
+ }
727
+ },
655
728
  review: {
656
729
  on: {
657
730
  NEXT: 'reward'
@@ -708,7 +781,10 @@ const CreateEventChannelsAndAppMachineConfig = {
708
781
  states: {
709
782
  setupEventChannel: {
710
783
  // get(eventChannelName) and setup state-change callbacks
711
- entry: [(c,e) => logger.debug(`[AEM] === NEW EVENT ${c.anearEvent.id} ===`), 'createEventChannel'],
784
+ entry: [(c,e) => {
785
+ const eventType = c.rehydrate ? 'LOAD_EVENT' : 'CREATE_EVENT'
786
+ logger.debug(`[AEM] === ${eventType} ${c.anearEvent.id} ===`)
787
+ }, 'createEventChannel'],
712
788
  invoke: {
713
789
  src: 'attachToEventChannel',
714
790
  onDone: {
@@ -720,7 +796,18 @@ const CreateEventChannelsAndAppMachineConfig = {
720
796
  }
721
797
  },
722
798
  on: {
723
- ATTACHED: {
799
+ ATTACHED: 'enterEventPresence'
800
+ }
801
+ },
802
+ enterEventPresence: {
803
+ invoke: {
804
+ src: 'enterEventPresence',
805
+ onDone: {
806
+ actions: ['subscribeToEventMessages'],
807
+ target: 'setupParticipantsDisplayChannel',
808
+ internal: true
809
+ },
810
+ onError: {
724
811
  actions: ['subscribeToEventMessages'],
725
812
  target: 'setupParticipantsDisplayChannel'
726
813
  }
@@ -782,9 +869,16 @@ const CreateEventChannelsAndAppMachineConfig = {
782
869
  }
783
870
  },
784
871
  createAppEventMachine: {
785
- entry: ['createAppEventMachine'],
786
- always: {
787
- target: '#activeEvent'
872
+ invoke: {
873
+ src: 'createAppEventMachine',
874
+ onDone: {
875
+ actions: ['setAppEventMachine'],
876
+ target: '#activeEvent',
877
+ internal: true
878
+ },
879
+ onError: {
880
+ target: '#activeEvent.failure'
881
+ }
788
882
  }
789
883
  }
790
884
  }
@@ -806,12 +900,20 @@ const AnearEventMachineStatesConfig = eventId => ({
806
900
 
807
901
  const AnearEventMachineFunctions = ({
808
902
  actions: {
809
- createAppEventMachine: assign({
810
- appEventMachine: context => {
811
- const machine = context.appEventMachineFactory(context.anearEvent)
812
- const service = interpret(machine)
813
- service.subscribe(AppMachineTransition(context.anearEvent))
814
- return service.start()
903
+ sendPausedAckToAppMachine: (context, _event) => {
904
+ if (context.appEventMachine) {
905
+ context.appEventMachine.send('PAUSED')
906
+ }
907
+ },
908
+ sendSavedAckToAppMachine: (context, _event) => {
909
+ if (context.appEventMachine) {
910
+ context.appEventMachine.send('SAVED')
911
+ }
912
+ },
913
+ setAppEventMachine: assign({
914
+ appEventMachine: (context, event) => {
915
+ const service = event.data.service
916
+ return service
815
917
  }
816
918
  }),
817
919
  notifyAppMachineRendered: (context, event) => {
@@ -924,8 +1026,7 @@ const AnearEventMachineFunctions = ({
924
1026
  return;
925
1027
  }
926
1028
 
927
- const isHost = anearParticipant.isHost;
928
- const eventName = isHost ? 'HOST_ENTER' : 'PARTICIPANT_ENTER';
1029
+ const eventName = getPresenceEventName(anearParticipant, 'ENTER');
929
1030
  const eventPayload = { participant: participantInfo };
930
1031
 
931
1032
  logger.debug(`[AEM] Sending ${eventName} for ${anearParticipant.id}`);
@@ -934,11 +1035,10 @@ const AnearEventMachineFunctions = ({
934
1035
  processDisconnectEvents: (context, event) => {
935
1036
  const participantId = event.data.id;
936
1037
  const participant = context.participants[participantId];
937
- const isHost = participant && participant.isHost;
938
- const eventName = isHost ? 'HOST_DISCONNECT' : 'PARTICIPANT_DISCONNECT';
1038
+ const eventName = getPresenceEventName(participant, 'DISCONNECT');
939
1039
  const participantMachine = context.participantMachines[participantId];
940
1040
 
941
- logger.debug(`[AEM] processing disconnect for ${participantId}. Is host? ${isHost}`);
1041
+ logger.debug(`[AEM] processing ${eventName} for ${participantId}`);
942
1042
  if (participantMachine) {
943
1043
  participantMachine.send('PARTICIPANT_DISCONNECT');
944
1044
  }
@@ -947,31 +1047,79 @@ const AnearEventMachineFunctions = ({
947
1047
  processReconnectEvents: (context, event) => {
948
1048
  const participantId = event.data.id;
949
1049
  const participant = context.participants[participantId];
950
- const isHost = participant && participant.isHost;
951
- const eventName = isHost ? 'HOST_RECONNECT' : 'PARTICIPANT_RECONNECT';
1050
+ const eventName = getPresenceEventName(participant, 'RECONNECT');
952
1051
  const participantMachine = context.participantMachines[participantId];
953
1052
 
954
- logger.debug(`[AEM] processing reconnect for ${participantId}. Is host? ${isHost}`);
1053
+ logger.debug(`[AEM] processing ${eventName} for ${participantId}`);
955
1054
  if (participantMachine) {
956
1055
  participantMachine.send('PARTICIPANT_RECONNECT');
957
1056
  }
958
1057
  context.appEventMachine.send(eventName, { participantId });
959
1058
  },
1059
+ updateParticipantGeoLocation: assign({
1060
+ participants: (context, event) => {
1061
+ const { id, geoLocation } = event.data;
1062
+ const participantInfoToUpdate = context.participants[id];
1063
+
1064
+ if (!participantInfoToUpdate) return context.participants;
1065
+
1066
+ // Create a new, updated info object
1067
+ const updatedParticipantInfo = {
1068
+ ...participantInfoToUpdate,
1069
+ geoLocation
1070
+ };
1071
+
1072
+ // Return a new participants object with the updated participant info
1073
+ return {
1074
+ ...context.participants,
1075
+ [id]: updatedParticipantInfo
1076
+ };
1077
+ }
1078
+ }),
1079
+ processUpdateEvents: (context, event) => {
1080
+ const { id } = event.data;
1081
+ // NOTE: get the full AnearParticipant object from the AEM instance,
1082
+ // NOT the plain info object from the context.
1083
+ const participant = context.participants[id];
1084
+
1085
+ if (!participant) return;
1086
+
1087
+ const eventName = getPresenceEventName(participant, 'UPDATE');
1088
+ const participantMachine = context.participantMachines[id];
1089
+
1090
+ // AppM gets the role-specific event
1091
+ const appMPayload = { type: eventName, participant };
1092
+
1093
+ logger.debug(`[AEM] processing ${eventName} for ${id}`);
1094
+ if (participantMachine) {
1095
+ // APM always gets the generic event
1096
+ const apmPayload = { type: 'PARTICIPANT_UPDATE', participant };
1097
+ participantMachine.send(apmPayload);
1098
+ }
1099
+ context.appEventMachine.send(appMPayload);
1100
+ },
960
1101
  sendExitToAppMachine: (context, event) => {
961
- // coming from an action channel message, event.data.id
962
1102
  const participantId = event.data.id;
963
- const participantInfo = context.participants[participantId];
964
- if (participantInfo) {
965
- logger.debug(`[AEM] sending PARTICIPANT_EXIT to AppM for participant ${participantId}`);
966
- context.appEventMachine.send('PARTICIPANT_EXIT', { participantId });
1103
+ const participant = context.participants[participantId];
1104
+ if (participant) {
1105
+ const eventName = getPresenceEventName(participant, 'EXIT');
1106
+ logger.debug(`[AEM] sending ${eventName} to AppM for participant ${participantId}`);
1107
+ context.appEventMachine.send(eventName, { participantId });
967
1108
  } else {
968
1109
  logger.warn(`[AEM] Participant info not found for id ${participantId} during sendExitToAppMachine`);
969
1110
  }
970
1111
  },
971
- sendHostExitToAppMachine: (context, event) => {
972
- const participantId = event.data.id;
973
- logger.debug(`[AEM] sending HOST_EXIT to AppM for host ${participantId}`);
974
- context.appEventMachine.send('HOST_EXIT', { participantId });
1112
+ sendBootEventToParticipantMachine: (context, event) => {
1113
+ const { participantId, reason } = event.data;
1114
+ const participantMachine = context.participantMachines[participantId];
1115
+ if (participantMachine) {
1116
+ participantMachine.send({
1117
+ type: 'BOOT_PARTICIPANT',
1118
+ data: { reason }
1119
+ })
1120
+ } else {
1121
+ logger.warn(`[AEM] Participant machine not found for id ${participantId} during sendBootEventToParticipantMachine`);
1122
+ }
975
1123
  },
976
1124
  sendExitToParticipantMachine: (context, event) => {
977
1125
  // coming from an action channel message, event.data.id
@@ -986,18 +1134,27 @@ const AnearEventMachineFunctions = ({
986
1134
  sendParticipantExitEvents: context => {
987
1135
  Object.values(context.participantMachines).forEach(pm => pm.send('PARTICIPANT_EXIT'))
988
1136
  },
1137
+ updateParticipantPresence: (context, event) => {
1138
+ const participantId = event.data.id;
1139
+ const participant = context.participants[participantId];
1140
+ const participantMachine = context.participantMachines[participantId];
989
1141
 
1142
+ if (!participant) {
1143
+ logger.warn(`[AEM] Participant info not found for id ${participantId} during updateParticipantPresence`);
1144
+ return;
1145
+ }
990
1146
 
991
- updateParticipantPresence: (context, event) => {
992
- // lookup the participantMachine and update its context
993
- const participantMachine = context.participantMachines[event.data.id]
1147
+ // APM always gets the generic event
994
1148
  if (participantMachine) {
995
1149
  // opportunity to send presence data update like geoLocation, and
996
1150
  // to inform app that a participant still has interest in the possibly long
997
1151
  // running, light-participation event
998
- participantMachine.send('PARTICIPANT_UPDATE', event.data)
1152
+ participantMachine.send('PARTICIPANT_UPDATE', event.data);
999
1153
  }
1000
- context.appEventMachine.send('PARTICIPANT_UPDATE', event.data)
1154
+
1155
+ // AppM gets the role-specific event
1156
+ const eventName = getPresenceEventName(participant, 'UPDATE');
1157
+ context.appEventMachine.send(eventName, event.data);
1001
1158
  },
1002
1159
  processParticipantAction: (context, event) => {
1003
1160
  // event.data.participantId,
@@ -1053,16 +1210,23 @@ const AnearEventMachineFunctions = ({
1053
1210
  { participantId: event.participantId }
1054
1211
  ),
1055
1212
  setupParticipantsTimeout: assign((context, event) => {
1056
- const timeoutMsecs = event.data.participantsTimeout.msecs
1057
- const allParticipantIds = getPlayingParticipantIds(context)
1058
- logger.debug(`[AEM] Starting participants action timeout for ${timeoutMsecs}ms. Responders: ${allParticipantIds.join(', ')}`)
1213
+ // Only set up a new timeout if one is provided in the event data.
1214
+ // This prevents overwriting an existing timeout during a simple re-render.
1215
+ if (event.data && event.data.participantsTimeout) {
1216
+ const timeoutMsecs = event.data.participantsTimeout.msecs
1217
+ const allParticipantIds = getPlayingParticipantIds(context)
1218
+ logger.debug(`[AEM] Starting participants action timeout for ${timeoutMsecs}ms. Responders: ${allParticipantIds.join(', ')}`)
1059
1219
 
1060
- return {
1061
- participantsActionTimeout: {
1062
- msecs: timeoutMsecs,
1063
- nonResponders: new Set(allParticipantIds)
1220
+ return {
1221
+ participantsActionTimeout: {
1222
+ msecs: timeoutMsecs,
1223
+ startedAt: Date.now(),
1224
+ nonResponders: new Set(allParticipantIds)
1225
+ }
1064
1226
  }
1065
1227
  }
1228
+ // If no timeout data, return empty object to not change context
1229
+ return {};
1066
1230
  }),
1067
1231
  processParticipantResponse: assign((context, event) => {
1068
1232
  const participantId = event.data.participantId
@@ -1078,17 +1242,24 @@ const AnearEventMachineFunctions = ({
1078
1242
  }
1079
1243
  }),
1080
1244
  removeLeavingParticipantFromTimeout: assign((context, event) => {
1081
- const participantId = event.data.id
1082
- const { nonResponders, ...rest } = context.participantsActionTimeout
1083
- const newNonResponders = new Set(nonResponders)
1084
- newNonResponders.delete(participantId)
1245
+ const participantId = event.data.id;
1246
+ const participant = context.participants[participantId];
1247
+
1248
+ // If there's no active timeout, or if the leaving participant is the host, do nothing.
1249
+ if (!context.participantsActionTimeout || (participant && participant.isHost)) {
1250
+ return {};
1251
+ }
1252
+
1253
+ const { nonResponders, ...rest } = context.participantsActionTimeout;
1254
+ const newNonResponders = new Set(nonResponders);
1255
+ newNonResponders.delete(participantId);
1085
1256
 
1086
1257
  return {
1087
1258
  participantsActionTimeout: {
1088
1259
  ...rest,
1089
1260
  nonResponders: newNonResponders
1090
1261
  }
1091
- }
1262
+ };
1092
1263
  }),
1093
1264
  sendActionsTimeoutToAppM: (context, _event) => {
1094
1265
  const { nonResponders, msecs } = context.participantsActionTimeout
@@ -1139,6 +1310,63 @@ const AnearEventMachineFunctions = ({
1139
1310
  logInvalidParticipantEnter: (c, e) => logger.info("[AEM] Error: Unexepected PARTICIPANT_ENTER with id: ", e.data.id),
1140
1311
  },
1141
1312
  services: {
1313
+ saveAppEventContext: async (context, event) => {
1314
+ // Events like PAUSE/SAVE are sent as send('PAUSE', { appmContext: {...} })
1315
+ // event.appmContext -> { context, resumeEvent }
1316
+ const appmContext = event?.appmContext || {}
1317
+ const payload = {
1318
+ eventId: context.anearEvent.id,
1319
+ savedAt: new Date().toISOString(),
1320
+ ...appmContext
1321
+ }
1322
+ await AnearApi.saveAppEventContext(context.anearEvent.id, payload)
1323
+ return 'done'
1324
+ },
1325
+ enterEventPresence: async (context, _event) => {
1326
+ const data = { actor: 'AEM', eventId: context.anearEvent.id, start: Date.now() }
1327
+ await RealtimeMessaging.setPresence(context.eventChannel, data)
1328
+ return { service: started }
1329
+ },
1330
+ createAppEventMachine: async (context, _event) => {
1331
+ // Build the AppM, optionally rehydrating from saved app_event_context
1332
+ const baseMachine = context.appEventMachineFactory(context.anearEvent)
1333
+
1334
+ let machineToStart = baseMachine
1335
+ let resumeEvent = null
1336
+
1337
+ if (context.rehydrate) {
1338
+ try {
1339
+ const { appmContext } = await AnearApi.getLatestAppEventContext(context.anearEvent.id)
1340
+ if (appmContext && typeof appmContext === 'object') {
1341
+ const savedContext = appmContext.context
1342
+ resumeEvent = appmContext.resumeEvent
1343
+ if (savedContext && typeof savedContext === 'object') {
1344
+ machineToStart = baseMachine.withContext(savedContext)
1345
+ }
1346
+ }
1347
+ } catch (e) {
1348
+ // Log and proceed without rehydration
1349
+ logger.warn('[AEM] Failed to fetch or parse app_event_context. Starting clean.', e)
1350
+ }
1351
+ }
1352
+
1353
+ const service = interpret(machineToStart)
1354
+ service.subscribe(AppMachineTransition(context.anearEvent))
1355
+ // Auto-cleanup when AppM final: notify AEM
1356
+ try {
1357
+ service.onDone(() => {
1358
+ logger.debug('[AEM] AppM reached final state, sending APPM_FINAL for cleanup-only shutdown')
1359
+ context.anearEvent.send('APPM_FINAL')
1360
+ })
1361
+ } catch (_e) {}
1362
+ const started = service.start()
1363
+
1364
+ if (resumeEvent && resumeEvent.type) {
1365
+ started.send(resumeEvent)
1366
+ }
1367
+
1368
+ return { service: started }
1369
+ },
1142
1370
  renderDisplay: async (context, event) => {
1143
1371
  const displayEventProcessor = new DisplayEventProcessor(context)
1144
1372
 
@@ -1201,9 +1429,6 @@ const AnearEventMachineFunctions = ({
1201
1429
  transitionToCanceled: (context, event) => {
1202
1430
  return AnearApi.transitionEvent(context.anearEvent.id, 'canceled')
1203
1431
  },
1204
- transitionToPaused: (context, event) => {
1205
- return AnearApi.transitionEvent(context.anearEvent.id, 'paused')
1206
- },
1207
1432
  eventTransitionClosed: async (context, event) => {
1208
1433
  // This service handles the transition of the event to 'closed' via AnearApi
1209
1434
  // and the publishing of the 'EVENT_TRANSITION' message to ABRs.
@@ -1224,6 +1449,10 @@ const AnearEventMachineFunctions = ({
1224
1449
  }
1225
1450
  },
1226
1451
  guards: {
1452
+ isReconnectUpdate: (context, event) => {
1453
+ // participant exists and event.data.type is undefined
1454
+ return context.participants[event.data.id] && !event.data.type
1455
+ },
1227
1456
  isPermanentLeave: (context, event) => {
1228
1457
  // The remote client has left the event. This is a permanent exit
1229
1458
  // from the event
@@ -1237,18 +1466,18 @@ const AnearEventMachineFunctions = ({
1237
1466
  return false
1238
1467
  }
1239
1468
  },
1240
- isHostLeaving: (context, event) => {
1241
- const participantId = event.data.id;
1242
- const participant = context.participants[participantId];
1243
- return participant && participant.isHost;
1469
+ isBooting: (_c, event) => {
1470
+ return event.data.type === 'BOOTED'
1244
1471
  },
1245
1472
  participantExists: (context, event) => !!context.participants[event.data.id],
1246
- eventCreatorIsHost: (context, event) => context.anearEvent.hosted,
1247
- isOpenHouseEvent: (context, event) => context.anearEvent.openHouse || false,
1473
+ eventCreatorIsHost: (context, _e) => context.anearEvent.hosted,
1474
+ isOpenHouseEvent: (context, _e) => context.anearEvent.openHouse || false,
1248
1475
  isParticipantsTimeoutActive: (context, event) => {
1249
- return event.data && event.data.participantsTimeout && event.data.participantsTimeout.msecs > 0
1476
+ const isStartingNewTimeout = event.data && event.data.participantsTimeout && event.data.participantsTimeout.msecs > 0;
1477
+ const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
1478
+ return isStartingNewTimeout || isTimeoutAlreadyRunning;
1250
1479
  },
1251
- allParticipantsResponded: (context, event) => {
1480
+ allParticipantsResponded: (context, _e) => {
1252
1481
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
1253
1482
  }
1254
1483
  },
@@ -1258,7 +1487,7 @@ const AnearEventMachineFunctions = ({
1258
1487
  timeoutEventAnnounce: context => C.TIMEOUT_MSECS.ANNOUNCE,
1259
1488
  timeoutEventStart: context => C.TIMEOUT_MSECS.START,
1260
1489
  timeoutRendered: context => C.TIMEOUT_MSECS.RENDERED_EVENT_DELAY,
1261
- participantsActionTimeout: (context, event) => {
1490
+ participantsActionTimeout: (context, _e) => {
1262
1491
  return context.participantsActionTimeout.msecs
1263
1492
  }
1264
1493
  }
@@ -1269,7 +1498,8 @@ const AnearEventMachine = (anearEvent, {
1269
1498
  pugTemplates,
1270
1499
  appEventMachineFactory,
1271
1500
  appParticipantMachineFactory,
1272
- imageAssetsUrl
1501
+ imageAssetsUrl,
1502
+ rehydrate = false
1273
1503
  }) => {
1274
1504
  const expandedConfig = {predictableActionArguments: true, ...AnearEventMachineStatesConfig(anearEvent.id)}
1275
1505
 
@@ -1282,7 +1512,8 @@ const AnearEventMachine = (anearEvent, {
1282
1512
  pugTemplates,
1283
1513
  pugHelpers,
1284
1514
  appEventMachineFactory,
1285
- appParticipantMachineFactory
1515
+ appParticipantMachineFactory,
1516
+ rehydrate
1286
1517
  )
1287
1518
 
1288
1519
  const service = interpret(eventMachine.withContext(anearEventMachineContext))