anear-js-api 1.4.3 → 1.5.0

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
@@ -42,12 +42,14 @@ class AssetFileCollector {
42
42
  await this.walkDirectory(fullPath, filesData);
43
43
  } else if (entry.isFile()) {
44
44
  const relativePath = path.relative(this.baseDir, fullPath).replace(/\\/g, '/'); // Ensure forward slashes
45
+ const stat = await fs.stat(fullPath)
45
46
  const contentHash = await this.computeFileHash(fullPath);
46
47
  const contentType = this.getContentType(fullPath);
47
48
  filesData.push({
48
49
  path: relativePath,
49
50
  content_hash: contentHash,
50
51
  content_type: contentType,
52
+ mtime_ms: stat.mtimeMs,
51
53
  });
52
54
  }
53
55
  }
@@ -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.3",
3
+ "version": "1.5.0",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {