RealBlocks / server /src /services /realtime.ts
SafeSight's picture
Add end-to-end logging for block_state_sync persistence
de7a2cd
Raw
History Blame Contribute Delete
48.9 kB
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../config';
import { getDatabase } from '../database';
import { encryptCombined, decryptCombined } from './encryption';
interface AuthPayload {
userId: string;
username: string;
email: string;
}
interface CollaborationSession {
userId: string;
username: string;
projectId: string;
socketId: string;
activeFileId: string | null;
cursor: { line: number; ch: number } | null;
selectedBlockId: string | null;
selectedElementId: string | null;
joinedAt: number;
permission: 'view' | 'edit' | 'admin';
}
const sessions = new Map<string, CollaborationSession>();
const projectRooms = new Map<string, Map<string, CollaborationSession[]>>();
// Server-authoritative block state per project per file
const projectBlockState = new Map<string, Map<string, { xml: string; updatedBy: string; updatedAt: number }>>();
// Collaborator pointer positions per project
const projectPointerPositions = new Map<string, Map<string, { x: number; y: number; username: string }>>();
// Collaborator block drag state per project
const projectBlockDrags = new Map<string, Map<string, { blockId: string; x: number; y: number }>>();
let io: Server;
// ─── Broadcast helper ───
function broadcastToRoom(projectId: string, event: string, data: any) {
const room = `project:${projectId}`;
io.to(room).emit(event, data);
}
// ─── Default project data (moved from routes/projects.ts) ───
function getDefaultProjectData(framework: string): any {
const base = {
files: [] as any[],
blocks: {} as Record<string, any>,
visual: {} as Record<string, any>,
blocksXml: {} as Record<string, string>,
blockCode: {} as Record<string, string>,
};
switch (framework) {
case 'web':
return {
...base,
files: [
{ id: 'index.html', name: 'index.html', type: 'html', content: '' },
{ id: 'styles.css', name: 'styles.css', type: 'css', content: '' },
{ id: 'app.js', name: 'app.js', type: 'js', content: '' },
],
};
case 'electron':
return {
...base,
files: [
{ id: 'main.ts', name: 'main.ts', type: 'typescript', content: '' },
{ id: 'preload.ts', name: 'preload.ts', type: 'typescript', content: '' },
{ id: 'index.html', name: 'index.html', type: 'html', content: '' },
{ id: 'styles.css', name: 'styles.css', type: 'css', content: '' },
{ id: 'renderer.ts', name: 'renderer.ts', type: 'typescript', content: '' },
{ id: 'package.json', name: 'package.json', type: 'json', content: '' },
],
};
case 'maui':
return {
...base,
files: [
{ id: 'MainPage.xaml', name: 'MainPage.xaml', type: 'xaml', content: '' },
{ id: 'MainPage.xaml.cs', name: 'MainPage.xaml.cs', type: 'csharp', content: '' },
{ id: 'App.xaml', name: 'App.xaml', type: 'xaml', content: '' },
{ id: 'App.xaml.cs', name: 'App.xaml.cs', type: 'csharp', content: '' },
{ id: 'MauiProgram.cs', name: 'MauiProgram.cs', type: 'csharp', content: '' },
{ id: 'Models', name: 'Models', type: 'folder', children: [] },
{ id: 'ViewModels', name: 'ViewModels', type: 'folder', children: [] },
{ id: 'Services', name: 'Services', type: 'folder', children: [] },
],
};
case 'nodejs':
return {
...base,
files: [
{ id: 'server.js', name: 'server.js', type: 'js', content: '' },
{ id: 'package.json', name: 'package.json', type: 'json', content: '' },
{ id: '.env', name: '.env', type: 'env', content: '' },
{ id: 'routes', name: 'routes', type: 'folder', children: [] },
{ id: 'models', name: 'models', type: 'folder', children: [] },
{ id: 'middleware', name: 'middleware', type: 'folder', children: [] },
],
};
default:
return base;
}
}
export function initRealtime(httpServer: HttpServer): Server {
io = new Server(httpServer, {
cors: {
origin: config.corsOrigin,
credentials: true,
methods: ['GET', 'POST'],
},
pingInterval: 25000,
pingTimeout: 20000,
});
// Auth middleware
io.use((socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const decoded = jwt.verify(token as string, config.jwtSecret) as { userId: string; email: string };
const db = getDatabase();
const user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(decoded.userId) as any;
if (!user) return next(new Error('User not found'));
(socket as any).user = { userId: user.id, username: user.username, email: user.email };
next();
} catch {
next(new Error('Invalid token'));
}
});
// ─── Dev-mode catch-all logger ───
if (config.nodeEnv === 'development') {
io.on('connection', (socket: Socket) => {
socket.onAny((event, ...args) => {
const payloadSize = JSON.stringify(args).length;
console.log(`[WS] ${event} from ${socket.id} (${payloadSize} bytes)`);
});
});
}
io.on('connection', (socket: Socket) => {
const user = (socket as any).user as AuthPayload;
// ═══════════════════════════════════════════════
// PROJECT ROOM JOINING
// ═══════════════════════════════════════════════
socket.on('join_project', ({ projectId }: { projectId: string }) => {
if (!projectId) return;
const db = getDatabase();
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
if (!project) return;
let permission: 'view' | 'edit' | 'admin' = 'view';
if (project.user_id === user.userId) {
permission = 'admin';
} else {
const collab = db.prepare(
'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
).get(projectId, user.userId) as any;
if (!collab) return;
permission = collab.permission;
}
socket.join(`project:${projectId}`);
const session: CollaborationSession = {
userId: user.userId,
username: user.username,
projectId,
socketId: socket.id,
activeFileId: null,
cursor: null,
selectedBlockId: null,
selectedElementId: null,
joinedAt: Date.now(),
permission,
};
sessions.set(socket.id, session);
if (!projectRooms.has(projectId)) {
projectRooms.set(projectId, new Map());
}
const room = projectRooms.get(projectId)!;
if (!room.has(user.userId)) {
room.set(user.userId, []);
}
room.get(user.userId)!.push(session);
socket.to(`project:${projectId}`).emit('collaborator_joined', {
userId: user.userId,
username: user.username,
permission,
joinedAt: session.joinedAt,
});
// Deduplicate: only send the most recent session per user
const collaboratorList: any[] = [];
for (const [uid, sessions] of room) {
const latest = sessions.reduce((a, b) => a.joinedAt > b.joinedAt ? a : b);
collaboratorList.push({
userId: uid,
username: latest.username,
permission: latest.permission,
activeFileId: latest.activeFileId,
cursor: latest.cursor,
selectedBlockId: latest.selectedBlockId,
selectedElementId: latest.selectedElementId,
joinedAt: latest.joinedAt,
});
}
socket.emit('collaborator_list', { collaborators: collaboratorList });
// Send initial block state for this project
const blockState = projectBlockState.get(projectId);
console.log('[server:join_project] Sending initialBlocksXml for project:', projectId, 'blockState size:', blockState?.size, 'entries:');
if (blockState) {
for (const [fid, entry] of blockState) {
console.log('[server:join_project] file:', fid, 'xmlLength:', entry.xml?.length, 'updatedBy:', entry.updatedBy);
}
socket.emit('block_state_synced', {
fileId: '',
xml: '',
updatedBy: '',
initialBlocksXml: Object.fromEntries(blockState),
});
}
});
// ═══════════════════════════════════════════════
// LEAVE PROJECT
// ═══════════════════════════════════════════════
socket.on('leave_project', ({ projectId }: { projectId: string }) => {
leaveProject(socket, projectId);
});
// ═══════════════════════════════════════════════
// FILE CHANGE
// ═══════════════════════════════════════════════
socket.on('file_changed', (data: { projectId: string; fileId: string; content: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
socket.to(`project:${data.projectId}`).emit('file_updated', {
fileId: data.fileId,
content: data.content,
updatedBy: user.userId,
updatedByUsername: user.username,
});
});
// ═══════════════════════════════════════════════
// ACTIVE FILE CHANGE
// ═══════════════════════════════════════════════
socket.on('active_file_changed', (data: { projectId: string; fileId: string | null }) => {
const session = sessions.get(socket.id);
if (session) {
session.activeFileId = data.fileId;
}
socket.to(`project:${data.projectId}`).emit('collaborator_active_file', {
userId: user.userId,
username: user.username,
fileId: data.fileId,
});
});
// ═══════════════════════════════════════════════
// CURSOR MOVE
// ═══════════════════════════════════════════════
socket.on('cursor_move', (data: { projectId: string; fileId: string; cursor: { line: number; ch: number } }) => {
const session = sessions.get(socket.id);
if (session) {
session.cursor = data.cursor;
session.activeFileId = data.fileId;
}
socket.to(`project:${data.projectId}`).emit('collaborator_cursor', {
userId: user.userId,
username: user.username,
fileId: data.fileId,
cursor: data.cursor,
});
});
// ═══════════════════════════════════════════════
// BLOCK SELECTION
// ═══════════════════════════════════════════════
socket.on('block_selection_changed', (data: { projectId: string; blockId: string | null }) => {
const session = sessions.get(socket.id);
if (session) {
session.selectedBlockId = data.blockId;
}
socket.to(`project:${data.projectId}`).emit('collaborator_block_selected', {
userId: user.userId,
username: user.username,
blockId: data.blockId,
});
});
// ═══════════════════════════════════════════════
// VISUAL ELEMENT HOVER
// ═══════════════════════════════════════════════
socket.on('visual_element_hover', (data: { projectId: string; elementId: string | null }) => {
const session = sessions.get(socket.id);
if (session) {
session.selectedElementId = data.elementId;
}
socket.to(`project:${data.projectId}`).emit('collaborator_element_hover', {
userId: user.userId,
username: user.username,
elementId: data.elementId,
});
});
// ═══════════════════════════════════════════════
// FILE ADDED
// ═══════════════════════════════════════════════
socket.on('file_added', (data: { projectId: string; file: any; parentId?: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
socket.to(`project:${data.projectId}`).emit('file_added', {
file: data.file,
parentId: data.parentId,
addedBy: user.username,
});
});
// ═══════════════════════════════════════════════
// FILE RENAMED
// ═══════════════════════════════════════════════
socket.on('file_renamed', (data: { projectId: string; fileId: string; name: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
socket.to(`project:${data.projectId}`).emit('file_renamed', {
fileId: data.fileId,
name: data.name,
renamedBy: user.username,
});
});
// ═══════════════════════════════════════════════
// FILE DELETED
// ═══════════════════════════════════════════════
socket.on('file_deleted', (data: { projectId: string; fileId: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
socket.to(`project:${data.projectId}`).emit('file_deleted', {
fileId: data.fileId,
deletedBy: user.username,
});
});
// ═══════════════════════════════════════════════
// VISUAL ELEMENT ADDED
// ═══════════════════════════════════════════════
socket.on('visual_element_added', (data: { projectId: string; element: any; parentId?: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'visual_element_added', {
element: data.element,
parentId: data.parentId,
});
});
// ═══════════════════════════════════════════════
// VISUAL ELEMENT UPDATED
// ═══════════════════════════════════════════════
socket.on('visual_element_updated', (data: { projectId: string; elementId: string; updates: any }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'visual_element_updated', {
elementId: data.elementId,
updates: data.updates,
});
});
// ═══════════════════════════════════════════════
// VISUAL ELEMENT REMOVED
// ═══════════════════════════════════════════════
socket.on('visual_element_removed', (data: { projectId: string; elementId: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'visual_element_removed', {
elementId: data.elementId,
});
});
// ═══════════════════════════════════════════════
// VISUAL ELEMENTS REORDERED
// ═══════════════════════════════════════════════
socket.on('visual_elements_reordered', (data: { projectId: string; elements: any[] }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'visual_elements_reordered', {
elements: data.elements,
});
});
// ═══════════════════════════════════════════════
// SAVE PROJECT
// ═══════════════════════════════════════════════
socket.on('save_project', (data: { projectId: string; data: any }, callback?: (response: any) => void) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') {
if (callback) callback({ error: 'Permission denied' });
return;
}
saveProjectData(session, data.projectId, data.data, callback);
});
// ═══════════════════════════════════════════════
// POINTER MOVE (server tracks + broadcasts)
// ═══════════════════════════════════════════════
socket.on('pointer_move', (data: { projectId: string; x: number; y: number }) => {
const session = sessions.get(socket.id);
if (!session) return;
// Store pointer position
if (!projectPointerPositions.has(data.projectId)) {
projectPointerPositions.set(data.projectId, new Map<string, { x: number; y: number; username: string }>());
}
projectPointerPositions.get(data.projectId)!.set(session.userId, {
x: data.x,
y: data.y,
username: session.username,
});
// Broadcast to room
socket.to(`project:${data.projectId}`).emit('collaborator_pointer_move', {
userId: session.userId,
username: session.username,
x: data.x,
y: data.y,
});
});
// ═══════════════════════════════════════════════
// BLOCK DRAG (server tracks + broadcasts)
// ═══════════════════════════════════════════════
socket.on('block_drag_start', (data: { projectId: string; blockId: string; x: number; y: number }) => {
const session = sessions.get(socket.id);
if (!session) return;
if (!projectBlockDrags.has(data.projectId)) {
projectBlockDrags.set(data.projectId, new Map());
}
projectBlockDrags.get(data.projectId)!.set(session.userId, {
blockId: data.blockId,
x: data.x,
y: data.y,
});
socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_start', {
userId: session.userId,
blockId: data.blockId,
x: data.x,
y: data.y,
});
});
socket.on('block_drag_move', (data: { projectId: string; blockId: string; x: number; y: number }) => {
const session = sessions.get(socket.id);
if (!session) return;
const drags = projectBlockDrags.get(data.projectId);
if (drags) {
drags.set(session.userId, { blockId: data.blockId, x: data.x, y: data.y });
}
socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_move', {
userId: session.userId,
blockId: data.blockId,
x: data.x,
y: data.y,
});
});
socket.on('block_drag_end', (data: { projectId: string; blockId: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
const drags = projectBlockDrags.get(data.projectId);
if (drags) {
drags.delete(session.userId);
}
socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_end', {
userId: session.userId,
blockId: data.blockId,
});
});
socket.on('block_connect', (data: { projectId: string; childId: string; parentId: string; inputName?: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('collaborator_block_connect', {
userId: session.userId,
childId: data.childId,
parentId: data.parentId,
inputName: data.inputName,
});
});
socket.on('block_change', (data: { projectId: string; blockId: string; name: string; value: any; element: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('collaborator_block_change', {
userId: session.userId,
blockId: data.blockId,
name: data.name,
value: data.value,
element: data.element,
});
});
socket.on('block_create_event', (data: { projectId: string; fileId: string; blockId: string; xml: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('collaborator_block_create_event', {
userId: session.userId,
fileId: data.fileId,
blockId: data.blockId,
xml: data.xml,
});
});
// ═══════════════════════════════════════════════
// BLOCK STATE SYNC (server-authoritative)
// ═══════════════════════════════════════════════
socket.on('block_state_sync', (data: { projectId: string; fileId: string; xml: string }, callback?: (response: any) => void) => {
const session = sessions.get(socket.id);
if (!session) {
console.log('[server:block_state_sync] No session for socket', socket.id);
if (callback) callback({ error: 'Not joined' });
return;
}
if (session.permission === 'view') {
console.log('[server:block_state_sync] Permission denied for user', session.userId);
if (callback) callback({ error: 'Permission denied' });
return;
}
console.log('[server:block_state_sync] Storing blocks for project:', data.projectId, 'file:', data.fileId, 'xmlLength:', data.xml?.length, 'user:', session.userId);
if (!projectBlockState.has(data.projectId)) {
projectBlockState.set(data.projectId, new Map());
}
projectBlockState.get(data.projectId)!.set(data.fileId, {
xml: data.xml,
updatedBy: session.userId,
updatedAt: Date.now(),
});
console.log('[server:block_state_sync] projectBlockState now has', projectBlockState.get(data.projectId)!.size, 'files');
// Broadcast to ALL other clients in the room
socket.to(`project:${data.projectId}`).emit('block_state_synced', {
fileId: data.fileId,
xml: data.xml,
updatedBy: session.userId,
});
if (callback) callback({ success: true });
});
socket.on('request_initial_block_state', (data: { projectId: string }, callback?: (response: any) => void) => {
const session = sessions.get(socket.id);
if (!session) {
if (callback) callback({ error: 'Not joined' });
return;
}
const state = projectBlockState.get(data.projectId);
if (callback) callback({ blocksXml: state ? Object.fromEntries(state) : {} });
});
// ═══════════════════════════════════════════════
// TEXT FIELD CURSOR (stub β€” Phase F)
// ═══════════════════════════════════════════════
socket.on('text_field_focus', (data: { projectId: string; fieldId: string; fileId: string; cursorPosition: number }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('text_field_focus', {
userId: session.userId,
username: session.username,
fieldId: data.fieldId,
fileId: data.fileId,
cursorPosition: data.cursorPosition,
active: true,
});
});
socket.on('text_field_cursor', (data: { projectId: string; fieldId: string; cursorPosition: number }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('text_field_cursor', {
userId: session.userId,
fieldId: data.fieldId,
cursorPosition: data.cursorPosition,
active: true,
});
});
socket.on('text_field_blur', (data: { projectId: string; fieldId: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
socket.to(`project:${data.projectId}`).emit('text_field_blur', {
userId: session.userId,
fieldId: data.fieldId,
});
});
// ═══════════════════════════════════════════════
// CSS RULES (stub β€” Phase E)
// ═══════════════════════════════════════════════
socket.on('css_rule_added', (data: { projectId: string; rule: any }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_rule_added', { rule: data.rule });
});
socket.on('css_rule_updated', (data: { projectId: string; ruleId: string; updates: any }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_rule_updated', { ruleId: data.ruleId, updates: data.updates });
});
socket.on('css_rule_removed', (data: { projectId: string; ruleId: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_rule_removed', { ruleId: data.ruleId });
});
socket.on('css_rule_reordered', (data: { projectId: string; ruleIds: string[] }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_rule_reordered', { ruleIds: data.ruleIds });
});
socket.on('css_prop_added', (data: { projectId: string; ruleId: string; property: any }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_prop_added', { ruleId: data.ruleId, property: data.property });
});
socket.on('css_prop_updated', (data: { projectId: string; ruleId: string; propertyId: string; updates: any }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_prop_updated', { ruleId: data.ruleId, propertyId: data.propertyId, updates: data.updates });
});
socket.on('css_prop_removed', (data: { projectId: string; ruleId: string; propertyId: string }) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') return;
broadcastToRoom(data.projectId, 'css_prop_removed', { ruleId: data.ruleId, propertyId: data.propertyId });
});
// ═══════════════════════════════════════════════
// ELEMENT REGISTRY / ID (stub β€” Phase B)
// ═══════════════════════════════════════════════
socket.on('element_registry_updated', (data: { projectId: string; registry: any }) => {
const session = sessions.get(socket.id);
if (!session) return;
broadcastToRoom(data.projectId, 'element_registry_updated', { registry: data.registry });
});
socket.on('element_id_changed', (data: { projectId: string; elementId: string; newId: string; fileId: string }) => {
const session = sessions.get(socket.id);
if (!session) return;
broadcastToRoom(data.projectId, 'element_id_changed', {
elementId: data.elementId,
newId: data.newId,
fileId: data.fileId,
});
});
// ═══════════════════════════════════════════════
// PROJECT CREATE (moved from REST)
// ═══════════════════════════════════════════════
socket.on('project_create', (data: { name: string; framework: string; description?: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const defaultData = getDefaultProjectData(data.framework);
const id = uuidv4();
const now = Date.now();
const combined = encryptCombined(JSON.stringify(defaultData));
db.prepare(`
INSERT INTO projects (id, user_id, name, framework, description, encrypted_data, encryption_iv, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, user.userId, data.name, data.framework, data.description || '', combined, '', now, now);
if (callback) {
callback({
project: {
id, name: data.name, framework: data.framework,
description: data.description || '', data: defaultData,
created_at: now, updated_at: now,
},
});
}
} catch (error: any) {
console.error('Create project error:', error);
if (callback) callback({ error: 'Internal server error' });
}
});
// ═══════════════════════════════════════════════
// PROJECT LIST (moved from REST)
// ═══════════════════════════════════════════════
socket.on('project_list', (_data: any, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const projects = db.prepare(
'SELECT id, name, framework, description, created_at, updated_at FROM projects WHERE user_id = ? ORDER BY updated_at DESC'
).all(user.userId);
if (callback) callback({ projects });
} catch (error: any) {
console.error('List projects error:', error);
if (callback) callback({ error: 'Internal server error' });
}
});
// ═══════════════════════════════════════════════
// PROJECT LOAD (moved from REST)
// ═══════════════════════════════════════════════
socket.on('project_load', (data: { projectId: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
let project = db.prepare(
'SELECT * FROM projects WHERE id = ? AND user_id = ?'
).get(data.projectId, user.userId) as any;
if (!project) {
const collab = db.prepare(
'SELECT p.* FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?'
).get(data.projectId, user.userId) as any;
if (!collab) {
if (callback) callback({ error: 'Project not found' });
return;
}
project = collab;
}
const decrypted = decryptCombined(project.encrypted_data);
const projectData = JSON.parse(decrypted);
if (callback) {
callback({
project: {
id: project.id, name: project.name,
framework: project.framework, description: project.description,
data: projectData,
created_at: project.created_at, updated_at: project.updated_at,
},
});
}
} catch (error: any) {
console.error('Load project error:', error);
if (callback) callback({ error: 'Failed to decrypt project data' });
}
});
// ═══════════════════════════════════════════════
// PROJECT DELETE (moved from REST)
// ═══════════════════════════════════════════════
socket.on('project_delete', (data: { projectId: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(data.projectId) as any;
if (!project) {
if (callback) callback({ error: 'Project not found' });
return;
}
if (project.user_id !== user.userId) {
if (callback) callback({ error: 'Only the owner can delete a project' });
return;
}
db.prepare('DELETE FROM projects WHERE id = ?').run(data.projectId);
// Kick all sockets from the project room
broadcastToRoom(data.projectId, 'project_deleted', { projectId: data.projectId });
if (callback) callback({ message: 'Project deleted successfully' });
} catch (error: any) {
console.error('Delete project error:', error);
if (callback) callback({ error: 'Internal server error' });
}
});
// ═══════════════════════════════════════════════
// PROJECT UPDATE
// ═══════════════════════════════════════════════
socket.on('project_update', (data: { projectId: string; updates: any }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
let project = db.prepare(
'SELECT * FROM projects WHERE id = ? AND user_id = ?'
).get(data.projectId, user.userId) as any;
let permission: string | null = 'admin';
if (!project) {
const collab = db.prepare(
"SELECT p.*, c.permission FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?"
).get(data.projectId, user.userId) as any;
if (!collab || collab.permission === 'view') {
if (callback) callback({ error: 'Permission denied' });
return;
}
project = collab;
permission = collab.permission;
}
const now = Date.now();
let encryptedData = project.encrypted_data;
let encryptedIv = project.encryption_iv;
if (data.updates.data) {
encryptedData = encryptCombined(JSON.stringify(data.updates.data));
encryptedIv = '';
}
db.prepare(`
UPDATE projects SET name = ?, description = ?, encrypted_data = ?, encryption_iv = ?, updated_at = ?
WHERE id = ?
`).run(
data.updates.name || project.name,
data.updates.description ?? project.description,
encryptedData, encryptedIv, now, data.projectId
);
if (callback) callback({ message: 'Project updated', updated_at: now });
} catch (error: any) {
console.error('Update project error:', error);
if (callback) callback({ error: 'Internal server error' });
}
});
// ═══════════════════════════════════════════════
// PROJECT SYNC (sync to /data storage)
// ═══════════════════════════════════════════════
socket.on('project_sync', async (data: { projectId: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const project = db.prepare(
'SELECT * FROM projects WHERE id = ? AND user_id = ?'
).get(data.projectId, user.userId) as any;
if (!project) {
if (callback) callback({ error: 'Project not found' });
return;
}
const { uploadToStorage } = await import('./storage');
const result = await uploadToStorage(
`projects/${user.userId}/${data.projectId}.enc`,
Buffer.from(project.encrypted_data)
);
if (result.success) {
if (callback) callback({ message: 'Project synced to storage' });
} else {
if (callback) callback({ error: 'Sync failed', details: result.message });
}
} catch (error: any) {
console.error('Sync project error:', error);
if (callback) callback({ error: 'Sync failed' });
}
});
// ═══════════════════════════════════════════════
// VERSION (stub β€” Phase G)
// ═══════════════════════════════════════════════
socket.on('save_version', (data: { projectId: string; diffs: any; message: string; parentId?: string; type?: string }, callback?: (response: any) => void) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') {
if (callback) callback({ error: 'Permission denied' });
return;
}
try {
const db = getDatabase();
const id = uuidv4();
const now = Date.now();
db.prepare(`
INSERT INTO versions (id, project_id, user_id, username, timestamp, parent_id, type, message, diffs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, data.projectId, session.userId, session.username, now, data.parentId || null, data.type || 'save', data.message || '', JSON.stringify(data.diffs));
broadcastToRoom(data.projectId, 'version_saved', {
id, projectId: data.projectId, userId: session.userId,
username: session.username, timestamp: now, parentId: data.parentId,
type: data.type, message: data.message,
});
if (callback) callback({ success: true, versionId: id });
} catch (error: any) {
console.error('Save version error:', error);
if (callback) callback({ error: 'Save failed' });
}
});
socket.on('get_versions', (data: { projectId: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const versions = db.prepare(
'SELECT id, project_id, user_id, username, timestamp, parent_id, type, message FROM versions WHERE project_id = ? ORDER BY timestamp ASC'
).all(data.projectId);
if (callback) callback({ versions });
} catch (error: any) {
console.error('Get versions error:', error);
if (callback) callback({ error: 'Internal server error' });
}
});
socket.on('get_version_snapshot', (data: { projectId: string; versionId: string }, callback?: (response: any) => void) => {
try {
const db = getDatabase();
const allVersions = db.prepare(
'SELECT * FROM versions WHERE project_id = ? ORDER BY timestamp ASC'
).all(data.projectId) as any[];
// Find the target version's index
const targetIdx = allVersions.findIndex((v: any) => v.id === data.versionId);
if (targetIdx === -1) {
if (callback) callback({ error: 'Version not found' });
return;
}
// Reconstruct by applying diffs from version 0 to target
const project = db.prepare('SELECT encrypted_data FROM projects WHERE id = ?').get(data.projectId) as any;
if (!project) {
if (callback) callback({ error: 'Project not found' });
return;
}
const decrypted = decryptCombined(project.encrypted_data);
let currentData = JSON.parse(decrypted);
for (let i = 0; i <= targetIdx; i++) {
const version = allVersions[i];
const diffs = JSON.parse(version.diffs);
currentData = applyDiffs(currentData, diffs);
}
if (callback) callback({ project: currentData });
} catch (error: any) {
console.error('Get version snapshot error:', error);
if (callback) callback({ error: 'Failed to reconstruct version' });
}
});
socket.on('restore_version', (data: { projectId: string; versionId: string }, callback?: (response: any) => void) => {
const session = sessions.get(socket.id);
if (!session || session.permission === 'view') {
if (callback) callback({ error: 'Permission denied' });
return;
}
try {
const db = getDatabase();
const allVersions = db.prepare(
'SELECT * FROM versions WHERE project_id = ? ORDER BY timestamp ASC'
).all(data.projectId) as any[];
const targetIdx = allVersions.findIndex((v: any) => v.id === data.versionId);
if (targetIdx === -1) {
if (callback) callback({ error: 'Version not found' });
return;
}
const project = db.prepare('SELECT encrypted_data FROM projects WHERE id = ?').get(data.projectId) as any;
let currentData = project ? JSON.parse(decryptCombined(project.encrypted_data)) : null;
for (let i = 0; i <= targetIdx; i++) {
const version = allVersions[i];
const diffs = JSON.parse(version.diffs);
currentData = applyDiffs(currentData, diffs);
}
// Overwrite project with restored data
const encryptedData = encryptCombined(JSON.stringify(currentData));
const now = Date.now();
db.prepare(
'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?'
).run(encryptedData, '', now, data.projectId);
// Record restore in versions
const restoreId = uuidv4();
const restoreDiffs = JSON.stringify([{ fileId: 'metadata', op: 'project_meta_change', newValue: { restoredFrom: data.versionId } }]);
db.prepare(`
INSERT INTO versions (id, project_id, user_id, username, timestamp, parent_id, type, message, diffs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(restoreId, data.projectId, session.userId, session.username, now, data.versionId, 'restore', `Restored version ${data.versionId}`, restoreDiffs);
// Broadcast new project data + version to room
broadcastToRoom(data.projectId, 'project_loaded', { project: currentData });
broadcastToRoom(data.projectId, 'version_saved', {
id: restoreId, projectId: data.projectId, userId: session.userId,
username: session.username, timestamp: now, parentId: data.versionId,
type: 'restore', message: `Restored version`,
});
if (callback) callback({ success: true });
} catch (error: any) {
console.error('Restore version error:', error);
if (callback) callback({ error: 'Restore failed' });
}
});
// ═══════════════════════════════════════════════
// DISCONNECT
// ═══════════════════════════════════════════════
socket.on('disconnect', () => {
const session = sessions.get(socket.id);
if (session) {
leaveProject(socket, session.projectId);
sessions.delete(socket.id);
}
});
});
return io;
}
// ─── Helpers ───
function leaveProject(socket: Socket, projectId: string) {
const room = projectRooms.get(projectId);
const userId = (socket as any).user?.userId;
let userHasRemainingSessions = false;
if (room) {
const userSessions = room.get(userId);
if (userSessions) {
const idx = userSessions.findIndex(s => s.socketId === socket.id);
if (idx !== -1) userSessions.splice(idx, 1);
userHasRemainingSessions = userSessions.length > 0;
if (!userHasRemainingSessions) room.delete(userId);
}
if (room.size === 0) projectRooms.delete(projectId);
}
socket.leave(`project:${projectId}`);
sessions.delete(socket.id);
// Only broadcast leave if user has no remaining sessions (other tabs still open)
if (!userHasRemainingSessions) {
const pointers = projectPointerPositions.get(projectId);
if (pointers) pointers.delete(userId);
const drags = projectBlockDrags.get(projectId);
if (drags) drags.delete(userId);
socket.to(`project:${projectId}`).emit('collaborator_left', { userId });
}
}
function saveProjectData(session: CollaborationSession, projectId: string, data: any, callback?: (response: any) => void) {
try {
const db = getDatabase();
const encryptedData = encryptCombined(JSON.stringify(data));
const now = Date.now();
db.prepare(
'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?'
).run(encryptedData, '', now, projectId);
io.to(`project:${projectId}`).emit('project_saved', {
updatedAt: now,
savedBy: session.username,
});
if (callback) callback({ success: true, updatedAt: now });
} catch (error: any) {
console.error('Save project error:', error);
if (callback) callback({ error: 'Save failed' });
}
}
function applyDiffs(currentData: any, diffs: any[]): any {
if (!diffs || !Array.isArray(diffs)) return currentData;
const data = JSON.parse(JSON.stringify(currentData)); // deep clone
for (const diff of diffs) {
switch (diff.op) {
case 'content_change': {
if (!data.files) break;
const file = data.files.find((f: any) => f.id === diff.fileId);
if (file) file.content = diff.newValue;
break;
}
case 'file_add': {
if (!data.files) data.files = [];
data.files.push({ id: diff.fileId, ...diff.newValue });
break;
}
case 'file_remove': {
if (!data.files) break;
data.files = data.files.filter((f: any) => f.id !== diff.fileId);
break;
}
case 'file_rename': {
if (!data.files) break;
const file = data.files.find((f: any) => f.id === diff.fileId);
if (file) {
file.name = diff.newValue;
file.id = diff.newValue;
}
break;
}
case 'project_meta_change': {
if (diff.newValue.name) data.name = diff.newValue.name;
if (diff.newValue.description) data.description = diff.newValue.description;
break;
}
}
}
return data;
}
export function getIO(): Server {
return io;
}