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,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;
}
}