FRE-600: Implement Phase 1 WebSocket + Yjs CRDT foundation

- Create TypeScript and Vite configuration for SolidJS
- Implement Yjs document structure for screenplay collaboration
- Build WebSocket connection manager with exponential backoff reconnection
- Create CRDT document manager with undo/redo support
- Set up WebSocket sync server with JWT authentication
- Add SolidJS reactive bindings for Yjs shared types
- Build collaborative editor component
- Write unit tests for CRDT operations
- Document implementation in analysis/fre600_websocket_foundation.md

Architecture: Yjs chosen over Automerge for better ecosystem and
Tauri compatibility. WebSocket for sync, WebRTC for video.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-22 23:08:27 -04:00
parent 6cf6858b1c
commit ef1b15c9ea
22 changed files with 2851 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
/**
* Collaborative Editor Component
* SolidJS component for real-time screenplay editing
*/
import { Component, createEffect, onMount } from 'solid-js';
import { Doc, Text } from 'yjs';
import { useCollaborativeText } from './solid-bindings';
export interface CollaborativeEditorProps {
doc: Doc;
projectId: string;
userId: string;
className?: string;
onCollaboratorJoin?: (userId: string) => void;
onCollaboratorLeave?: (userId: string) => void;
}
export const CollaborativeEditor: Component<CollaborativeEditorProps> = (props) => {
const text = () => props.doc.getText('main');
const { text: textContent, handleChange } = useCollaborativeText(text());
let textareaRef: HTMLTextAreaElement | undefined;
onMount(() => {
// Initialize textarea with current document content
if (textareaRef) {
textareaRef.value = textContent();
}
// Listen for document changes
const observer = () => {
if (textareaRef) {
const cursorPos = textareaRef.selectionStart;
textareaRef.value = textContent();
// Restore cursor position
textareaRef.setSelectionRange(cursorPos, cursorPos);
}
};
text().observe(observer);
return () => {
text().unobserve(observer);
};
});
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
handleChange(target.value);
};
return (
<div class={props.className || 'collaborative-editor'}>
<textarea
ref={textareaRef}
onInput={handleInput}
class="screenplay-editor"
style={{
width: '100%',
height: '400px',
fontFamily: '"Courier New", Courier, monospace',
fontSize: '14px',
lineHeight: '1.5',
padding: '16px',
border: '1px solid #ccc',
borderRadius: '4px',
resize: 'vertical',
}}
/>
</div>
);
};
export default CollaborativeEditor;

View File

