feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
No files matched your search
+1
-77
@@ -602,31 +602,6 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async getDocumentCRDT(id: string): Promise<{ docId: string; state?: string; updates?: string[] }> {
|
||||
return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`);
|
||||
},
|
||||
|
||||
async saveDocumentCRDT(id: string, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> {
|
||||
const body = typeof payload === 'string' ? { state: payload } : payload;
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async clearDocumentAwareness(id: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
||||
method: 'POST',
|
||||
@@ -675,44 +650,6 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
|
||||
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
|
||||
},
|
||||
|
||||
async lockDocument(
|
||||
docId: string
|
||||
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
|
||||
try {
|
||||
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
|
||||
`/api/v1/documents/${docId}/lock`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
} catch (e: any) {
|
||||
return { locked: true, acquired: false, message: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
|
||||
try {
|
||||
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
return { extended: false };
|
||||
}
|
||||
},
|
||||
|
||||
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
|
||||
try {
|
||||
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
return { unlocked: false };
|
||||
}
|
||||
},
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
@@ -1398,23 +1335,10 @@ export function sendWebSocketMessage(msg: any): void {
|
||||
// SSE Fallback: dispatch via REST endpoints
|
||||
try {
|
||||
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
|
||||
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
|
||||
api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
|
||||
api.clearDocumentAwareness(parsed.docId).catch(() => {});
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {});
|
||||
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
||||
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
|
||||
api.lockDocument(parsed.docId).catch(() => {});
|
||||
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
|
||||
api.unlockDocument(parsed.docId).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error dispatching fallback REST message for SSE:', err);
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { BlockType, DocumentBlock } from './types';
|
||||
|
||||
// Convert Uint8Array to Base64
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// Convert Base64 to Uint8Array
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const COLLAB_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Emerald
|
||||
'#F59E0B', // Amber
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#F97316', // Orange
|
||||
'#14B8A6', // Teal
|
||||
];
|
||||
|
||||
export function getCollaboratorColor(userId: string): string {
|
||||
if (!userId) return COLLAB_COLORS[0];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const idx = Math.abs(hash) % COLLAB_COLORS.length;
|
||||
return COLLAB_COLORS[idx];
|
||||
}
|
||||
|
||||
export interface CollaboratorAwareness {
|
||||
userId: string;
|
||||
userName: string;
|
||||
activeBlockId: string | null;
|
||||
color: string;
|
||||
lastActive: number;
|
||||
}
|
||||
|
||||
export class DocumentCRDTSession {
|
||||
public docId: string;
|
||||
public doc: Y.Doc;
|
||||
private order: Y.Array<string>;
|
||||
private blocks: Y.Map<Y.Map<any>>;
|
||||
private destroyed: boolean = false;
|
||||
|
||||
public onLocalUpdate?: (base64Update: string) => void;
|
||||
public onRemoteChange?: (blocks: DocumentBlock[]) => void;
|
||||
|
||||
constructor(docId: string) {
|
||||
this.docId = docId;
|
||||
this.doc = new Y.Doc({ guid: docId });
|
||||
this.order = this.doc.getArray<string>('blockOrder');
|
||||
this.blocks = this.doc.getMap<Y.Map<any>>('blocks');
|
||||
|
||||
this.doc.on('update', (update: Uint8Array, origin: any) => {
|
||||
if (this.destroyed) return;
|
||||
if (origin !== 'remote') {
|
||||
const b64 = uint8ArrayToBase64(update);
|
||||
if (this.onLocalUpdate) {
|
||||
this.onLocalUpdate(b64);
|
||||
}
|
||||
} else {
|
||||
if (this.onRemoteChange) {
|
||||
this.onRemoteChange(this.getBlocks());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.doc.destroy();
|
||||
}
|
||||
|
||||
public getBlocks(): DocumentBlock[] {
|
||||
const orderList = this.order.toArray();
|
||||
const result: DocumentBlock[] = [];
|
||||
for (const id of orderList) {
|
||||
const bMap = this.blocks.get(id);
|
||||
if (bMap) {
|
||||
result.push({
|
||||
id,
|
||||
docId: this.docId,
|
||||
type: (bMap.get('type') || 'bullet') as BlockType,
|
||||
content: bMap.get('content') || '',
|
||||
level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0,
|
||||
checked: !!bMap.get('checked'),
|
||||
collapsed: !!bMap.get('collapsed'),
|
||||
lang: bMap.get('lang') || '',
|
||||
sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0,
|
||||
updatedAt: bMap.get('updatedAt') || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public initFromBlocks(initialBlocks: DocumentBlock[]): void {
|
||||
this.doc.transact(() => {
|
||||
if (this.order.length === 0 && initialBlocks.length > 0) {
|
||||
const ids: string[] = [];
|
||||
for (const b of initialBlocks) {
|
||||
ids.push(b.id);
|
||||
const bMap = new Y.Map<any>();
|
||||
bMap.set('id', b.id);
|
||||
bMap.set('type', b.type || 'bullet');
|
||||
bMap.set('content', b.content || '');
|
||||
bMap.set('level', b.level || 0);
|
||||
bMap.set('checked', !!b.checked);
|
||||
bMap.set('collapsed', !!b.collapsed);
|
||||
bMap.set('lang', b.lang || '');
|
||||
bMap.set('sortOrder', b.sortOrder || 0);
|
||||
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
||||
this.blocks.set(b.id, bMap);
|
||||
}
|
||||
this.order.push(ids);
|
||||
}
|
||||
}, 'init');
|
||||
}
|
||||
|
||||
public updateBlock(id: string, updates: Partial<DocumentBlock>): void {
|
||||
this.doc.transact(() => {
|
||||
const bMap = this.blocks.get(id);
|
||||
if (bMap) {
|
||||
if (updates.type !== undefined) bMap.set('type', updates.type);
|
||||
if (updates.content !== undefined) bMap.set('content', updates.content);
|
||||
if (updates.level !== undefined) bMap.set('level', updates.level);
|
||||
if (updates.checked !== undefined) bMap.set('checked', updates.checked);
|
||||
if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed);
|
||||
if (updates.lang !== undefined) bMap.set('lang', updates.lang);
|
||||
if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder);
|
||||
bMap.set('updatedAt', new Date().toISOString());
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public insertBlock(index: number, block: DocumentBlock): void {
|
||||
this.doc.transact(() => {
|
||||
const bMap = new Y.Map<any>();
|
||||
bMap.set('id', block.id);
|
||||
bMap.set('type', block.type || 'bullet');
|
||||
bMap.set('content', block.content || '');
|
||||
bMap.set('level', block.level || 0);
|
||||
bMap.set('checked', !!block.checked);
|
||||
bMap.set('collapsed', !!block.collapsed);
|
||||
bMap.set('lang', block.lang || '');
|
||||
bMap.set('sortOrder', block.sortOrder || 0);
|
||||
bMap.set('updatedAt', new Date().toISOString());
|
||||
this.blocks.set(block.id, bMap);
|
||||
|
||||
if (index >= 0 && index <= this.order.length) {
|
||||
this.order.insert(index, [block.id]);
|
||||
} else {
|
||||
this.order.push([block.id]);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public deleteBlock(id: string): void {
|
||||
this.doc.transact(() => {
|
||||
const currentOrder = this.order.toArray();
|
||||
const idx = currentOrder.indexOf(id);
|
||||
if (idx !== -1) {
|
||||
this.order.delete(idx, 1);
|
||||
}
|
||||
this.blocks.delete(id);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public deleteMultipleBlocks(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const toDeleteSet = new Set(ids);
|
||||
this.doc.transact(() => {
|
||||
const currentOrder = this.order.toArray();
|
||||
// Delete from order backwards to preserve indices
|
||||
for (let i = currentOrder.length - 1; i >= 0; i--) {
|
||||
if (toDeleteSet.has(currentOrder[i])) {
|
||||
this.order.delete(i, 1);
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
this.blocks.delete(id);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public reorderBlocks(newOrder: string[]): void {
|
||||
this.doc.transact(() => {
|
||||
this.order.delete(0, this.order.length);
|
||||
this.order.push(newOrder);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public syncFullBlocks(blocks: DocumentBlock[]): void {
|
||||
this.doc.transact(() => {
|
||||
const nextIds = blocks.map((b) => b.id);
|
||||
const currentIds = new Set(this.order.toArray());
|
||||
|
||||
// Upsert all blocks
|
||||
for (const b of blocks) {
|
||||
let bMap = this.blocks.get(b.id);
|
||||
if (!bMap) {
|
||||
bMap = new Y.Map<any>();
|
||||
this.blocks.set(b.id, bMap);
|
||||
}
|
||||
bMap.set('id', b.id);
|
||||
bMap.set('type', b.type || 'bullet');
|
||||
bMap.set('content', b.content || '');
|
||||
bMap.set('level', b.level || 0);
|
||||
bMap.set('checked', !!b.checked);
|
||||
bMap.set('collapsed', !!b.collapsed);
|
||||
bMap.set('lang', b.lang || '');
|
||||
bMap.set('sortOrder', b.sortOrder || 0);
|
||||
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
||||
}
|
||||
|
||||
// Delete blocks no longer present
|
||||
const nextIdSet = new Set(nextIds);
|
||||
currentIds.forEach((id) => {
|
||||
if (!nextIdSet.has(id)) {
|
||||
this.blocks.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update order if different
|
||||
const currentOrder = this.order.toArray();
|
||||
const orderChanged =
|
||||
currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]);
|
||||
if (orderChanged) {
|
||||
this.order.delete(0, this.order.length);
|
||||
this.order.push(nextIds);
|
||||
}
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
public applyRemoteUpdate(base64Update: string): void {
|
||||
try {
|
||||
const bytes = base64ToUint8Array(base64Update);
|
||||
Y.applyUpdate(this.doc, bytes, 'remote');
|
||||
} catch (err) {
|
||||
console.error('[CRDT] Failed to apply remote update:', err);
|
||||
}
|
||||
}
|
||||
|
||||
public applyRemoteUpdates(base64Updates: string[]): void {
|
||||
for (const b64 of base64Updates) {
|
||||
this.applyRemoteUpdate(b64);
|
||||
}
|
||||
}
|
||||
|
||||
public encodeState(): string {
|
||||
const bytes = Y.encodeStateAsUpdate(this.doc);
|
||||
return uint8ArrayToBase64(bytes);
|
||||
}
|
||||
}
|
||||
@@ -153,16 +153,18 @@ export interface SystemSettings {
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
}
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
lang?: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
@@ -211,6 +213,8 @@ export interface Document {
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderId?: string;
|
||||
markdownCache?: string;
|
||||
content: string;
|
||||
blocks: DocumentBlock[];
|
||||
tags: string[];
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { DocumentBlock } from './types';
|
||||
import { getToken, api } from './api';
|
||||
|
||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
||||
|
||||
export interface DocSyncEvent {
|
||||
type:
|
||||
| 'doc_state'
|
||||
| 'block_created'
|
||||
| 'block_updated'
|
||||
| 'block_deleted'
|
||||
| 'block_moved'
|
||||
| 'title_updated'
|
||||
| 'blocks_replaced';
|
||||
docId?: string;
|
||||
clientId?: string;
|
||||
userId?: string;
|
||||
title?: string;
|
||||
block?: DocumentBlock;
|
||||
blockId?: string;
|
||||
afterBlockId?: string;
|
||||
newIndex?: number;
|
||||
content?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
blockType?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
export function useDocumentSync(
|
||||
docId: string | null,
|
||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||
canEdit: boolean = true
|
||||
) {
|
||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const clientIdRef = useRef<string>(
|
||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
);
|
||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||
onRemoteEventRef.current = onRemoteEvent;
|
||||
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const fallbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docId) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let retryCount = 0;
|
||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||
clientIdRef.current = currentClientId;
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||
docId
|
||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(currentClientId)}`;
|
||||
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDisposed) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0;
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isDisposed) return;
|
||||
try {
|
||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||
return;
|
||||
}
|
||||
onRemoteEventRef.current(parsed);
|
||||
} catch {
|
||||
// Ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handled in onclose
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (isDisposed) return;
|
||||
wsRef.current = null;
|
||||
retryCount += 1;
|
||||
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
|
||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
|
||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
const sendRawEvent = useCallback(
|
||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||
if (!docId || !canEdit) return;
|
||||
const ws = wsRef.current;
|
||||
const payload: DocSyncEvent = {
|
||||
...evt,
|
||||
docId,
|
||||
clientId: clientIdRef.current,
|
||||
};
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} else if (fallbackFn) {
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
}
|
||||
fallbackTimerRef.current = window.setTimeout(() => {
|
||||
fallbackFn();
|
||||
}, 600);
|
||||
}
|
||||
},
|
||||
[docId, canEdit]
|
||||
);
|
||||
|
||||
const sendBlockCreated = useCallback(
|
||||
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_created',
|
||||
block,
|
||||
afterBlockId,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: [block],
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockUpdated = useCallback(
|
||||
(block: DocumentBlock) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_updated',
|
||||
blockId: block.id,
|
||||
block,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockDeleted = useCallback(
|
||||
(blockId: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_deleted',
|
||||
blockId,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
deletedIds: [blockId],
|
||||
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockMoved = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_moved',
|
||||
blockId,
|
||||
newIndex,
|
||||
level,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: allBlocks,
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendTitleUpdated = useCallback(
|
||||
(title: string) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'title_updated',
|
||||
title,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { title }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlocksReplaced = useCallback(
|
||||
(blocks: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'blocks_replaced',
|
||||
blocks,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { blocks }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
clientId: clientIdRef.current,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
sendBlocksReplaced,
|
||||
};
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { getToken } from './api';
|
||||
|
||||
export type SyncTransportStatus = 'ws' | 'sse' | 'offline';
|
||||
|
||||
export interface YjsHybridProviderOptions {
|
||||
docId: string;
|
||||
ydoc: Y.Doc;
|
||||
onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
onAwareness?: (payload: any) => void;
|
||||
onSynced?: () => void;
|
||||
}
|
||||
|
||||
export class YjsHybridProvider {
|
||||
public readonly docId: string;
|
||||
public readonly ydoc: Y.Doc;
|
||||
private status: SyncTransportStatus = 'offline';
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
private ws: WebSocket | null = null;
|
||||
private eventSource: EventSource | null = null;
|
||||
private wsWatchdogTimer: any = null;
|
||||
private destroyed: boolean = false;
|
||||
private isConnectingWS: boolean = false;
|
||||
private isConnectingSSE: boolean = false;
|
||||
private onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
private onAwareness?: (payload: any) => void;
|
||||
private onSynced?: () => void;
|
||||
|
||||
constructor(options: YjsHybridProviderOptions) {
|
||||
this.docId = options.docId;
|
||||
this.ydoc = options.ydoc;
|
||||
this.onStatusChange = options.onStatusChange;
|
||||
this.onAwareness = options.onAwareness;
|
||||
this.onSynced = options.onSynced;
|
||||
|
||||
// 1. Offline-First: Bind IndexedDB persistence
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`mindsift-doc-${this.docId}`, this.ydoc);
|
||||
this.indexeddbProvider.on('synced', () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to local document updates
|
||||
this.ydoc.on('update', this.handleDocUpdate);
|
||||
|
||||
// 3. Connect via Primary Transport (WebSocket) with 4-second watchdog fallback
|
||||
this.connect();
|
||||
|
||||
// 4. Online/offline window event listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline);
|
||||
window.addEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(newStatus: SyncTransportStatus) {
|
||||
if (this.status !== newStatus) {
|
||||
this.status = newStatus;
|
||||
if (this.onStatusChange) {
|
||||
this.onStatusChange(newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus(): SyncTransportStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private handleOnline = () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.status === 'offline') {
|
||||
this.connect();
|
||||
}
|
||||
};
|
||||
|
||||
private handleOffline = () => {
|
||||
if (this.destroyed) return;
|
||||
this.setStatus('offline');
|
||||
};
|
||||
|
||||
public connect() {
|
||||
if (this.destroyed) return;
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
if (this.destroyed || this.isConnectingWS) return;
|
||||
this.isConnectingWS = true;
|
||||
|
||||
this.cleanupWebSocket();
|
||||
|
||||
const token = getToken() || '';
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(this.docId)}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
// 4-second watchdog timer: If WebSocket doesn't establish within 4 seconds, fallback to HTTP/SSE
|
||||
this.clearWatchdog();
|
||||
this.wsWatchdogTimer = setTimeout(() => {
|
||||
if (this.status !== 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket timeout (4s) for doc ${this.docId}. Switching to HTTP fallback (SSE + POST)...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.isConnectingWS = false;
|
||||
this.clearWatchdog();
|
||||
this.cleanupSSE();
|
||||
this.setStatus('ws');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const update = new Uint8Array(event.data);
|
||||
Y.applyUpdate(this.ydoc, update, 'remote');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[YjsProvider] WS message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.warn(`[YjsProvider] WebSocket error on doc ${this.docId}:`, err);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
this.isConnectingWS = false;
|
||||
// If closed abnormally (proxy drop, 1006, etc.), fallback to HTTP
|
||||
if (!this.destroyed && this.status === 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket closed (code ${event.code}). Falling back to HTTP/SSE...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[YjsProvider] WebSocket creation failed:`, e);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackToHTTP() {
|
||||
if (this.destroyed) return;
|
||||
this.cleanupWebSocket();
|
||||
this.connectSSE();
|
||||
}
|
||||
|
||||
private connectSSE() {
|
||||
if (this.destroyed || this.isConnectingSSE) return;
|
||||
this.isConnectingSSE = true;
|
||||
|
||||
this.cleanupSSE();
|
||||
|
||||
const token = getToken() || '';
|
||||
const sseUrl = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/stream?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const es = new EventSource(sseUrl);
|
||||
this.eventSource = es;
|
||||
|
||||
es.onopen = () => {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('sse');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
this.isConnectingSSE = false;
|
||||
if (!this.destroyed) {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
private handleInit(payload: { state?: string; updates?: string[] }) {
|
||||
if (payload.state) {
|
||||
try {
|
||||
const stateBytes = this.base64ToUint8(payload.state);
|
||||
Y.applyUpdate(this.ydoc, stateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial state:', e);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(payload.updates)) {
|
||||
for (const u of payload.updates) {
|
||||
if (u) {
|
||||
try {
|
||||
const updateBytes = this.base64ToUint8(u);
|
||||
Y.applyUpdate(this.ydoc, updateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteUpdate(payload: { update?: string }) {
|
||||
if (payload.update) {
|
||||
try {
|
||||
const bytes = this.base64ToUint8(payload.update);
|
||||
Y.applyUpdate(this.ydoc, bytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply remote update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDocUpdate = (update: Uint8Array, origin: any) => {
|
||||
// Only send updates originating locally (ignore 'remote' and 'indexeddb')
|
||||
if (this.destroyed || origin === 'remote' || origin === this.indexeddbProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(update);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Failed to send update over WS, falling back to HTTP POST:', e);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream via HTTP POST fallback
|
||||
this.sendUpdateViaHTTP(update);
|
||||
};
|
||||
|
||||
private async sendUpdateViaHTTP(update: Uint8Array) {
|
||||
const token = getToken() || '';
|
||||
const url = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/update?token=${encodeURIComponent(token)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: new Blob([update as any]),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[YjsProvider] HTTP sync update status: ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Network error during HTTP sync update (persisted locally in IndexedDB):', e);
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
public sendAwareness(payload: any) {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: this.docId,
|
||||
payload,
|
||||
}));
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// HTTP awareness fallback
|
||||
const token = getToken() || '';
|
||||
fetch(`/api/v1/documents/${encodeURIComponent(this.docId)}/awareness`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
private clearWatchdog() {
|
||||
if (this.wsWatchdogTimer) {
|
||||
clearTimeout(this.wsWatchdogTimer);
|
||||
this.wsWatchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupWebSocket() {
|
||||
this.clearWatchdog();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupSSE() {
|
||||
if (this.eventSource) {
|
||||
try {
|
||||
this.eventSource.onopen = null;
|
||||
this.eventSource.onmessage = null;
|
||||
this.eventSource.onerror = null;
|
||||
this.eventSource.close();
|
||||
} catch {}
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private base64ToUint8(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.destroyed = true;
|
||||
this.clearWatchdog();
|
||||
this.cleanupWebSocket();
|
||||
this.cleanupSSE();
|
||||
this.ydoc.off('update', this.handleDocUpdate);
|
||||
if (this.indexeddbProvider) {
|
||||
try {
|
||||
this.indexeddbProvider.destroy();
|
||||
} catch {}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user