anear-js-api 2.3.0 → 2.3.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 +39 -18
- package/lib/models/AnearEvent.js +7 -3
- package/lib/state_machines/AnearEventMachine.js +47 -9
- 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: {
|
|
@@ -1827,7 +1857,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1827
1857
|
participantPrivateChannels: context.participantPrivateChannels
|
|
1828
1858
|
}
|
|
1829
1859
|
}),
|
|
1830
|
-
|
|
1860
|
+
cancelAllParticipantsTimeout: assign(() => ({
|
|
1831
1861
|
participantsActionTimeout: null,
|
|
1832
1862
|
pendingCancelAction: null
|
|
1833
1863
|
})),
|
|
@@ -1844,7 +1874,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1844
1874
|
}
|
|
1845
1875
|
}),
|
|
1846
1876
|
setupPendingCancelConfirmationsForManualCancel: assign(({ context }) => {
|
|
1847
|
-
// Get all active participants (for manual
|
|
1877
|
+
// Get all active participants (for manual cancelAllParticipantsTimeout call)
|
|
1848
1878
|
const activeParticipantIds = getActiveParticipantIds(context)
|
|
1849
1879
|
const pendingConfirmations = new Set(activeParticipantIds)
|
|
1850
1880
|
|
|
@@ -1890,7 +1920,7 @@ const AnearEventMachineFunctions = ({
|
|
|
1890
1920
|
null
|
|
1891
1921
|
|
|
1892
1922
|
if (cancelActionType) {
|
|
1893
|
-
logger.debug(`[AEM] Setting up cancel action type for
|
|
1923
|
+
logger.debug(`[AEM] Setting up cancel action type for allParticipants timeout orchestration: ${cancelActionType}`)
|
|
1894
1924
|
}
|
|
1895
1925
|
|
|
1896
1926
|
return {
|
|
@@ -1924,6 +1954,10 @@ const AnearEventMachineFunctions = ({
|
|
|
1924
1954
|
}
|
|
1925
1955
|
return updates
|
|
1926
1956
|
}),
|
|
1957
|
+
logDroppedUnrecognizedAction: ({ context, event }) => {
|
|
1958
|
+
const participantId = event?.data?.participantId
|
|
1959
|
+
logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
|
|
1960
|
+
},
|
|
1927
1961
|
processParticipantAction: ({ context, event }) => {
|
|
1928
1962
|
// event.data.participantId,
|
|
1929
1963
|
// event.data.payload: {"appEventMachineACTION": {action event keys and values}}
|
|
@@ -2659,6 +2693,10 @@ const AnearEventMachineFunctions = ({
|
|
|
2659
2693
|
const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
|
|
2660
2694
|
return isStartingNewTimeout || isTimeoutAlreadyRunning;
|
|
2661
2695
|
},
|
|
2696
|
+
isRecognizedActionPayload: ({ context, event }) => {
|
|
2697
|
+
const participantId = event?.data?.participantId
|
|
2698
|
+
return !!resolveIncomingActionEnvelope(context, participantId, event?.data?.payload)
|
|
2699
|
+
},
|
|
2662
2700
|
allParticipantsResponded: ({ context }) => {
|
|
2663
2701
|
return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
|
|
2664
2702
|
},
|
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: {
|