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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
)
|