/** * 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} */ (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} */ (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); }