anear-js-api 1.6.6 → 2.0.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.
package/README.md CHANGED
@@ -115,6 +115,31 @@ ANEARAPP_API_VERSION=v1
115
115
  ANEARAPP_API_URL=http://api.lvh.me:3001/developer # Optional, for local development
116
116
  ```
117
117
 
118
+ ### Local Dev Quickstart (no Docker)
119
+
120
+ To keep development behavior close to today:
121
+
122
+ 1. Keep runtime asset publishing enabled (default):
123
+
124
+ ```bash
125
+ unset ANEARAPP_SKIP_RUNTIME_ASSET_SYNC
126
+ ```
127
+
128
+ 2. Start your app process normally:
129
+
130
+ ```bash
131
+ npm install
132
+ node index.js
133
+ ```
134
+
135
+ 3. If your ANAPI environment is not Rails `development`, set:
136
+
137
+ ```bash
138
+ export ANEARAPP_SINGLE_LATEST_VERSION=true
139
+ ```
140
+
141
+ This preserves a single mutable `latest` app version for iterative development.
142
+
118
143
  ### Developer API Authentication
119
144
 
120
145
  `anear-js-api` communicates with the **ANAPI Developer API** (`/developer/v1/*` endpoints) using JWT authentication:
@@ -263,6 +288,29 @@ The JSAPI automatically handles asset management:
263
288
  2. **Image Upload**: All images in `assets/images/` are uploaded to CloudFront
264
289
  3. **Template Preloading**: All PUG templates are preloaded and cached
265
290
 
291
+ ### CI-Managed Assets Mode
292
+
293
+ For containerized deployments, you can publish assets during CI and skip runtime asset sync:
294
+
295
+ ```bash
296
+ ANEARAPP_SKIP_RUNTIME_ASSET_SYNC=true
297
+ ```
298
+
299
+ When this variable is enabled, `AnearCoreServiceMachine` skips image/font/CSS upload states and proceeds directly to template compilation and event lifecycle listening.
300
+
301
+ CI helpers are available under:
302
+
303
+ - `anear-js-api/lib/ci/publishAssets.js`
304
+ - `anear-js-api/lib/ci/registerAppVersion.js`
305
+
306
+ ### Versioned Lifecycle Channels
307
+
308
+ ACSM subscribes to versioned lifecycle channels derived from ANAPI app metadata:
309
+
310
+ `anear:<appId>:v:<appVersion>:e`
311
+
312
+ ANAPI must return `latest_app_version` in developer app payloads for startup channel resolution.
313
+
266
314
  ### Asset Structure
267
315
 
268
316
  ```
@@ -80,6 +80,24 @@ class AnearApi extends ApiService {
80
80
  return attrs
81
81
  }
82
82
 
83
+ async createAppVersion(appId, attrs) {
84
+ logger.debug(`API: POST app_versions for app ${appId}`)
85
+ const json = await this.post("app_versions", attrs, { app: appId })
86
+ return json.data
87
+ }
88
+
89
+ async updateAppVersion(appVersionId, attrs) {
90
+ logger.debug(`API: PUT app_versions/${appVersionId}`)
91
+ const json = await this.put("app_versions", appVersionId, attrs)
92
+ return json.data
93
+ }
94
+
95
+ async getLatestAppVersion(appId) {
96
+ logger.debug(`API: GET apps/${appId}/latest_app_version`)
97
+ const json = await this.get(`apps/${appId}/latest_app_version`)
98
+ return json.data
99
+ }
100
+
83
101
  async saveAppEventContext(eventId, appmContext) {
84
102
  logger.debug(`API: POST developer events/${eventId}/app_event_context`)
85
103
  const path = `events/${eventId}/app_event_context`
@@ -0,0 +1,35 @@
1
+ "use strict"
2
+
3
+ const C = require('../utils/Constants')
4
+ const ImageAssetsUploader = require('../utils/ImageAssetsUploader')
5
+ const FontAssetsUploader = require('../utils/FontAssetsUploader')
6
+ const CssUploader = require('../utils/CssUploader')
7
+
8
+ async function publishAssetsFromCi(appId = process.env.ANEARAPP_APP_ID) {
9
+ if (!appId) {
10
+ throw new Error('ANEARAPP_APP_ID must be set to publish assets from CI')
11
+ }
12
+
13
+ const imageAssetsUploader = new ImageAssetsUploader(C.ImagesDirPath, appId)
14
+ const imageAssetsUrl = await imageAssetsUploader.uploadAssets()
15
+
16
+ const fontAssetsUploader = new FontAssetsUploader(C.FontsDirPath, appId)
17
+ const fontAssetsUrl = await fontAssetsUploader.uploadAssets()
18
+
19
+ const cssUploader = new CssUploader(
20
+ C.CssDirPath,
21
+ imageAssetsUrl,
22
+ fontAssetsUrl,
23
+ appId
24
+ )
25
+ const cssUrl = await cssUploader.uploadCss()
26
+
27
+ return {
28
+ appId,
29
+ imageAssetsUrl,
30
+ fontAssetsUrl,
31
+ cssUrl
32
+ }
33
+ }
34
+
35
+ module.exports = { publishAssetsFromCi }
@@ -0,0 +1,43 @@
1
+ "use strict"
2
+
3
+ const AnearApi = require('../api/AnearApi')
4
+
5
+ async function registerAppVersionFromCi({
6
+ appId = process.env.ANEARAPP_APP_ID,
7
+ version = process.env.APP_VERSION,
8
+ gitSha = process.env.GITHUB_SHA,
9
+ imageUri = process.env.IMAGE_URI,
10
+ assetManifestHash = process.env.ASSET_MANIFEST_HASH,
11
+ status = 'building'
12
+ } = {}) {
13
+ if (!appId || !version) {
14
+ throw new Error('ANEARAPP_APP_ID and APP_VERSION are required to register an app version')
15
+ }
16
+
17
+ const data = await AnearApi.createAppVersion(appId, {
18
+ version,
19
+ status,
20
+ git_sha: gitSha,
21
+ image_uri: imageUri,
22
+ asset_manifest_hash: assetManifestHash
23
+ })
24
+
25
+ return data
26
+ }
27
+
28
+ async function markAppVersionStatus(appVersionId, status, extraAttrs = {}) {
29
+ if (!appVersionId || !status) {
30
+ throw new Error('appVersionId and status are required to update app version status')
31
+ }
32
+
33
+ const data = await AnearApi.updateAppVersion(appVersionId, {
34
+ status,
35
+ ...extraAttrs
36
+ })
37
+ return data
38
+ }
39
+
40
+ module.exports = {
41
+ registerAppVersionFromCi,
42
+ markAppVersionStatus
43
+ }
@@ -58,8 +58,9 @@ const ImageAssetsUploader = require('../utils/ImageAssetsUploader')
58
58
  const FontAssetsUploader = require('../utils/FontAssetsUploader')
59
59
  const C = require('../utils/Constants')
60
60
 
61
- const CreateEventChannelNameTemplate = appId => `anear:${appId}:e`
61
+ const CreateVersionedEventChannelNameTemplate = (appId, appVersion) => `anear:${appId}:v:${normalizeVersionToken(appVersion)}:e`
62
62
  const DefaultTemplatesRootDir = "./views"
63
+ const SkipRuntimeAssetSync = process.env.ANEARAPP_SKIP_RUNTIME_ASSET_SYNC === 'true'
63
64
 
64
65
  // Process-wide signal handling.
65
66
  // We install exactly once per process to avoid duplicate handlers in reconnect/restart scenarios.
@@ -152,9 +153,21 @@ const AnearCoreServiceMachineConfig = appId => ({
152
153
  actions: ({ context }) => logger.warn(`[ACSM] Realtime connected, but shutdown already requested for app ${context.appId}; not subscribing to CREATE/LOAD commands`)
153
154
  }
154
155
  ],
155
- ATTACHED: 'uploadNewImageAssets'
156
+ ATTACHED: 'syncAssetsOrSkip'
156
157
  }
157
158
  },
159
+ syncAssetsOrSkip: {
160
+ always: [
161
+ {
162
+ guard: 'runtimeAssetSyncDisabled',
163
+ actions: () => logger.info('[ACSM] Runtime asset sync disabled; assuming CI has already published assets'),
164
+ target: 'loadAndCompilePugTemplates'
165
+ },
166
+ {
167
+ target: 'uploadNewImageAssets'
168
+ }
169
+ ]
170
+ },
158
171
  uploadNewImageAssets: {
159
172
  invoke: {
160
173
  src: 'uploadNewImageAssets',
@@ -298,7 +311,13 @@ const AnearCoreServiceMachineFunctions = {
298
311
  createEventsCreationChannel: assign(
299
312
  {
300
313
  newEventCreationChannel: ({ context, self }) => {
301
- const channelName = CreateEventChannelNameTemplate(context.appId)
314
+ const attrs = context.appData?.data?.attributes || {}
315
+ const appVersion = attrs['latest-app-version']
316
+ if (!appVersion) {
317
+ throw new Error('[ACSM] Missing latest-app-version in app data; cannot subscribe to lifecycle channel')
318
+ }
319
+ const channelName = CreateVersionedEventChannelNameTemplate(context.appId, appVersion)
320
+ logger.info(`[ACSM] Subscribing to versioned lifecycle channel ${channelName}`)
302
321
  return RealtimeMessaging.getChannel(channelName, self)
303
322
  }
304
323
  }
@@ -314,7 +333,7 @@ const AnearCoreServiceMachineFunctions = {
314
333
  self,
315
334
  'LOAD_EVENT'
316
335
  )
317
- logger.info(`[ACSM] Subscribed to CREATE_EVENT and LOAD_EVENT on events channel (pid=${process.pid})`)
336
+ logger.info(`[ACSM] Subscribed to CREATE_EVENT and LOAD_EVENT on versioned lifecycle channel (pid=${process.pid})`)
318
337
  },
319
338
  logIgnoredLifecycleCommand: ({ event, context }) => {
320
339
  logger.warn(`[ACSM] Ignoring ${event.type} because shutdown has been requested (appId=${context.appId} pid=${process.pid})`)
@@ -436,10 +455,15 @@ const AnearCoreServiceMachineFunctions = {
436
455
  },
437
456
  guards: {
438
457
  noImageAssetFilesFound: ({ event }) => event.output === null,
439
- acceptLifecycleCommands: ({ context }) => !context.shutdownRequested
458
+ acceptLifecycleCommands: ({ context }) => !context.shutdownRequested,
459
+ runtimeAssetSyncDisabled: () => SkipRuntimeAssetSync
440
460
  }
441
461
  }
442
462
 
463
+ function normalizeVersionToken(value) {
464
+ return String(value).replace(/[^A-Za-z0-9_.-]/g, '_')
465
+ }
466
+
443
467
  const AnearCoreServiceMachine = (appEventMachineFactory, appParticipantMachineFactory = null) => {
444
468
  const appId = process.env.ANEARAPP_APP_ID
445
469
  const machineConfig = AnearCoreServiceMachineConfig(appId)
@@ -654,7 +654,20 @@ const AnearParticipantMachineFunctions = {
654
654
  actors: {
655
655
  publishPrivateDisplay: fromPromise(async ({ input }) => {
656
656
  const { context, event } = input
657
- const displayMessage = { content: event.content }
657
+ let displayMessage
658
+ if (event.targets != null && typeof event.targets === 'object' && !Array.isArray(event.targets)) {
659
+ displayMessage = { targets: event.targets }
660
+ } else if (event.anchor != null && event.content !== undefined) {
661
+ const val = (event.type === 'replace' || event.type === 'append')
662
+ ? { content: event.content, type: event.type }
663
+ : event.content
664
+ displayMessage = { targets: { [event.anchor]: val } }
665
+ } else {
666
+ displayMessage = { content: event.content }
667
+ }
668
+ if (event.timeout != null) {
669
+ displayMessage.timeout = event.timeout
670
+ }
658
671
  EventStats.recordPublish(context.eventStats, context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
659
672
  await RealtimeMessaging.publish(context.privateChannel, 'PRIVATE_DISPLAY', displayMessage)
660
673
  return { timeout: event.timeout }
@@ -169,37 +169,46 @@ const AppMachineTransition = (anearEvent) => {
169
169
 
170
170
  if (meta.allParticipants) {
171
171
  viewer = 'allParticipants'
172
- const { viewPath, props } = _extractViewAndProps(meta.allParticipants)
173
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.allParticipants.timeout)
172
+ const extracted = _extractViewAndProps(meta.allParticipants)
173
+ const config = meta.allParticipants
174
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, typeof config === 'object' && !Array.isArray(config) ? config.timeout : undefined)
174
175
 
175
- // Extract cancel property if present
176
- if (meta.allParticipants.cancel && !cancelActionType) {
177
- cancelActionType = meta.allParticipants.cancel
176
+ if (typeof config === 'object' && !Array.isArray(config) && config.cancel && !cancelActionType) {
177
+ cancelActionType = config.cancel
178
178
  }
179
179
 
180
- displayEvent = RenderContextBuilder.buildDisplayEvent(
181
- viewPath,
182
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
183
- viewer,
184
- null,
185
- null,
186
- props
187
- )
180
+ if (extracted.targets && extracted.targets.length > 0) {
181
+ displayEvent = RenderContextBuilder.buildDisplayEventWithTargets(
182
+ extracted.targets,
183
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
184
+ viewer,
185
+ null,
186
+ null
187
+ )
188
+ } else {
189
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
190
+ extracted.viewPath,
191
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
192
+ viewer,
193
+ null,
194
+ null,
195
+ extracted.props,
196
+ extracted.anchor,
197
+ extracted.type,
198
+ extracted.content,
199
+ extracted.contentType
200
+ )
201
+ }
188
202
  displayEvents.push(displayEvent)
189
203
  }
190
204
 
191
205
  if (meta.eachParticipant) {
192
- // Check if participant is a function (new selective rendering format)
193
206
  viewer = 'eachParticipant'
194
207
  if (typeof meta.eachParticipant === 'function') {
195
- // New selective participant rendering - cancel property is not supported in function format
196
- // (would need to be in the object format, see legacy handling below)
197
208
  const participantRenderFunc = meta.eachParticipant
198
209
  const participantDisplays = participantRenderFunc(appContext, event)
199
210
 
200
211
  if (Array.isArray(participantDisplays)) {
201
- // Build the base render context once and reuse it for all participant displays
202
- // This avoids redundant context building when multiple participants receive different views
203
212
  const baseAppRenderContext = RenderContextBuilder.buildAppRenderContext(
204
213
  appContext,
205
214
  appStateName,
@@ -208,27 +217,79 @@ const AppMachineTransition = (anearEvent) => {
208
217
  null
209
218
  )
210
219
 
220
+ const byParticipant = new Map()
211
221
  participantDisplays.forEach(participantDisplay => {
212
- if (participantDisplay && participantDisplay.participantId && participantDisplay.view) {
213
- // For selective rendering, timeout is handled directly in the participant display object
214
- const timeout = participantDisplay.timeout || null
215
- const props = participantDisplay.props || {}
222
+ if (!participantDisplay || !participantDisplay.participantId) return
223
+ const participantViewPath = participantDisplay.view || participantDisplay.viewPath || null
224
+ const hasDirectContent = participantDisplay.content !== undefined && participantDisplay.content !== null
225
+ if (!participantViewPath && !hasDirectContent) return
226
+ const pid = participantDisplay.participantId
227
+ const targetSpec = {
228
+ viewPath: participantViewPath,
229
+ view: participantViewPath,
230
+ anchor: participantDisplay.anchor != null ? participantDisplay.anchor : null,
231
+ type: (participantDisplay.type === 'replace' || participantDisplay.type === 'append') ? participantDisplay.type : null,
232
+ props: participantDisplay.props || {},
233
+ content: hasDirectContent ? participantDisplay.content : null,
234
+ contentType: (participantDisplay.contentType === 'html' || participantDisplay.contentType === 'text') ? participantDisplay.contentType : null
235
+ }
236
+ const existing = byParticipant.get(pid)
237
+ if (existing) {
238
+ existing.targets.push(targetSpec)
239
+ if (participantDisplay.timeout != null && existing.timeout == null) {
240
+ existing.timeout = participantDisplay.timeout
241
+ }
242
+ } else {
243
+ byParticipant.set(pid, {
244
+ participantId: pid,
245
+ targets: [targetSpec],
246
+ timeout: participantDisplay.timeout ?? null
247
+ })
248
+ }
249
+ })
216
250
 
251
+ byParticipant.forEach(({ participantId: pid, targets: targetSpecs, timeout: to }) => {
252
+ if (targetSpecs.length === 1 && !targetSpecs[0].anchor) {
253
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
254
+ targetSpecs[0].viewPath,
255
+ baseAppRenderContext,
256
+ viewer,
257
+ pid,
258
+ to,
259
+ targetSpecs[0].props,
260
+ null,
261
+ null,
262
+ targetSpecs[0].content,
263
+ targetSpecs[0].contentType
264
+ )
265
+ } else if (targetSpecs.length === 1) {
217
266
  displayEvent = RenderContextBuilder.buildDisplayEvent(
218
- participantDisplay.view,
219
- baseAppRenderContext, // Reuse the same base context
267
+ targetSpecs[0].viewPath,
268
+ baseAppRenderContext,
269
+ viewer,
270
+ pid,
271
+ to,
272
+ targetSpecs[0].props,
273
+ targetSpecs[0].anchor,
274
+ targetSpecs[0].type,
275
+ targetSpecs[0].content,
276
+ targetSpecs[0].contentType
277
+ )
278
+ } else {
279
+ displayEvent = RenderContextBuilder.buildDisplayEventWithTargets(
280
+ targetSpecs,
281
+ baseAppRenderContext,
220
282
  viewer,
221
- participantDisplay.participantId,
222
- timeout,
223
- props
283
+ pid,
284
+ to
224
285
  )
225
- displayEvents.push(displayEvent)
226
286
  }
287
+ displayEvents.push(displayEvent)
227
288
  })
228
289
  }
229
290
  } else {
230
291
  // Legacy participant rendering - normalize to selective format
231
- const { viewPath, props } = _extractViewAndProps(meta.eachParticipant)
292
+ const { viewPath, props, anchor, type, content, contentType } = _extractViewAndProps(meta.eachParticipant)
232
293
  const timeoutFn = RenderContextBuilder.buildTimeoutFn('participant', meta.eachParticipant.timeout)
233
294
 
234
295
  // Extract cancel property if present (for legacy eachParticipant format)
@@ -243,7 +304,11 @@ const AppMachineTransition = (anearEvent) => {
243
304
  viewer,
244
305
  'ALL_PARTICIPANTS', // Special marker for "all participants"
245
306
  null,
246
- props
307
+ props,
308
+ anchor,
309
+ type,
310
+ content,
311
+ contentType
247
312
  )
248
313
  displayEvents.push(displayEvent)
249
314
  }
@@ -251,33 +316,63 @@ const AppMachineTransition = (anearEvent) => {
251
316
 
252
317
  if (meta.host) {
253
318
  viewer = 'host'
254
- const { viewPath, props } = _extractViewAndProps(meta.host)
255
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.host.timeout)
256
-
257
- displayEvent = RenderContextBuilder.buildDisplayEvent(
258
- viewPath,
259
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
260
- viewer,
261
- null,
262
- null,
263
- props
264
- )
319
+ const extracted = _extractViewAndProps(meta.host)
320
+ const config = meta.host
321
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, typeof config === 'object' && !Array.isArray(config) ? config.timeout : undefined)
322
+
323
+ if (extracted.targets && extracted.targets.length > 0) {
324
+ displayEvent = RenderContextBuilder.buildDisplayEventWithTargets(
325
+ extracted.targets,
326
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
327
+ viewer,
328
+ null,
329
+ null
330
+ )
331
+ } else {
332
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
333
+ extracted.viewPath,
334
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
335
+ viewer,
336
+ null,
337
+ null,
338
+ extracted.props,
339
+ extracted.anchor,
340
+ extracted.type,
341
+ extracted.content,
342
+ extracted.contentType
343
+ )
344
+ }
265
345
  displayEvents.push(displayEvent)
266
346
  }
267
347
 
268
348
  if (meta.spectators) {
269
349
  viewer = 'spectators'
270
- const { viewPath, props } = _extractViewAndProps(meta.spectators)
271
- timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, meta.spectators.timeout)
272
-
273
- displayEvent = RenderContextBuilder.buildDisplayEvent(
274
- viewPath,
275
- RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
276
- viewer,
277
- null,
278
- null,
279
- props
280
- )
350
+ const extracted = _extractViewAndProps(meta.spectators)
351
+ const config = meta.spectators
352
+ timeoutFn = RenderContextBuilder.buildTimeoutFn(viewer, typeof config === 'object' && !Array.isArray(config) ? config.timeout : undefined)
353
+
354
+ if (extracted.targets && extracted.targets.length > 0) {
355
+ displayEvent = RenderContextBuilder.buildDisplayEventWithTargets(
356
+ extracted.targets,
357
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
358
+ viewer,
359
+ null,
360
+ null
361
+ )
362
+ } else {
363
+ displayEvent = RenderContextBuilder.buildDisplayEvent(
364
+ extracted.viewPath,
365
+ RenderContextBuilder.buildAppRenderContext(appContext, appStateName, event.type, viewer, timeoutFn),
366
+ viewer,
367
+ null,
368
+ null,
369
+ extracted.props,
370
+ extracted.anchor,
371
+ extracted.type,
372
+ extracted.content,
373
+ extracted.contentType
374
+ )
375
+ }
281
376
  displayEvents.push(displayEvent)
282
377
  }
283
378
  })
@@ -303,20 +398,39 @@ const AppMachineTransition = (anearEvent) => {
303
398
  }
304
399
 
305
400
  const _extractViewAndProps = (config) => {
306
- if (!config) return { viewPath: null, props: {} }
401
+ if (!config) return { viewPath: null, props: {}, anchor: null, type: null, content: null, contentType: null, targets: null }
307
402
 
308
403
  if (typeof config === 'string') {
309
- return { viewPath: config, props: {} }
404
+ return { viewPath: config, props: {}, anchor: null, type: null, content: null, contentType: null, targets: null }
405
+ }
406
+
407
+ if (Array.isArray(config)) {
408
+ const targets = config
409
+ .filter(t => t && ((t.view || t.viewPath) || (t.content !== undefined && t.content !== null)))
410
+ .map(t => ({
411
+ viewPath: t.view || t.viewPath,
412
+ view: t.view || t.viewPath,
413
+ anchor: t.anchor != null ? t.anchor : null,
414
+ type: (t.type === 'replace' || t.type === 'append') ? t.type : null,
415
+ props: t.props || {},
416
+ content: (t.content !== undefined && t.content !== null) ? t.content : null,
417
+ contentType: (t.contentType === 'html' || t.contentType === 'text') ? t.contentType : null
418
+ }))
419
+ return { viewPath: null, props: {}, anchor: null, type: null, content: null, contentType: null, targets: targets.length > 0 ? targets : null }
310
420
  }
311
421
 
312
422
  if (typeof config === 'object') {
313
- const viewPath = config.view
423
+ const viewPath = config.view || config.viewPath || null
314
424
  const props = config.props || {}
315
- return { viewPath, props }
425
+ const anchor = config.anchor != null ? config.anchor : null
426
+ const type = (config.type === 'replace' || config.type === 'append') ? config.type : null
427
+ const content = (config.content !== undefined && config.content !== null) ? config.content : null
428
+ const contentType = (config.contentType === 'html' || config.contentType === 'text') ? config.contentType : null
429
+ return { viewPath, props, anchor, type, content, contentType, targets: null }
316
430
  }
317
431
 
318
432
  logger.warn(`[AppMachineTransition] Unknown meta format: ${JSON.stringify(config)}`)
319
- return { viewPath: null, props: {} }
433
+ return { viewPath: null, props: {}, anchor: null, type: null, content: null, contentType: null, targets: null }
320
434
  }
321
435
 
322
436
  const _stringifiedState = (stateValue) => {
@@ -111,32 +111,53 @@ class DisplayEventProcessor {
111
111
  }
112
112
 
113
113
  _processSingle(displayEvent) {
114
- const { viewPath, appRenderContext, viewer, participantId, timeout: displayTimeout, props } = displayEvent
114
+ const { viewPath, targets: targetsArray, appRenderContext, viewer, participantId, timeout: displayTimeout, props, anchor, type, content, contentType } = displayEvent
115
115
  const timeoutFn = appRenderContext.meta.timeoutFn
116
116
 
117
- const normalizedPath = viewPath.endsWith(C.PugSuffix) ? viewPath : `${viewPath}${C.PugSuffix}`
118
- const template = this.pugTemplates[normalizedPath]
119
- let publishPromise
120
- let timeout = null
121
-
122
- if (!template) {
123
- throw new Error(`Template not found: ${normalizedPath}`)
124
- }
125
-
126
- const templateRenderContext = {
117
+ const baseContext = {
127
118
  ...appRenderContext,
128
119
  anearEvent: this.anearEvent,
129
120
  participants: this.participantsIndex,
130
- ...this.pugHelpers,
131
- props
121
+ ...this.pugHelpers
132
122
  }
133
123
 
134
- const formattedDisplayMessage = () => {
135
- const displayMessage = {
136
- content: template(templateRenderContext),
124
+ const templateRenderContext = { ...baseContext, props: props || {} }
125
+ const normalizedPath = viewPath ? ((viewPath || '').endsWith(C.PugSuffix) ? viewPath : `${viewPath}${C.PugSuffix}`) : null
126
+ const template = normalizedPath ? this.pugTemplates[normalizedPath] : null
127
+ let publishPromise
128
+ let timeout = null
129
+
130
+ const formattedDisplayMessage = (contextOverrides = {}) => {
131
+ const ctx = { ...templateRenderContext, ...contextOverrides }
132
+ if (Array.isArray(targetsArray) && targetsArray.length > 0) {
133
+ const targetsObj = {}
134
+ for (const t of targetsArray) {
135
+ const a = t.anchor
136
+ if (a == null) continue
137
+ targetsObj[a] = this._buildTargetValue(t, ctx)
138
+ }
139
+ return { targets: targetsObj }
137
140
  }
138
141
 
139
- return displayMessage
142
+ if (content !== null && content !== undefined) {
143
+ const directContent = this._stringifyContent(content)
144
+ if (anchor != null) {
145
+ return { targets: { [anchor]: this._buildTargetValue({ content: directContent, contentType, type }, ctx) } }
146
+ }
147
+ return { content: directContent }
148
+ }
149
+
150
+ if (!template) throw new Error(`Template not found: ${normalizedPath || viewPath || '[unknown]'}`)
151
+ const html = template(ctx)
152
+ if (anchor != null) {
153
+ const val = this._buildTargetValue({ content: html, contentType: contentType || 'html', type }, ctx)
154
+ return { targets: { [anchor]: val } }
155
+ }
156
+ return { content: html }
157
+ }
158
+
159
+ if (!Array.isArray(targetsArray) && content == null && !template) {
160
+ throw new Error(`Template not found: ${normalizedPath || viewPath || '[unknown]'}`)
140
161
  }
141
162
 
142
163
  // The viewer determines who sees the view. It can be set directly on the
@@ -146,7 +167,7 @@ class DisplayEventProcessor {
146
167
  let displaySent = false
147
168
  switch (displayViewer) {
148
169
  case 'allParticipants':
149
- const templateName = normalizedPath.replace(C.PugSuffix, '')
170
+ const templateName = normalizedPath ? normalizedPath.replace(C.PugSuffix, '') : '[inline-content]'
150
171
  logger.info(`[DisplayEventProcessor] RENDER_DISPLAY template=${templateName} viewer=allParticipants`)
151
172
 
152
173
  // Use display timeout if available, otherwise fall back to timeoutFn
@@ -183,7 +204,7 @@ class DisplayEventProcessor {
183
204
  break
184
205
 
185
206
  case 'spectators':
186
- const templateNameSpectators = normalizedPath.replace(C.PugSuffix, '')
207
+ const templateNameSpectators = normalizedPath ? normalizedPath.replace(C.PugSuffix, '') : '[inline-content]'
187
208
  logger.info(`[DisplayEventProcessor] RENDER_DISPLAY template=${templateNameSpectators} viewer=spectators`)
188
209
 
189
210
  // If this event/app does not support spectators, the spectatorsDisplayChannel
@@ -229,20 +250,17 @@ class DisplayEventProcessor {
229
250
  break
230
251
 
231
252
  case 'eachParticipant': {
232
- const templateNameEach = normalizedPath.replace(C.PugSuffix, '')
253
+ const templateNameEach = (targetsArray?.[0]?.viewPath || targetsArray?.[0]?.view || normalizedPath || '[inline-content]').replace(C.PugSuffix, '')
233
254
  if (participantId === 'ALL_PARTICIPANTS') {
234
- // Legacy behavior - send to all participants (uses timeoutFn)
235
255
  logger.info(`[DisplayEventProcessor] RENDER_DISPLAY template=${templateNameEach} viewer=eachParticipant participantId=ALL_PARTICIPANTS`)
236
- publishPromise = this._processPrivateParticipantDisplays(template, templateRenderContext, timeoutFn)
237
- displaySent = true // We attempt to send to all in index; at least one may receive
256
+ publishPromise = this._processPrivateParticipantDisplays(template, templateRenderContext, timeoutFn, anchor, type, targetsArray, content, contentType)
257
+ displaySent = true
238
258
  } else if (participantId) {
239
- // Selective participant rendering - send to specific participant only (uses displayTimeout)
240
259
  logger.info(`[DisplayEventProcessor] RENDER_DISPLAY template=${templateNameEach} viewer=eachParticipant participantId=${participantId}`)
241
- const result = this._processSelectiveParticipantDisplayWithSent(template, templateRenderContext, null, participantId, displayTimeout)
260
+ const result = this._processSelectiveParticipantDisplayWithSent(template, templateRenderContext, null, participantId, displayTimeout, anchor, type, targetsArray, content, contentType)
242
261
  publishPromise = result.promise
243
262
  displaySent = result.displaySent
244
263
  } else {
245
- // Fallback - should not happen with unified approach
246
264
  logger.warn(`[DisplayEventProcessor] Unexpected participant display event without participantId`)
247
265
  publishPromise = Promise.resolve()
248
266
  }
@@ -250,7 +268,7 @@ class DisplayEventProcessor {
250
268
  }
251
269
 
252
270
  case 'host':
253
- const templateNameHost = normalizedPath.replace(C.PugSuffix, '')
271
+ const templateNameHost = normalizedPath ? normalizedPath.replace(C.PugSuffix, '') : '[inline-content]'
254
272
  logger.info(`[DisplayEventProcessor] RENDER_DISPLAY template=${templateNameHost} viewer=host`)
255
273
  // Host may optionally have a real timeout (if configured). Otherwise, host
256
274
  // mirrors the allParticipants visual timer without starting a host timeout.
@@ -285,7 +303,12 @@ class DisplayEventProcessor {
285
303
  publishPromise = this._processHostDisplay(
286
304
  template,
287
305
  templateRenderContext,
288
- hostOwnMsecs !== null ? (() => hostOwnMsecs) : null
306
+ hostOwnMsecs !== null ? (() => hostOwnMsecs) : null,
307
+ anchor,
308
+ type,
309
+ targetsArray,
310
+ content,
311
+ contentType
289
312
  )
290
313
  displaySent = true
291
314
  break
@@ -296,7 +319,7 @@ class DisplayEventProcessor {
296
319
  return { publishPromise, timeout, displaySent }
297
320
  }
298
321
 
299
- _processHostDisplay(template, templateRenderContext, timeoutFn) {
322
+ _processHostDisplay(template, templateRenderContext, timeoutFn, anchor = null, type = null, targetsArray = null, content = null, contentType = null) {
300
323
  const hostEntry = Object.entries(this.participants).find(
301
324
  ([id, participantInfo]) => participantInfo.isHost
302
325
  );
@@ -311,29 +334,23 @@ class DisplayEventProcessor {
311
334
  if (!hostMachine) {
312
335
  throw new Error(`[DisplayEventProcessor] Host participant machine not found for hostId: ${hostId}`)
313
336
  }
314
- this._sendPrivateDisplay(hostMachine, hostId, template, templateRenderContext, timeoutFn)
337
+ this._sendPrivateDisplay(hostMachine, hostId, template, templateRenderContext, timeoutFn, anchor, type, targetsArray, content, contentType)
315
338
  return Promise.resolve()
316
339
  }
317
340
 
318
- _processPrivateParticipantDisplays(template, templateRenderContext, timeoutFn) {
341
+ _processPrivateParticipantDisplays(template, templateRenderContext, timeoutFn, anchor = null, type = null, targetsArray = null, content = null, contentType = null) {
319
342
  Object.values(this.participantsIndex.all).forEach(
320
343
  participantStruct => {
321
- // Exclude the host from receiving displays targeted at all participants.
322
- // Host-specific displays should use the `meta: { host: '...' }` syntax.
323
344
  if (participantStruct.info.isHost) return;
324
-
325
- // Skip inactive participants (idle in open house events)
326
- // Note: _buildParticipantsIndex already filters these out, but adding check for safety
327
345
  if (participantStruct.info.active === false) return;
328
346
 
329
347
  const participantId = participantStruct.info.id
330
348
  const participantMachine = this.participantMachines[participantId]
331
- // Skip if no machine (shouldn't happen since we filter active participants, but safety check)
332
349
  if (!participantMachine) {
333
350
  logger.warn(`[DisplayEventProcessor] Participant machine not found for ${participantId}, skipping display`)
334
351
  return
335
352
  }
336
- this._sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, timeoutFn)
353
+ this._sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, timeoutFn, anchor, type, targetsArray, content, contentType)
337
354
  }
338
355
  )
339
356
 
@@ -345,7 +362,7 @@ class DisplayEventProcessor {
345
362
  * When participant is not found (exited), we treat as delivered (displaySent: false) so the
346
363
  * render cycle can complete and we avoid infinite RENDER_DISPLAY/RENDERED loops.
347
364
  */
348
- _processSelectiveParticipantDisplayWithSent(template, templateRenderContext, timeoutFn, participantId, displayTimeout = null) {
365
+ _processSelectiveParticipantDisplayWithSent(template, templateRenderContext, timeoutFn, participantId, displayTimeout = null, anchor = null, type = null, targetsArray = null, content = null, contentType = null) {
349
366
  const participantStruct = this.participantsIndex.get(participantId)
350
367
 
351
368
  if (!participantStruct) {
@@ -353,13 +370,11 @@ class DisplayEventProcessor {
353
370
  return { promise: Promise.resolve(), displaySent: false }
354
371
  }
355
372
 
356
- // Exclude the host from receiving displays targeted at participants
357
373
  if (participantStruct.info.isHost) {
358
374
  logger.warn(`[DisplayEventProcessor] Cannot send participant display to host ${participantId}`)
359
375
  return { promise: Promise.resolve(), displaySent: false }
360
376
  }
361
377
 
362
- // Skip inactive participants (idle in open house events)
363
378
  if (participantStruct.info.active === false) {
364
379
  logger.debug(`[DisplayEventProcessor] Skipping display for inactive participant ${participantId}`)
365
380
  return { promise: Promise.resolve(), displaySent: false }
@@ -371,29 +386,26 @@ class DisplayEventProcessor {
371
386
  return { promise: Promise.resolve(), displaySent: false }
372
387
  }
373
388
 
374
- // Use displayTimeout if available, otherwise fall back to timeoutFn
375
389
  const effectiveTimeoutFn = displayTimeout !== null ?
376
390
  () => displayTimeout :
377
391
  timeoutFn
378
392
 
379
- this._sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, effectiveTimeoutFn)
393
+ this._sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, effectiveTimeoutFn, anchor, type, targetsArray, content, contentType)
380
394
  return { promise: Promise.resolve(), displaySent: true }
381
395
  }
382
396
 
383
- _processSelectiveParticipantDisplay(template, templateRenderContext, timeoutFn, participantId, displayTimeout = null) {
384
- const { promise } = this._processSelectiveParticipantDisplayWithSent(template, templateRenderContext, timeoutFn, participantId, displayTimeout)
397
+ _processSelectiveParticipantDisplay(template, templateRenderContext, timeoutFn, participantId, displayTimeout = null, anchor = null, type = null, targetsArray = null, content = null, contentType = null) {
398
+ const { promise } = this._processSelectiveParticipantDisplayWithSent(template, templateRenderContext, timeoutFn, participantId, displayTimeout, anchor, type, targetsArray, content, contentType)
385
399
  return promise
386
400
  }
387
401
 
388
- _sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, timeoutFn) {
402
+ _sendPrivateDisplay(participantMachine, participantId, template, templateRenderContext, timeoutFn, anchor = null, type = null, targetsArray = null, content = null, contentType = null) {
389
403
  let timeout = null
390
404
 
391
405
  const participantStruct = this.participantsIndex.get(participantId)
392
406
  const appCtx = templateRenderContext.app || {}
393
407
 
394
408
  if (timeoutFn) {
395
- // For participant displays, any non-null / positive timeout value should
396
- // start a real per-participant timer on that participant's APM.
397
409
  if (!participantStruct) {
398
410
  logger.warn(`[DisplayEventProcessor] Participant struct not found for ${participantId}, skipping timeout calculation`)
399
411
  } else {
@@ -402,7 +414,6 @@ class DisplayEventProcessor {
402
414
  if (typeof msecs === 'number' && msecs > 0) {
403
415
  timeout = msecs
404
416
  } else {
405
- // Log when timeout function returns null/undefined to help diagnose issues
406
417
  logger.debug(`[DisplayEventProcessor] Timeout function returned ${msecs === null ? 'null' : msecs === undefined ? 'undefined' : `non-numeric value: ${msecs}`} for ${participantId}`)
407
418
  }
408
419
  } catch (error) {
@@ -415,16 +426,8 @@ class DisplayEventProcessor {
415
426
  participant: participantStruct
416
427
  }
417
428
 
418
- // Build visual timeout (meta.timeout) used by the countdown bar.
419
- // For participant displays we support two patterns:
420
- // 1) Global participants timeout (allParticipants flow) – mirror the
421
- // shared participantsActionTimeout so EVERYONE sees the same bar,
422
- // regardless of whether their own APM is timed.
423
- // 2) Per-participant timeout (eachParticipant flow) – no global timeout;
424
- // only the timed participant gets a bar, derived from their own APM.
425
429
  let visualTimeout = null
426
430
  try {
427
- // First, prefer a shared participants timeout if one exists (e.g., move timer).
428
431
  const pat = this.precomputedParticipantsTimeout || this.participantsActionTimeout
429
432
  if (pat && typeof pat.msecs === 'number') {
430
433
  const now = Date.now()
@@ -432,10 +435,6 @@ class DisplayEventProcessor {
432
435
  const remainingMsecs = Math.max(0, pat.msecs - (now - start))
433
436
  visualTimeout = { msecs: pat.msecs, remainingMsecs }
434
437
  } else {
435
- // Fallback: no global timeout.
436
- // If this participant currently has an active APM timer, show a bar based on that.
437
- // This enables "visual-only" re-renders (timeout=null) during an active wait without
438
- // attempting to restart/reset the timer.
439
438
  const state = participantMachine?.state
440
439
  const ctx = state?.context
441
440
  if (ctx && ctx.actionTimeoutStart && ctx.actionTimeoutMsecs != null && ctx.actionTimeoutMsecs > 0) {
@@ -448,13 +447,11 @@ class DisplayEventProcessor {
448
447
  remainingMsecs
449
448
  }
450
449
  } else if (timeout !== null) {
451
- // Backward compatibility: if we have an explicit timeout, mirror it.
452
450
  visualTimeout = { msecs: timeout, remainingMsecs: timeout }
453
451
  }
454
452
  }
455
453
  } catch (_e) {
456
- // If anything goes wrong computing visualTimeout, we simply omit it and
457
- // let the templates fall back to their default behavior.
454
+ // ignore
458
455
  }
459
456
 
460
457
  if (visualTimeout) {
@@ -464,16 +461,86 @@ class DisplayEventProcessor {
464
461
  }
465
462
  }
466
463
 
467
- const privateHtml = template(privateRenderContext)
468
-
469
- const renderMessage = { content: privateHtml }
464
+ let renderMessage
465
+ if (Array.isArray(targetsArray) && targetsArray.length > 0) {
466
+ const targetsObj = {}
467
+ for (const t of targetsArray) {
468
+ const a = t.anchor
469
+ if (a == null) continue
470
+ targetsObj[a] = this._buildTargetValue(t, privateRenderContext)
471
+ }
472
+ renderMessage = { targets: targetsObj }
473
+ } else if (content !== null && content !== undefined) {
474
+ const directContent = this._stringifyContent(content)
475
+ if (anchor != null) {
476
+ renderMessage = { targets: { [anchor]: this._buildTargetValue({ content: directContent, contentType, type }, privateRenderContext) } }
477
+ } else {
478
+ renderMessage = { content: directContent }
479
+ }
480
+ } else {
481
+ if (!template) {
482
+ throw new Error(`[DisplayEventProcessor] Template not found for participant ${participantId}`)
483
+ }
484
+ const privateHtml = template(privateRenderContext)
485
+ if (anchor != null) {
486
+ const val = this._buildTargetValue({ content: privateHtml, contentType: contentType || 'html', type }, privateRenderContext)
487
+ renderMessage = { targets: { [anchor]: val } }
488
+ } else {
489
+ renderMessage = { content: privateHtml }
490
+ }
491
+ }
470
492
  if (timeout !== null) {
471
493
  renderMessage.timeout = timeout
472
494
  }
473
495
 
474
- // v5: send events as objects
475
496
  participantMachine.send({ type: 'RENDER_DISPLAY', ...renderMessage })
476
497
  }
498
+
499
+ _normalizeAnchorUpdateType(type) {
500
+ return (type === 'replace' || type === 'append') ? type : null
501
+ }
502
+
503
+ _normalizeContentType(contentType) {
504
+ return (contentType === 'html' || contentType === 'text') ? contentType : null
505
+ }
506
+
507
+ _stringifyContent(content) {
508
+ if (typeof content === 'string') return content
509
+ return String(content)
510
+ }
511
+
512
+ _buildTargetValue(targetSpec, baseContext) {
513
+ const ty = this._normalizeAnchorUpdateType(targetSpec?.type)
514
+ const ct = this._normalizeContentType(targetSpec?.contentType)
515
+
516
+ if (targetSpec && targetSpec.content !== null && targetSpec.content !== undefined) {
517
+ const directContent = this._stringifyContent(targetSpec.content)
518
+ if (ty || ct) {
519
+ const payload = { content: directContent }
520
+ if (ty) payload.type = ty
521
+ if (ct) payload.contentType = ct
522
+ return payload
523
+ }
524
+ return directContent
525
+ }
526
+
527
+ const pathSource = targetSpec?.viewPath || targetSpec?.view
528
+ if (!pathSource) {
529
+ throw new Error('[DisplayEventProcessor] Target spec requires either content or view/viewPath')
530
+ }
531
+ const path = pathSource.endsWith(C.PugSuffix) ? pathSource : `${pathSource}${C.PugSuffix}`
532
+ const tmpl = this.pugTemplates[path]
533
+ if (!tmpl) throw new Error(`Template not found: ${path}`)
534
+ const tCtx = { ...baseContext, props: targetSpec?.props || {} }
535
+ const html = tmpl(tCtx)
536
+ if (ty || ct) {
537
+ const payload = { content: html }
538
+ if (ty) payload.type = ty
539
+ if (ct) payload.contentType = ct
540
+ return payload
541
+ }
542
+ return html
543
+ }
477
544
  }
478
545
 
479
546
  module.exports = DisplayEventProcessor
@@ -57,15 +57,19 @@ class RenderContextBuilder {
57
57
 
58
58
  /**
59
59
  * Build display event object
60
- * @param {string} viewPath - Template/view path
60
+ * @param {string|null} viewPath - Template/view path
61
61
  * @param {Object} appRenderContext - Context object
62
62
  * @param {string} viewer - Optional viewer type (host, participant, etc.)
63
63
  * @param {string} participantId - Optional participant ID for selective rendering
64
64
  * @param {number} timeout - Optional timeout in milliseconds
65
65
  * @param {Object} props - Optional arguments for the view
66
+ * @param {string} anchor - Optional anchor name for single-anchor inject
67
+ * @param {string} type - Optional 'replace' | 'append' for anchor updates; default 'replace'
68
+ * @param {string|number|null} content - Optional direct content payload for full/anchor updates
69
+ * @param {string|null} contentType - Optional content kind: 'html' | 'text'
66
70
  * @returns {Object} Display event object
67
71
  */
68
- static buildDisplayEvent(viewPath, appRenderContext, viewer = null, participantId = null, timeout = null, props = {}) {
72
+ static buildDisplayEvent(viewPath, appRenderContext, viewer = null, participantId = null, timeout = null, props = {}, anchor = null, type = null, content = null, contentType = null) {
69
73
  const displayEvent = {
70
74
  viewPath,
71
75
  appRenderContext,
@@ -84,6 +88,42 @@ class RenderContextBuilder {
84
88
  displayEvent.timeout = timeout
85
89
  }
86
90
 
91
+ if (anchor != null) {
92
+ displayEvent.anchor = anchor
93
+ }
94
+
95
+ if (type != null && (type === 'replace' || type === 'append')) {
96
+ displayEvent.type = type
97
+ }
98
+
99
+ if (content !== null && content !== undefined) {
100
+ displayEvent.content = content
101
+ }
102
+
103
+ if (contentType != null && (contentType === 'html' || contentType === 'text')) {
104
+ displayEvent.contentType = contentType
105
+ }
106
+
107
+ return displayEvent
108
+ }
109
+
110
+ /**
111
+ * Build display event object with multiple targets
112
+ * @param {Array} targets - Array of { viewPath?, anchor?, type?, props?, content?, contentType? }
113
+ * @param {Object} appRenderContext - Context object
114
+ * @param {string} viewer - Viewer type
115
+ * @param {string} participantId - Optional participant ID for selective rendering
116
+ * @param {number} timeout - Optional timeout in milliseconds
117
+ * @returns {Object} Display event object with targets array
118
+ */
119
+ static buildDisplayEventWithTargets(targets, appRenderContext, viewer = null, participantId = null, timeout = null) {
120
+ const displayEvent = {
121
+ targets: targets,
122
+ appRenderContext
123
+ }
124
+ if (viewer) displayEvent.viewer = viewer
125
+ if (participantId) displayEvent.participantId = participantId
126
+ if (timeout !== null) displayEvent.timeout = timeout
87
127
  return displayEvent
88
128
  }
89
129
  }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "1.6.6",
3
+ "version": "2.0.1",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
7
- "test": "jest"
7
+ "test": "jest",
8
+ "audit:prod": "npm audit --omit=dev",
9
+ "audit:prod:fix": "npm audit fix --omit=dev",
10
+ "audit:full": "npm audit",
11
+ "audit:full:fix": "npm audit fix"
8
12
  },
9
13
  "repository": {
10
14
  "type": "git",
@@ -0,0 +1,57 @@
1
+ const AppMachineTransition = require('../lib/utils/AppMachineTransition')
2
+
3
+ describe('AppMachineTransition eachParticipant content targets', () => {
4
+ test('accepts direct content without a view in function-based eachParticipant meta', () => {
5
+ const anearEvent = { send: jest.fn() }
6
+ const onTransition = AppMachineTransition(anearEvent)
7
+
8
+ const state = {
9
+ context: { scoreByParticipant: { p1: 98, p2: 77 } },
10
+ value: 'play',
11
+ getMeta: () => ({
12
+ play: {
13
+ eachParticipant: (appContext) => ([
14
+ {
15
+ participantId: 'p1',
16
+ anchor: 'seat_p1_score',
17
+ content: String(appContext.scoreByParticipant.p1),
18
+ contentType: 'text',
19
+ type: 'replace'
20
+ },
21
+ {
22
+ participantId: 'p2',
23
+ anchor: 'seat_p2_score',
24
+ content: String(appContext.scoreByParticipant.p2),
25
+ contentType: 'text',
26
+ type: 'replace'
27
+ }
28
+ ])
29
+ }
30
+ })
31
+ }
32
+
33
+ onTransition(state, { type: 'TURN_COMPLETE' }, { source: 'test' })
34
+
35
+ expect(anearEvent.send).toHaveBeenCalledTimes(1)
36
+ const payload = anearEvent.send.mock.calls[0][0]
37
+ expect(payload.type).toBe('RENDER_DISPLAY')
38
+ expect(payload.displayEvents).toHaveLength(2)
39
+ expect(payload.displayEvents[0]).toMatchObject({
40
+ viewer: 'eachParticipant',
41
+ participantId: 'p1',
42
+ anchor: 'seat_p1_score',
43
+ content: '98',
44
+ contentType: 'text',
45
+ type: 'replace'
46
+ })
47
+ expect(payload.displayEvents[1]).toMatchObject({
48
+ viewer: 'eachParticipant',
49
+ participantId: 'p2',
50
+ anchor: 'seat_p2_score',
51
+ content: '77',
52
+ contentType: 'text',
53
+ type: 'replace'
54
+ })
55
+ })
56
+ })
57
+
@@ -0,0 +1,87 @@
1
+ const DisplayEventProcessor = require('../lib/utils/DisplayEventProcessor')
2
+
3
+ const makeProcessor = (overrides = {}) => {
4
+ const participantMachine = { send: jest.fn(), state: { context: {} } }
5
+ const baseContext = {
6
+ anearEvent: {},
7
+ participantsActionTimeout: null,
8
+ pugTemplates: {},
9
+ pugHelpers: {},
10
+ participantMachines: { p1: participantMachine },
11
+ participantsDisplayChannel: 'participants-channel',
12
+ spectatorsDisplayChannel: null,
13
+ participants: {
14
+ p1: { id: 'p1', isHost: false, active: true }
15
+ },
16
+ eventStats: null,
17
+ ...overrides
18
+ }
19
+
20
+ return {
21
+ processor: new DisplayEventProcessor(baseContext),
22
+ participantMachine
23
+ }
24
+ }
25
+
26
+ describe('DisplayEventProcessor anchor updates', () => {
27
+ test('sends direct text content to an anchor for eachParticipant', () => {
28
+ const { processor, participantMachine } = makeProcessor()
29
+
30
+ processor._processSingle({
31
+ appRenderContext: { app: { score: 98 }, meta: { viewer: 'eachParticipant', timeoutFn: null } },
32
+ viewer: 'eachParticipant',
33
+ participantId: 'p1',
34
+ anchor: 'seat_p1_score',
35
+ content: 98,
36
+ contentType: 'text',
37
+ type: 'replace',
38
+ props: {}
39
+ })
40
+
41
+ expect(participantMachine.send).toHaveBeenCalledWith({
42
+ type: 'RENDER_DISPLAY',
43
+ targets: {
44
+ seat_p1_score: {
45
+ content: '98',
46
+ contentType: 'text',
47
+ type: 'replace'
48
+ }
49
+ }
50
+ })
51
+ })
52
+
53
+ test('supports multi-target participant updates mixing text and html', () => {
54
+ const { processor, participantMachine } = makeProcessor({
55
+ pugTemplates: {
56
+ 'seat_label.pug': () => '<span>Seat A</span>'
57
+ }
58
+ })
59
+
60
+ processor._processSingle({
61
+ appRenderContext: { app: {}, meta: { viewer: 'eachParticipant', timeoutFn: null } },
62
+ viewer: 'eachParticipant',
63
+ participantId: 'p1',
64
+ targets: [
65
+ { anchor: 'seat_p1_score', content: 12, contentType: 'text', type: 'replace' },
66
+ { anchor: 'seat_p1_label', viewPath: 'seat_label', type: 'replace' }
67
+ ],
68
+ props: {}
69
+ })
70
+
71
+ expect(participantMachine.send).toHaveBeenCalledWith({
72
+ type: 'RENDER_DISPLAY',
73
+ targets: {
74
+ seat_p1_score: {
75
+ content: '12',
76
+ contentType: 'text',
77
+ type: 'replace'
78
+ },
79
+ seat_p1_label: {
80
+ content: '<span>Seat A</span>',
81
+ type: 'replace'
82
+ }
83
+ }
84
+ })
85
+ })
86
+ })
87
+