@@ -0,0 +1,203 @@
/**
* Unit tests for CRDT operations
* Tests Yjs document merging and conflict resolution
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc, Text } from 'yjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { createScreenplayDoc, getOrCreateSharedTypes } from '../lib/collaboration/yjs-document';
describe('CRDT Operations', () => {
describe('Document Creation', () => {
it('should create a new Yjs document with proper structure', () => {
const doc = createScreenplayDoc('project-1', {
title: 'Test Screenplay',
author: 'Test Author',
});
expect(doc).toBeDefined();
const sharedTypes = getOrCreateSharedTypes(doc);
expect(sharedTypes.text).toBeDefined();
expect(sharedTypes.metadata).toBeDefined();
expect(sharedTypes.characters).toBeDefined();
expect(sharedTypes.scenes).toBeDefined();
});
it('should initialize metadata with default values', () => {
const doc = createScreenplayDoc('project-1', {});
const metadata = doc.getMap('metadata').toJSON();
expect(metadata.projectId).toBe('project-1');
expect(metadata.title).toBe('Untitled Screenplay');
expect(metadata.version).toBe(1);
expect(metadata.createdAt).toBeDefined();
expect(metadata.updatedAt).toBeDefined();
});
});
describe('Text Synchronization', () => {
it('should sync text changes between two documents', () => {
const doc1 = new Doc();
const doc2 = new Doc();
const text1 = doc1.getText('main');
const text2 = doc2.getText('main');
// Insert text in doc1
text1.insert(0, 'Hello World');
// Encode and apply update to doc2
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('Hello World');
});
it('should handle concurrent insertions correctly', () => {
const doc1 = new Doc();
const doc2 = new Doc();
const text1 = doc1.getText('main');
const text2 = doc2.getText('main');
// Both documents start with same content
text1.insert(0, 'Hello');
const update1 = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update1);
// Concurrent inserts at different positions
text1.insert(5, ' World'); // Doc1 inserts at position 5
const update2 = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update2);
// Both should have the same final content
expect(text1.toString()).toBe('Hello World');
expect(text2.toString()).toBe('Hello');
});
it('should handle concurrent deletions correctly', () => {
const doc1 = new Doc();
const doc2 = new Doc();
const text1 = doc1.getText('main');
const text2 = doc2.getText('main');
// Both start with same content
text1.insert(0, 'Hello World');
const initialUpdate = encodeStateAsUpdate(doc1);
applyUpdate(doc2, initialUpdate);
// Concurrent deletions
text1.delete(0, 5); // Delete 'Hello'
const update1 = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update1);
text2.delete(0, 6); // Delete 'Hello ' (including space)
const update2 = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update2);
// Both should converge to similar state
expect(text1.toString()).toBe(text2.toString());
});
});
describe('Undo/Redo', () => {
it('should undo and redo text changes', () => {
const doc = new Doc();
const text = doc.getText('main');
const UndoManager = await import('yjs').then(m => m.UndoManager);
const undoManager = new UndoManager([text]);
// Initial insert
text.insert(0, 'Hello');
undoManager.capture();
// Second insert
text.insert(5, ' World');
undoManager.capture();
expect(text.toString()).toBe('Hello World');
// Undo
undoManager.undo();
expect(text.toString()).toBe('Hello');
// Redo
undoManager.redo();
expect(text.toString()).toBe('Hello World');
});
});
describe('Metadata Management', () => {
it('should update metadata correctly', () => {
const doc = createScreenplayDoc('project-1', {
title: 'Original Title',
author: 'Original Author',
});
const metadata = doc.getMap('metadata');
// Update title
metadata.set('title', 'Updated Title');
expect(metadata.get('title')).toBe('Updated Title');
expect(metadata.get('author')).toBe('Original Author');
});
it('should track version increments', () => {
const doc = createScreenplayDoc('project-1', {});
const metadata = doc.getMap('metadata');
const initialVersion = metadata.get('version');
// Simulate update
metadata.set('updatedAt', new Date().toISOString());
metadata.set('version', (metadata.get('version') || 0) + 1);
expect(metadata.get('version')).toBeGreaterThan(initialVersion || 0);
});
});
describe('Character References', () => {
it('should store and retrieve character references', () => {
const doc = createScreenplayDoc('project-1', {});
const characters = doc.getMap('characters');
const character = {
id: 'char-1',
name: 'John Doe',
shortName: 'John',
description: 'Protagonist',
};
characters.set('char-1', character);
const retrieved = characters.get('char-1');
expect(retrieved?.name).toBe('John Doe');
expect(retrieved?.shortName).toBe('John');
});
});
describe('Scene Metadata', () => {
it('should store scene metadata', () => {
const doc = createScreenplayDoc('project-1', {});
const scenes = doc.getMap('scenes');
const scene = {
id: 'scene-1',
slugline: 'INT. OFFICE - DAY',
startTime: 0,
duration: 150,
};
scenes.set('scene-1', scene);
const retrieved = scenes.get('scene-1');
expect(retrieved?.slugline).toBe('INT. OFFICE - DAY');
expect(retrieved?.duration).toBe(150);
});
});
});

View File

@@ -0,0 +1,137 @@
/**
* CRDT Document Manager
* Coordinates Yjs document lifecycle, persistence, and sync
*/
import { Doc, Text, Map as YMap, UndoManager } from 'yjs';
import { WebSocketConnection, WebSocketConnectionManager } from './websocket-connection';
import { createScreenplayDoc, getOrCreateSharedTypes, ScreenplayMetadata } from './yjs-document';
export interface CRDTDocumentManager {
initialize(projectId: string, serverUrl: string, authToken: string): Promise<Doc>;
getText(type: string): Text;
getMetadata(): ScreenplayMetadata;
getProvider(): any; // WebSocketProvider
applyRemoteUpdate(update: Uint8Array, origin: string): void;
createUndoStack(): UndoManager;
createRedoStack(): UndoManager;
destroy(): void;
}
export class CRDTDocument implements CRDTDocumentManager {
private doc: Doc | null = null;
private connection: WebSocketConnectionManager | null = null;
private undoManager: UndoManager | null = null;
private redoManager: UndoManager | null = null;
private projectId: string | null = null;
async initialize(
projectId: string,
serverUrl: string,
authToken: string
): Promise<Doc> {
this.projectId = projectId;
// Create Yjs document
this.doc = createScreenplayDoc(projectId, {});
// Initialize WebSocket connection
this.connection = new WebSocketConnection({
serverUrl,
documentName: `project-${projectId}`,
authToken,
reconnectInterval: 1000,
maxReconnectInterval: 30000,
});
// Connect to WebSocket server
await this.connection.connect();
// Get the provider to access the synced document
const provider = this.connection.getProvider();
// Sync local document with remote state
// Yjs WebSocketProvider handles this automatically on connect
// Initialize undo/redo managers
const sharedTypes = getOrCreateSharedTypes(this.doc);
this.undoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
this.redoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
return this.doc;
}
getText(type: string = 'main'): Text {
if (!this.doc) {
throw new Error('Document not initialized. Call initialize() first.');
}
return this.doc.getText(type);
}
getMetadata(): ScreenplayMetadata {
if (!this.doc) {
throw new Error('Document not initialized. Call initialize() first.');
}
const meta = this.doc.getMap<ScreenplayMetadata>('metadata');
return meta.toJSON() as ScreenplayMetadata;
}
getProvider(): any {
if (!this.connection) {
throw new Error('Connection not initialized. Call initialize() first.');
}
return this.connection.getProvider();
}
applyRemoteUpdate(update: Uint8Array, origin: string): void {
if (!this.doc) {
throw new Error('Document not initialized.');
}
// Apply the update to the document
// Yjs handles the CRDT merge automatically
this.doc.applyUpdate(update, origin);
}
createUndoStack(): UndoManager {
if (!this.undoManager) {
throw new Error('Document not initialized. Call initialize() first.');
}
return this.undoManager;
}
createRedoStack(): UndoManager {
if (!this.redoManager) {
throw new Error('Document not initialized. Call initialize() first.');
}
return this.redoManager;
}
destroy(): void {
if (this.undoManager) {
this.undoManager.destroy();
this.undoManager = null;
}
if (this.redoManager) {
this.redoManager.destroy();
this.redoManager = null;
}
if (this.connection) {
this.connection.disconnect();
this.connection = null;
}
if (this.doc) {
this.doc = null;
}
this.projectId = null;
}
}

View File

@@ -0,0 +1,154 @@
/**
* SolidJS bindings for Yjs
* Provides reactive primitives for collaborative editing
*/
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { Text, Map as YMap, Array as YArray, Doc, ObservableMapEvent, ObservableArrayEvent } from 'yjs';
/**
* Create a reactive binding to a Yjs Text instance
* Automatically syncs changes between local state and Yjs document
*/
export function useYText(yText: Text) {
const [text, setText] = createSignal(yText.toString());
const observer = (event: any) => {
setText(yText.toString());
};
yText.observe(observer);
onCleanup(() => {
yText.unobserve(observer);
});
const updateText = (newText: string) => {
yText.delete(0, yText.length);
yText.insert(0, newText);
};
return {
text,
updateText,
yText,
};
}
/**
* Create a reactive binding to a Yjs Map
*/
export function useYMap<T extends Record<string, any>>(yMap: YMap<T>) {
const [data, setData] = createSignal<T>(yMap.toJSON() as T);
const observer = (event: ObservableMapEvent) => {
setData(yMap.toJSON() as T);
};
yMap.observe(observer);
onCleanup(() => {
yMap.unobserve(observer);
});
const updateMap = (updates: Partial<T>) => {
Object.entries(updates).forEach(([key, value]) => {
yMap.set(key as keyof T, value);
});
};
const setValue = <K extends keyof T>(key: K, value: T[K]) => {
yMap.set(key, value);
};
const getValue = <K extends keyof T>(key: K): T[K] | undefined => {
return yMap.get(key);
};
return {
data,
updateMap,
setValue,
getValue,
yMap,
};
}
/**
* Create a reactive binding to a Yjs Array
*/
export function useYArray<T>(yArray: YArray<T>) {
const [items, setItems] = createSignal<T[]>(yArray.toArray());
const observer = (event: ObservableArrayEvent) => {
setItems(yArray.toArray());
};
yArray.observe(observer);
onCleanup(() => {
yArray.unobserve(observer);
});
const updateArray = (updates: T[]) => {
yArray.delete(0, yArray.length);
yArray.insert(0, updates);
};
const pushItem = (item: T) => {
yArray.push([item]);
};
const removeItem = (index: number) => {
yArray.delete(index);
};
return {
items,
updateArray,
pushItem,
removeItem,
yArray,
};
}
/**
* Create a collaborative editor binding
* Binds a Yjs Text instance to a SolidJS component
*/
export function useCollaborativeText(yText: Text) {
const { text, updateText } = useYText(yText);
const handleChange = (newText: string) => {
updateText(newText);
};
return {
text,
handleChange,
};
}
/**
* Create a reactive document binding
* Provides access to all shared types in a Yjs Doc
*/
export function useCollaborativeDoc(doc: Doc) {
const getText = createMemo(() => doc.getText('main'));
const getMetadata = createMemo(() => doc.getMap('metadata'));
const getCharacters = createMemo(() => doc.getMap('characters'));
const getScenes = createMemo(() => doc.getMap('scenes'));
const text = useYText(getText());
const metadata = useYMap(getMetadata());
const characters = useYMap(getCharacters());
const scenes = useYMap(getScenes());
return {
doc,
text,
metadata,
characters,
scenes,
};
}

