UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

820 lines (688 loc) • 23.2 kB
import { InstancePresenceRecordType, PageRecordType } from '@tldraw/tlschema' import { createTLSchema, createTLStore, ZERO_INDEX_KEY } from 'tldraw' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RecordOpType } from '../lib/diff' import { getTlsyncProtocolVersion } from '../lib/protocol' import { WebSocketMinimal } from '../lib/ServerSocketAdapter' import { TLSocketRoom, TLSyncLog } from '../lib/TLSocketRoom' import { TLSyncErrorCloseEventReason } from '../lib/TLSyncClient' function getStore() { const schema = createTLSchema() const store = createTLStore({ schema }) return store } // Mock WebSocket implementation for testing function createMockSocket(overrides: Partial<WebSocketMinimal> = {}): WebSocketMinimal { return { send: vi.fn(), close: vi.fn(), readyState: WebSocket.OPEN, addEventListener: vi.fn(), removeEventListener: vi.fn(), ...overrides, } } // Helper to create test session metadata interface TestSessionMeta { userId: string userName: string } describe(TLSocketRoom, () => { it('allows being initialized with a non-empty TLStoreSnapshot', () => { const store = getStore() // populate with an empty document (document:document and page:page records) store.ensureStoreIsUsable() const snapshot = store.getStoreSnapshot() const room = new TLSocketRoom({ initialSnapshot: snapshot, }) expect(room.getCurrentSnapshot()).not.toMatchObject({ clock: 0, documents: [] }) expect(room.getCurrentSnapshot().clock).toBe(0) expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id))) .toMatchInlineSnapshot(` [ { "lastChangedClock": 0, "state": { "gridSize": 10, "id": "document:document", "meta": {}, "name": "", "typeName": "document", }, }, { "lastChangedClock": 0, "state": { "id": "page:page", "index": "a1", "meta": {}, "name": "Page 1", "typeName": "page", }, }, ] `) }) it('allows loading a TLStoreSnapshot at some later time', () => { const store = getStore() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) expect(room.getCurrentSnapshot()).toMatchObject({ clock: 0, documents: [] }) // populate with an empty document (document:document and page:page records) store.ensureStoreIsUsable() const snapshot = store.getStoreSnapshot() room.loadSnapshot(snapshot) expect(room.getCurrentSnapshot().clock).toBe(1) expect(room.getCurrentSnapshot().documents.sort((a, b) => a.state.id.localeCompare(b.state.id))) .toMatchInlineSnapshot(` [ { "lastChangedClock": 1, "state": { "gridSize": 10, "id": "document:document", "meta": {}, "name": "", "typeName": "document", }, }, { "lastChangedClock": 1, "state": { "id": "page:page", "index": "a1", "meta": {}, "name": "Page 1", "typeName": "page", }, }, ] `) }) it('getPresenceRecords correctly handles presence records', () => { const store = getStore() store.ensureStoreIsUsable() const snapshot = store.getStoreSnapshot() const room = new TLSocketRoom({ initialSnapshot: snapshot, }) // Create two separate sessions, each with their own presence record const sessionId1 = 'test-session-1' const sessionId2 = 'test-session-2' // Create mock sockets const mockSocket1: WebSocketMinimal = { send: vi.fn(), close: vi.fn(), readyState: 1, // WebSocket.OPEN addEventListener: vi.fn(), removeEventListener: vi.fn(), } const mockSocket2: WebSocketMinimal = { send: vi.fn(), close: vi.fn(), readyState: 1, // WebSocket.OPEN addEventListener: vi.fn(), removeEventListener: vi.fn(), } // Add sessions to the room room.handleSocketConnect({ sessionId: sessionId1, socket: mockSocket1, isReadonly: false, }) room.handleSocketConnect({ sessionId: sessionId2, socket: mockSocket2, isReadonly: false, }) // Send connect messages to establish the sessions const connectRequest1 = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: 8, schema: store.schema.serialize(), } room.handleSocketMessage(sessionId1, JSON.stringify(connectRequest1)) const connectRequest2 = { type: 'connect' as const, connectRequestId: 'connect-2', lastServerClock: 0, protocolVersion: 8, schema: store.schema.serialize(), } room.handleSocketMessage(sessionId2, JSON.stringify(connectRequest2)) // Create presence records for each session const presence1 = InstancePresenceRecordType.create({ id: InstancePresenceRecordType.createId('presence1'), userId: 'user1', userName: 'User 1', currentPageId: PageRecordType.createId('page'), }) const presence2 = InstancePresenceRecordType.create({ id: InstancePresenceRecordType.createId('presence2'), userId: 'user2', userName: 'User 2', currentPageId: PageRecordType.createId('page'), }) // Send push messages with presence data for each session const pushRequest1 = { type: 'push' as const, clientClock: 1, presence: [RecordOpType.Put, presence1] as [typeof RecordOpType.Put, typeof presence1], } room.handleSocketMessage(sessionId1, JSON.stringify(pushRequest1)) const pushRequest2 = { type: 'push' as const, clientClock: 2, presence: [RecordOpType.Put, presence2] as [typeof RecordOpType.Put, typeof presence2], } room.handleSocketMessage(sessionId2, JSON.stringify(pushRequest2)) // Get presence records const presenceRecords = room.getPresenceRecords() // Should return the presence records that were added through the protocol expect(Object.keys(presenceRecords)).toHaveLength(2) // Find the presence records by their user data since the IDs are generated by the room const user1Presence = Object.values(presenceRecords).find((p) => (p as any).userId === 'user1') const user2Presence = Object.values(presenceRecords).find((p) => (p as any).userId === 'user2') expect(user1Presence).toBeDefined() expect(user2Presence).toBeDefined() // Verify the records are properly structured expect(user1Presence).toMatchObject({ typeName: 'instance_presence', userId: 'user1', userName: 'User 1', }) expect(user2Presence).toMatchObject({ typeName: 'instance_presence', userId: 'user2', userName: 'User 2', }) // Should not include document records const documentRecordIds = Object.keys(presenceRecords).filter( (id) => presenceRecords[id].typeName === 'document' ) expect(documentRecordIds).toHaveLength(0) }) it('passes onDataChange handler through', async () => { const addPage = (room: TLSocketRoom) => room.updateStore((store) => { store.put( PageRecordType.create({ id: PageRecordType.createId(), name: '', index: ZERO_INDEX_KEY }) ) }) const store = getStore() store.ensureStoreIsUsable() let called = 0 const room = new TLSocketRoom({ onDataChange: () => ++called }) expect(called).toEqual(0) await addPage(room) expect(called).toEqual(1) room.loadSnapshot(room.getCurrentSnapshot()) expect(called).toEqual(1) await addPage(room) expect(called).toEqual(2) }) it('sends custom messages', async () => { const json = JSON.stringify const store = getStore() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot() }) const sessionId = 'test-session-1' const send = vi.fn() // Add session to the room const mockSocket: WebSocketMinimal = { send, close: vi.fn(), readyState: WebSocket.OPEN } room.handleSocketConnect({ sessionId, socket: mockSocket }) // Send connect message to establish the session const connect = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: store.schema.serialize(), } room.handleSocketMessage(sessionId, json(connect)) room.sendCustomMessage(sessionId, 'hello world') expect(send.mock.lastCall).toEqual([json({ type: 'custom', data: 'hello world' })]) }) describe('Room state resetting behavior', () => { it('sets documentClock to oldRoom.clock + 1 when resetting room state', () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) // Load a snapshot to increment the clock const snapshot = store.getStoreSnapshot() room.loadSnapshot(snapshot) const oldClock = room.getCurrentSnapshot().clock expect(oldClock).toBe(1) // Reset with a new snapshot const newSnapshot = store.getStoreSnapshot() room.loadSnapshot(newSnapshot) const newSnapshotResult = room.getCurrentSnapshot() expect(newSnapshotResult.documentClock).toBe(oldClock + 1) expect(newSnapshotResult.clock).toBe(oldClock + 1) }) it('updates all documents lastChangedClock when resetting', () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) // Get initial clock const initialClock = room.getCurrentSnapshot().clock // Reset with a new snapshot const newSnapshot = store.getStoreSnapshot() room.loadSnapshot(newSnapshot) const result = room.getCurrentSnapshot() expect(result.clock).toBe(initialClock + 1) // All documents should have updated lastChangedClock for (const doc of result.documents) { expect(doc.lastChangedClock).toBe(initialClock + 1) } }) it('preserves existing tombstones with original clock values', async () => { // Create a room with initial state const store = getStore() store.ensureStoreIsUsable() const testPageId = PageRecordType.createId('test_page') store.put([ PageRecordType.create({ id: testPageId, name: 'Test Page', index: ZERO_INDEX_KEY }), ]) const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) await room.updateStore((store) => { store.delete(testPageId) }) const deletionClock = room.getCurrentDocumentClock() expect(room.getCurrentSnapshot().tombstones).toEqual({ [testPageId]: deletionClock, }) room.loadSnapshot(room.getCurrentSnapshot()) expect(room.getCurrentSnapshot().tombstones).toEqual({ [testPageId]: deletionClock, }) expect(room.getCurrentSnapshot().documentClock).toBe(deletionClock + 1) }) it('handles empty snapshot reset correctly', () => { const store = getStore() // Don't call ensureStoreIsUsable to get an empty snapshot const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) const oldClock = room.getCurrentSnapshot().clock // Reset with empty snapshot const emptySnapshot = store.getStoreSnapshot() room.loadSnapshot(emptySnapshot) const result = room.getCurrentSnapshot() expect(result.documentClock).toBe(oldClock + 1) expect(result.clock).toBe(oldClock + 1) expect(result.documents).toHaveLength(0) }) it('preserves schema when resetting room state', () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) const originalSchema = room.getCurrentSnapshot().schema // Reset with a new snapshot const newSnapshot = store.getStoreSnapshot() room.loadSnapshot(newSnapshot) const result = room.getCurrentSnapshot() expect(result.schema).toEqual(originalSchema) }) }) describe('Constructor options', () => { it('sets up logging with default console.error when log option missing', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const room = new TLSocketRoom({}) // Create a session first, then send invalid message to trigger JSON parse error const socket = createMockSocket() room.handleSocketConnect({ sessionId: 'test-session', socket, }) // Send invalid JSON to trigger JSON parse error which should call console.error room.handleSocketMessage('test-session', '{invalid json') expect(consoleSpy).toHaveBeenCalled() consoleSpy.mockRestore() }) it('uses custom logger when provided', () => { const mockLog: TLSyncLog = { warn: vi.fn(), error: vi.fn(), } const room = new TLSocketRoom({ log: mockLog }) // Create a session first, then send invalid message const socket = createMockSocket() room.handleSocketConnect({ sessionId: 'test-session', socket, }) // Send invalid JSON to trigger JSON parse error which should call log.error room.handleSocketMessage('test-session', '{invalid json') expect(mockLog.error).toHaveBeenCalled() }) it('initializes with custom client timeout', () => { const customTimeout = 15000 const room = new TLSocketRoom({ clientTimeout: customTimeout }) expect(room.opts.clientTimeout).toBe(customTimeout) }) }) describe('Session management', () => { let room: TLSocketRoom let onSessionRemoved: ReturnType<typeof vi.fn> beforeEach(() => { onSessionRemoved = vi.fn() room = new TLSocketRoom({ onSessionRemoved }) }) it('handles multiple concurrent sessions', () => { const sessions = ['session1', 'session2', 'session3'] const sockets = sessions.map(() => createMockSocket()) sessions.forEach((sessionId, index) => { room.handleSocketConnect({ sessionId, socket: sockets[index], }) const connectRequest = { type: 'connect' as const, connectRequestId: `connect-${index}`, lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage(sessionId, JSON.stringify(connectRequest)) }) expect(room.getNumActiveSessions()).toBe(3) const sessionInfo = room.getSessions() expect(sessionInfo).toHaveLength(3) expect(sessionInfo.every((s) => s.isConnected)).toBe(true) }) it('handles readonly sessions correctly', () => { const socket = createMockSocket() room.handleSocketConnect({ sessionId: 'readonly-session', socket, isReadonly: true, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('readonly-session', JSON.stringify(connectRequest)) const sessions = room.getSessions() expect(sessions[0].isReadonly).toBe(true) }) }) describe('Message handling', () => { let room: TLSocketRoom let socket: WebSocketMinimal let onBeforeSendMessage: ReturnType<typeof vi.fn> let onAfterReceiveMessage: ReturnType<typeof vi.fn> beforeEach(() => { onBeforeSendMessage = vi.fn() onAfterReceiveMessage = vi.fn() room = new TLSocketRoom({ onBeforeSendMessage, onAfterReceiveMessage, }) socket = createMockSocket() }) it('calls onBeforeSendMessage for outgoing messages', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) expect(onBeforeSendMessage).toHaveBeenCalled() const call = onBeforeSendMessage.mock.calls[0][0] expect(call.sessionId).toBe('test-session') expect(call.message).toBeDefined() expect(call.stringified).toBeDefined() }) it('calls onAfterReceiveMessage for valid incoming messages', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) expect(onAfterReceiveMessage).toHaveBeenCalled() const call = onAfterReceiveMessage.mock.calls[0][0] expect(call.sessionId).toBe('test-session') expect(call.message).toBeDefined() expect(call.stringified).toBeDefined() }) }) describe('WebSocket error handling', () => { let room: TLSocketRoom let socket: WebSocketMinimal beforeEach(() => { room = new TLSocketRoom({}) socket = createMockSocket() }) it('handles socket errors correctly', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) expect(room.getSessions()).toHaveLength(1) // Trigger socket error - should not throw expect(() => room.handleSocketError('test-session')).not.toThrow() }) it('handles socket close correctly', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) expect(room.getSessions()).toHaveLength(1) // Trigger socket close - should not throw expect(() => room.handleSocketClose('test-session')).not.toThrow() }) }) describe('Custom messages', () => { let room: TLSocketRoom let socket: WebSocketMinimal beforeEach(() => { room = new TLSocketRoom({}) socket = createMockSocket() }) it('sends custom messages to connected sessions', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) const customData = { type: 'notification', message: 'Hello World' } room.sendCustomMessage('test-session', customData) expect(socket.send).toHaveBeenCalledWith(JSON.stringify({ type: 'custom', data: customData })) }) it('handles custom message to non-existent session gracefully', () => { // Should not throw an error expect(() => { room.sendCustomMessage('nonexistent-session', { test: 'data' }) }).not.toThrow() }) }) describe('Session closing', () => { let room: TLSocketRoom let socket: WebSocketMinimal beforeEach(() => { room = new TLSocketRoom({}) socket = createMockSocket() }) it('closes session without fatal reason', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) room.closeSession('test-session') // Session should be removed expect(room.getSessions()).toHaveLength(0) }) it('closes session with fatal reason', () => { room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) room.closeSession('test-session', TLSyncErrorCloseEventReason.FORBIDDEN) // Session should be removed expect(room.getSessions()).toHaveLength(0) }) }) describe('Room lifecycle', () => { it('closes room correctly', () => { const room = new TLSocketRoom({}) const socket = createMockSocket() room.handleSocketConnect({ sessionId: 'test-session', socket, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } room.handleSocketMessage('test-session', JSON.stringify(connectRequest)) expect(room.getSessions()).toHaveLength(1) // Close the room room.close() // Room should be marked as closed expect(room.isClosed()).toBe(true) }) }) describe('Store updates', () => { it('executes async store updates', async () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) const initialClock = room.getCurrentDocumentClock() await room.updateStore(async (store) => { const page = PageRecordType.create({ id: PageRecordType.createId('new-page'), name: 'New Page', index: ZERO_INDEX_KEY, }) store.put(page) }) expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock) }) it('handles errors in store updates', async () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) await expect(async () => { await room.updateStore(() => { throw new Error('Test error') }) }).rejects.toThrow('Test error') }) }) describe('Session metadata handling', () => { it('handles sessions with metadata correctly', () => { const roomWithMeta = new TLSocketRoom<any, TestSessionMeta>({}) const socket = createMockSocket() const meta: TestSessionMeta = { userId: 'user123', userName: 'Alice' } roomWithMeta.handleSocketConnect({ sessionId: 'meta-session', socket, meta, }) const connectRequest = { type: 'connect' as const, connectRequestId: 'connect-1', lastServerClock: 0, protocolVersion: getTlsyncProtocolVersion(), schema: createTLSchema().serialize(), } roomWithMeta.handleSocketMessage('meta-session', JSON.stringify(connectRequest)) const sessions = roomWithMeta.getSessions() expect(sessions[0].meta).toEqual(meta) }) }) describe('Clock operations', () => { it('increments clock after store updates', async () => { const store = getStore() store.ensureStoreIsUsable() const room = new TLSocketRoom({ initialSnapshot: store.getStoreSnapshot(), }) const initialClock = room.getCurrentDocumentClock() await room.updateStore((store) => { store.put( PageRecordType.create({ id: PageRecordType.createId('test-page'), name: 'Test', index: ZERO_INDEX_KEY, }) ) }) expect(room.getCurrentDocumentClock()).toBeGreaterThan(initialClock) }) }) })