anear-js-api 1.2.0 → 1.2.2

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.
@@ -101,7 +101,7 @@ class ApiService {
101
101
  }
102
102
 
103
103
  prepareGetRequest(resource, params={}) {
104
- const urlString = this.idParamUrlString(resource, params)
104
+ const urlString = this.idParamUrlString(resource, params)
105
105
  return new fetch.Request(
106
106
  urlString, {
107
107
  method: 'GET',
@@ -97,6 +97,14 @@ class AnearEvent extends JsonApiResource {
97
97
  this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
98
98
  }
99
99
 
100
+ cancelAllParticipantTimeouts() {
101
+ // Cancel all active participant-level timeouts. This sends CANCEL_TIMEOUT
102
+ // to all APMs, which will benignly clear any active timeout timers.
103
+ // Useful for scenarios where a game event (e.g., LIAR call) should immediately
104
+ // cancel all individual participant timeouts.
105
+ this.send({ type: "CANCEL_ALL_PARTICIPANT_TIMEOUTS" })
106
+ }
107
+
100
108
  render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
101
109
  // Explicit render method for guaranteed rendering control
102
110
  // This complements the meta: {} approach for when you need explicit control
@@ -162,7 +162,11 @@ const AnearEventMachineContext = (
162
162
  spectatorsDisplayChannel: null, // display all spectators
163
163
  participants: {},
164
164
  participantMachines: {},
165
- participantsActionTimeout: null
165
+ participantsActionTimeout: null,
166
+ suppressParticipantTimeouts: false, // Flag to suppress PARTICIPANT_TIMEOUT events (cleared when new timers are set up via render)
167
+ eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
168
+ pendingCancelConfirmations: null, // Set of participantIds waiting for TIMER_CANCELED confirmation
169
+ pendingCancelAction: null // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
166
170
  })
167
171
 
168
172
  const ActiveEventGlobalEvents = {
@@ -378,6 +382,7 @@ const ActiveEventStatesConfig = {
378
382
  input: ({ context, event }) => ({ context, event }),
379
383
  onDone: {
380
384
  target: 'notifyingPausedRenderComplete',
385
+ actions: ['clearParticipantTimeoutSuppression']
381
386
  // v5 note: internal transitions are the default (v4 had `internal: true`)
382
387
  },
383
388
  onError: {
@@ -416,6 +421,7 @@ const ActiveEventStatesConfig = {
416
421
  input: ({ context, event }) => ({ context, event }),
417
422
  onDone: {
418
423
  target: 'notifyingRenderComplete',
424
+ actions: ['clearParticipantTimeoutSuppression']
419
425
  // v5 note: internal transitions are the default (v4 had `internal: true`)
420
426
  },
421
427
  onError: {
@@ -484,6 +490,9 @@ const ActiveEventStatesConfig = {
484
490
  BOOT_PARTICIPANT: {
485
491
  actions: 'sendBootEventToParticipantMachine'
486
492
  },
493
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
494
+ actions: 'cancelAllParticipantTimeouts'
495
+ },
487
496
  SPECTATOR_ENTER: {
488
497
  actions: 'sendSpectatorEnterToAppEventMachine'
489
498
  }
@@ -527,6 +536,7 @@ const ActiveEventStatesConfig = {
527
536
  input: ({ context, event }) => ({ context, event }),
528
537
  onDone: {
529
538
  target: 'notifyingRenderComplete',
539
+ actions: ['clearParticipantTimeoutSuppression']
530
540
  // v5 note: internal transitions are the default (v4 had `internal: true`)
531
541
  },
532
542
  onError: {
@@ -616,6 +626,9 @@ const ActiveEventStatesConfig = {
616
626
  },
617
627
  BOOT_PARTICIPANT: {
618
628
  actions: 'sendBootEventToParticipantMachine'
629
+ },
630
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
631
+ actions: 'cancelAllParticipantTimeouts'
619
632
  }
620
633
  },
621
634
  states: {
@@ -634,6 +647,34 @@ const ActiveEventStatesConfig = {
634
647
  waitingForActions: {
635
648
  id: 'waitingForActions',
636
649
  entry: () => logger.debug('[AEM] live state...waiting for actions'),
650
+ initial: 'idle',
651
+ states: {
652
+ idle: {},
653
+ waitingForCancelConfirmations: {
654
+ entry: ['sendCancelTimeoutToAllAPMs', 'setupPendingCancelConfirmations'],
655
+ on: {
656
+ TIMER_CANCELED: {
657
+ actions: ['removeFromPendingCancelConfirmations']
658
+ },
659
+ PARTICIPANT_EXIT: {
660
+ actions: ['removeFromPendingCancelConfirmations']
661
+ },
662
+ // Drop only ACTION and PARTICIPANT_TIMEOUT events during cancellation wait
663
+ // All other events (presence events, etc.) will bubble up to parent handlers
664
+ ACTION: {
665
+ actions: ['dropEventDuringCancellation']
666
+ },
667
+ PARTICIPANT_TIMEOUT: {
668
+ actions: ['dropEventDuringCancellation']
669
+ }
670
+ },
671
+ always: {
672
+ guard: 'allCancelConfirmationsReceived',
673
+ actions: ['forwardPendingCancelAction', 'clearCancelState'],
674
+ target: '#waitingForActions.idle'
675
+ }
676
+ }
677
+ },
637
678
  on: {
638
679
  ACTION: {
639
680
  actions: ['processParticipantAction']
@@ -642,6 +683,13 @@ const ActiveEventStatesConfig = {
642
683
  PARTICIPANT_TIMEOUT: {
643
684
  actions: ['processParticipantTimeout'],
644
685
  // v5 note: internal transitions are the default (v4 had `internal: true`)
686
+ },
687
+ TRIGGER_CANCEL_SEQUENCE: {
688
+ target: '.waitingForCancelConfirmations'
689
+ },
690
+ TIMER_CANCELED: {
691
+ // Ignore if not in cancellation state (defensive)
692
+ actions: []
645
693
  }
646
694
  }
647
695
  },
@@ -654,10 +702,11 @@ const ActiveEventStatesConfig = {
654
702
  {
655
703
  guard: 'isParticipantsTimeoutActive',
656
704
  target: 'notifyingRenderCompleteWithTimeout',
657
- actions: 'setupParticipantsTimeout'
705
+ actions: ['setupParticipantsTimeout', 'setupCancelActionType', 'clearParticipantTimeoutSuppression']
658
706
  },
659
707
  {
660
708
  target: 'notifyingRenderComplete',
709
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
661
710
  // v5 note: internal transitions are the default (v4 had `internal: true`)
662
711
  }
663
712
  ],
@@ -693,13 +742,70 @@ const ActiveEventStatesConfig = {
693
742
  target: 'handleParticipantsTimeout'
694
743
  }
695
744
  },
745
+ initial: 'waiting',
746
+ states: {
747
+ waiting: {
748
+ // The actual waiting state. Timer continues running when we transition to .rendering.
749
+ },
750
+ rendering: {
751
+ invoke: {
752
+ src: 'renderDisplay',
753
+ input: ({ context, event }) => ({ context, event }),
754
+ onDone: [
755
+ {
756
+ guard: 'isParticipantsTimeoutActive',
757
+ target: 'notifyingRenderComplete',
758
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression']
759
+ // Note: We do NOT call setupParticipantsTimeout here because we're already in waitAllParticipantsResponse
760
+ // and the timer is still running from the parent state (preserved by nested state pattern).
761
+ },
762
+ {
763
+ target: '#waitingForActions',
764
+ actions: ['setupCancelActionType', 'clearParticipantTimeoutSuppression', 'notifyAppMachineRendered']
765
+ }
766
+ ],
767
+ onError: {
768
+ target: '#activeEvent.failure'
769
+ }
770
+ }
771
+ },
772
+ notifyingRenderComplete: {
773
+ after: {
774
+ timeoutRendered: {
775
+ // Target sibling state (stays within parent, timer continues)
776
+ target: 'waiting',
777
+ actions: ['notifyAppMachineRendered']
778
+ }
779
+ }
780
+ }
781
+ },
696
782
  on: {
697
- ACTION: {
698
- actions: ['processAndForwardAction', 'processParticipantResponse'],
699
- // v5 note: internal transitions are the default (v4 had `internal: true`)
783
+ ACTION: [
784
+ {
785
+ // Check if this is a cancel ACTION for allParticipants timeout
786
+ guard: ({ context, event }) => {
787
+ const { cancelActionType } = context.participantsActionTimeout || {}
788
+ if (!cancelActionType) return false
789
+ const eventMessagePayload = JSON.parse(event.data.payload)
790
+ const [appEventName] = Object.entries(eventMessagePayload)[0]
791
+ return cancelActionType === appEventName
792
+ },
793
+ actions: ['storeCancelActionForAllParticipants'],
794
+ target: 'handleParticipantsTimeoutCanceled'
795
+ },
796
+ {
797
+ actions: ['processAndForwardAction', 'processParticipantResponse'],
798
+ // v5 note: internal transitions are the default (v4 had `internal: true`)
799
+ }
800
+ ],
801
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
802
+ actions: ['cancelAllParticipantTimeouts'],
803
+ target: 'handleParticipantsTimeoutCanceled'
700
804
  },
701
805
  RENDER_DISPLAY: {
702
- target: '#liveRendering'
806
+ // Re-render without exiting waitAllParticipantsResponse.
807
+ // This preserves the parent's after: timer because we stay within the parent state.
808
+ target: '.rendering'
703
809
  },
704
810
  PARTICIPANT_LEAVE: [
705
811
  {
@@ -720,6 +826,10 @@ const ActiveEventStatesConfig = {
720
826
  entry: ['sendActionsTimeoutToAppM', 'clearParticipantsTimeout'],
721
827
  always: 'waitingForActions'
722
828
  },
829
+ handleParticipantsTimeoutCanceled: {
830
+ entry: ['sendActionsTimeoutToAppMCanceled', 'clearParticipantsTimeout', 'forwardPendingCancelActionIfExists'],
831
+ always: 'waitingForActions'
832
+ },
723
833
  participantEntering: {
724
834
  // a PARTICIPANT_ENTER received from a new user JOIN click. Unless already exists,
725
835
  // create an AnearParticipantMachine instance.
@@ -1591,6 +1701,114 @@ const AnearEventMachineFunctions = ({
1591
1701
  sendParticipantExitEvents: ({ context }) => {
1592
1702
  Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'PARTICIPANT_EXIT' }))
1593
1703
  },
1704
+ cancelAllParticipantTimeouts: assign(({ context }) => {
1705
+ // Send CANCEL_TIMEOUT to all participant machines to cancel any active
1706
+ // participant-level timeouts. This is useful when a game event (e.g., LIAR call)
1707
+ // should immediately cancel all individual participant timeouts.
1708
+ // APMs that are not in waitParticipantResponse state will benignly ignore this event.
1709
+ logger.info(`[AEM] Cancelling all participant timeouts (manual) for ${Object.keys(context.participantMachines).length} participants`)
1710
+ Object.values(context.participantMachines).forEach(pm => pm.send({ type: 'CANCEL_TIMEOUT' }))
1711
+
1712
+ // Note: If we're in waitAllParticipantsResponse state, the nested state's handler
1713
+ // will also handle this event and cancel the allParticipants timeout.
1714
+
1715
+ // Set suppression flag to ignore stale PARTICIPANT_TIMEOUT events from cancelled timers.
1716
+ // This flag will be cleared when new timers are set up (via render completion).
1717
+ return {
1718
+ suppressParticipantTimeouts: true
1719
+ }
1720
+ }),
1721
+ sendCancelTimeoutToAllAPMs: ({ context }) => {
1722
+ const participantCount = Object.keys(context.participantMachines).length
1723
+ logger.info(`[AEM] Sending CANCEL_TIMEOUT to all ${participantCount} APMs for declarative cancel ACTION`)
1724
+ Object.values(context.participantMachines).forEach(pm => {
1725
+ pm.send({ type: 'CANCEL_TIMEOUT' })
1726
+ })
1727
+ },
1728
+ setupPendingCancelConfirmations: assign(({ context, event }) => {
1729
+ // Get all active participants with timers (participants in waitParticipantResponse state)
1730
+ const activeParticipantIds = getActiveParticipantIds(context)
1731
+ const pendingConfirmations = new Set(activeParticipantIds)
1732
+
1733
+ logger.info(`[AEM] Starting cancellation sequence for ACTION ${event.data.appEventName}. Waiting for ${pendingConfirmations.size} APM confirmations: ${[...pendingConfirmations].join(', ')}`)
1734
+
1735
+ return {
1736
+ pendingCancelConfirmations: pendingConfirmations,
1737
+ pendingCancelAction: {
1738
+ participantId: event.data.participantId,
1739
+ appEventName: event.data.appEventName,
1740
+ payload: event.data.payload
1741
+ },
1742
+ suppressParticipantTimeouts: true // Drop PARTICIPANT_TIMEOUT events
1743
+ }
1744
+ }),
1745
+ removeFromPendingCancelConfirmations: assign(({ context, event }) => {
1746
+ const participantId = event.participantId || event.data?.id
1747
+ if (!context.pendingCancelConfirmations || !participantId) return {}
1748
+
1749
+ const newPending = new Set(context.pendingCancelConfirmations)
1750
+ newPending.delete(participantId)
1751
+ const remaining = newPending.size
1752
+
1753
+ logger.debug(`[AEM] TIMER_CANCELED received from ${participantId}. ${remaining} confirmations remaining.`)
1754
+
1755
+ return {
1756
+ pendingCancelConfirmations: newPending
1757
+ }
1758
+ }),
1759
+ forwardPendingCancelAction: ({ context }) => {
1760
+ if (!context.pendingCancelAction) {
1761
+ logger.warn(`[AEM] forwardPendingCancelAction called but pendingCancelAction is null`)
1762
+ return
1763
+ }
1764
+
1765
+ const { participantId, appEventName, payload } = context.pendingCancelAction
1766
+
1767
+ const actionEventPayload = {
1768
+ participantId,
1769
+ payload
1770
+ }
1771
+
1772
+ logger.info(`[AEM] All cancellation confirmations received. Forwarding cancel ACTION ${appEventName} from ${participantId} to AppM and APM`)
1773
+
1774
+ // Forward to AppM
1775
+ context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
1776
+
1777
+ // Forward to APM
1778
+ const participantMachine = context.participantMachines[participantId]
1779
+ if (participantMachine) {
1780
+ participantMachine.send({ type: 'ACTION', ...actionEventPayload })
1781
+ } else {
1782
+ logger.warn(`[AEM] Participant machine not found for ${participantId} when forwarding cancel ACTION`)
1783
+ }
1784
+ },
1785
+ clearCancelState: assign({
1786
+ pendingCancelConfirmations: null,
1787
+ pendingCancelAction: null,
1788
+ suppressParticipantTimeouts: false
1789
+ }),
1790
+ dropEventDuringCancellation: ({ context, event }) => {
1791
+ // Drop/ignore ACTION and PARTICIPANT_TIMEOUT events received during cancellation wait
1792
+ // All other events (presence events, etc.) are not handled here and will bubble up to parent handlers
1793
+ logger.debug(`[AEM] Dropping ${event.type} event during cancellation wait`)
1794
+ },
1795
+ clearParticipantTimeoutSuppression: assign({
1796
+ suppressParticipantTimeouts: false
1797
+ }),
1798
+ setupCancelActionType: assign(({ context, event }) => {
1799
+ const cancelActionType =
1800
+ (event && event.output && event.output.cancelActionType) ||
1801
+ (event && event.data && event.data.cancelActionType) ||
1802
+ null
1803
+
1804
+ if (cancelActionType) {
1805
+ logger.debug(`[AEM] Setting up cancel action type for eachParticipant timeout: ${cancelActionType}`)
1806
+ }
1807
+
1808
+ return {
1809
+ eachParticipantCancelActionType: cancelActionType
1810
+ }
1811
+ }),
1594
1812
  updateParticipantPresence: ({ context, event }) => {
1595
1813
  const participantId = event.data.id;
1596
1814
  const participant = context.participants[participantId];
@@ -1613,7 +1831,7 @@ const AnearEventMachineFunctions = ({
1613
1831
  const eventName = getPresenceEventName(participant, 'UPDATE');
1614
1832
  context.appEventMachine.send({ type: eventName, data: event.data });
1615
1833
  },
1616
- processParticipantAction: ({ context, event }) => {
1834
+ processParticipantAction: ({ context, event, self }) => {
1617
1835
  // event.data.participantId,
1618
1836
  // event.data.payload: {"appEventMachineACTION": {action event keys and values}}
1619
1837
  // e.g. {"MOVE":{"x":1, "y":2}}
@@ -1623,7 +1841,25 @@ const AnearEventMachineFunctions = ({
1623
1841
  const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
1624
1842
 
1625
1843
  logger.debug(`[AEM] got Event ${appEventName} from payload from participant ${participantId}`)
1844
+ logger.debug(`[AEM] eachParticipantCancelActionType is: ${context.eachParticipantCancelActionType}`)
1845
+
1846
+ // Check if this ACTION should trigger cancellation for eachParticipant timeout
1847
+ if (context.eachParticipantCancelActionType === appEventName) {
1848
+ logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} from ${participantId} (eachParticipant timeout cancellation)`)
1849
+ // Trigger cancellation sequence via self-send
1850
+ self.send({
1851
+ type: 'TRIGGER_CANCEL_SEQUENCE',
1852
+ data: {
1853
+ participantId,
1854
+ appEventName,
1855
+ payload,
1856
+ event // Store original event for reference
1857
+ }
1858
+ })
1859
+ return // Don't forward yet, wait for cancellation to complete
1860
+ }
1626
1861
 
1862
+ // Normal flow: forward immediately
1627
1863
  const actionEventPayload = {
1628
1864
  participantId,
1629
1865
  payload
@@ -1643,6 +1879,9 @@ const AnearEventMachineFunctions = ({
1643
1879
  // This assumes the current participant IS a non-responder.
1644
1880
  const isFinalAction = nonResponders.size === 1 && nonResponders.has(participantId);
1645
1881
 
1882
+ if (isFinalAction) {
1883
+ logger.debug(`[AEM] Final ACTION received from ${participantId}, allParticipants timeout cancelled (all responded)`)
1884
+ }
1646
1885
  logger.info(`[AEM] Participants FINAL ACTION is ${isFinalAction}`)
1647
1886
 
1648
1887
  // Forward to AppM with the finalAction flag
@@ -1662,10 +1901,35 @@ const AnearEventMachineFunctions = ({
1662
1901
  participantMachine.send({ type: 'ACTION', ...apm_Payload });
1663
1902
  }
1664
1903
  },
1665
- processParticipantTimeout: ({ context, event }) =>
1666
- context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId: event.participantId }),
1904
+ storeCancelActionForAllParticipants: assign(({ context, event }) => {
1905
+ const participantId = event.data.participantId
1906
+ const eventMessagePayload = JSON.parse(event.data.payload)
1907
+ const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
1908
+
1909
+ logger.info(`[AEM] Intercepting cancel ACTION ${appEventName} for allParticipants timeout from ${participantId}`)
1910
+
1911
+ return {
1912
+ pendingCancelAction: {
1913
+ participantId,
1914
+ appEventName,
1915
+ payload
1916
+ }
1917
+ }
1918
+ }),
1919
+ processParticipantTimeout: ({ context, event }) => {
1920
+ // Suppress PARTICIPANT_TIMEOUT events after cancelAllParticipantTimeouts() until new timers
1921
+ // are set up via render. This handles race conditions where an APM's timer fires just
1922
+ // before receiving CANCEL_TIMEOUT. The flag is cleared when renders complete (new timers set up).
1923
+ if (context.suppressParticipantTimeouts) {
1924
+ logger.debug(`[AEM] Suppressing PARTICIPANT_TIMEOUT from ${event.participantId} (cancelled timers, waiting for new render)`)
1925
+ return
1926
+ }
1927
+ context.appEventMachine.send({ type: 'PARTICIPANT_TIMEOUT', participantId: event.participantId })
1928
+ },
1667
1929
  setupParticipantsTimeout: assign(({ context, event }) => {
1668
- // Only set up a new timeout if one is provided by the display render workflow.
1930
+ // Only set up a new timeout if one is provided by the display render workflow
1931
+ // AND we don't already have an active timeout (i.e., we're entering from waitingForActions,
1932
+ // not re-rendering during waitAllParticipantsResponse).
1669
1933
  //
1670
1934
  // v5 note: `renderDisplay` is implemented as a Promise actor (fromPromise).
1671
1935
  // The actor's resolved value is delivered on the done event as `event.output`
@@ -1676,21 +1940,29 @@ const AnearEventMachineFunctions = ({
1676
1940
  (event && event.data && event.data.participantsTimeout) ||
1677
1941
  null
1678
1942
 
1679
- // This prevents overwriting an existing timeout during a simple re-render.
1680
- if (participantsTimeout) {
1943
+ // Only set up a NEW timeout if one is provided and we don't already have an active timeout.
1944
+ // If we're re-rendering during waitAllParticipantsResponse, the timer is still running
1945
+ // from the parent state (preserved by nested state pattern), so we don't overwrite it.
1946
+ if (participantsTimeout && !context.participantsActionTimeout) {
1681
1947
  const timeoutMsecs = participantsTimeout.msecs
1682
1948
  const allParticipantIds = getActiveParticipantIds(context)
1683
- logger.debug(`[AEM] Starting participants action timeout for ${timeoutMsecs}ms. Responders: ${allParticipantIds.join(', ')}`)
1949
+ const cancelActionType =
1950
+ (event && event.output && event.output.cancelActionType) ||
1951
+ (event && event.data && event.data.cancelActionType) ||
1952
+ null
1953
+
1954
+ logger.debug(`[AEM] Setting up allParticipants timeout: ${timeoutMsecs}ms for ${allParticipantIds.length} participants: ${allParticipantIds.join(', ')}${cancelActionType ? `, cancel: ${cancelActionType}` : ''}`)
1684
1955
 
1685
1956
  return {
1686
1957
  participantsActionTimeout: {
1687
1958
  msecs: timeoutMsecs,
1688
1959
  startedAt: Date.now(),
1689
- nonResponders: new Set(allParticipantIds)
1960
+ nonResponders: new Set(allParticipantIds),
1961
+ cancelActionType: cancelActionType
1690
1962
  }
1691
1963
  }
1692
1964
  }
1693
- // If no timeout data, return empty object to not change context
1965
+ // If no timeout data or timeout already exists, return empty object to not change context
1694
1966
  return {};
1695
1967
  }),
1696
1968
  processParticipantResponse: assign(({ context, event }) => {
@@ -1698,6 +1970,9 @@ const AnearEventMachineFunctions = ({
1698
1970
  const { nonResponders, ...rest } = context.participantsActionTimeout
1699
1971
  const newNonResponders = new Set(nonResponders)
1700
1972
  newNonResponders.delete(participantId)
1973
+ const remainingCount = newNonResponders.size
1974
+
1975
+ logger.debug(`[AEM] Participant ${participantId} responded, removing from allParticipants timeout. ${remainingCount} participants remaining.`)
1701
1976
 
1702
1977
  return {
1703
1978
  participantsActionTimeout: {
@@ -1729,12 +2004,41 @@ const AnearEventMachineFunctions = ({
1729
2004
  sendActionsTimeoutToAppM: ({ context }) => {
1730
2005
  const { nonResponders, msecs } = context.participantsActionTimeout
1731
2006
  const nonResponderIds = [...nonResponders]
1732
- logger.info(`[AEM] Participants action timed out. Non-responders: ${nonResponderIds.join(', ')}`)
2007
+ logger.info(`[AEM] allParticipants timeout expired after ${msecs}ms. Non-responders: ${nonResponderIds.join(', ')}`)
1733
2008
 
1734
2009
  if (context.appEventMachine) {
1735
2010
  context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds })
1736
2011
  }
1737
2012
  },
2013
+ sendActionsTimeoutToAppMCanceled: ({ context }) => {
2014
+ const { nonResponders, msecs } = context.participantsActionTimeout
2015
+ const nonResponderIds = [...nonResponders]
2016
+ logger.info(`[AEM] allParticipants timeout cancelled after ${msecs}ms. Non-responders at cancellation: ${nonResponderIds.join(', ')}`)
2017
+
2018
+ if (context.appEventMachine) {
2019
+ context.appEventMachine.send({ type: 'ACTIONS_TIMEOUT', timeout: msecs, nonResponderIds, canceled: true })
2020
+ }
2021
+ },
2022
+ forwardPendingCancelActionIfExists: ({ context }) => {
2023
+ if (!context.pendingCancelAction) return // No cancel ACTION to forward
2024
+
2025
+ const { participantId, appEventName, payload } = context.pendingCancelAction
2026
+ const actionEventPayload = { participantId, payload }
2027
+
2028
+ // Forward to AppM
2029
+ context.appEventMachine.send({ type: appEventName, ...actionEventPayload })
2030
+
2031
+ // Forward to APM
2032
+ const participantMachine = context.participantMachines[participantId]
2033
+ if (participantMachine) {
2034
+ participantMachine.send({ type: 'ACTION', ...actionEventPayload })
2035
+ }
2036
+
2037
+ logger.info(`[AEM] Forwarded cancel ACTION ${appEventName} from ${participantId} to AppM and APM after allParticipants cancellation`)
2038
+
2039
+ // Clear the pending action
2040
+ context.pendingCancelAction = null
2041
+ },
1738
2042
  clearParticipantsTimeout: assign({
1739
2043
  participantsActionTimeout: null
1740
2044
  }),
@@ -1985,8 +2289,9 @@ const AnearEventMachineFunctions = ({
1985
2289
  renderDisplay: fromPromise(async ({ input }) => {
1986
2290
  const { context, event } = input
1987
2291
  const displayEventProcessor = new DisplayEventProcessor(context)
2292
+ const cancelActionType = event.cancelActionType || null
1988
2293
 
1989
- return await displayEventProcessor.processAndPublish(event.displayEvents)
2294
+ return await displayEventProcessor.processAndPublish(event.displayEvents, cancelActionType)
1990
2295
  }),
1991
2296
  getAttachedCreatorOrHost: fromPromise(async ({ input }) => {
1992
2297
  const { context } = input
@@ -2162,6 +2467,9 @@ const AnearEventMachineFunctions = ({
2162
2467
  },
2163
2468
  allParticipantsResponded: ({ context }) => {
2164
2469
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
2470
+ },
2471
+ allCancelConfirmationsReceived: ({ context }) => {
2472
+ return !context.pendingCancelConfirmations || context.pendingCancelConfirmations.size === 0
2165
2473
  }
2166
2474
  },
2167
2475
  delays: {
@@ -161,6 +161,7 @@ const AnearParticipantMachineConfig = participantId => ({
161
161
  initial: 'idle',
162
162
  states: {
163
163
  idle: {
164
+ entry: ['ensureTimerCleared'],
164
165
  on: {
165
166
  RENDER_DISPLAY: {
166
167
  target: '#renderDisplay'
@@ -168,6 +169,12 @@ const AnearParticipantMachineConfig = participantId => ({
168
169
  PRIVATE_DISPLAY: {
169
170
  target: '#renderDisplay'
170
171
  },
172
+ CANCEL_TIMEOUT: {
173
+ // Acknowledge CANCEL_TIMEOUT even when idle (no active timer).
174
+ // This ensures the AEM's cancellation sequence completes for all participants.
175
+ actions: ['sendTimerCanceled'],
176
+ // v5 note: internal transitions are the default (v4 had `internal: true`)
177
+ },
171
178
  PARTICIPANT_DISCONNECT: {
172
179
  target: 'waitReconnect'
173
180
  },
@@ -235,8 +242,7 @@ const AnearParticipantMachineConfig = participantId => ({
235
242
  onDone: [
236
243
  { guard: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
237
244
  // If this display does not configure a timeout, explicitly clear any
238
- // previously-running action timeout. Otherwise, a stale actionTimeoutStart
239
- // from an older prompt can be "resumed" later and immediately fire.
245
+ // previously-running action timeout.
240
246
  { actions: 'nullActionTimeout', target: 'idle' }
241
247
  ],
242
248
  onError: {
@@ -251,6 +257,7 @@ const AnearParticipantMachineConfig = participantId => ({
251
257
  }
252
258
  },
253
259
  waitParticipantResponse: {
260
+ entry: 'logWaitParticipantResponseEntry',
254
261
  always: {
255
262
  guard: 'isTimeoutImmediate',
256
263
  actions: 'logImmediateTimeout',
@@ -262,16 +269,41 @@ const AnearParticipantMachineConfig = participantId => ({
262
269
  target: '#participantTimedOut'
263
270
  }
264
271
  },
272
+ initial: 'waiting',
273
+ states: {
274
+ waiting: {
275
+ // The actual waiting state. Timer continues running when we transition to .rendering.
276
+ },
277
+ rendering: {
278
+ invoke: {
279
+ src: 'publishPrivateDisplay',
280
+ input: ({ context, event }) => ({ context, event }),
281
+ onDone: {
282
+ // Transition back to waiting - timer continues because we never exited waitParticipantResponse
283
+ // Target sibling state (stays within parent, timer continues)
284
+ target: 'waiting'
285
+ },
286
+ onError: {
287
+ target: '#error'
288
+ }
289
+ },
290
+ on: {
291
+ PARTICIPANT_EXIT: {
292
+ actions: 'logExit',
293
+ target: '#cleanupAndExit'
294
+ }
295
+ }
296
+ }
297
+ },
265
298
  on: {
266
299
  RENDER_DISPLAY: {
267
- // Re-render the current prompt for this participant (e.g. after reconnect)
268
- // without resetting the original timeout start. The updateActionTimeout
269
- // action will recompute the remaining time based on the original
270
- // actionTimeoutStart so the participant does not get extra time.
271
- target: '#renderDisplay'
300
+ // Re-render the current prompt without exiting waitParticipantResponse.
301
+ // This preserves the parent's after: timer because we stay within the parent state.
302
+ target: '.rendering'
272
303
  },
273
304
  PRIVATE_DISPLAY: {
274
- target: '#renderDisplay'
305
+ // Same as RENDER_DISPLAY - re-render without interrupting timer
306
+ target: '.rendering'
275
307
  },
276
308
  PARTICIPANT_EXIT: {
277
309
  actions: 'logExit',
@@ -285,6 +317,14 @@ const AnearParticipantMachineConfig = participantId => ({
285
317
  ],
286
318
  target: 'idle'
287
319
  },
320
+ CANCEL_TIMEOUT: {
321
+ // Cancel the active timeout and transition to idle. This is used
322
+ // when the AppM wants to immediately cancel all participant timeouts
323
+ // (e.g., when a LIAR call interrupts the wait state).
324
+ // Always send TIMER_CANCELED to acknowledge, even if no timer was active.
325
+ actions: ['nullActionTimeout', 'sendTimerCanceled'],
326
+ target: 'idle'
327
+ },
288
328
  PARTICIPANT_DISCONNECT: {
289
329
  actions: 'updateRemainingTimeoutOnDisconnect',
290
330
  target: 'waitReconnect'
@@ -297,7 +337,7 @@ const AnearParticipantMachineConfig = participantId => ({
297
337
  },
298
338
  participantTimedOut: {
299
339
  id: 'participantTimedOut',
300
- entry: ['sendTimeoutEvents', 'nullActionTimeout'],
340
+ entry: ['logParticipantTimedOut', 'sendTimeoutEvents', 'nullActionTimeout'],
301
341
  always: 'idle'
302
342
  },
303
343
  cleanupAndExit: {
@@ -412,6 +452,7 @@ const AnearParticipantMachineFunctions = {
412
452
  }
413
453
  }),
414
454
  sendTimeoutEvents: ({ context }) => {
455
+ logger.info(`[APM] Timer expired for ${context.anearParticipant.id}, sending PARTICIPANT_TIMEOUT to AEM`)
415
456
  // AEM currently expects `event.participantId` (not nested under `data`).
416
457
  context.anearEvent.send({
417
458
  type: 'PARTICIPANT_TIMEOUT',
@@ -419,6 +460,13 @@ const AnearParticipantMachineFunctions = {
419
460
  })
420
461
  if (context.appParticipantMachine) context.appParticipantMachine.send({ type: 'PARTICIPANT_TIMEOUT' })
421
462
  },
463
+ sendTimerCanceled: ({ context }) => {
464
+ logger.debug(`[APM] Sending TIMER_CANCELED to AEM for ${context.anearParticipant.id}`)
465
+ context.anearEvent.send({
466
+ type: 'TIMER_CANCELED',
467
+ participantId: context.anearParticipant.id
468
+ })
469
+ },
422
470
  sendActionToAppParticipantMachine: ({ context, event }) => {
423
471
  if (context.appParticipantMachine) context.appParticipantMachine.send(event)
424
472
  },
@@ -429,34 +477,41 @@ const AnearParticipantMachineFunctions = {
429
477
  const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
430
478
  const now = CurrentDateTimestamp()
431
479
 
432
- // If we already have a running timer (e.g. after a disconnect/reconnect
433
- // cycle or a re-render), treat actionTimeoutMsecs as the remaining
434
- // duration for this leg and subtract only the time since the last
435
- // (re)start. This avoids double-subtracting elapsed time across
436
- // multiple renders.
437
- if (context.actionTimeoutStart) {
438
- const elapsed = now - context.actionTimeoutStart
439
- const base = context.actionTimeoutMsecs != null ? context.actionTimeoutMsecs : timeoutFromEvent
440
- const remaining = base - elapsed
441
- const remainingMsecs = remaining > 0 ? remaining : 0
442
-
443
- logger.debug(`[APM] Resuming timer for ${context.anearParticipant.id} with ${remainingMsecs}ms remaining`)
444
- return {
445
- actionTimeoutMsecs: remainingMsecs,
446
- actionTimeoutStart: now
447
- }
480
+ // Start a new timer. This is only called when transitioning from idle to waitParticipantResponse.
481
+ // When RENDER_DISPLAY arrives during waitParticipantResponse, we transition to the nested
482
+ // .rendering state, which preserves the parent's after: timer (no resume needed).
483
+ const existingMsecs = context.actionTimeoutMsecs
484
+ const existingStart = context.actionTimeoutStart
485
+ if (existingMsecs != null || existingStart != null) {
486
+ logger.debug(`[APM] WARNING: Starting new timer for ${context.anearParticipant.id} but existing timer state found: msecs=${existingMsecs}, start=${existingStart}`)
448
487
  }
449
-
450
- // First time this participant sees this prompt: start a fresh timer.
451
488
  logger.debug(`[APM] Starting new timer for ${context.anearParticipant.id} with ${timeoutFromEvent}ms`)
452
489
  return {
453
490
  actionTimeoutMsecs: timeoutFromEvent,
454
491
  actionTimeoutStart: now
455
492
  }
456
493
  }),
457
- nullActionTimeout: assign({
458
- actionTimeoutMsecs: (_args) => null,
459
- actionTimeoutStart: (_args) => null
494
+ nullActionTimeout: assign(({ context }) => {
495
+ if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
496
+ logger.debug(`[APM] Cancelling timer for ${context.anearParticipant.id} (ACTION received or timeout cleared)`)
497
+ }
498
+ return {
499
+ actionTimeoutMsecs: null,
500
+ actionTimeoutStart: null
501
+ }
502
+ }),
503
+ ensureTimerCleared: assign(({ context }) => {
504
+ // Defensive: Ensure timer state is cleared when entering idle state.
505
+ // This prevents stale timer state from affecting new displays, especially
506
+ // if a RENDER_DISPLAY arrives before timeout cleanup completes.
507
+ if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
508
+ logger.debug(`[APM] Clearing stale timer state for ${context.anearParticipant.id} on idle entry (msecs=${context.actionTimeoutMsecs}, start=${context.actionTimeoutStart})`)
509
+ return {
510
+ actionTimeoutMsecs: null,
511
+ actionTimeoutStart: null
512
+ }
513
+ }
514
+ return {}
460
515
  }),
461
516
  updateRemainingTimeoutOnDisconnect: assign(({ context }) => {
462
517
  if (context.actionTimeoutStart) {
@@ -492,6 +547,8 @@ const AnearParticipantMachineFunctions = {
492
547
  },
493
548
  logAttached: ({ context }) => logger.debug(`[APM] Got ATTACHED for privateChannel for ${context.anearParticipant.id}`),
494
549
  logLive: ({ context }) => logger.debug(`[APM] Participant ${context.anearParticipant.id} is LIVE!`),
550
+ logWaitParticipantResponseEntry: ({ context }) => logger.debug(`[APM] ENTERING waitParticipantResponse state for ${context.anearParticipant.id} - timer will be (re)started if context provides timeout`),
551
+ logParticipantTimedOut: ({ context }) => logger.debug(`[APM] ENTERING participantTimedOut state for ${context.anearParticipant.id} - timeout occurred, clearing timer and sending PARTICIPANT_TIMEOUT`),
495
552
  logExit: (_args) => logger.debug('[APM] got PARTICIPANT_EXIT. Exiting...'),
496
553
  logDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has DISCONNECTED`),
497
554
  logActionTimeoutWhileDisconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} timed out on action while disconnected.`),
@@ -15,6 +15,9 @@ const REFRESH_EVENT_TYPES = new Set([
15
15
  // Temporary disconnects (useful for "halt/pause" and UI indicators):
16
16
  'PARTICIPANT_DISCONNECT',
17
17
  'HOST_DISCONNECT',
18
+ // Permanent exits (should update participant counts and UI):
19
+ 'PARTICIPANT_EXIT',
20
+ 'HOST_EXIT',
18
21
  // Location/heartbeat updates can also want a "refresh current view":
19
22
  'PARTICIPANT_UPDATE',
20
23
  'HOST_UPDATE'
@@ -91,6 +94,7 @@ const AppMachineTransition = (anearEvent) => {
91
94
  const appStateName = _stringifiedState(value)
92
95
 
93
96
  const displayEvents = []
97
+ let cancelActionType = null // Track cancel property from meta configuration
94
98
 
95
99
  // Process all meta objects to handle unpredictable AppM structures
96
100
  metaObjects.forEach(meta => {
@@ -109,6 +113,11 @@ const AppMachineTransition = (anearEvent) => {
109
113
  const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
110
114
  timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
111
115
 
116
+ // Extract cancel property if present
117
+ if (meta.allParticipants.cancel && !cancelActionType) {
118
+ cancelActionType = meta.allParticipants.cancel
119
+ }
120
+
112
121
  displayEvent = RenderContextBuilder.buildDisplayEvent(
113
122
  viewPath,
114
123
  RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
@@ -124,7 +133,8 @@ const AppMachineTransition = (anearEvent) => {
124
133
  // Check if participant is a function (new selective rendering format)
125
134
  viewer = 'eachParticipant'
126
135
  if (typeof meta.eachParticipant === 'function') {
127
- // New selective participant rendering
136
+ // New selective participant rendering - cancel property is not supported in function format
137
+ // (would need to be in the object format, see legacy handling below)
128
138
  const participantRenderFunc = meta.eachParticipant
129
139
  const participantDisplays = participantRenderFunc(appContext, event)
130
140
 
@@ -162,6 +172,11 @@ const AppMachineTransition = (anearEvent) => {
162
172
  const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
163
173
  const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
164
174
 
175
+ // Extract cancel property if present (for legacy eachParticipant format)
176
+ if (meta.eachParticipant.cancel && !cancelActionType) {
177
+ cancelActionType = meta.eachParticipant.cancel
178
+ }
179
+
165
180
  const renderContext = RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, 'eachParticipant', timeoutFn)
166
181
  displayEvent = RenderContextBuilder.buildDisplayEvent(
167
182
  viewPath,
@@ -210,7 +225,15 @@ const AppMachineTransition = (anearEvent) => {
210
225
 
211
226
  if (displayEvents.length > 0) {
212
227
  logger.debug(`[AppMachineTransition] sending RENDER_DISPLAY with ${displayEvents.length} displayEvents`)
213
- anearEvent.send({ type: 'RENDER_DISPLAY', displayEvents })
228
+ const renderDisplayPayload = {
229
+ type: 'RENDER_DISPLAY',
230
+ displayEvents
231
+ }
232
+ // Include cancel property only if defined
233
+ if (cancelActionType) {
234
+ renderDisplayPayload.cancelActionType = cancelActionType
235
+ }
236
+ anearEvent.send(renderDisplayPayload)
214
237
  }
215
238
  }
216
239
 
@@ -56,7 +56,7 @@ class DisplayEventProcessor {
56
56
  this.participantsIndex = this._buildParticipantsIndex(anearEventMachineContext.participants)
57
57
  }
58
58
 
59
- processAndPublish(displayEvents) {
59
+ processAndPublish(displayEvents, cancelActionType = null) {
60
60
  let participantsTimeout = null
61
61
 
62
62
  // Pre-compute allParticipants timeout for this render batch so other viewers
@@ -88,7 +88,7 @@ class DisplayEventProcessor {
88
88
  })
89
89
 
90
90
  return Promise.all(publishPromises).then(() => {
91
- return { participantsTimeout }
91
+ return { participantsTimeout, cancelActionType }
92
92
  })
93
93
  }
94
94
 
@@ -19,6 +19,81 @@ const PugHelpers = s3ImageAssetsUrl => ({
19
19
  } catch (error) {
20
20
  throw new Error("Invalid JSON for data-click-action:", finalPayload);
21
21
  }
22
+ },
23
+ /**
24
+ * Format a timestamp in the event's timezone (or fallback to local time)
25
+ * General-purpose helper that works for both time and date formatting.
26
+ *
27
+ * @param {number|Date} timestamp - Timestamp in milliseconds or Date object
28
+ * @param {string|null} timezone - IANA timezone ID (e.g., 'America/New_York') or null for local time
29
+ * @param {object} options - Intl.DateTimeFormat options
30
+ * - For time: { hour: 'numeric', minute: '2-digit', hour12: true }
31
+ * - For date: { weekday: 'short', month: 'short', day: 'numeric' }
32
+ * - For both: { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }
33
+ * @returns {string} Formatted date/time string
34
+ *
35
+ * @example
36
+ * // Format time only
37
+ * formatDateTime(timestamp, app.eventTimezone)
38
+ * // => "3:08 PM"
39
+ *
40
+ * // Format date only
41
+ * formatDateTime(timestamp, app.eventTimezone, { weekday: 'short', month: 'short', day: 'numeric' })
42
+ * // => "Thu, Jan 1"
43
+ *
44
+ * // Format date and time
45
+ * formatDateTime(timestamp, app.eventTimezone, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
46
+ * // => "Jan 1, 3:08 PM"
47
+ */
48
+ formatDateTime: (timestamp, timezone, options = { hour: 'numeric', minute: '2-digit', hour12: true }) => {
49
+ const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
50
+
51
+ if (timezone && typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
52
+ const formatter = new Intl.DateTimeFormat('en-US', { ...options, timeZone: timezone })
53
+ return formatter.format(date)
54
+ }
55
+
56
+ // Fallback: format based on what options were requested
57
+ const hasTimeOptions = options.hour !== undefined || options.minute !== undefined
58
+ const hasDateOptions = options.weekday !== undefined || options.month !== undefined || options.day !== undefined || options.year !== undefined
59
+
60
+ // If only time options, use simple time fallback
61
+ if (hasTimeOptions && !hasDateOptions) {
62
+ const hours = date.getHours()
63
+ const minutes = date.getMinutes()
64
+ const ampm = hours >= 12 ? 'PM' : 'AM'
65
+ const displayHours = hours % 12 || 12
66
+ return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
67
+ }
68
+
69
+ // If date options (with or without time), use UTC fallback for consistency
70
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
71
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
72
+ const parts = []
73
+
74
+ if (options.weekday === 'short' || options.weekday === 'long') {
75
+ parts.push(days[date.getUTCDay()])
76
+ }
77
+ if (options.month === 'short' || options.month === 'long') {
78
+ parts.push(months[date.getUTCMonth()])
79
+ }
80
+ if (options.day === 'numeric') {
81
+ parts.push(date.getUTCDate())
82
+ }
83
+ if (options.year === 'numeric') {
84
+ parts.push(date.getUTCFullYear())
85
+ }
86
+
87
+ // Add time if requested
88
+ if (hasTimeOptions) {
89
+ const hours = date.getUTCHours()
90
+ const minutes = date.getUTCMinutes()
91
+ const ampm = hours >= 12 ? 'PM' : 'AM'
92
+ const displayHours = hours % 12 || 12
93
+ parts.push(`${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`)
94
+ }
95
+
96
+ return parts.join(' ')
22
97
  }
23
98
  })
24
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -19,7 +19,7 @@ test('constructor', () => {
19
19
  test('attributes by index', () => {
20
20
  const tq = new TriviaQuestions(json1)
21
21
  let attr = tq.attributes(0)
22
-
22
+
23
23
  expect(attr['question-text']).toBe("How much is 1 + 15?")
24
24
  expect(attr.category).toBe("math")
25
25