anear-js-api 0.5.2 → 0.5.3

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.
@@ -43,6 +43,10 @@ confirmMove: {
43
43
  return [{
44
44
  participantId: movingParticipantId,
45
45
  view: 'participant/MoveConfirmation', // A view confirming their move was received
46
+ props: {
47
+ message: 'Your move has been recorded. Waiting for opponent...',
48
+ move: event.moveData
49
+ },
46
50
  timeout: 2000 // A short timeout for the confirmation display
47
51
  }];
48
52
  }
@@ -137,94 +141,4 @@ services: {
137
141
  The `DisplayEventProcessor` loops through each `displayEvent` and, based on the target, decides what to do.
138
142
 
139
143
  * **File:** `/Users/machvee/dev/anear-js-api/lib/utils/DisplayEventProcessor.js`
140
- * **Description:** For an `eachParticipant` target, it doesn't publish to a public channel. Instead, it performs a critical handoff: it finds the specific participant's own state machine (`AnearParticipantMachine`) and sends a *private* `RENDER_DISPLAY` event directly to that machine. This is the key to sending a message to only one person.
141
- * **Code Reference (lines 142-156 and 229-256):**
142
-
143
- ```javascript
144
- // ... inside DisplayEventProcessor.js _processSingle
145
- case 'eachParticipant':
146
- // ...
147
- // It determines we need to send to a specific participant
148
- publishPromise = this._processSelectiveParticipantDisplay(
149
- template,
150
- templateRenderContext,
151
- null,
152
- participantId, // e.g., 'participant-123'
153
- displayTimeout // e.g., 10000
154
- );
155
- // ...
156
-
157
- // ... inside _sendPrivateDisplay
158
- _sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, timeoutFn) {
159
- // ... it compiles the pug template into HTML ...
160
- const privateHtml = template(privateRenderContext);
161
-
162
- const renderMessage = { content: privateHtml };
163
- if (timeout !== null) {
164
- renderMessage.timeout = timeout; // Attaches the 10000ms timeout
165
- }
166
-
167
- // CRITICAL: It sends an event to the specific participant's machine, NOT to Ably.
168
- participantMachine.send('RENDER_DISPLAY', renderMessage);
169
- }
170
- ```
171
- ---
172
-
173
- ### Part 4: The Target & Delivery (`AnearParticipantMachine.js`)
174
-
175
- **Context:** Each participant in an event has their own instance of the `AnearParticipantMachine` (APM). This machine manages their connection, timeouts, and, most importantly, their private Ably channel.
176
-
177
- #### Lifecycle Step 5: Receiving the Private Display Command
178
-
179
- The target participant's APM receives the `RENDER_DISPLAY` event from the `DisplayEventProcessor`.
180
-
181
- * **File:** `/Users/machvee/dev/anear-js-api/lib/state_machines/AnearParticipantMachine.js`
182
- * **Description:** The APM transitions to its own `renderDisplay` state, where it invokes its `publishPrivateDisplay` service. The payload it received (`renderMessage`) contains the final HTML and the 10-second timeout.
183
- * **Code Reference (lines 96-98 and 147-160):**
184
-
185
- ```javascript
186
- // ... inside AnearParticipantMachine.js
187
- idle: {
188
- on: {
189
- RENDER_DISPLAY: {
190
- target: '#renderDisplay'
191
- },
192
- // ...
193
- renderDisplay: {
194
- id: 'renderDisplay',
195
- invoke: {
196
- src: 'publishPrivateDisplay', // This is the final step
197
- onDone: [
198
- // After publishing, it starts waiting for the user's ACTION
199
- { cond: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
200
- { target: 'idle', internal: true }
201
- ],
202
- // ...
203
- ```
204
-
205
- #### Lifecycle Step 6: Sending the Ably Message
206
-
207
- This is the final hop. The `publishPrivateDisplay` service does one thing: it publishes the HTML to the participant's private channel.
208
-
209
- * **File:** `/Users/machvee/dev/anear-js-api/lib/state_machines/AnearParticipantMachine.js`
210
- * **Description:** It uses the `RealtimeMessaging` utility to send the message over Ably. The participant's browser, which is subscribed to this unique channel, receives the message and updates the DOM.
211
- * **Code Reference (lines 348-358):**
212
-
213
- ```javascript
214
- // ... inside AnearParticipantMachine.js
215
- services: {
216
- publishPrivateDisplay: async (context, event) => {
217
- // event.content is the final HTML from the DisplayEventProcessor
218
- const displayMessage = { content: event.content };
219
-
220
- await RealtimeMessaging.publish(
221
- context.privateChannel, // The participant's unique channel
222
- 'PRIVATE_DISPLAY',
223
- displayMessage
224
- );
225
-
226
- // It returns the timeout so the onDone transition knows to start the timer
227
- return { timeout: event.timeout };
228
- },
229
- // ...
230
- ```
144
+ * **Description:** For an `eachParticipant`
@@ -26,7 +26,11 @@ const Config = {
26
26
  meta: {
27
27
  eachParticipant: {
28
28
  view: 'PlayableGameBoard',
29
- timeout: calcParticipantTimeout
29
+ timeout: calcParticipantTimeout,
30
+ props: {
31
+ title: "Your Move!",
32
+ highlightLastMove: true
33
+ }
30
34
  },
31
35
  spectators: 'ViewableGameBoard'
32
36
  }
@@ -135,7 +139,7 @@ const actions = {
135
139
  - `null`: No timeout
136
140
  - `number`: Fixed timeout in milliseconds
137
141
  - `Function`: Dynamic timeout function `(appContext, participantId) => msecs`
138
- - **`props`** (Object): Additional properties merged into meta (optional)
142
+ - **`props`** (Object): Additional properties made available at the root of the pug template's render context (optional)
139
143
 
140
144
  ## When to Use Each Approach
141
145
 
@@ -79,6 +79,28 @@ class AnearApi extends ApiService {
79
79
  logger.debug('getAppFontAssetsUploadUrls response:', attrs)
80
80
  return attrs
81
81
  }
82
+
83
+ async saveAppEventContext(eventId, appmContext) {
84
+ logger.debug(`API: POST developer events/${eventId}/app_event_context`)
85
+ const path = `events/${eventId}/app_event_context`
86
+ return this.postRaw(path, { appm_context: appmContext })
87
+ }
88
+
89
+ async getLatestAppEventContext(eventId) {
90
+ logger.debug(`API: GET developer events/${eventId}/app_event_context`)
91
+ const path = `events/${eventId}/app_event_context`
92
+ const json = await this.get(path)
93
+ const attrs = json.data && json.data.attributes ? json.data.attributes : {}
94
+ const eventIdAttr = attrs['event-id']
95
+ const raw = attrs['appm-context']
96
+ let appmContext = null
97
+ try {
98
+ appmContext = typeof raw === 'string' ? JSON.parse(raw) : raw
99
+ } catch (e) {
100
+ // leave appmContext as null if parsing fails
101
+ }
102
+ return { eventId: eventIdAttr, appmContext }
103
+ }
82
104
  }
83
105
 
84
106
  // Instantiate and export the API immediately
@@ -56,6 +56,18 @@ class ApiService {
56
56
  return this.issueRequest(request)
57
57
  }
58
58
 
59
+ postRaw(path, body={}) {
60
+ const urlString = `${this.api_base_url}/${path}`
61
+ const request = new fetch.Request(
62
+ urlString, {
63
+ method: 'POST',
64
+ headers: this.default_headers,
65
+ body: JSON.stringify(body)
66
+ }
67
+ )
68
+ return this.issueRequest(request)
69
+ }
70
+
59
71
  put(resource, id, attributes, relationships={}) {
60
72
  const payload = this.formatPayload(resource, attributes, relationships)
61
73
  const urlString = `${this.api_base_url}/${resource}/${id}`
@@ -1,5 +1,6 @@
1
1
  "use strict"
2
2
  const logger = require('../utils/Logger')
3
+ const anearApi = require('../api/AnearApi')
3
4
 
4
5
  const JsonApiResource = require('./JsonApiResource')
5
6
 
@@ -46,6 +47,20 @@ class AnearEvent extends JsonApiResource {
46
47
  this.send("CLOSE")
47
48
  }
48
49
 
50
+ pauseEvent(context, resumeEvent = { type: 'RESUME' }) {
51
+ // Persist via AEM and acknowledge with PAUSED
52
+ this.send('PAUSE', { appmContext: { context, resumeEvent } })
53
+ }
54
+
55
+ saveEvent(context, resumeEvent = { type: 'RESUME' }) {
56
+ // Delegate save to the AEM; AEM will persist via ANAPI and acknowledge with SAVED
57
+ this.send('SAVE', { appmContext: { context, resumeEvent } })
58
+ }
59
+
60
+ bootParticipant(participantId, reason) {
61
+ this.send("BOOT_PARTICIPANT", { participantId, reason })
62
+ }
63
+
49
64
  render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
50
65
  // Explicit render method for guaranteed rendering control
51
66
  // This complements the meta: {} approach for when you need explicit control
@@ -80,14 +95,6 @@ class AnearEvent extends JsonApiResource {
80
95
  })
81
96
  }
82
97
 
83
- pauseEvent() {
84
- this.send("PAUSE")
85
- }
86
-
87
- resumeEvent() {
88
- this.send("RESUME")
89
- }
90
-
91
98
  getClonedFromEvent() {
92
99
  // if the current event was a clone of previous event, fetch if from
93
100
  // Peristence and return
@@ -134,7 +134,7 @@ const AnearCoreServiceMachineConfig = appId => ({
134
134
  }
135
135
  },
136
136
  waitAnearEventLifecycleCommand: {
137
- // The Anear API backend will send CREATE_EVENT messages with the event JSON data
137
+ // The Anear API backend will send CREATE_EVENT or LOAD_EVENT messages with the event JSON data
138
138
  // to this createEventMessages Channel when it needs to create a new instance of an
139
139
  // Event
140
140
  entry: (context) => logger.debug(`Waiting on ${context.appData.data.attributes['short-name']} lifecycle command`),
@@ -142,6 +142,9 @@ const AnearCoreServiceMachineConfig = appId => ({
142
142
  CREATE_EVENT: {
143
143
  actions: ['startNewEventMachine']
144
144
  },
145
+ LOAD_EVENT: {
146
+ actions: ['startNewEventMachine']
147
+ },
145
148
  EVENT_MACHINE_EXIT: {
146
149
  actions: [
147
150
  'cleanupEventMachine'
@@ -234,6 +237,11 @@ const AnearCoreServiceMachineFunctions = {
234
237
  context.coreServiceMachine,
235
238
  'CREATE_EVENT'
236
239
  )
240
+ RealtimeMessaging.subscribe(
241
+ context.newEventCreationChannel,
242
+ context.coreServiceMachine,
243
+ 'LOAD_EVENT'
244
+ )
237
245
  },
238
246
  startNewEventMachine: assign(
239
247
  {
@@ -242,11 +250,12 @@ const AnearCoreServiceMachineFunctions = {
242
250
  const anearEvent = new AnearEvent(eventJSON)
243
251
 
244
252
  if (context.anearEventMachines[anearEvent.id]) {
245
- logger.info(`[ACSM] Event machine for ${anearEvent.id} already exists. Ignoring CREATE_EVENT.`)
253
+ logger.info(`[ACSM] Event machine for ${anearEvent.id} already exists. Ignoring ${event.type}.`)
246
254
  return context.anearEventMachines
247
255
  }
248
256
 
249
- const service = AnearEventMachine(anearEvent, context)
257
+ const isLoadEvent = event.type === 'LOAD_EVENT'
258
+ const service = AnearEventMachine(anearEvent, { ...context, rehydrate: isLoadEvent })
250
259
 
251
260
  return {
252
261
  ...context.anearEventMachines,
@@ -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,22 @@ 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
+ nonResponders: new Set(allParticipantIds)
1224
+ }
1064
1225
  }
1065
1226
  }
1227
+ // If no timeout data, return empty object to not change context
1228
+ return {};
1066
1229
  }),
1067
1230
  processParticipantResponse: assign((context, event) => {
1068
1231
  const participantId = event.data.participantId
@@ -1078,17 +1241,24 @@ const AnearEventMachineFunctions = ({
1078
1241
  }
1079
1242
  }),
1080
1243
  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)
1244
+ const participantId = event.data.id;
1245
+ const participant = context.participants[participantId];
1246
+
1247
+ // If there's no active timeout, or if the leaving participant is the host, do nothing.
1248
+ if (!context.participantsActionTimeout || (participant && participant.isHost)) {
1249
+ return {};
1250
+ }
1251
+
1252
+ const { nonResponders, ...rest } = context.participantsActionTimeout;
1253
+ const newNonResponders = new Set(nonResponders);
1254
+ newNonResponders.delete(participantId);
1085
1255
 
1086
1256
  return {
1087
1257
  participantsActionTimeout: {
1088
1258
  ...rest,
1089
1259
  nonResponders: newNonResponders
1090
1260
  }
1091
- }
1261
+ };
1092
1262
  }),
1093
1263
  sendActionsTimeoutToAppM: (context, _event) => {
1094
1264
  const { nonResponders, msecs } = context.participantsActionTimeout
@@ -1139,6 +1309,63 @@ const AnearEventMachineFunctions = ({
1139
1309
  logInvalidParticipantEnter: (c, e) => logger.info("[AEM] Error: Unexepected PARTICIPANT_ENTER with id: ", e.data.id),
1140
1310
  },
1141
1311
  services: {
1312
+ saveAppEventContext: async (context, event) => {
1313
+ // Events like PAUSE/SAVE are sent as send('PAUSE', { appmContext: {...} })
1314
+ // event.appmContext -> { context, resumeEvent }
1315
+ const appmContext = event?.appmContext || {}
1316
+ const payload = {
1317
+ eventId: context.anearEvent.id,
1318
+ savedAt: new Date().toISOString(),
1319
+ ...appmContext
1320
+ }
1321
+ await AnearApi.saveAppEventContext(context.anearEvent.id, payload)
1322
+ return 'done'
1323
+ },
1324
+ enterEventPresence: async (context, _event) => {
1325
+ const data = { actor: 'AEM', eventId: context.anearEvent.id, start: Date.now() }
1326
+ await RealtimeMessaging.setPresence(context.eventChannel, data)
1327
+ return { service: started }
1328
+ },
1329
+ createAppEventMachine: async (context, _event) => {
1330
+ // Build the AppM, optionally rehydrating from saved app_event_context
1331
+ const baseMachine = context.appEventMachineFactory(context.anearEvent)
1332
+
1333
+ let machineToStart = baseMachine
1334
+ let resumeEvent = null
1335
+
1336
+ if (context.rehydrate) {
1337
+ try {
1338
+ const { appmContext } = await AnearApi.getLatestAppEventContext(context.anearEvent.id)
1339
+ if (appmContext && typeof appmContext === 'object') {
1340
+ const savedContext = appmContext.context
1341
+ resumeEvent = appmContext.resumeEvent
1342
+ if (savedContext && typeof savedContext === 'object') {
1343
+ machineToStart = baseMachine.withContext(savedContext)
1344
+ }
1345
+ }
1346
+ } catch (e) {
1347
+ // Log and proceed without rehydration
1348
+ logger.warn('[AEM] Failed to fetch or parse app_event_context. Starting clean.', e)
1349
+ }
1350
+ }
1351
+
1352
+ const service = interpret(machineToStart)
1353
+ service.subscribe(AppMachineTransition(context.anearEvent))
1354
+ // Auto-cleanup when AppM final: notify AEM
1355
+ try {
1356
+ service.onDone(() => {
1357
+ logger.debug('[AEM] AppM reached final state, sending APPM_FINAL for cleanup-only shutdown')
1358
+ context.anearEvent.send('APPM_FINAL')
1359
+ })
1360
+ } catch (_e) {}
1361
+ const started = service.start()
1362
+
1363
+ if (resumeEvent && resumeEvent.type) {
1364
+ started.send(resumeEvent)
1365
+ }
1366
+
1367
+ return { service: started }
1368
+ },
1142
1369
  renderDisplay: async (context, event) => {
1143
1370
  const displayEventProcessor = new DisplayEventProcessor(context)
1144
1371
 
@@ -1201,9 +1428,6 @@ const AnearEventMachineFunctions = ({
1201
1428
  transitionToCanceled: (context, event) => {
1202
1429
  return AnearApi.transitionEvent(context.anearEvent.id, 'canceled')
1203
1430
  },
1204
- transitionToPaused: (context, event) => {
1205
- return AnearApi.transitionEvent(context.anearEvent.id, 'paused')
1206
- },
1207
1431
  eventTransitionClosed: async (context, event) => {
1208
1432
  // This service handles the transition of the event to 'closed' via AnearApi
1209
1433
  // and the publishing of the 'EVENT_TRANSITION' message to ABRs.
@@ -1224,6 +1448,10 @@ const AnearEventMachineFunctions = ({
1224
1448
  }
1225
1449
  },
1226
1450
  guards: {
1451
+ isReconnectUpdate: (context, event) => {
1452
+ // participant exists and event.data.type is undefined
1453
+ return context.participants[event.data.id] && !event.data.type
1454
+ },
1227
1455
  isPermanentLeave: (context, event) => {
1228
1456
  // The remote client has left the event. This is a permanent exit
1229
1457
  // from the event
@@ -1237,18 +1465,18 @@ const AnearEventMachineFunctions = ({
1237
1465
  return false
1238
1466
  }
1239
1467
  },
1240
- isHostLeaving: (context, event) => {
1241
- const participantId = event.data.id;
1242
- const participant = context.participants[participantId];
1243
- return participant && participant.isHost;
1468
+ isBooting: (_c, event) => {
1469
+ return event.data.type === 'BOOTED'
1244
1470
  },
1245
1471
  participantExists: (context, event) => !!context.participants[event.data.id],
1246
- eventCreatorIsHost: (context, event) => context.anearEvent.hosted,
1247
- isOpenHouseEvent: (context, event) => context.anearEvent.openHouse || false,
1472
+ eventCreatorIsHost: (context, _e) => context.anearEvent.hosted,
1473
+ isOpenHouseEvent: (context, _e) => context.anearEvent.openHouse || false,
1248
1474
  isParticipantsTimeoutActive: (context, event) => {
1249
- return event.data && event.data.participantsTimeout && event.data.participantsTimeout.msecs > 0
1475
+ const isStartingNewTimeout = event.data && event.data.participantsTimeout && event.data.participantsTimeout.msecs > 0;
1476
+ const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
1477
+ return isStartingNewTimeout || isTimeoutAlreadyRunning;
1250
1478
  },
1251
- allParticipantsResponded: (context, event) => {
1479
+ allParticipantsResponded: (context, _e) => {
1252
1480
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
1253
1481
  }
1254
1482
  },
@@ -1258,7 +1486,7 @@ const AnearEventMachineFunctions = ({
1258
1486
  timeoutEventAnnounce: context => C.TIMEOUT_MSECS.ANNOUNCE,
1259
1487
  timeoutEventStart: context => C.TIMEOUT_MSECS.START,
1260
1488
  timeoutRendered: context => C.TIMEOUT_MSECS.RENDERED_EVENT_DELAY,
1261
- participantsActionTimeout: (context, event) => {
1489
+ participantsActionTimeout: (context, _e) => {
1262
1490
  return context.participantsActionTimeout.msecs
1263
1491
  }
1264
1492
  }
@@ -1269,7 +1497,8 @@ const AnearEventMachine = (anearEvent, {
1269
1497
  pugTemplates,
1270
1498
  appEventMachineFactory,
1271
1499
  appParticipantMachineFactory,
1272
- imageAssetsUrl
1500
+ imageAssetsUrl,
1501
+ rehydrate = false
1273
1502
  }) => {
1274
1503
  const expandedConfig = {predictableActionArguments: true, ...AnearEventMachineStatesConfig(anearEvent.id)}
1275
1504
 
@@ -1282,7 +1511,8 @@ const AnearEventMachine = (anearEvent, {
1282
1511
  pugTemplates,
1283
1512
  pugHelpers,
1284
1513
  appEventMachineFactory,
1285
- appParticipantMachineFactory
1514
+ appParticipantMachineFactory,
1515
+ rehydrate
1286
1516
  )
1287
1517
 
1288
1518
  const service = interpret(eventMachine.withContext(anearEventMachineContext))
@@ -109,6 +109,10 @@ const AnearParticipantMachineConfig = participantId => ({
109
109
  PARTICIPANT_RECONNECT: {
110
110
  actions: 'logReconnected',
111
111
  internal: true
112
+ },
113
+ BOOT_PARTICIPANT: {
114
+ actions: 'logBooted',
115
+ target: '#cleanupAndExit'
112
116
  }
113
117
  }
114
118
  },
@@ -151,11 +155,17 @@ const AnearParticipantMachineConfig = participantId => ({
151
155
  src: 'publishPrivateDisplay',
152
156
  onDone: [
153
157
  { cond: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
154
- { target: 'idle', internal: true }
158
+ { target: 'idle' }
155
159
  ],
156
160
  onError: {
157
161
  target: '#error'
158
162
  }
163
+ },
164
+ on: {
165
+ PARTICIPANT_EXIT: {
166
+ actions: 'logExit',
167
+ target: '#cleanupAndExit'
168
+ }
159
169
  }
160
170
  },
161
171
  waitParticipantResponse: {
@@ -223,6 +233,35 @@ const AnearParticipantMachineConfig = participantId => ({
223
233
  }
224
234
  }
225
235
  },
236
+ booting: {
237
+ initial: 'publishingShutdown',
238
+ states: {
239
+ publishingShutdown: {
240
+ invoke: {
241
+ id: 'publishBootMessage',
242
+ src: 'publishBootMessageService',
243
+ onDone: 'waitingForClientExit',
244
+ onError: '#cleanupAndExit'
245
+ }
246
+ },
247
+ waitingForClientExit: {
248
+ after: {
249
+ bootCleanupTimeout: {
250
+ target: '#cleanupAndExit'
251
+ }
252
+ },
253
+ on: {
254
+ PARTICIPANT_EXIT: '#cleanupAndExit',
255
+ ACTION: {
256
+ internal: true
257
+ },
258
+ PARTICIPANT_DISCONNECT: {
259
+ internal: true
260
+ }
261
+ }
262
+ }
263
+ }
264
+ },
226
265
  done: {
227
266
  id: 'done',
228
267
  entry: ['notifyEventMachineExit'],
@@ -356,6 +395,20 @@ const AnearParticipantMachineFunctions = {
356
395
 
357
396
  return { timeout: event.timeout }
358
397
  },
398
+ publishBootMessageService: async (context, event) => {
399
+ const { reason } = event.data;
400
+ const payload = {
401
+ content: {
402
+ reason: reason || 'You have been removed from the event.'
403
+ }
404
+ }
405
+
406
+ await RealtimeMessaging.publish(
407
+ context.privateChannel,
408
+ 'FORCE_SHUTDOWN',
409
+ payload
410
+ )
411
+ },
359
412
  attachToPrivateChannel: (context, event) => RealtimeMessaging.attachTo(context.privateChannel),
360
413
  detachPrivateChannel: async (context, event) => {
361
414
  return context.privateChannel ? RealtimeMessaging.detachAll([context.privateChannel]) : Promise.resolve()
@@ -368,6 +421,7 @@ const AnearParticipantMachineFunctions = {
368
421
  wasMidTurnOnDisconnect: context => context.actionTimeoutMsecs !== null && context.actionTimeoutMsecs > 0
369
422
  },
370
423
  delays: {
424
+ bootCleanupTimeout: () => C.TIMEOUT_MSECS.BOOT_EXIT,
371
425
  actionTimeout: context => context.actionTimeoutMsecs,
372
426
  dynamicReconnectTimeout: context => {
373
427
  // If an action timeout is active, use its remaining time.
@@ -37,7 +37,6 @@ const AppMachineTransition = (anearEvent) => {
37
37
  // Process all meta objects to handle unpredictable AppM structures
38
38
  metaObjects.forEach(meta => {
39
39
  let viewer
40
- let viewPath
41
40
  let timeoutFn
42
41
  let displayEvent
43
42
 
@@ -49,18 +48,23 @@ const AppMachineTransition = (anearEvent) => {
49
48
 
50
49
  if (meta.allParticipants) {
51
50
  viewer = 'allParticipants'
52
- viewPath = _getViewPath(meta.allParticipants)
51
+ const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
53
52
  timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
54
53
 
55
54
  displayEvent = RenderContextBuilder.buildDisplayEvent(
56
55
  viewPath,
57
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn)
56
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
57
+ viewer,
58
+ null,
59
+ null,
60
+ props
58
61
  )
59
62
  displayEvents.push(displayEvent)
60
63
  }
61
64
 
62
65
  if (meta.eachParticipant) {
63
66
  // Check if participant is a function (new selective rendering format)
67
+ viewer = 'eachParticipant'
64
68
  if (typeof meta.eachParticipant === 'function') {
65
69
  // New selective participant rendering
66
70
  const participantRenderFunc = meta.eachParticipant
@@ -73,7 +77,7 @@ const AppMachineTransition = (anearEvent) => {
73
77
  appContext,
74
78
  appStateName,
75
79
  event.type,
76
- 'eachParticipant',
80
+ viewer,
77
81
  null
78
82
  )
79
83
 
@@ -81,13 +85,15 @@ const AppMachineTransition = (anearEvent) => {
81
85
  if (participantDisplay && participantDisplay.participantId && participantDisplay.view) {
82
86
  // For selective rendering, timeout is handled directly in the participant display object
83
87
  const timeout = participantDisplay.timeout || null
88
+ const props = participantDisplay.props || {}
84
89
 
85
90
  displayEvent = RenderContextBuilder.buildDisplayEvent(
86
91
  participantDisplay.view,
87
92
  baseAppRenderContext, // Reuse the same base context
88
- 'eachParticipant',
93
+ viewer,
89
94
  participantDisplay.participantId,
90
- timeout
95
+ timeout,
96
+ props
91
97
  )
92
98
  displayEvents.push(displayEvent)
93
99
  }
@@ -95,15 +101,17 @@ const AppMachineTransition = (anearEvent) => {
95
101
  }
96
102
  } else {
97
103
  // Legacy participant rendering - normalize to selective format
98
- const viewPath = _getViewPath(meta.eachParticipant)
104
+ const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
99
105
  const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
100
106
 
101
107
  const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn),
102
108
  displayEvent = RenderContextBuilder.buildDisplayEvent(
103
109
  viewPath,
104
110
  renderContext,
105
- 'eachParticipant',
106
- 'ALL_PARTICIPANTS' // Special marker for "all participants"
111
+ viewer,
112
+ 'ALL_PARTICIPANTS', // Special marker for "all participants"
113
+ null,
114
+ props
107
115
  )
108
116
  displayEvents.push(displayEvent)
109
117
  }
@@ -111,24 +119,31 @@ const AppMachineTransition = (anearEvent) => {
111
119
 
112
120
  if (meta.host) {
113
121
  viewer = 'host'
114
- viewPath = _getViewPath(meta.host)
122
+ const { viewPath, props } = _extractViewAndProps(meta.host)
115
123
  timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.host.timeout)
116
124
 
117
125
  displayEvent = RenderContextBuilder.buildDisplayEvent(
118
126
  viewPath,
119
127
  RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
120
- 'host'
128
+ viewer,
129
+ null,
130
+ null,
131
+ props
121
132
  )
122
133
  displayEvents.push(displayEvent)
123
134
  }
124
135
 
125
136
  if (meta.spectators) {
126
137
  viewer = 'spectators'
127
- viewPath = _getViewPath(meta.spectators)
138
+ const { viewPath, props } = _extractViewAndProps(meta.spectators)
128
139
 
129
140
  displayEvent = RenderContextBuilder.buildDisplayEvent(
130
141
  viewPath,
131
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer)
142
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer),
143
+ viewer,
144
+ null,
145
+ null,
146
+ props
132
147
  )
133
148
  displayEvents.push(displayEvent)
134
149
  }
@@ -140,28 +155,28 @@ const AppMachineTransition = (anearEvent) => {
140
155
  }
141
156
  }
142
157
 
143
- // Send CLOSE AFTER processing meta (including exit displays)
144
- if (isDone) {
145
- logger.debug('[AppMachineTransition] AppM reached final state, sending CLOSE')
146
- anearEvent.send('CLOSE')
147
- }
158
+ // Note: When AppM reaches a final state, the service.onDone() callback
159
+ // in AnearEventMachine.js will fire and send APPM_FINAL to trigger cleanup.
160
+ // Apps should explicitly call anearEvent.closeEvent() or anearEvent.cancelEvent()
161
+ // as entry actions in final states if they need API state transitions.
148
162
  }
149
163
  }
150
164
 
151
- const _getViewPath = (config) => {
152
- if (!config) return null
153
- if (typeof config === 'string') return config
165
+ const _extractViewAndProps = (config) => {
166
+ if (!config) return { viewPath: null, props: {} }
167
+
168
+ if (typeof config === 'string') {
169
+ return { viewPath: config, props: {} }
170
+ }
171
+
154
172
  if (typeof config === 'object') {
155
- // Handle various possible view path formats
156
- if (config.view) return config.view
157
- if (config.template) return config.template
158
- if (config.path) return config.path
159
- // If no view path found, log warning but don't throw
160
- logger.warn(`[AppMachineTransition] No view path found in config: ${JSON.stringify(config)}`)
161
- return null
173
+ const viewPath = config.view
174
+ const props = config.props || {}
175
+ return { viewPath, props }
162
176
  }
177
+
163
178
  logger.warn(`[AppMachineTransition] Unknown meta format: ${JSON.stringify(config)}`)
164
- return null
179
+ return { viewPath: null, props: {} }
165
180
  }
166
181
 
167
182
  const _stringifiedState = (stateValue) => {
@@ -13,7 +13,8 @@ module.exports = {
13
13
  ANNOUNCE: 5 * 60 * 1000, // 5 minutes
14
14
  START: 5 * 60 * 1000, // 5 minutes
15
15
  RENDERED_EVENT_DELAY: 100, // 100 milliseconds
16
- RECONNECT: 30 * 1000 // 30 seconds
16
+ RECONNECT: 30 * 1000, // 30 seconds
17
+ BOOT_EXIT: 2 * 1000 // 5 seconds
17
18
  },
18
19
  EventStates: {
19
20
  CREATED: 'created',
@@ -8,9 +8,13 @@
8
8
  * meta: {
9
9
  * viewer: // 'eachParticipant', 'allParticipants', 'spectators'
10
10
  * state: // Stringified state name (e.g., 'live.registration.waitForOpponentToJoin')
11
- * event: // The triggering event for this transition ('PARTICIPANT_ENTER')
11
+ * event: // The triggering event for this transition ('PARTIPANT_ENTER')
12
12
  * timeout: // null|| func}
13
13
  * }
14
+ * props: {
15
+ * // All properties from the `props` object in the AppM's meta block.
16
+ * // e.g., meta: { view: 'foo', props: { title: 'Hello' } } -> `props.title`
17
+ * }
14
18
  *
15
19
  * // When 'eachParticipant' displayType
16
20
  * allParticipants: // all: Map of all participants [info, context] for this event, get(id) => info, context
@@ -77,7 +81,7 @@ class DisplayEventProcessor {
77
81
  }
78
82
 
79
83
  _processSingle(displayEvent) {
80
- const { viewPath, appRenderContext, target, participantId, timeout: displayTimeout } = displayEvent
84
+ const { viewPath, appRenderContext, viewer, participantId, timeout: displayTimeout, props } = displayEvent
81
85
  const timeoutFn = appRenderContext.meta.timeoutFn
82
86
 
83
87
  const normalizedPath = viewPath.endsWith(C.PugSuffix) ? viewPath : `${viewPath}${C.PugSuffix}`
@@ -93,7 +97,8 @@ class DisplayEventProcessor {
93
97
  ...appRenderContext,
94
98
  anearEvent: this.anearEvent,
95
99
  participants: this.participantsIndex,
96
- ...this.pugHelpers
100
+ ...this.pugHelpers,
101
+ props
97
102
  }
98
103
 
99
104
  const formattedDisplayMessage = () => {
@@ -104,11 +109,11 @@ class DisplayEventProcessor {
104
109
  return displayMessage
105
110
  }
106
111
 
107
- // The target determines who sees the view. It defaults to the 'viewer'
108
- // from the meta block, but can be overridden (e.g., for 'host').
109
- const displayTarget = target || appRenderContext.meta.viewer
112
+ // The viewer determines who sees the view. It can be set directly on the
113
+ // display event, but if not, it falls back to the viewer from the meta block.
114
+ const displayViewer = viewer || appRenderContext.meta.viewer
110
115
 
111
- switch (displayTarget) {
116
+ switch (displayViewer) {
112
117
  case 'allParticipants':
113
118
  logger.debug(`[DisplayEventProcessor] Publishing ${viewPath} to PARTICIPANTS_DISPLAY`)
114
119
 
@@ -162,7 +167,7 @@ class DisplayEventProcessor {
162
167
  break
163
168
 
164
169
  default:
165
- throw new Error(`Unknown display target: ${displayTarget}`)
170
+ throw new Error(`Unknown display viewer: ${displayViewer}`)
166
171
  }
167
172
  return { publishPromise, timeout }
168
173
  }
@@ -85,6 +85,15 @@ class RealtimeMessaging {
85
85
  return channel.publish(msgType, message)
86
86
  }
87
87
 
88
+ async setPresence(channel, data) {
89
+ try {
90
+ await channel.presence.enter(data)
91
+ logger.debug(`[RTM] presence.enter on ${channel.name} with`, data)
92
+ } catch (e) {
93
+ logger.warn(`[RTM] presence.enter failed on ${channel.name}`, e)
94
+ }
95
+ }
96
+
88
97
  /**
89
98
  * Detach a list of Ably channels.
90
99
  * If any detach fails, Promise.all will reject.
@@ -59,19 +59,21 @@ class RenderContextBuilder {
59
59
  * Build display event object
60
60
  * @param {string} viewPath - Template/view path
61
61
  * @param {Object} appRenderContext - Context object
62
- * @param {string} target - Optional target (host, participant, etc.)
62
+ * @param {string} viewer - Optional viewer type (host, participant, etc.)
63
63
  * @param {string} participantId - Optional participant ID for selective rendering
64
64
  * @param {number} timeout - Optional timeout in milliseconds
65
+ * @param {Object} props - Optional arguments for the view
65
66
  * @returns {Object} Display event object
66
67
  */
67
- static buildDisplayEvent(viewPath, appRenderContext, target = null, participantId = null, timeout = null) {
68
+ static buildDisplayEvent(viewPath, appRenderContext, viewer = null, participantId = null, timeout = null, props = {}) {
68
69
  const displayEvent = {
69
70
  viewPath,
70
- appRenderContext
71
+ appRenderContext,
72
+ props
71
73
  }
72
74
 
73
- if (target) {
74
- displayEvent.target = target
75
+ if (viewer) {
76
+ displayEvent.viewer = viewer
75
77
  }
76
78
 
77
79
  if (participantId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {