diff --git a/package.json b/package.json index e764e9a..3b3f364 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "./editor/AceEditor.js": "./dist/editor/AceEditor.js", "./editor/AttributePool.js": "./dist/editor/AttributePool.js", "./editor/Changeset.js": "./dist/editor/Changeset.js", - "./editor/changesettracker.js": "./dist/editor/changesettracker.js" + "./editor/changesettracker.js": "./dist/editor/changesettracker.js", + "./collab/clientMessage.js": "./dist/collab/clientMessage.js" }, "files": [ "dist" @@ -36,7 +37,8 @@ "dev": "vite", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "vitest --project storybook", + "test": "vitest --project storybook --project unit", + "test:unit": "vitest --project unit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/src/collab/clientMessage.ts b/src/collab/clientMessage.ts new file mode 100644 index 0000000..439fb37 --- /dev/null +++ b/src/collab/clientMessage.ts @@ -0,0 +1,218 @@ +/** + * Collab CLIENT_MESSAGE handling — the client-side counterpart of the + * server's COLLABROOM CLIENT_MESSAGE family. + * + * Mirrors the original Etherpad behavior (`pad.handleClientMessage` in + * `pad.ts` / `pad_userlist.ts`): + * + * - `suggestUserName`: another user typed a name suggestion for an unnamed + * user. When the suggestion targets *this* user and this user has no name + * yet, the suggested name is adopted locally and a `USERINFO_UPDATE` is + * sent back to the server so everyone else sees the new name. + * - `padoptions`: tolerated as a no-op (pad-wide settings are not supported + * on this stack; the server ignores them too). + * - any other payload type is ignored without throwing, so future server + * additions never break older clients. + * + * Wire format notes (verified against the etherpad-go stack): + * - server -> client frames are socket.io-style JSON arrays: + * ["message", {type: "COLLABROOM", data: {type: "CLIENT_MESSAGE", payload: {...}}}] + * - client -> server frames are JSON objects: + * {event: "message", data: {type: "COLLABROOM", ...}} + * `extractClientMessagePayload` accepts both framings (and already-unwrapped + * messages), so hosts can feed it whatever level of the stack they have. + */ + +import { EventBus, editorBus } from '../editor/core/EventBus.js'; +import type { EditorEvents } from '../editor/core/EventBus.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Minimal user info shape shared with the collab client. */ +export interface CollabUserInfo { + userId: string; + name?: string | null; + colorId?: string | number | null; + [key: string]: unknown; +} + +/** Payload of a `suggestUserName` CLIENT_MESSAGE. */ +export interface SuggestUserNamePayload { + type: 'suggestUserName'; + /** The suggested display name. */ + newName: string; + /** The author id of the (still unnamed) user the suggestion targets. */ + unnamedId: string; +} + +/** Payload of a `padoptions` CLIENT_MESSAGE (tolerated, not applied). */ +export interface PadOptionsPayload { + type: 'padoptions'; + options?: Record; + changedBy?: string; +} + +/** Any CLIENT_MESSAGE payload — known types plus a tolerant fallback. */ +export type ClientMessagePayload = + | SuggestUserNamePayload + | PadOptionsPayload + | { type: string; [key: string]: unknown }; + +/** The `USERINFO_UPDATE` collab message sent after adopting a name. */ +export interface UserInfoUpdateMessage { + type: 'USERINFO_UPDATE'; + userInfo: CollabUserInfo; +} + +/** Host integration points for the handler. */ +export interface ClientMessageHandlerContext { + /** Returns the local user's current info (id, name, colorId). */ + getMyUserInfo: () => CollabUserInfo; + /** Adopts the suggested name locally (update state/UI). */ + setMyUserName: (name: string) => void; + /** + * Sends a collab message to the server (the host wraps it into its + * COLLABROOM envelope, like `collabClient.sendMessage` does). + */ + sendMessage: (msg: UserInfoUpdateMessage) => void; + /** Event bus to announce the adopted name on. Defaults to `editorBus`. */ + bus?: EventBus; +} + +export interface ClientMessageHandler { + /** + * Handles an unwrapped CLIENT_MESSAGE payload. + * Returns `true` when the message was recognized and acted upon. + * Never throws on unknown or malformed payloads. + */ + handleClientMessage: (payload: unknown) => boolean; + /** + * Convenience: unwraps a raw frame / COLLABROOM message and handles it. + * Returns `true` when a CLIENT_MESSAGE was found and acted upon. + */ + handleFrame: (frame: unknown) => boolean; +} + +// --------------------------------------------------------------------------- +// Frame unwrapping +// --------------------------------------------------------------------------- + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Extracts the CLIENT_MESSAGE payload from a parsed server message at any + * wrapping level: + * + * - socket.io-style array frame: `["message", {type: "COLLABROOM", ...}]` + * - object frame: `{event: "message", data: {type: "COLLABROOM", ...}}` + * - COLLABROOM message: `{type: "COLLABROOM", data: {type: "CLIENT_MESSAGE", payload}}` + * - collab message: `{type: "CLIENT_MESSAGE", payload}` + * + * Returns the payload, or `null` when the input is not a CLIENT_MESSAGE. + */ +export const extractClientMessagePayload = ( + frame: unknown, +): ClientMessagePayload | null => { + let msg: unknown = frame; + + // ["message", data] (server -> client framing) + if (Array.isArray(msg)) { + if (msg[0] !== 'message') return null; + msg = msg[1]; + } + + if (!isRecord(msg)) return null; + + // {event: "message", data: {...}} (client -> server framing) + if (typeof msg.event === 'string') { + if (msg.event !== 'message') return null; + msg = msg.data; + if (!isRecord(msg)) return null; + } + + // {type: "COLLABROOM", data: {...}} + if (msg.type === 'COLLABROOM') { + msg = msg.data; + if (!isRecord(msg)) return null; + } + + // {type: "CLIENT_MESSAGE", payload: {...}} + if (msg.type !== 'CLIENT_MESSAGE') return null; + const payload = msg.payload; + if (!isRecord(payload) || typeof payload.type !== 'string') return null; + return payload as ClientMessagePayload; +}; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/** + * Creates a CLIENT_MESSAGE handler bound to the host's collab integration + * points. See the module docs for the behavior of each payload type. + */ +export const createClientMessageHandler = ( + context: ClientMessageHandlerContext, +): ClientMessageHandler => { + const bus = context.bus ?? editorBus; + + const handleSuggestUserName = (payload: Record): boolean => { + const { newName, unnamedId } = payload; + const myUserInfo = context.getMyUserInfo(); + // Mirror the original guard: only adopt suggestions that target this + // user, carry a non-empty name, and only while this user is unnamed. + if ( + typeof newName !== 'string' || + newName === '' || + unnamedId !== myUserInfo.userId || + myUserInfo.name + ) { + return false; + } + + context.setMyUserName(newName); + + // Propagate the adopted name to the server (collabClient.updateUserInfo + // equivalent) so all other clients learn the new name. + const userInfo: CollabUserInfo = { + ...myUserInfo, + name: newName, + }; + context.sendMessage({ type: 'USERINFO_UPDATE', userInfo }); + + bus.emit('user:info:updated', { + userId: myUserInfo.userId, + name: newName, + colorId: + typeof myUserInfo.colorId === 'string' ? myUserInfo.colorId : undefined, + }); + return true; + }; + + const handleClientMessage = (payload: unknown): boolean => { + if (!isRecord(payload) || typeof payload.type !== 'string') return false; + switch (payload.type) { + case 'suggestUserName': + return handleSuggestUserName(payload); + case 'padoptions': + // Tolerated no-op: pad-wide settings are not supported on this + // stack (the server ignores them as well). Swallow silently so the + // message never surfaces as an error. + return true; + default: + // Unknown CLIENT_MESSAGE types are ignored without throwing. + return false; + } + }; + + const handleFrame = (frame: unknown): boolean => { + const payload = extractClientMessagePayload(frame); + if (payload === null) return false; + return handleClientMessage(payload); + }; + + return { handleClientMessage, handleFrame }; +}; diff --git a/src/index.ts b/src/index.ts index 5ab12c6..9dc1e9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,3 +21,13 @@ export type { Attribute } from './editor/types/Attribute.js'; export { default as AttributePool } from './editor/AttributePool.js'; export { makeChangesetTracker } from './editor/changesettracker.js'; export type { ChangesetTracker, AceCallbacksProvider, ChangesetTrackerCallbacks } from './editor/changesettracker.js'; +export { createClientMessageHandler, extractClientMessagePayload } from './collab/clientMessage.js'; +export type { + ClientMessageHandler, + ClientMessageHandlerContext, + ClientMessagePayload, + CollabUserInfo, + PadOptionsPayload, + SuggestUserNamePayload, + UserInfoUpdateMessage, +} from './collab/clientMessage.js'; diff --git a/test/clientMessage.test.ts b/test/clientMessage.test.ts new file mode 100644 index 0000000..bf65a8e --- /dev/null +++ b/test/clientMessage.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createClientMessageHandler, + extractClientMessagePayload, +} from '../src/collab/clientMessage.js'; +import type { + ClientMessageHandlerContext, + CollabUserInfo, +} from '../src/collab/clientMessage.js'; +import { EventBus } from '../src/editor/core/EventBus.js'; +import type { EditorEvents } from '../src/editor/core/EventBus.js'; + +const MY_USER_ID = 'a.myAuthorId123'; + +const makeHarness = (myUserInfo: Partial = {}) => { + const userInfo: CollabUserInfo = { + userId: MY_USER_ID, + name: null, + colorId: '#ffcc00', + ...myUserInfo, + }; + const bus = new EventBus(); + const setMyUserName = vi.fn((name: string) => { + userInfo.name = name; + }); + const sendMessage = vi.fn(); + const context: ClientMessageHandlerContext = { + getMyUserInfo: () => userInfo, + setMyUserName, + sendMessage, + bus, + }; + return { + handler: createClientMessageHandler(context), + userInfo, + bus, + setMyUserName, + sendMessage, + }; +}; + +const suggestUserNamePayload = (overrides: Record = {}) => ({ + type: 'suggestUserName', + newName: 'Alice', + unnamedId: MY_USER_ID, + ...overrides, +}); + +describe('createClientMessageHandler', () => { + describe('suggestUserName (receiving)', () => { + it('adopts the suggested name and sends a USERINFO_UPDATE', () => { + const { handler, setMyUserName, sendMessage } = makeHarness(); + + const handled = handler.handleClientMessage(suggestUserNamePayload()); + + expect(handled).toBe(true); + expect(setMyUserName).toHaveBeenCalledExactlyOnceWith('Alice'); + expect(sendMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'USERINFO_UPDATE', + userInfo: { + userId: MY_USER_ID, + name: 'Alice', + colorId: '#ffcc00', + }, + }); + }); + + it('emits user:info:updated on the bus', () => { + const { handler, bus } = makeHarness(); + const events: EditorEvents['user:info:updated'][] = []; + bus.on('user:info:updated', (data) => events.push(data)); + + handler.handleClientMessage(suggestUserNamePayload()); + + expect(events).toEqual([ + { userId: MY_USER_ID, name: 'Alice', colorId: '#ffcc00' }, + ]); + }); + + it('ignores suggestions targeting another user', () => { + const { handler, setMyUserName, sendMessage } = makeHarness(); + + const handled = handler.handleClientMessage( + suggestUserNamePayload({ unnamedId: 'a.someoneElse' }), + ); + + expect(handled).toBe(false); + expect(setMyUserName).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('ignores suggestions when the user already has a name', () => { + const { handler, setMyUserName, sendMessage } = makeHarness({ + name: 'Bob', + }); + + const handled = handler.handleClientMessage(suggestUserNamePayload()); + + expect(handled).toBe(false); + expect(setMyUserName).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('ignores suggestions with an empty newName', () => { + const { handler, sendMessage } = makeHarness(); + + const handled = handler.handleClientMessage( + suggestUserNamePayload({ newName: '' }), + ); + + expect(handled).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('tolerance', () => { + it('treats padoptions as a handled no-op', () => { + const { handler, setMyUserName, sendMessage } = makeHarness(); + + const handled = handler.handleClientMessage({ + type: 'padoptions', + options: { view: { useMonospaceFont: true } }, + changedBy: 'a.someoneElse', + }); + + expect(handled).toBe(true); + expect(setMyUserName).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('ignores unknown payload types without throwing', () => { + const { handler } = makeHarness(); + + expect(handler.handleClientMessage({ type: 'someFutureType' })).toBe(false); + expect(handler.handleClientMessage(null)).toBe(false); + expect(handler.handleClientMessage('garbage')).toBe(false); + expect(handler.handleClientMessage({ noType: true })).toBe(false); + }); + }); + + describe('handleFrame (wire format)', () => { + it('handles the server relay frame end-to-end (socket.io-style array)', () => { + const { handler, sendMessage } = makeHarness(); + + // Exact shape relayed by the Go server's HandleClientMessage: + // json.Marshal([]any{"message", message.Data}) + const frame = JSON.parse(JSON.stringify([ + 'message', + { + component: 'pad', + type: 'COLLABROOM', + data: { + type: 'CLIENT_MESSAGE', + payload: { type: 'suggestUserName', newName: 'Alice', unnamedId: MY_USER_ID }, + }, + }, + ])); + + expect(handler.handleFrame(frame)).toBe(true); + expect(sendMessage).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ type: 'USERINFO_UPDATE' }), + ); + }); + + it('handles the {event, data} object framing', () => { + const { handler } = makeHarness(); + + const frame = { + event: 'message', + data: { + type: 'COLLABROOM', + data: { + type: 'CLIENT_MESSAGE', + payload: { type: 'suggestUserName', newName: 'Alice', unnamedId: MY_USER_ID }, + }, + }, + }; + + expect(handler.handleFrame(frame)).toBe(true); + }); + + it('ignores non-CLIENT_MESSAGE frames', () => { + const { handler, sendMessage } = makeHarness(); + + expect(handler.handleFrame(['message', { type: 'COLLABROOM', data: { type: 'USER_NEWINFO', userInfo: {} } }])).toBe(false); + expect(handler.handleFrame(['somethingElse', {}])).toBe(false); + expect(handler.handleFrame({ event: 'connect' })).toBe(false); + expect(handler.handleFrame(undefined)).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + }); + }); +}); + +describe('extractClientMessagePayload', () => { + const payload = { type: 'suggestUserName', newName: 'Alice', unnamedId: MY_USER_ID }; + const collabRoom = { + type: 'COLLABROOM', + data: { type: 'CLIENT_MESSAGE', payload }, + }; + + it('unwraps all supported framings', () => { + expect(extractClientMessagePayload(['message', collabRoom])).toEqual(payload); + expect(extractClientMessagePayload({ event: 'message', data: collabRoom })).toEqual(payload); + expect(extractClientMessagePayload(collabRoom)).toEqual(payload); + expect(extractClientMessagePayload(collabRoom.data)).toEqual(payload); + }); + + it('returns null for anything that is not a CLIENT_MESSAGE', () => { + expect(extractClientMessagePayload(null)).toBe(null); + expect(extractClientMessagePayload([])).toBe(null); + expect(extractClientMessagePayload(['message'])).toBe(null); + expect(extractClientMessagePayload({ type: 'COLLABROOM', data: { type: 'NEW_CHANGES' } })).toBe(null); + expect(extractClientMessagePayload({ type: 'CLIENT_MESSAGE' })).toBe(null); + expect(extractClientMessagePayload({ type: 'CLIENT_MESSAGE', payload: { noType: 1 } })).toBe(null); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e990ff4..e77407c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,14 +3,27 @@ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { playwright } from '@vitest/browser-playwright'; export default defineConfig({ - plugins: [storybookTest({ configDir: '.storybook' })], test: { - name: 'storybook', - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [{ browser: 'chromium' }], - }, + projects: [ + { + plugins: [storybookTest({ configDir: '.storybook' })], + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, + }, + { + test: { + name: 'unit', + include: ['test/**/*.test.ts'], + environment: 'node', + }, + }, + ], }, });