anear-js-api 1.4.4 → 1.5.1

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.
@@ -61,6 +61,24 @@ const C = require('../utils/Constants')
61
61
  const CreateEventChannelNameTemplate = appId => `anear:${appId}:e`
62
62
  const DefaultTemplatesRootDir = "./views"
63
63
 
64
+ // Process-wide signal handling.
65
+ // We install exactly once per process to avoid duplicate handlers in reconnect/restart scenarios.
66
+ let _sigtermHandlerInstalled = false
67
+ let _exitScheduled = false
68
+
69
+ function scheduleExit(code, message) {
70
+ if (_exitScheduled) return
71
+ _exitScheduled = true
72
+
73
+ if (message) logger.info(`${message} (pid=${process.pid})`)
74
+
75
+ // Give file logger a moment to flush (simple-node-logger writes to a stream).
76
+ // `process.exit()` exits immediately and can drop buffered writes.
77
+ const delayMs = 200
78
+ process.exitCode = code
79
+ setTimeout(() => process.exit(code), delayMs)
80
+ }
81
+
64
82
  const AnearCoreServiceMachineContext = (appId, appEventMachineFactory, appParticipantMachineFactory) => ({
65
83
  appId,
66
84
  appData: null,
@@ -71,6 +89,7 @@ const AnearCoreServiceMachineContext = (appId, appEventMachineFactory, appPartic
71
89
  imageAssetsUrl: null,
72
90
  fontAssetsUrl: null,
73
91
  newEventCreationChannel: null,
92
+ shutdownRequested: false, // First SIGTERM received; stop accepting new events
74
93
  retryDelay: 0
75
94
  })
76
95
 
@@ -78,11 +97,22 @@ const AnearCoreServiceMachineConfig = appId => ({
78
97
  id: `AnearCoreServiceMachine_${appId}`,
79
98
  initial: 'fetchAppDataWithRetry',
80
99
  context: ({ input }) => input,
100
+ on: {
101
+ SIGTERM: {
102
+ actions: [
103
+ 'logSigtermReceived',
104
+ 'hardExitIfSecondSigterm',
105
+ 'markShutdownRequested',
106
+ 'detachCreateEventChannel',
107
+ 'exitIfNoActiveEvents'
108
+ ]
109
+ }
110
+ },
81
111
  states: {
82
112
  fetchAppDataWithRetry: {
83
113
  entry: ({ context }) => {
84
114
  const appId = context.appId || process.env.ANEARAPP_APP_ID || 'unknown'
85
- logger.info(`[ACSM] ===== Booting App (${appId}) on anear-js-api version ${anearJsApiVersion} =====`)
115
+ logger.info(`[ACSM] ===== Booting App (${appId}) on anear-js-api version ${anearJsApiVersion} pid=${process.pid} =====`)
86
116
  },
87
117
  initial: 'fetchAppData',
88
118
  states: {
@@ -113,9 +143,15 @@ const AnearCoreServiceMachineConfig = appId => ({
113
143
  id: 'initRealtimeMessaging',
114
144
  entry: ['initRealtime'],
115
145
  on: {
116
- CONNECTED: {
117
- actions: ['createEventsCreationChannel', 'subscribeCreateEventMessages', ({ context }) => logger.info(`[ACSM] Realtime messaging connected for app ${context.appId}`)]
118
- },
146
+ CONNECTED: [
147
+ {
148
+ guard: 'acceptLifecycleCommands',
149
+ actions: ['createEventsCreationChannel', 'subscribeCreateEventMessages', ({ context }) => logger.info(`[ACSM] Realtime messaging connected for app ${context.appId}`)]
150
+ },
151
+ {
152
+ actions: ({ context }) => logger.warn(`[ACSM] Realtime connected, but shutdown already requested for app ${context.appId}; not subscribing to CREATE/LOAD commands`)
153
+ }
154
+ ],
119
155
  ATTACHED: 'uploadNewImageAssets'
120
156
  }
121
157
  },
@@ -177,15 +213,18 @@ const AnearCoreServiceMachineConfig = appId => ({
177
213
  logger.debug(`Waiting on ${shortName} lifecycle command`)
178
214
  },
179
215
  on: {
180
- CREATE_EVENT: {
181
- actions: ['startNewEventMachine']
182
- },
183
- LOAD_EVENT: {
184
- actions: ['startNewEventMachine']
185
- },
216
+ CREATE_EVENT: [
217
+ { guard: 'acceptLifecycleCommands', actions: ['startNewEventMachine'] },
218
+ { actions: ['logIgnoredLifecycleCommand'] }
219
+ ],
220
+ LOAD_EVENT: [
221
+ { guard: 'acceptLifecycleCommands', actions: ['startNewEventMachine'] },
222
+ { actions: ['logIgnoredLifecycleCommand'] }
223
+ ],
186
224
  EVENT_MACHINE_EXIT: {
187
225
  actions: [
188
- 'cleanupEventMachine'
226
+ 'cleanupEventMachine',
227
+ 'exitIfNoActiveEvents'
189
228
  ]
190
229
  }
191
230
  }
@@ -275,7 +314,82 @@ const AnearCoreServiceMachineFunctions = {
275
314
  self,
276
315
  'LOAD_EVENT'
277
316
  )
278
- logger.info(`[ACSM] Subscribed to CREATE_EVENT and LOAD_EVENT on events channel`)
317
+ logger.info(`[ACSM] Subscribed to CREATE_EVENT and LOAD_EVENT on events channel (pid=${process.pid})`)
318
+ },
319
+ logIgnoredLifecycleCommand: ({ event, context }) => {
320
+ logger.warn(`[ACSM] Ignoring ${event.type} because shutdown has been requested (appId=${context.appId} pid=${process.pid})`)
321
+ },
322
+ logSigtermReceived: ({ context, event }) => {
323
+ const count = event?.data?.count || 1
324
+ const activeIds = Object.keys(context.anearEventMachines || {})
325
+ const activeCount = activeIds.length
326
+
327
+ if (count === 1) {
328
+ if (activeCount === 0) {
329
+ logger.info(`[ACSM] SIGTERM received: detaching create/load channel and exiting cleanly (no active events) (pid=${process.pid})`)
330
+ } else {
331
+ logger.info(`[ACSM] SIGTERM received: detaching create/load channel; stopping all CREATE_EVENT/LOAD_EVENT requests; waiting for ${activeCount} active event(s) to terminate (pid=${process.pid})`)
332
+ }
333
+ } else {
334
+ logger.warn(`[ACSM] SIGTERM received again (count=${count}) while shutdown in progress (pid=${process.pid})`)
335
+ }
336
+ },
337
+ hardExitIfSecondSigterm: ({ context, event }) => {
338
+ const count = event?.data?.count || 1
339
+ if (context.shutdownRequested && count >= 2) {
340
+ const activeIds = Object.keys(context.anearEventMachines || {})
341
+ const activeCount = activeIds.length
342
+ if (activeCount > 0) {
343
+ logger.warn(`[ACSM] Second SIGTERM received; forcing shutdown with ${activeCount} active event(s) still running (pid=${process.pid})`)
344
+ } else {
345
+ logger.warn(`[ACSM] Second SIGTERM received; forcing shutdown (pid=${process.pid})`)
346
+ }
347
+ // Hard exit: do not wait for cleanup.
348
+ process.exit(1)
349
+ }
350
+ },
351
+ markShutdownRequested: assign({
352
+ shutdownRequested: () => true
353
+ }),
354
+ detachCreateEventChannel: ({ context }) => {
355
+ const ch = context.newEventCreationChannel
356
+ if (!ch) {
357
+ logger.info(`[ACSM] SIGTERM received before create-event channel existed; nothing to detach yet (pid=${process.pid})`)
358
+ return
359
+ }
360
+
361
+ try {
362
+ // Unsubscribe message listeners to prevent CREATE/LOAD callbacks firing.
363
+ if (typeof ch.unsubscribe === 'function') {
364
+ ch.unsubscribe('CREATE_EVENT')
365
+ ch.unsubscribe('LOAD_EVENT')
366
+ }
367
+ } catch (e) {
368
+ logger.warn('[ACSM] Error unsubscribing create-event channel listeners', e)
369
+ }
370
+
371
+ try {
372
+ // Detach to stop receiving messages from Ably for this channel.
373
+ const maybePromise = ch.detach()
374
+ if (maybePromise && typeof maybePromise.catch === 'function') {
375
+ maybePromise.catch(e => logger.warn('[ACSM] Error detaching create-event channel', e))
376
+ }
377
+ } catch (e) {
378
+ logger.warn('[ACSM] Error detaching create-event channel', e)
379
+ }
380
+
381
+ logger.info(`[ACSM] Detached from create-event channel; will not create/load any more events (pid=${process.pid})`)
382
+ },
383
+ exitIfNoActiveEvents: ({ context }) => {
384
+ if (!context.shutdownRequested) return
385
+
386
+ const activeCount = Object.keys(context.anearEventMachines || {}).length
387
+ if (activeCount === 0) {
388
+ scheduleExit(
389
+ 0,
390
+ '[ACSM] SIGTERM shutdown complete: no active events remaining; exiting cleanly'
391
+ )
392
+ }
279
393
  },
280
394
  startNewEventMachine: assign(
281
395
  {
@@ -321,7 +435,8 @@ const AnearCoreServiceMachineFunctions = {
321
435
  retry_with_backoff_delay: ({ context }) => context.retryDelay
322
436
  },
323
437
  guards: {
324
- noImageAssetFilesFound: ({ event }) => event.output === null
438
+ noImageAssetFilesFound: ({ event }) => event.output === null,
439
+ acceptLifecycleCommands: ({ context }) => !context.shutdownRequested
325
440
  }
326
441
  }
327
442
 
@@ -337,7 +452,26 @@ const AnearCoreServiceMachine = (appEventMachineFactory, appParticipantMachineFa
337
452
 
338
453
  const coreServiceMachine = createMachine(machineConfig, AnearCoreServiceMachineFunctions)
339
454
 
340
- return createActor(coreServiceMachine, { input: anearCoreServiceMachineContext }).start()
455
+ const actor = createActor(coreServiceMachine, { input: anearCoreServiceMachineContext }).start()
456
+
457
+ if (!_sigtermHandlerInstalled) {
458
+ _sigtermHandlerInstalled = true
459
+ let sigtermCount = 0
460
+ process.on('SIGTERM', () => {
461
+ sigtermCount += 1
462
+ try {
463
+ // Log at the point we definitely caught the signal (even if the actor is wedged).
464
+ // This should land in the same log file as ACSM startup logs.
465
+ logger.info(`[ACSM] process SIGTERM caught (count=${sigtermCount}) pid=${process.pid}`)
466
+ actor.send({ type: 'SIGTERM', data: { count: sigtermCount } })
467
+ } catch (e) {
468
+ // If actor is gone or send fails, fall back to hard exit on second signal.
469
+ if (sigtermCount >= 2) process.exit(1)
470
+ }
471
+ })
472
+ }
473
+
474
+ return actor
341
475
  }
342
476
 
343
477
  module.exports = AnearCoreServiceMachine
@@ -111,6 +111,12 @@ const AnearParticipantMachineContext = (anearParticipant, anearEvent, appPartici
111
111
  actionTimeoutMsecs: null,
112
112
  actionTimeoutStart: null,
113
113
  capturedTimeoutMsecs: null, // Captured timeout value for logging before clearing
114
+ // If the AppM emits rapid successive RENDER_DISPLAY events (e.g. dealing animation),
115
+ // a participant may still be in the middle of publishing the prior PRIVATE_DISPLAY.
116
+ // Without buffering, any new RENDER_DISPLAY arriving while in `live.renderDisplay`
117
+ // would be dropped (no handler in that state). We keep the latest and flush it
118
+ // immediately after the current publish completes.
119
+ queuedRenderDisplay: null,
114
120
  lastSeen: CurrentDateTimestamp()
115
121
  })
116
122
 
@@ -235,16 +241,21 @@ const AnearParticipantMachineConfig = participantId => ({
235
241
  src: 'publishPrivateDisplay',
236
242
  input: ({ context, event }) => ({ context, event }),
237
243
  onDone: [
238
- { guard: 'hasActionTimeout', actions: 'updateActionTimeout', target: 'waitParticipantResponse' },
244
+ { guard: 'hasActionTimeout', actions: ['updateActionTimeout', 'sendQueuedRenderDisplayIfAny', 'clearQueuedRenderDisplay'], target: 'waitParticipantResponse' },
239
245
  // If this display does not configure a timeout, explicitly clear any
240
246
  // previously-running action timeout.
241
- { actions: 'nullActionTimeout', target: 'idle' }
247
+ { actions: ['nullActionTimeout', 'sendQueuedRenderDisplayIfAny', 'clearQueuedRenderDisplay'], target: 'idle' }
242
248
  ],
243
249
  onError: {
244
250
  target: '#error'
245
251
  }
246
252
  },
247
253
  on: {
254
+ // Buffer the latest render request if we are currently publishing.
255
+ // (Otherwise, RENDER_DISPLAY is unhandled in this state and gets dropped.)
256
+ RENDER_DISPLAY: {
257
+ actions: 'queueRenderDisplay'
258
+ },
248
259
  PARTICIPANT_EXIT: {
249
260
  actions: 'logExit',
250
261
  target: '#cleanupAndExit'
@@ -625,6 +636,28 @@ const AnearParticipantMachineFunctions = {
625
636
  logger.error(`[APM] ⚠️ Only after timers are cancelled (APM transitions to idle) can new RENDER_DISPLAY with timeouts be issued.`)
626
637
  logger.error(`[APM] ⚠️ This ${eventType} event is being IGNORED to prevent timer conflicts.`)
627
638
  }
639
+ ,
640
+ queueRenderDisplay: assign(({ context, event }) => {
641
+ // Keep only the latest queued render display.
642
+ // Event shape is already { type: 'RENDER_DISPLAY', content, timeout? }.
643
+ const participantId = context?.anearParticipant?.id
644
+ const replaced = !!context?.queuedRenderDisplay
645
+ logger.debug(`[APM] queueing RENDER_DISPLAY for ${participantId}${replaced ? ' (replacing prior queued render)' : ''}`)
646
+ return { queuedRenderDisplay: event }
647
+ }),
648
+ sendQueuedRenderDisplayIfAny: ({ context, self }) => {
649
+ const queued = context.queuedRenderDisplay
650
+ if (queued && queued.type === 'RENDER_DISPLAY') {
651
+ const participantId = context?.anearParticipant?.id
652
+ logger.debug(`[APM] flushing queued RENDER_DISPLAY for ${participantId}`)
653
+ try {
654
+ self.send(queued)
655
+ } catch (_e) {
656
+ // Best-effort; never interrupt the publish completion path.
657
+ }
658
+ }
659
+ },
660
+ clearQueuedRenderDisplay: assign(() => ({ queuedRenderDisplay: null }))
628
661
  },
629
662
  actors: {
630
663
  publishPrivateDisplay: fromPromise(async ({ input }) => {
@@ -23,6 +23,36 @@ const REFRESH_EVENT_TYPES = new Set([
23
23
  'HOST_UPDATE'
24
24
  ])
25
25
 
26
+ // JSON.stringify can throw on circular structures / BigInt.
27
+ // For render-dedupe signatures we want a stable-ish representation, not perfection.
28
+ const _safeSignatureStringify = (value) => {
29
+ const seen = new WeakSet()
30
+ return JSON.stringify(value, (key, val) => {
31
+ // Drop known non-serializable / noisy keys if they appear in context objects.
32
+ // (These can sneak in via app context depending on app patterns.)
33
+ if (
34
+ key === 'anearEvent' ||
35
+ key === 'appEventMachine' ||
36
+ key === 'service' ||
37
+ key === 'actor' ||
38
+ key === 'self' ||
39
+ key === 'parent'
40
+ ) {
41
+ return undefined
42
+ }
43
+
44
+ if (typeof val === 'bigint') return String(val)
45
+ if (typeof val === 'function') return `[function:${val.name || 'anonymous'}]`
46
+
47
+ if (val && typeof val === 'object') {
48
+ if (seen.has(val)) return '[Circular]'
49
+ seen.add(val)
50
+ }
51
+
52
+ return val
53
+ })
54
+ }
55
+
26
56
  /**
27
57
  * AppMachineTransition:
28
58
  * - Runs inside your appEventMachine onTransition.
@@ -73,7 +103,7 @@ const AppMachineTransition = (anearEvent) => {
73
103
  // re-rendering even if the AppM state didn't change.
74
104
  let baseSignature
75
105
  try {
76
- baseSignature = JSON.stringify({ stateName, meta: rawMeta, context: appContext })
106
+ baseSignature = _safeSignatureStringify({ stateName, meta: rawMeta, context: appContext })
77
107
  } catch (_e) {
78
108
  baseSignature = `${stateName}|${rawMeta ? Object.keys(rawMeta).join(',') : ''}`
79
109
  }
@@ -130,7 +130,7 @@ class RealtimeMessaging {
130
130
  }
131
131
 
132
132
  getChannel(channelName, actor, channelParams = {}) {
133
- logger.debug(`[RTM] creating channel ${channelName} for ${actor.id}`)
133
+ logger.debug(`[RTM] creating channel ${channelName} for ${actor.id} pid=${process.pid}`)
134
134
 
135
135
  const channel = this.ablyRealtime.channels.get(channelName, channelParams)
136
136
  this.enableCallbacks(channel, actor)
@@ -198,7 +198,7 @@ class RealtimeMessaging {
198
198
  const type = data.type ? data.type : 'NONE'
199
199
  const channelShortName = getChannelShortName(channel.name)
200
200
  const channelLabel = channelShortName === 'actions' ? 'actions:presence' : channelShortName
201
- logger.info(`[RTM] received presence ${eventName} participantId=${data.id} on channel ${channelLabel}`)
201
+ logger.info(`[RTM] received presence ${eventName} participantId=${data.id} on channel ${channelLabel} pid=${process.pid} actor=${actor.id}`)
202
202
  actor.send({ type: eventName, data })
203
203
  }
204
204
  )
@@ -269,7 +269,7 @@ class RealtimeMessaging {
269
269
  const participantId = message.data?.participantId || null
270
270
  const participantIdStr = participantId ? ` participantId=${participantId}` : ''
271
271
  const channelShortName = getChannelShortName(channel.name)
272
- logger.info(`[RTM] received message ${message.name}${participantIdStr} on channel ${channelShortName}`)
272
+ logger.info(`[RTM] received message ${message.name}${participantIdStr} on channel ${channelShortName} pid=${process.pid} actor=${actor.id}`)
273
273
  actor.send({ type: message.name, data: message.data })
274
274
  }
275
275
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.4.4",
3
+ "version": "1.5.1",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {