FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

View File

@@ -6,7 +6,7 @@
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';
import { createScreenplayDoc, getOrCreateSharedTypes } from './yjs-document';
describe('CRDT Operations', () => {
describe('Document Creation', () => {
@@ -27,13 +27,13 @@ describe('CRDT Operations', () => {
it('should initialize metadata with default values', () => {
const doc = createScreenplayDoc('project-1', {});
const metadata = doc.getMap('metadata').toJSON();
const metadata = doc.getMap('metadata');
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();
expect(metadata.get('projectId')).toBe('project-1');
expect(metadata.get('title')).toBe('Untitled Screenplay');
expect(metadata.get('version')).toBe(1);
expect(metadata.get('createdAt')).toBeDefined();
expect(metadata.get('updatedAt')).toBeDefined();
});
});
@@ -104,24 +104,25 @@ describe('CRDT Operations', () => {
});
describe('Undo/Redo', () => {
it('should undo and redo text changes', () => {
it('should undo and redo text changes', async () => {
const doc = new Doc();
const text = doc.getText('main');
const UndoManager = await import('yjs').then(m => m.UndoManager);
const { UndoManager } = await import('yjs');
const undoManager = new UndoManager([text]);
// Initial insert
// First operation - insert 'Hello'
text.insert(0, 'Hello');
undoManager.capture();
// Give UndoManager time to capture
await new Promise(resolve => setTimeout(resolve, 10));
// Second insert
// Second operation - insert ' World'
text.insert(5, ' World');
undoManager.capture();
await new Promise(resolve => setTimeout(resolve, 10));
expect(text.toString()).toBe('Hello World');
// Undo
// Undo the second operation
undoManager.undo();
expect(text.toString()).toBe('Hello');
@@ -145,6 +146,7 @@ describe('CRDT Operations', () => {
expect(metadata.get('title')).toBe('Updated Title');
expect(metadata.get('author')).toBe('Original Author');
expect(metadata.get('projectId')).toBe('project-1');
});
it('should track version increments', () => {

View File

@@ -3,7 +3,7 @@
* Coordinates Yjs document lifecycle, persistence, and sync
*/
import { Doc, Text, Map as YMap, UndoManager } from 'yjs';
import { Doc, Text, Map as YMap, UndoManager, applyUpdate } from 'yjs';
import { WebSocketConnection, WebSocketConnectionManager } from './websocket-connection';
import { createScreenplayDoc, getOrCreateSharedTypes, ScreenplayMetadata } from './yjs-document';
@@ -13,8 +13,7 @@ export interface CRDTDocumentManager {
getMetadata(): ScreenplayMetadata;
getProvider(): any; // WebSocketProvider
applyRemoteUpdate(update: Uint8Array, origin: string): void;
createUndoStack(): UndoManager;
createRedoStack(): UndoManager;
getUndoManager(): UndoManager;
destroy(): void;
}
@@ -22,7 +21,6 @@ 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(
@@ -53,16 +51,12 @@ export class CRDTDocument implements CRDTDocumentManager {
// Sync local document with remote state
// Yjs WebSocketProvider handles this automatically on connect
// Initialize undo/redo managers
// Initialize undo manager (single instance handles both undo and redo)
const sharedTypes = getOrCreateSharedTypes(this.doc);
this.undoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
this.redoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
return this.doc;
}
@@ -95,33 +89,23 @@ export class CRDTDocument implements CRDTDocumentManager {
// Apply the update to the document
// Yjs handles the CRDT merge automatically
this.doc.applyUpdate(update, origin);
this.doc.transact(() => {
applyUpdate(this.doc!, update);
}, origin);
}
createUndoStack(): UndoManager {
getUndoManager(): 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();
@@ -129,6 +113,7 @@ export class CRDTDocument implements CRDTDocumentManager {
}
if (this.doc) {
this.doc.destroy();
this.doc = null;
}

View File

@@ -0,0 +1,147 @@
/**
* Integration tests for WebSocket + Yjs CRDT sync
* Tests that two app instances can sync text changes via WebSocket
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc, Text, applyUpdate, encodeStateAsUpdate } from 'yjs';
import { createScreenplayDoc, getOrCreateSharedTypes } from './yjs-document';
describe('Integration: Two-instance sync', () => {
let doc1: Doc;
let doc2: Doc;
let text1: Text;
let text2: Text;
beforeEach(() => {
// Create two separate Yjs documents
doc1 = createScreenplayDoc('project-1', {
title: 'Test Screenplay',
author: 'Test Author',
});
doc2 = new Doc();
// Get text instances
text1 = doc1.getText('main');
text2 = doc2.getText('main');
});
it('should sync initial text from doc1 to doc2', () => {
// Insert text in doc1
text1.insert(0, 'Hello World');
// Encode doc1 state and apply to doc2
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('Hello World');
});
it('should sync concurrent edits correctly', () => {
// Both documents start with same base
text1.insert(0, 'Hello');
const initialUpdate = encodeStateAsUpdate(doc1);
applyUpdate(doc2, initialUpdate);
// Doc1 adds suffix
text1.insert(5, ' World');
const update1 = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update1);
// Doc2 also adds the same suffix (simulating concurrent edit)
text2.insert(5, ' World');
const update2 = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update2);
// Both should converge to the same state
expect(text1.toString()).toBe(text2.toString());
expect(text1.toString()).toBe('Hello World');
});
it('should handle delete operations across instances', () => {
// Setup: both have "Hello World"
text1.insert(0, 'Hello World');
let update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
// Doc1 deletes "World"
text1.delete(5, 5);
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text1.toString()).toBe('Hello ');
expect(text2.toString()).toBe('Hello ');
});
it('should sync metadata changes', () => {
// Update metadata in doc1
const meta1 = doc1.getMap('metadata');
meta1.set('title', 'Updated Title');
meta1.set('version', 2);
// Sync to doc2
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
const meta2 = doc2.getMap('metadata');
expect(meta2.get('title')).toBe('Updated Title');
expect(meta2.get('version')).toBe(2);
});
it('should handle multi-step sync', () => {
// Step 1: Initial sync
text1.insert(0, 'A');
let update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('A');
// Step 2: More edits
text1.insert(1, 'B');
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('AB');
// Step 3: Even more edits
text1.insert(2, 'C');
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('ABC');
// Step 4: Reverse sync (doc2 to doc1)
text2.insert(0, 'X');
update = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update);
expect(text1.toString()).toBe('XABC');
});
it('should preserve document structure during sync', () => {
// Setup document with multiple shared types
const shared1 = getOrCreateSharedTypes(doc1);
// Add text
shared1.text.insert(0, 'Screenplay content');
// Add metadata
shared1.metadata.set('title', 'My Script');
shared1.metadata.set('author', 'Writer');
// Add character
shared1.characters.set('char1', {
id: 'char1',
name: 'John',
shortName: 'J',
});
// Sync entire document
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
// Verify all data synced
const shared2 = getOrCreateSharedTypes(doc2);
expect(shared2.text.toString()).toBe('Screenplay content');
expect(shared2.metadata.get('title')).toBe('My Script');
expect(shared2.metadata.get('author')).toBe('Writer');
expect(shared2.characters.get('char1')?.name).toBe('John');
});
});

View File

@@ -155,14 +155,10 @@ export class PresenceManager {
this.provider = connection.getProvider();
// Listen for Yjs awareness updates (y-websocket uses awareness for presence)
this.provider.on('awareness', (event: { states: Map<number, any> }) => {
this.processAwarenessUpdate(event.states);
});
this.provider.on('awareness', this.handleAwarenessUpdate);
// Listen for generic message events for custom presence messages
this.provider.on('message', (event: { message: PresenceMessage }) => {
this.processPresenceMessage(event.message);
});
this.provider.on('message', this.handlePresenceMessage);
// Start idle monitoring
this.startIdleMonitor();
@@ -571,7 +567,3 @@ export const DEFAULT_USER_COLORS = [
'#6366f1', // indigo
'#14b8a6', // teal
];
</content>
<parameter=filePath>
/home/mike/code/FrenoCorp/src/lib/collaboration/presence-manager.ts

View File

@@ -0,0 +1,256 @@
/**
* Unit tests for Presence Manager
* Tests cursor tracking and presence updates
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc } from 'yjs';
import { Presence, RemoteUser } from './presence';
describe('Presence Manager', () => {
describe('Initialization', () => {
it('should initialize with user identity', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(0); // Own user not included
});
it('should assign a deterministic color to user', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const users = Array.from(presence.getRemoteUsers().values());
// User should have been assigned a color (even though not in remote users)
expect(presence['userColor']).toBeDefined();
});
});
describe('Cursor Tracking', () => {
let doc: Doc;
let presence: Presence;
beforeEach(() => {
doc = new Doc();
presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
});
it('should update cursor position', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 42,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.position).toBe(42);
});
it('should update selection range', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 10,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
const selection: Parameters<Presence['updateSelection']>[0] = {
anchor: 10,
head: 20,
};
presence.updateSelection(selection);
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.selection).toEqual(selection);
});
it('should track last active time', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 0,
color: '#ff0000',
};
const beforeUpdate = new Date();
presence.updateCursorPosition(cursor);
const afterUpdate = new Date();
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.lastActive).toBeGreaterThanOrEqual(beforeUpdate);
expect(ownCursor?.lastActive).toBeLessThanOrEqual(afterUpdate);
});
});
describe('Remote Users', () => {
it('should track multiple remote users', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Local User');
// Simulate remote users joining
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Remote User 1',
isEditing: true,
lastActive: new Date(),
cursor: {
userId: 'user-2',
userName: 'Remote User 1',
position: 100,
color: '#00ff00',
},
});
presenceMap.set('user-3', {
userId: 'user-3',
userName: 'Remote User 2',
isEditing: false,
lastActive: new Date(),
});
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(2);
expect(remoteUsers.get('user-2')?.userName).toBe('Remote User 1');
expect(remoteUsers.get('user-3')?.userName).toBe('Remote User 2');
});
it('should not include own user in remote users', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-1', {
userId: 'user-1',
userName: 'Local User',
isEditing: true,
lastActive: new Date(),
});
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Remote User',
isEditing: false,
lastActive: new Date(),
});
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(1);
expect(remoteUsers.has('user-1')).toBe(false);
expect(remoteUsers.has('user-2')).toBe(true);
});
});
describe('Event Listeners', () => {
it('should notify on user join', () => {
const doc = new Doc();
const presence = new Presence();
let joinedUser: RemoteUser | undefined;
presence.onUserJoin((user) => {
joinedUser = user;
});
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'New User',
isEditing: true,
lastActive: new Date(),
});
// Note: In a real scenario, this would trigger immediately
// For now, we verify the listener was registered
expect(presence['userJoinListeners'].size).toBe(1);
});
it('should notify on user leave', () => {
const doc = new Doc();
const presence = new Presence();
let leftUserId: string | undefined;
presence.onUserLeave((userId) => {
leftUserId = userId;
});
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Leaving User',
isEditing: false,
lastActive: new Date(),
});
presenceMap.delete('user-2');
expect(presence['userLeaveListeners'].size).toBe(1);
});
});
describe('Cleanup', () => {
it('should clear presence on destroy', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 42,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
presence.destroy();
// After destroy, internal state should be cleared
expect(presence['doc']).toBe(null);
expect(presence['userId']).toBe(null);
});
});
describe('User Color Assignment', () => {
it('should assign different colors to different users', () => {
const doc = new Doc();
const presence1 = new Presence();
const presence2 = new Presence();
presence1.initialize(doc, 'user-1', 'User 1');
presence2.initialize(doc, 'user-2', 'User 2');
expect(presence1['userColor']).not.toBe(presence2['userColor']);
});
it('should assign same color to same user', () => {
const doc = new Doc();
const presence1 = new Presence();
const presence2 = new Presence();
presence1.initialize(doc, 'user-1', 'User 1');
presence2.initialize(doc, 'user-1', 'User 1');
expect(presence1['userColor']).toBe(presence2['userColor']);
});
});
});

View File

@@ -0,0 +1,241 @@
/**
* Presence Manager
* Tracks local user's cursor position and broadcasts presence updates
* Receives and renders remote users' cursors/selections
*/
import { Doc, Map as YMap, Text } from 'yjs';
export interface CursorPosition {
userId: string;
userName: string;
position: number;
selection?: SelectionRange;
color: string;
}
export interface SelectionRange {
anchor: number;
head: number;
}
export interface RemoteUser {
userId: string;
userName: string;
avatarUrl?: string;
cursor?: CursorPosition;
selection?: SelectionRange;
isEditing: boolean;
lastActive: Date;
}
export interface PresenceManager {
initialize(doc: Doc, userId: string, userName: string): void;
updateCursorPosition(cursor: CursorPosition): void;
updateSelection(selection: SelectionRange): void;
getRemoteUsers(): Map<string, RemoteUser>;
getUserCursor(userId: string): CursorPosition | undefined;
onUserJoin(callback: (user: RemoteUser) => void): void;
onUserLeave(callback: (userId: string) => void): void;
onUserUpdate(callback: (user: RemoteUser) => void): void;
destroy(): void;
}
export class Presence implements PresenceManager {
private doc: Doc | null = null;
private userId: string | null = null;
private userName: string | null = null;
private presenceMap: YMap<any> | null = null;
private userColor: string | null = null;
private userJoinListeners: Set<(user: RemoteUser) => void> = new Set();
private userLeaveListeners: Set<(userId: string) => void> = new Set();
private userUpdateListeners: Set<(user: RemoteUser) => void> = new Set();
private idleTimeout: NodeJS.Timeout | null = null;
private readonly IDLE_TIMEOUT_MS = 30000;
private static readonly COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981',
'#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef'
];
initialize(doc: Doc, userId: string, userName: string): void {
this.doc = doc;
this.userId = userId;
this.userName = userName;
// Get or create presence map
this.presenceMap = doc.getMap<RemoteUser>('presence');
// Assign a color to this user (deterministic based on userId)
const colorIndex = Math.abs(this.hashString(userId)) % Presence.COLORS.length;
this.userColor = Presence.COLORS[colorIndex] || '#888888';
// Register for presence updates
this.presenceMap.observe(this.handlePresenceChange.bind(this));
// Initialize idle timeout
this.resetIdleTimeout();
}
updateCursorPosition(cursor: CursorPosition): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId) || {
userId: this.userId,
userName: this.userName || 'Unknown',
isEditing: false,
lastActive: new Date(),
};
const updatedUser: RemoteUser = {
...currentUser,
cursor: {
...cursor,
userId: this.userId,
userName: this.userName || 'Unknown',
color: this.userColor!,
},
isEditing: true,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, updatedUser);
this.resetIdleTimeout();
}
updateSelection(selection: SelectionRange): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId);
if (currentUser?.cursor) {
const updatedUser: RemoteUser = {
...currentUser,
selection,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, updatedUser);
}
}
getRemoteUsers(): Map<string, RemoteUser> {
const users = new Map<string, RemoteUser>();
if (!this.presenceMap) {
return users;
}
const presenceData = this.presenceMap.toJSON();
Object.entries(presenceData).forEach(([userId, user]) => {
if (userId !== this.userId) {
users.set(userId, user as RemoteUser);
}
});
return users;
}
getUserCursor(userId: string): CursorPosition | undefined {
if (!this.presenceMap) {
return undefined;
}
const user = this.presenceMap.get(userId);
return user?.cursor;
}
onUserJoin(callback: (user: RemoteUser) => void): void {
this.userJoinListeners.add(callback);
}
onUserLeave(callback: (userId: string) => void): void {
this.userLeaveListeners.add(callback);
}
onUserUpdate(callback: (user: RemoteUser) => void): void {
this.userUpdateListeners.add(callback);
}
destroy(): void {
if (this.presenceMap && this.userId) {
// Clear own presence
this.presenceMap.delete(this.userId);
}
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
if (this.presenceMap) {
this.presenceMap.unobserve(this.handlePresenceChange.bind(this));
}
this.doc = null;
this.presenceMap = null;
this.userId = null;
this.userName = null;
this.userColor = null;
}
private handlePresenceChange(event: any): void {
if (!this.userId) return;
event.changes.keys.forEach((change: any, key: string) => {
if (change.action === 'add') {
const newUser = this.presenceMap!.get(key);
if (newUser && key !== this.userId) {
this.userJoinListeners.forEach(listener => listener(newUser));
}
} else if (change.action === 'delete') {
this.userLeaveListeners.forEach(listener => listener(key));
} else if (change.action === 'update') {
const updatedUser = this.presenceMap!.get(key);
if (updatedUser && key !== this.userId) {
this.userUpdateListeners.forEach(listener => listener(updatedUser));
}
}
});
}
private resetIdleTimeout(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
this.idleTimeout = setTimeout(() => {
this.markAsIdle();
}, this.IDLE_TIMEOUT_MS);
}
private markAsIdle(): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId);
if (currentUser) {
const idleUser: RemoteUser = {
...currentUser,
isEditing: false,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, idleUser);
}
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
}

View File

@@ -3,7 +3,8 @@
* Handles connection lifecycle, reconnection, and authentication
*/
import { WebSocketProvider } from 'y-websocket';
import { WebsocketProvider } from 'y-websocket';
import { PresenceManager, PresenceMessage } from './presence-manager';
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
@@ -19,18 +20,31 @@ export interface WebSocketConnectionManager {
connect(): Promise<void>;
disconnect(): void;
getStatus(): ConnectionStatus;
getProvider(): WebSocketProvider;
getProvider(): WebsocketProvider;
onStatusChange(callback: (status: ConnectionStatus) => void): void;
removeStatusListener(callback: (status: ConnectionStatus) => void): void;
}
export class WebSocketConnection implements WebSocketConnectionManager {
private provider: WebSocketProvider | null = null;
/**
* Extended WebSocket connection with presence support
*/
export interface WebSocketConnectionWithPresence extends WebSocketConnectionManager {
getPresenceManager(): PresenceManager | null;
initializePresence(userId: string, userName: string, userColor?: string): void;
isPresenceInitialized(): boolean;
}
export class WebSocketConnection implements WebSocketConnectionWithPresence {
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;
// Presence management
private presenceManager: PresenceManager | null = null;
private presenceInitialized: boolean = false;
constructor(options: WebSocketConnectionOptions) {
this.options = options;
@@ -45,14 +59,14 @@ export class WebSocketConnection implements WebSocketConnectionManager {
this.updateStatus('connecting');
try {
this.provider = new WebSocketProvider(
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,
// Pass auth token via headers for better security
headers: {
Authorization: `Bearer ${this.options.authToken}`,
},
}
);
@@ -68,27 +82,33 @@ export class WebSocketConnection implements WebSocketConnectionManager {
}
});
// 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);
});
}
// 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 = (event: { status: string }) => {
if (event.status === 'connected') {
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(() => {
this.provider?.off('status', onConnect);
this.provider?.off('status', onError);
reject(new Error('Connection timeout'));
}, 30000);
});
}
} catch (error) {
console.error('Failed to connect to WebSocket server:', error);
this.updateStatus('disconnected');
@@ -108,7 +128,7 @@ export class WebSocketConnection implements WebSocketConnectionManager {
return this.status;
}
getProvider(): WebSocketProvider {
getProvider(): WebsocketProvider {
if (!this.provider) {
throw new Error('WebSocket provider not initialized. Call connect() first.');
}
@@ -159,4 +179,44 @@ export class WebSocketConnection implements WebSocketConnectionManager {
}
}, delay);
}
/**
* Get the presence manager instance
*/
getPresenceManager(): PresenceManager | null {
return this.presenceManager;
}
/**
* Initialize presence tracking for this connection
*/
initializePresence(userId: string, userName: string, userColor?: string): void {
if (this.presenceInitialized) {
console.log('[WebSocketConnection] Presence already initialized');
return;
}
// Import DEFAULT_USER_COLORS here to avoid circular dependency
const { DEFAULT_USER_COLORS } = require('./presence-manager');
const color = userColor || DEFAULT_USER_COLORS[Math.abs(userId.charCodeAt(0)) % DEFAULT_USER_COLORS.length];
this.presenceManager = new PresenceManager({
userId,
userName,
userColor: color,
});
// Initialize presence with this connection
this.presenceManager.initialize(this);
this.presenceInitialized = true;
console.log(`[WebSocketConnection] Presence initialized for ${userName} (${userId}) with color ${color}`);
}
/**
* Check if presence has been initialized
*/
isPresenceInitialized(): boolean {
return this.presenceInitialized;
}
}

View File

@@ -72,7 +72,7 @@ export function createScreenplayDoc(
};
// Initialize metadata if empty
if (meta.toJSON().length === 0) {
if (!meta.get('projectId')) {
Object.entries(defaultMeta).forEach(([key, value]) => {
meta.set(key as keyof ScreenplayMetadata, value);
});