View File

@@ -0,0 +1,162 @@
/**
* WebSocket connection manager for real-time collaboration
* Handles connection lifecycle, reconnection, and authentication
*/
import { WebSocketProvider } from 'y-websocket';
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
export interface WebSocketConnectionOptions {
serverUrl: string;
documentName: string;
authToken: string;
reconnectInterval?: number;
maxReconnectInterval?: number;
}
export interface WebSocketConnectionManager {
connect(): Promise<void>;
disconnect(): void;
getStatus(): ConnectionStatus;
getProvider(): WebSocketProvider;
onStatusChange(callback: (status: ConnectionStatus) => void): void;
removeStatusListener(callback: (status: ConnectionStatus) => void): void;
}
export class WebSocketConnection implements WebSocketConnectionManager {
private provider: WebSocketProvider | null = null;
private status: ConnectionStatus = 'disconnected';
private options: WebSocketConnectionOptions;
private statusListeners: Set<(status: ConnectionStatus) => void> = new Set();
private reconnectAttempts: number = 0;
private currentReconnectInterval: number;
constructor(options: WebSocketConnectionOptions) {
this.options = options;
this.currentReconnectInterval = options.reconnectInterval || 1000;
}
async connect(): Promise<void> {
if (this.provider) {
return;
}
this.updateStatus('connecting');
try {
this.provider = new WebSocketProvider(
this.options.serverUrl,
this.options.documentName,
{
connectOnLoad: true,
// Pass auth token via query params or headers
parameters: {
token: this.options.authToken,
},
}
);
// Listen for provider status changes
this.provider.on('status', (event: { status: string }) => {
const newStatus = event.status as ConnectionStatus;
this.updateStatus(newStatus);
if (newStatus === 'connected') {
this.reconnectAttempts = 0;
this.currentReconnectInterval = this.options.reconnectInterval || 1000;
}
});
// Wait for initial connection
if (this.provider.status === 'connected') {
this.updateStatus('connected');
} else {
// Wait for connection event
await new Promise<void>((resolve, reject) => {
const onConnect = () => {
this.provider?.off('status', onConnect);
resolve();
};
const onError = (error: Error) => {
this.provider?.off('status', onError);
reject(error);
};
this.provider.on('status', onConnect);
this.provider.on('status', onError);
// Timeout after 30 seconds
setTimeout(() => reject(new Error('Connection timeout')), 30000);
});
}
} catch (error) {
console.error('Failed to connect to WebSocket server:', error);
this.updateStatus('disconnected');
throw error;
}
}
disconnect(): void {
if (this.provider) {
this.provider.destroy();
this.provider = null;
this.updateStatus('disconnected');
}
}
getStatus(): ConnectionStatus {
return this.status;
}
getProvider(): WebSocketProvider {
if (!this.provider) {
throw new Error('WebSocket provider not initialized. Call connect() first.');
}
return this.provider;
}
onStatusChange(callback: (status: ConnectionStatus) => void): void {
this.statusListeners.add(callback);
}
removeStatusListener(callback: (status: ConnectionStatus) => void): void {
this.statusListeners.delete(callback);
}
private updateStatus(newStatus: ConnectionStatus): void {
const oldStatus = this.status;
this.status = newStatus;
console.log(`Connection status: ${oldStatus}${newStatus}`);
// Notify listeners
this.statusListeners.forEach(listener => listener(newStatus));
// Handle reconnection logic
if (newStatus === 'disconnected' && oldStatus === 'connected') {
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
const delay = Math.min(
this.currentReconnectInterval,
this.options.maxReconnectInterval || 30000
);
console.log(`Scheduling reconnection in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
setTimeout(async () => {
try {
this.updateStatus('reconnecting');
await this.connect();
} catch (error) {
console.error('Reconnection failed:', error);
this.reconnectAttempts++;
// Exponential backoff
this.currentReconnectInterval *= 2;
this.scheduleReconnect();
}
}, delay);
}
}

View File

@@ -0,0 +1,94 @@
/**
* Yjs document structure for screenplay collaboration
* Defines the shared types used in the CRDT document
*/
import { Doc, Text, Map as YMap, Array as YArray } from 'yjs';
/**
* Screenplay metadata stored in Yjs Map
*/
export interface ScreenplayMetadata {
title: string;
author: string;
projectId: string;
createdAt: string;
updatedAt: string;
version: number;
}
/**
* Character reference for the screenplay
*/
export interface CharacterRef {
id: string;
name: string;
shortName: string;
description?: string;
}
/**
* Scene metadata
*/
export interface SceneMeta {
id: string;
slugline: string;
startTime: number; // Position in document
duration: number;
}
/**
* Create a new Yjs document with the screenplay structure
*/
export function createScreenplayDoc(
projectId: string,
metadata: Partial<ScreenplayMetadata>
): Doc {
const doc = new Doc();
// Apply updates from remote clients
doc.on('update', (update: Uint8Array, origin: unknown) => {
// Origin can be 'local', 'remote', or a WebSocket provider
const isLocal = origin === 'local';
if (!isLocal) {
console.log(`Received update from ${origin}`);
}
});
// Initialize shared types
const text = doc.getText('main');
const meta = doc.getMap<ScreenplayMetadata>('metadata');
const characters = doc.getMap<CharacterRef>('characters');
const scenes = doc.getMap<SceneMeta>('scenes');
// Set default metadata
const defaultMeta: ScreenplayMetadata = {
title: metadata.title || 'Untitled Screenplay',
author: metadata.author || '',
projectId,
createdAt: metadata.createdAt || new Date().toISOString(),
updatedAt: metadata.updatedAt || new Date().toISOString(),
version: metadata.version || 1,
};
// Initialize metadata if empty
if (meta.toJSON().length === 0) {
Object.entries(defaultMeta).forEach(([key, value]) => {
meta.set(key as keyof ScreenplayMetadata, value);
});
}
return doc;
}
/**
* Get or create a shared type from the document
*/
export function getOrCreateSharedTypes(doc: Doc) {
return {
text: doc.getText('main'),
metadata: doc.getMap<ScreenplayMetadata>('metadata'),
characters: doc.getMap<CharacterRef>('characters'),
scenes: doc.getMap<SceneMeta>('scenes'),
};
}