anear-js-api 1.3.0 → 1.3.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.
@@ -658,7 +658,8 @@ const ActiveEventStatesConfig = {
658
658
  states: {
659
659
  idle: {},
660
660
  waitingForCancelConfirmations: {
661
- entry: ['sendCancelTimeoutToAllAPMs', 'setupPendingCancelConfirmations'],
661
+ // Note: Setup actions are called in the transitions that lead here, not in entry
662
+ // This allows different setup for declarative cancel (TRIGGER_CANCEL_SEQUENCE) vs manual cancel (CANCEL_ALL_PARTICIPANT_TIMEOUTS)
662
663
  on: {
663
664
  TIMER_CANCELED: {
664
665
  actions: ['removeFromPendingCancelConfirmations']
@@ -677,7 +678,7 @@ const ActiveEventStatesConfig = {
677
678
  },
678
679
  always: {
679
680
  guard: 'allCancelConfirmationsReceived',
680
- actions: ['forwardPendingCancelAction', 'clearCancelState'],
681
+ actions: ['forwardPendingCancelActionIfExists', 'clearCancelState'],
681
682
  target: '#waitingForActions.idle'
682
683
  }
683
684
  }
@@ -691,7 +692,12 @@ const ActiveEventStatesConfig = {
691
692
  actions: ['trackParticipantTimeout', 'processParticipantTimeout'],
692
693
  // v5 note: internal transitions are the default (v4 had `internal: true`)
693
694
  },
695
+ CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
696
+ actions: ['setupPendingCancelConfirmationsForManualCancel', 'sendCancelTimeoutToAllAPMs'],
697
+ target: '.waitingForCancelConfirmations'
698
+ },
694
699
  TRIGGER_CANCEL_SEQUENCE: {
700
+ actions: ['sendCancelTimeoutToAllAPMs', 'setupPendingCancelConfirmations'],
695
701
  target: '.waitingForCancelConfirmations'
696
702
  },
697
703
  TIMER_CANCELED: {
@@ -1749,6 +1755,19 @@ const AnearEventMachineFunctions = ({
1749
1755
  suppressParticipantTimeouts: true // Drop PARTICIPANT_TIMEOUT events
1750
1756
  }
1751
1757
  }),
1758
+ setupPendingCancelConfirmationsForManualCancel: assign(({ context }) => {
1759
+ // Get all active participants (for manual cancelAllParticipantTimeouts call)
1760
+ const activeParticipantIds = getActiveParticipantIds(context)
1761
+ const pendingConfirmations = new Set(activeParticipantIds)
1762
+
1763
+ logger.info(`[AEM] Starting manual cancellation sequence (cancelAllParticipantTimeouts). Waiting for ${pendingConfirmations.size} APM confirmations: ${[...pendingConfirmations].join(', ')}`)
1764
+
1765
+ return {
1766
+ pendingCancelConfirmations: pendingConfirmations,
1767
+ pendingCancelAction: null, // No ACTION to forward for manual cancel
1768
+ suppressParticipantTimeouts: true // Drop PARTICIPANT_TIMEOUT events
1769
+ }
1770
+ }),
1752
1771
  removeFromPendingCancelConfirmations: assign(({ context, event }) => {
1753
1772
  const participantId = event.participantId || event.data?.id
1754
1773
  if (!context.pendingCancelConfirmations || !participantId) return {}
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * - Enforce participant-level timeouts and activity rules:
25
25
  * - Action timeouts:
26
- * - After a PRIVATE_DISPLAY / RENDER_DISPLAY that expects input,
26
+ * - After a RENDER_DISPLAY that expects input,
27
27
  * starts a per-participant response timer.
28
28
  * - If the timer fires before an ACTION arrives, emits PARTICIPANT_TIMEOUT
29
29
  * back to the AEM and the appParticipantMachine.
@@ -64,7 +64,7 @@
64
64
  * Ably wiring or lifecycle plumbing.
65
65
  *
66
66
  * Incoming events (from AEM / system) – high level:
67
- * - RENDER_DISPLAY / PRIVATE_DISPLAY:
67
+ * - RENDER_DISPLAY:
68
68
  * - Prompt the participant with a new view or interaction on their private channel.
69
69
  * - Optionally include an action timeout; APM transitions into a wait state.
70
70
  *
@@ -87,7 +87,7 @@
87
87
  * 2. It creates and attaches to the participant’s private Ably channel, then
88
88
  * signals PARTICIPANT_MACHINE_READY back to the AEM.
89
89
  * 3. It enters the live state, handling:
90
- * - PRIVATE_DISPLAY / RENDER_DISPLAY → publish UI, optionally start timeout.
90
+ * - RENDER_DISPLAY → publish UI, optionally start timeout.
91
91
  * - ACTION → forward to appParticipantMachine, clear timeout, go idle.
92
92
  * - DISCONNECT / RECONNECT → manage reconnect windows and possible timeouts.
93
93
  * - EXIT / BOOT → send shutdown messaging and clean up.
@@ -166,9 +166,6 @@ const AnearParticipantMachineConfig = participantId => ({
166
166
  RENDER_DISPLAY: {
167
167
  target: '#renderDisplay'
168
168
  },
169
- PRIVATE_DISPLAY: {
170
- target: '#renderDisplay'
171
- },
172
169
  CANCEL_TIMEOUT: {
173
170
  // Acknowledge CANCEL_TIMEOUT even when idle (no active timer).
174
171
  // This ensures the AEM's cancellation sequence completes for all participants.
@@ -215,9 +212,6 @@ const AnearParticipantMachineConfig = participantId => ({
215
212
  RENDER_DISPLAY: {
216
213
  target: '#renderDisplay'
217
214
  },
218
- PRIVATE_DISPLAY: {
219
- target: '#renderDisplay'
220
- },
221
215
  PARTICIPANT_EXIT: {
222
216
  target: '#cleanupAndExit'
223
217
  },
@@ -264,6 +258,9 @@ const AnearParticipantMachineConfig = participantId => ({
264
258
  target: '#participantTimedOut'
265
259
  },
266
260
  after: {
261
+ // Note: This timer continues running even when in nested .rendering state.
262
+ // XState v5 handles this correctly - the timer fires from the parent state
263
+ // and can transition even during invoke processing.
267
264
  actionTimeout: {
268
265
  actions: 'nullActionTimeout',
269
266
  target: '#participantTimedOut'
@@ -272,17 +269,27 @@ const AnearParticipantMachineConfig = participantId => ({
272
269
  initial: 'waiting',
273
270
  states: {
274
271
  waiting: {
275
- // The actual waiting state. Timer continues running when we transition to .rendering.
272
+ // The actual waiting state. RENDER_DISPLAY is blocked here
273
+ // to prevent timer conflicts. AppM must cancel timers first.
276
274
  },
277
275
  rendering: {
276
+ // NOTE: This nested state is currently unreachable as RENDER_DISPLAY
277
+ // is blocked during waitParticipantResponse. Kept for potential future use or
278
+ // other edge cases. The parent's `after: actionTimeout` timer continues running
279
+ // during invoke if this state were to be entered.
278
280
  invoke: {
279
281
  src: 'publishPrivateDisplay',
280
282
  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
- },
283
+ onDone: [
284
+ {
285
+ guard: 'hasActionTimeout',
286
+ actions: 'updateActionTimeoutIfChanged',
287
+ target: 'waiting'
288
+ },
289
+ {
290
+ target: 'waiting'
291
+ }
292
+ ],
286
293
  onError: {
287
294
  target: '#error'
288
295
  }
@@ -297,13 +304,11 @@ const AnearParticipantMachineConfig = participantId => ({
297
304
  },
298
305
  on: {
299
306
  RENDER_DISPLAY: {
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'
303
- },
304
- PRIVATE_DISPLAY: {
305
- // Same as RENDER_DISPLAY - re-render without interrupting timer
306
- target: '.rendering'
307
+ // BLOCKED: RENDER_DISPLAY during active timeout wait is not allowed.
308
+ // The AppM must first cancel all participant timers (via cancelAllParticipantTimeouts()
309
+ // or cancel: "ACTION" in meta) and wait for ACK before issuing new RENDER_DISPLAY
310
+ // with timeouts. This prevents timer conflicts and ensures clean state transitions.
311
+ actions: 'logBlockedRenderDuringWait'
307
312
  },
308
313
  PARTICIPANT_EXIT: {
309
314
  actions: 'logExit',
@@ -346,7 +351,6 @@ const AnearParticipantMachineConfig = participantId => ({
346
351
  // Ignore most events during cleanup, but allow exit displays
347
352
  on: {
348
353
  RENDER_DISPLAY: { actions: 'logIgnoringRenderDisplayCleanup' },
349
- PRIVATE_DISPLAY: { actions: 'logIgnoringPrivateDisplayCleanup' },
350
354
  PARTICIPANT_EXIT: {
351
355
  actions: 'logIgnoringRedundantExit'
352
356
  },
@@ -407,7 +411,6 @@ const AnearParticipantMachineConfig = participantId => ({
407
411
  // Final state - ignore all events except exit displays
408
412
  on: {
409
413
  RENDER_DISPLAY: { actions: 'logIgnoringRenderDisplayDone' },
410
- PRIVATE_DISPLAY: { actions: 'logIgnoringPrivateDisplayDone' },
411
414
  '*': {
412
415
  actions: 'logIgnoringEventDone'
413
416
  }
@@ -491,6 +494,36 @@ const AnearParticipantMachineFunctions = {
491
494
  actionTimeoutStart: now
492
495
  }
493
496
  }),
497
+ updateActionTimeoutIfChanged: assign(({ context, event }) => {
498
+ const timeoutFromEvent = event?.output?.timeout ?? event?.data?.timeout
499
+ const now = CurrentDateTimestamp()
500
+
501
+ // When RENDER_DISPLAY arrives during waitParticipantResponse, check if the timeout has changed.
502
+ // If it has, update the timer. This handles state transitions where a new timeout is needed.
503
+ const existingMsecs = context.actionTimeoutMsecs
504
+ const existingStart = context.actionTimeoutStart
505
+
506
+ if (timeoutFromEvent == null || timeoutFromEvent <= 0) {
507
+ // New display has no timeout - preserve existing timer (this is a visual status update)
508
+ // Don't clear it, as we're still waiting for the original ACTION
509
+ logger.debug(`[APM] Preserving existing timer for ${context.anearParticipant.id} (new display has no timeout, visual status update)`)
510
+ return {}
511
+ }
512
+
513
+ // If we already have an active timer and receive a new timeout, this is a bug (detected above).
514
+ // Preserve the existing timer to avoid conflicts. The AppM should cancel timers first.
515
+ if (existingMsecs != null && existingStart != null) {
516
+ logger.warn(`[APM] AppM BUG: Received timeout=${timeoutFromEvent}ms while timer already active (${existingMsecs}ms) for ${context.anearParticipant.id}. Preserving existing timer. AppM should cancel timers first.`)
517
+ return {}
518
+ }
519
+
520
+ // No existing timer - start a new one (shouldn't happen in waitParticipantResponse, but handle gracefully)
521
+ logger.debug(`[APM] Starting new timer for ${context.anearParticipant.id} with ${timeoutFromEvent}ms`)
522
+ return {
523
+ actionTimeoutMsecs: timeoutFromEvent,
524
+ actionTimeoutStart: now
525
+ }
526
+ }),
494
527
  nullActionTimeout: assign(({ context }) => {
495
528
  if (context.actionTimeoutMsecs != null || context.actionTimeoutStart != null) {
496
529
  logger.debug(`[APM] Cancelling timer for ${context.anearParticipant.id} (ACTION received or timeout cleared)`)
@@ -556,13 +589,25 @@ const AnearParticipantMachineFunctions = {
556
589
  logReconnected: ({ context }) => logger.info(`[APM] Participant ${context.anearParticipant.id} has RECONNECTED`),
557
590
  logImmediateTimeout: ({ context }) => logger.debug(`[APM] Timeout of ${context.actionTimeoutMsecs}ms is immediate for ${context.anearParticipant.id}.`),
558
591
  logIgnoringRenderDisplayCleanup: (_args) => logger.debug('[APM] ignoring RENDER_DISPLAY during cleanup'),
559
- logIgnoringPrivateDisplayCleanup: (_args) => logger.debug('[APM] ignoring PRIVATE_DISPLAY during cleanup'),
560
592
  logIgnoringRedundantExit: () => logger.debug('[APM] ignoring redundant PARTICIPIPANT_EXIT during cleanup'),
561
593
  logIgnoringReconnectCleanup: () => logger.debug('[APM] ignoring PARTICIPANT_RECONNECT during cleanup - already timed out'),
562
594
  logIgnoringActionCleanup: () => logger.debug('[APM] ignoring ACTION during cleanup'),
563
595
  logIgnoringRenderDisplayDone: () => logger.debug('[APM] ignoring RENDER_DISPLAY in final state'),
564
- logIgnoringPrivateDisplayDone: () => logger.debug('[APM] ignoring PRIVATE_DISPLAY in final state'),
565
- logIgnoringEventDone: ({ event }) => logger.debug('[APM] ignoring event in final state: ', event.type)
596
+ logIgnoringEventDone: ({ event }) => logger.debug('[APM] ignoring event in final state: ', event.type),
597
+ logBlockedRenderDuringWait: ({ context, event }) => {
598
+ const eventType = event?.type ?? 'RENDER_DISPLAY'
599
+ const timeout = event?.timeout ?? event?.data?.timeout
600
+ const participantId = context.anearParticipant.id
601
+ const remainingMsecs = context.actionTimeoutMsecs
602
+ const timeoutInfo = timeout ? ` (new timeout: ${timeout}ms)` : ''
603
+
604
+ logger.error(`[APM] ⚠️ BLOCKED: ${eventType} received during active timeout wait for participant ${participantId}. Current timer: ${remainingMsecs}ms remaining.${timeoutInfo}`)
605
+ logger.error(`[APM] ⚠️ The AppM must first cancel all participant timers using one of these methods:`)
606
+ logger.error(`[APM] ⚠️ 1. Declarative: Add cancel: "ACTION_NAME" to meta.eachParticipant or meta.allParticipants`)
607
+ logger.error(`[APM] ⚠️ 2. Manual: Call anearEvent.cancelAllParticipantTimeouts() and wait for ACK`)
608
+ logger.error(`[APM] ⚠️ Only after timers are cancelled (APM transitions to idle) can new RENDER_DISPLAY with timeouts be issued.`)
609
+ logger.error(`[APM] ⚠️ This ${eventType} event is being IGNORED to prevent timer conflicts.`)
610
+ }
566
611
  },
567
612
  actors: {
568
613
  publishPrivateDisplay: fromPromise(async ({ input }) => {
@@ -102,6 +102,14 @@ const AppMachineTransition = (anearEvent) => {
102
102
  let timeoutFn
103
103
  let displayEvent
104
104
 
105
+ // Extract cancel from meta level (for function-based eachParticipant or top-level cancel)
106
+ // Supports both "cancel" (developer-friendly) and "cancelActionType" (internal)
107
+ if (meta.cancel && !cancelActionType) {
108
+ cancelActionType = meta.cancel
109
+ } else if (meta.cancelActionType && !cancelActionType) {
110
+ cancelActionType = meta.cancelActionType
111
+ }
112
+
105
113
  // Validate that participants: and participant: are not both defined
106
114
  if (meta.allParticipants && meta.eachParticipant) {
107
115
  logger.error(`[AppMachineTransition] Invalid meta configuration: both 'allParticipants' and 'eachParticipant' are defined. Only one can be used per state.`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {