anear-js-api 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -18
- package/lib/models/AnearEvent.js +7 -3
- package/lib/state_machines/AnearEventMachine.js +64 -14
- package/lib/utils/Constants.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ The AppM developer is responsible for everything related to the specific event's
|
|
|
77
77
|
|
|
78
78
|
- **Game/Event Logic:** Controls the flow, rules, and state of the interactive event.
|
|
79
79
|
- **User Experience (UX/UI):** Defines what the user sees at every stage via `meta` display properties in its state nodes. All display content is the AppM's responsibility.
|
|
80
|
-
- **Decision Maker:** The AppM is the decision-maker. When the JSAPI notifies it of an event (e.g.,
|
|
80
|
+
- **Decision Maker:** The AppM is the decision-maker. When the JSAPI notifies it of an event (e.g., an `ACTION`/`ACTIONS_TIMEOUT`), the AppM decides what to do. Should the game end? Should the participant be removed? Should the state change? That is application-level logic.
|
|
81
81
|
|
|
82
82
|
This strict separation allows AppM developers to focus entirely on their application logic without needing to manage the complexities of real-time infrastructure.
|
|
83
83
|
|
|
@@ -169,6 +169,36 @@ Your AppM state names are completely independent of these AEM states. You can st
|
|
|
169
169
|
2. **Action Events**: Participant clicks → `actionsChannel` → App logic
|
|
170
170
|
3. **Display Events**: App state transitions → `AppMachineTransition` → Channel rendering
|
|
171
171
|
|
|
172
|
+
## Action Payload Tokenization
|
|
173
|
+
|
|
174
|
+
JSAPI tokenizes `anear-action` payloads for participant-private displays to prevent client-side payload inspection/tampering.
|
|
175
|
+
|
|
176
|
+
### Render-time behavior
|
|
177
|
+
|
|
178
|
+
- App templates still use `action(...)` in Pug.
|
|
179
|
+
- For private participant renders, `DisplayEventProcessor` replaces action JSON with short opaque tokens.
|
|
180
|
+
- JSAPI stores an ephemeral lookup map per participant and render cycle:
|
|
181
|
+
- `participantActionLookup[participantId][token] = { APP_EVENT: payload }`
|
|
182
|
+
- The lookup map is replaced on each render cycle.
|
|
183
|
+
|
|
184
|
+
### Action-ingest behavior (AEM)
|
|
185
|
+
|
|
186
|
+
When an `ACTION` is received on the actions channel:
|
|
187
|
+
|
|
188
|
+
1. AEM tries token resolution from `participantActionLookup`.
|
|
189
|
+
2. If token lookup fails, AEM accepts JSON payloads marked as trusted ABR client payloads (`__anearClient: true`, forms also include `__anearForm: true`).
|
|
190
|
+
3. Any unrecognized/unmarked payload is dropped and not forwarded to AppM.
|
|
191
|
+
|
|
192
|
+
### Forms
|
|
193
|
+
|
|
194
|
+
`anear-form` submissions remain JSON-based (they carry user-entered values). ABR marks form payloads with `__anearForm: true` and `__anearClient: true`; AEM strips these markers before forwarding payload to AppM.
|
|
195
|
+
|
|
196
|
+
### Security model
|
|
197
|
+
|
|
198
|
+
- Tokenized button actions remove app-specific payload details from client-visible markup.
|
|
199
|
+
- Forged/replayed unknown tokens are rejected by default.
|
|
200
|
+
- AppM guards are still required for domain correctness (turn validity, ownership checks, ranges, etc.).
|
|
201
|
+
|
|
172
202
|
## Participant Presence Events
|
|
173
203
|
|
|
174
204
|
The JSAPI handles participant lifecycle through presence events:
|
|
@@ -196,7 +226,7 @@ meta: {
|
|
|
196
226
|
allParticipants: 'TemplateName', // String template name
|
|
197
227
|
spectators: 'TemplateName', // String template name
|
|
198
228
|
|
|
199
|
-
// Object format
|
|
229
|
+
// Object format (timeout metadata is legacy; prefer <anear-timeout> in PUG for participant-local UX)
|
|
200
230
|
eachParticipant: {
|
|
201
231
|
view: 'TemplateName',
|
|
202
232
|
timeout: (appContext, participantId) => 30000,
|
|
@@ -231,21 +261,12 @@ PUG templates receive rich context including:
|
|
|
231
261
|
|
|
232
262
|
### Participant-local Timeouts
|
|
233
263
|
|
|
234
|
-
Participant-local timeout UX is handled by browser runtime (`<anear-timeout>`). JSAPI does not emit `PARTICIPANT_TIMEOUT
|
|
264
|
+
Participant-local timeout UX is handled by browser runtime (`<anear-timeout>`). JSAPI does not emit `PARTICIPANT_TIMEOUT`; timeout-driven actions should arrive as normal `ACTION` payloads (for example `ACTION_TIMEOUT` if your app uses that event name).
|
|
235
265
|
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
timeout: (context, participant) => {
|
|
241
|
-
// Only current player gets timeout
|
|
242
|
-
return context.currentPlayerId === participant.info.id ? 30000 : null
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
on: {
|
|
247
|
-
MOVE: 'nextTurn'
|
|
248
|
-
}
|
|
266
|
+
```pug
|
|
267
|
+
//- Active player's controls wrapped with browser-owned timeout UX
|
|
268
|
+
anear-timeout(duration='30000')
|
|
269
|
+
anear-action(payload=action("MOVE")) Move
|
|
249
270
|
```
|
|
250
271
|
|
|
251
272
|
### Group Timeouts
|
|
@@ -328,8 +349,8 @@ await anearEvent.startEvent() // Transition to live
|
|
|
328
349
|
await anearEvent.closeEvent() // Transition to complete
|
|
329
350
|
await anearEvent.cancelEvent() // Transition to cancelled
|
|
330
351
|
|
|
331
|
-
//
|
|
332
|
-
anearEvent.
|
|
352
|
+
// allParticipants timeout management
|
|
353
|
+
anearEvent.cancelAllParticipantsTimeout() // Cancel active allParticipants timeout orchestration
|
|
333
354
|
```
|
|
334
355
|
|
|
335
356
|
## XState Integration
|
package/lib/models/AnearEvent.js
CHANGED
|
@@ -110,10 +110,14 @@ class AnearEvent extends JsonApiResource {
|
|
|
110
110
|
this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
cancelAllParticipantsTimeout() {
|
|
114
114
|
// Cancels active allParticipants timeout tracking in AEM.
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
this.send({ type: "CANCEL_ALL_PARTICIPANTS_TIMEOUT" })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Backward-compatible alias (legacy misspelling/wording).
|
|
119
|
+
cancelAllParticipantTimeouts() {
|
|
120
|
+
this.cancelAllParticipantsTimeout()
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
|
|
@@ -132,7 +132,19 @@ const getPresenceEventName = (participant, presenceAction) => {
|
|
|
132
132
|
return `${role}_${presenceAction}`;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* ACTION payload tokenization contract
|
|
137
|
+
* -----------------------------------
|
|
138
|
+
* - For participant-private displays, DisplayEventProcessor tokenizes anear-action payloads:
|
|
139
|
+
* participantActionLookup[participantId][token] = { APP_EVENT_NAME: payloadObject }
|
|
140
|
+
* - Incoming ACTION messages are first resolved by token lookup.
|
|
141
|
+
* - Raw JSON payloads are accepted when ABR marks them as trusted client payloads
|
|
142
|
+
* (for example forms and web-component actions).
|
|
143
|
+
* - Unknown/unmarked payloads are dropped (not forwarded to AppM).
|
|
144
|
+
*/
|
|
135
145
|
const FORM_PAYLOAD_MARKER = '__anearForm'
|
|
146
|
+
const CLIENT_PAYLOAD_MARKER = '__anearClient'
|
|
147
|
+
const TRUSTED_CLIENT_PAYLOAD_MARKERS = [FORM_PAYLOAD_MARKER, CLIENT_PAYLOAD_MARKER]
|
|
136
148
|
|
|
137
149
|
const parseActionEnvelopeObject = (payloadObject) => {
|
|
138
150
|
if (!payloadObject || typeof payloadObject !== 'object' || Array.isArray(payloadObject)) return null
|
|
@@ -167,11 +179,12 @@ const resolveIncomingActionEnvelope = (context, participantId, rawPayload) => {
|
|
|
167
179
|
if (!parsed) return null
|
|
168
180
|
const payload = parsed.payload
|
|
169
181
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
170
|
-
|
|
182
|
+
const hasTrustedMarker = TRUSTED_CLIENT_PAYLOAD_MARKERS.some(marker => payload[marker] === true)
|
|
183
|
+
if (!hasTrustedMarker) return null
|
|
171
184
|
|
|
172
185
|
const cleanedPayload = { ...payload }
|
|
173
|
-
delete cleanedPayload[
|
|
174
|
-
return { appEventName: parsed.appEventName, payload: cleanedPayload, source: '
|
|
186
|
+
TRUSTED_CLIENT_PAYLOAD_MARKERS.forEach(marker => delete cleanedPayload[marker])
|
|
187
|
+
return { appEventName: parsed.appEventName, payload: cleanedPayload, source: 'client' }
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
const RealtimeMessaging = require('../utils/RealtimeMessaging')
|
|
@@ -238,7 +251,7 @@ const AnearEventMachineContext = (
|
|
|
238
251
|
exitedParticipantPrivateChannels: [],
|
|
239
252
|
participantsActionTimeout: null,
|
|
240
253
|
participantActionLookup: {}, // Per-render ephemeral action token lookup by participant ID.
|
|
241
|
-
eachParticipantCancelActionType: null, // ACTION type that
|
|
254
|
+
eachParticipantCancelActionType: null, // ACTION type that can cancel active allParticipants timeout orchestration
|
|
242
255
|
pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
|
|
243
256
|
consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
|
|
244
257
|
consecutiveAllParticipantsTimeoutCount: 0, // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
|
|
@@ -567,8 +580,12 @@ const ActiveEventStatesConfig = {
|
|
|
567
580
|
BOOT_PARTICIPANT: {
|
|
568
581
|
actions: 'sendBootEventToParticipant'
|
|
569
582
|
},
|
|
583
|
+
CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
|
|
584
|
+
actions: 'cancelAllParticipantsTimeout'
|
|
585
|
+
},
|
|
586
|
+
// Backward-compatible alias.
|
|
570
587
|
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
|
|
571
|
-
actions: '
|
|
588
|
+
actions: 'cancelAllParticipantsTimeout'
|
|
572
589
|
},
|
|
573
590
|
SPECTATOR_ENTER: {
|
|
574
591
|
actions: 'sendSpectatorEnterToAppEventMachine'
|
|
@@ -694,6 +711,8 @@ const ActiveEventStatesConfig = {
|
|
|
694
711
|
BOOT_PARTICIPANT: {
|
|
695
712
|
actions: 'sendBootEventToParticipant'
|
|
696
713
|
},
|
|
714
|
+
CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
|
|
715
|
+
// Backward-compatible alias.
|
|
697
716
|
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
|
|
698
717
|
},
|
|
699
718
|
states: {
|
|
@@ -726,6 +745,8 @@ const ActiveEventStatesConfig = {
|
|
|
726
745
|
ACTION: {
|
|
727
746
|
actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
|
|
728
747
|
},
|
|
748
|
+
CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
|
|
749
|
+
// Backward-compatible alias.
|
|
729
750
|
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
|
|
730
751
|
}
|
|
731
752
|
},
|
|
@@ -831,12 +852,21 @@ const ActiveEventStatesConfig = {
|
|
|
831
852
|
target: 'handleParticipantsTimeoutCanceled'
|
|
832
853
|
},
|
|
833
854
|
{
|
|
855
|
+
guard: 'isRecognizedActionPayload',
|
|
834
856
|
actions: ['resetParticipantTimeoutCount', 'processAndForwardAction', 'processParticipantResponse'],
|
|
835
857
|
// v5 note: internal transitions are the default (v4 had `internal: true`)
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
actions: ['logDroppedUnrecognizedAction']
|
|
836
861
|
}
|
|
837
862
|
],
|
|
863
|
+
CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
|
|
864
|
+
actions: ['cancelAllParticipantsTimeout'],
|
|
865
|
+
target: 'handleParticipantsTimeoutCanceled'
|
|
866
|
+
},
|
|
867
|
+
// Backward-compatible alias.
|
|
838
868
|
CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
|
|
839
|
-
actions: ['
|
|
869
|
+
actions: ['cancelAllParticipantsTimeout'],
|
|
840
870
|
target: 'handleParticipantsTimeoutCanceled'
|
|
841
871
|
},
|
|
842
872
|
RENDER_DISPLAY: {
|
|
@@ -1438,19 +1468,31 @@ const AnearEventMachineFunctions = ({
|
|
|
1438
1468
|
return { ...s, liveDurationMs: (s.liveDurationMs || 0) + add, liveStartedAt: null }
|
|
1439
1469
|
}
|
|
1440
1470
|
}),
|
|
1441
|
-
setLastRenderResult: assign(({ event }) => {
|
|
1471
|
+
setLastRenderResult: assign(({ context, event }) => {
|
|
1442
1472
|
const output = event?.output ?? event?.data
|
|
1443
1473
|
if (!output || typeof output !== 'object') {
|
|
1444
1474
|
return {
|
|
1445
1475
|
lastRenderResult: null,
|
|
1446
|
-
participantActionLookup: {}
|
|
1476
|
+
participantActionLookup: context.participantActionLookup || {}
|
|
1447
1477
|
}
|
|
1448
1478
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
participantActionLookup: output.participantActionLookup && typeof output.participantActionLookup === 'object'
|
|
1479
|
+
const incomingLookup =
|
|
1480
|
+
output.participantActionLookup && typeof output.participantActionLookup === 'object'
|
|
1452
1481
|
? output.participantActionLookup
|
|
1453
1482
|
: {}
|
|
1483
|
+
// Merge token maps by participant so a selective/private re-render does not
|
|
1484
|
+
// invalidate untouched participants' existing action tokens.
|
|
1485
|
+
// For participants present in this render batch, replace their token map
|
|
1486
|
+
// atomically with the fresh map generated during render.
|
|
1487
|
+
const participantActionLookup = {
|
|
1488
|
+
...(context.participantActionLookup || {}),
|
|
1489
|
+
}
|
|
1490
|
+
Object.keys(incomingLookup).forEach((participantId) => {
|
|
1491
|
+
participantActionLookup[participantId] = incomingLookup[participantId]
|
|
1492
|
+
})
|
|
1493
|
+
return {
|
|
1494
|
+
lastRenderResult: output,
|
|
1495
|
+
participantActionLookup
|
|
1454
1496
|
}
|
|
1455
1497
|
}),
|
|
1456
1498
|
notifyAppMachineRendered: ({ context }) => {
|
|
@@ -1827,7 +1869,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1827
1869
|
participantPrivateChannels: context.participantPrivateChannels
|
|
1828
1870
|
}
|
|
1829
1871
|
}),
|
|
1830
|
-
|
|
1872
|
+
cancelAllParticipantsTimeout: assign(() => ({
|
|
1831
1873
|
participantsActionTimeout: null,
|
|
1832
1874
|
pendingCancelAction: null
|
|
1833
1875
|
})),
|
|
@@ -1844,7 +1886,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1844
1886
|
}
|
|
1845
1887
|
}),
|
|
1846
1888
|
setupPendingCancelConfirmationsForManualCancel: assign(({ context }) => {
|
|
1847
|
-
// Get all active participants (for manual
|
|
1889
|
+
// Get all active participants (for manual cancelAllParticipantsTimeout call)
|
|
1848
1890
|
const activeParticipantIds = getActiveParticipantIds(context)
|
|
1849
1891
|
const pendingConfirmations = new Set(activeParticipantIds)
|
|
1850
1892
|
|
|
@@ -1890,7 +1932,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1890
1932
|
null
|
|
1891
1933
|
|
|
1892
1934
|
if (cancelActionType) {
|
|
1893
|
-
logger.debug(`[AEM] Setting up cancel action type for
|
|
1935
|
+
logger.debug(`[AEM] Setting up cancel action type for allParticipants timeout orchestration: ${cancelActionType}`)
|
|
1894
1936
|
}
|
|
1895
1937
|
|
|
1896
1938
|
return {
|
|
@@ -1924,6 +1966,10 @@ const AnearEventMachineFunctions = ({
|
|
|
1924
1966
|
}
|
|
1925
1967
|
return updates
|
|
1926
1968
|
}),
|
|
1969
|
+
logDroppedUnrecognizedAction: ({ context, event }) => {
|
|
1970
|
+
const participantId = event?.data?.participantId
|
|
1971
|
+
logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
|
|
1972
|
+
},
|
|
1927
1973
|
processParticipantAction: ({ context, event }) => {
|
|
1928
1974
|
// event.data.participantId,
|
|
1929
1975
|
// event.data.payload: {"appEventMachineACTION": {action event keys and values}}
|
|
@@ -2659,6 +2705,10 @@ const AnearEventMachineFunctions = ({
|
|
|
2659
2705
|
const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
|
|
2660
2706
|
return isStartingNewTimeout || isTimeoutAlreadyRunning;
|
|
2661
2707
|
},
|
|
2708
|
+
isRecognizedActionPayload: ({ context, event }) => {
|
|
2709
|
+
const participantId = event?.data?.participantId
|
|
2710
|
+
return !!resolveIncomingActionEnvelope(context, participantId, event?.data?.payload)
|
|
2711
|
+
},
|
|
2662
2712
|
allParticipantsResponded: ({ context }) => {
|
|
2663
2713
|
return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
|
|
2664
2714
|
},
|
package/lib/utils/Constants.js
CHANGED
|
@@ -17,7 +17,8 @@ module.exports = {
|
|
|
17
17
|
BOOT_EXIT: 2 * 1000 // 5 seconds
|
|
18
18
|
},
|
|
19
19
|
DEAD_MAN_SWITCH: {
|
|
20
|
-
// Maximum consecutive timeouts
|
|
20
|
+
// Maximum consecutive allParticipants timeouts (where everyone timed out)
|
|
21
|
+
// before auto-canceling the event.
|
|
21
22
|
MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS: 4
|
|
22
23
|
},
|
|
23
24
|
EventStates: {
|