5ba7717ecd
Critical Fix: - StateStore now uses global Hooks.callAll directly (per spec) - Removed hooks parameter from StateStore constructor - Updated module.js to pass only adapter.settings - Updated tests to stub globalThis.Hooks Minor Cleanup: - Fixed misleading warning in SocketHandler.registerPendingOp - Added clarifying comment for setMatrix _revision behavior Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
138 lines
5.5 KiB
JavaScript
138 lines
5.5 KiB
JavaScript
/**
|
|
* Socket message contract.
|
|
*
|
|
* Two message directions:
|
|
* Intent (GM → all): scrying-pool.visibility.set { opId, userId, targetState, baseRevision }
|
|
* Echo (all ← GM): scrying-pool.visibility.updated { opId, userId, state, revision }
|
|
*
|
|
* Validated at both send and receive. Payload ≥ 4096 bytes → throw before emit.
|
|
*
|
|
* @module contracts/socket-message
|
|
*/
|
|
|
|
/** @typedef {'scrying-pool.visibility.set'|'scrying-pool.visibility.updated'|'scrying-pool.preset.apply'|'scrying-pool.preset.applied'} SocketEventName */
|
|
|
|
/**
|
|
* @typedef {Object} SocketIntentPayload
|
|
* @property {string} opId - Unique operation ID (non-empty string).
|
|
* @property {string} userId - Target participant userId (non-empty string).
|
|
* @property {string} targetState - Desired VisibilityState.
|
|
* @property {number} baseRevision - Revision counter when the GM issued the intent (for latest-revision-wins guard).
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SocketEchoPayload
|
|
* @property {string} opId - Matches the originating intent opId.
|
|
* @property {string} userId - Affected participant userId.
|
|
* @property {string} state - Authoritative VisibilityState after this operation.
|
|
* @property {number} revision - Monotonically increasing revision counter.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SocketMessage
|
|
* @property {SocketEventName} event - Socket event name.
|
|
* @property {SocketIntentPayload|SocketEchoPayload} payload - Message payload.
|
|
*/
|
|
|
|
export const SOCKET_EVENTS = Object.freeze({
|
|
VISIBILITY_SET: "scrying-pool.visibility.set",
|
|
VISIBILITY_UPDATED: "scrying-pool.visibility.updated",
|
|
PRESET_APPLY: "scrying-pool.preset.apply",
|
|
PRESET_APPLIED: "scrying-pool.preset.applied",
|
|
});
|
|
|
|
export const MAX_PAYLOAD_BYTES = 4096;
|
|
|
|
/**
|
|
* Creates a socket intent message (GM → all clients).
|
|
* @param {string} opId - Unique operation ID.
|
|
* @param {string} userId - Target participant userId.
|
|
* @param {string} targetState - Desired VisibilityState.
|
|
* @param {number} baseRevision - Revision counter at time of intent.
|
|
* @returns {SocketMessage}
|
|
*/
|
|
export function createSocketIntentMessage(opId, userId, targetState, baseRevision) {
|
|
return {
|
|
event: SOCKET_EVENTS.VISIBILITY_SET,
|
|
payload: { opId, userId, targetState, baseRevision },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a socket echo message (authoritative broadcast to all clients).
|
|
* @param {string} opId - Originating intent opId.
|
|
* @param {string} userId - Affected participant userId.
|
|
* @param {string} state - Authoritative VisibilityState.
|
|
* @param {number} revision - Revision counter.
|
|
* @returns {SocketMessage}
|
|
*/
|
|
export function createSocketEchoMessage(opId, userId, state, revision) {
|
|
return {
|
|
event: SOCKET_EVENTS.VISIBILITY_UPDATED,
|
|
payload: { opId, userId, state, revision },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates a SocketMessage DTO. Throws TypeError on any violation.
|
|
* @param {unknown} data - Value to validate.
|
|
* @returns {SocketMessage} The validated message.
|
|
* @throws {TypeError} If data fails validation.
|
|
*/
|
|
export function isValidSocketMessage(data) {
|
|
if (data === null || typeof data !== "object") {
|
|
throw new TypeError("SocketMessage: must be an object");
|
|
}
|
|
const obj = /** @type {Record<string, unknown>} */ (data);
|
|
const { event, payload, ...rest } = obj;
|
|
if (Object.keys(rest).length > 0) {
|
|
throw new TypeError(`SocketMessage: unknown keys: ${Object.keys(rest).join(", ")}`);
|
|
}
|
|
if (!Object.values(SOCKET_EVENTS).includes(/** @type {any} */ (event))) {
|
|
throw new TypeError(`SocketMessage: unknown event "${event}"`);
|
|
}
|
|
if (payload === null || typeof payload !== "object") {
|
|
throw new TypeError("SocketMessage: payload must be an object");
|
|
}
|
|
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
// Validate intent payload
|
|
if (event === SOCKET_EVENTS.VISIBILITY_SET) {
|
|
const { opId, userId, targetState, baseRevision, ...payloadRest } = p;
|
|
if (Object.keys(payloadRest).length > 0) {
|
|
throw new TypeError(`SocketMessage intent: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
|
|
}
|
|
if (typeof opId !== "string" || opId.length === 0) {
|
|
throw new TypeError("SocketMessage: opId must be a non-empty string");
|
|
}
|
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
throw new TypeError("SocketMessage: userId must be a non-empty string");
|
|
}
|
|
if (typeof targetState !== "string" || targetState.length === 0) {
|
|
throw new TypeError("SocketMessage: targetState must be a non-empty string");
|
|
}
|
|
if (typeof baseRevision !== "number" || !Number.isFinite(baseRevision) || baseRevision < 0) {
|
|
throw new TypeError("SocketMessage: baseRevision must be a finite non-negative number");
|
|
}
|
|
}
|
|
// Validate echo payload
|
|
if (event === SOCKET_EVENTS.VISIBILITY_UPDATED) {
|
|
const { opId, userId, state, revision, ...payloadRest } = p;
|
|
if (Object.keys(payloadRest).length > 0) {
|
|
throw new TypeError(`SocketMessage echo: unknown payload keys: ${Object.keys(payloadRest).join(", ")}`);
|
|
}
|
|
if (typeof opId !== "string" || opId.length === 0) {
|
|
throw new TypeError("SocketMessage: opId must be a non-empty string");
|
|
}
|
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
throw new TypeError("SocketMessage: userId must be a non-empty string");
|
|
}
|
|
if (typeof state !== "string" || state.length === 0) {
|
|
throw new TypeError("SocketMessage: state must be a non-empty string");
|
|
}
|
|
if (typeof revision !== "number" || !Number.isFinite(revision) || revision < 0) {
|
|
throw new TypeError("SocketMessage: revision must be a finite non-negative number");
|
|
}
|
|
}
|
|
return /** @type {SocketMessage} */ (data);
|
|
}
|