π₯ Real-Time Collaboration
Google Docs-Style Multi-User Editing with Durable Objects
π― Collaboration Vision
FlowState enables Google Docs-style real-time collaboration where multiple producers can work on the same project simultaneously. See each other's cursors, edits sync instantly, and changes never conflict.
Technology: Cloudflare Durable Objects for state management + WebSockets for real-time sync. No separate infrastructure needed.
β‘ Architecture Overview
βββββββββββββββ βββββββββββββββββββββββ βββββββββββββββ
β User A ββββββββββΆβ Durable Object ββββββββββΆβ User B β
β (Browser) β WS β (Project State) β WS β (Browser) β
βββββββββββββββ βββββββββββββββββββββββ βββββββββββββββ
β
βΌ
βββββββββββββββββββββββ
β D1 Database β
β (Persistence) β
βββββββββββββββββββββββ
π Sync Strategy: CRDTs
We use Conflict-free Replicated Data Types (CRDTs) to ensure all clients converge to the same state without conflicts.
| Data Type | CRDT | Use Case |
|---|---|---|
| Track list | LWW-Register | Track add/remove/reorder |
| Clip positions | LWW-Map | Moving clips on timeline |
| Mixer values | LWW-Register | Volume, pan, mute |
| Transport state | G-Counter + Tombstone | Play/stop consensus |
| Undo history | G-Set | Per-user undo stacks |
LWW-Register Implementation
// crdt.ts
interface LWWRegister<T> {
value: T;
timestamp: number;
peerId: string;
}
function mergeLWW<T>(local: LWWRegister<T>, remote: LWWRegister<T>): LWWRegister<T> {
// Later timestamp wins
if (remote.timestamp > local.timestamp) {
return remote;
}
// Same timestamp: use peerId as tiebreaker
if (remote.timestamp === local.timestamp && remote.peerId > local.peerId) {
return remote;
}
return local;
}
ποΈ Durable Object Implementation
// durable-objects/project-room.ts
export class ProjectRoom implements DurableObject {
private sessions: Map<WebSocket, SessionInfo> = new Map();
private state: DurableObjectState;
private projectState: ProjectState | null = null;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/websocket') {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 400 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
await this.handleSession(server, request);
return new Response(null, { status: 101, webSocket: client });
}
return new Response('Not found', { status: 404 });
}
private async handleSession(ws: WebSocket, request: Request) {
ws.accept();
const userId = new URL(request.url).searchParams.get('userId')!;
const session: SessionInfo = {
id: crypto.randomUUID(),
userId,
cursor: null,
connectedAt: Date.now()
};
this.sessions.set(ws, session);
// Load project state if not loaded
if (!this.projectState) {
this.projectState = await this.state.storage.get('project') || createEmptyProject();
}
// Send initial state
ws.send(JSON.stringify({
type: 'init',
state: this.projectState,
peers: Array.from(this.sessions.values()).map(s => ({
id: s.id,
userId: s.userId,
cursor: s.cursor
}))
}));
// Broadcast new peer
this.broadcast({
type: 'peer_joined',
peer: { id: session.id, userId: session.userId }
}, ws);
// Handle messages
ws.addEventListener('message', (event) => {
this.handleMessage(ws, JSON.parse(event.data as string));
});
ws.addEventListener('close', () => {
const session = this.sessions.get(ws);
this.sessions.delete(ws);
if (session) {
this.broadcast({ type: 'peer_left', peerId: session.id });
}
});
}
private async handleMessage(ws: WebSocket, message: CollabMessage) {
const session = this.sessions.get(ws)!;
switch (message.type) {
case 'operation':
// Apply CRDT operation
this.projectState = applyOperation(this.projectState!, message.operation);
// Persist
await this.state.storage.put('project', this.projectState);
// Broadcast to others
this.broadcast({
type: 'operation',
operation: message.operation,
peerId: session.id
}, ws);
break;
case 'cursor':
session.cursor = message.position;
this.broadcast({
type: 'cursor',
peerId: session.id,
position: message.position
}, ws);
break;
case 'transport':
// Transport commands need consensus
this.broadcast({
type: 'transport',
command: message.command,
peerId: session.id
});
break;
}
}
private broadcast(message: any, exclude?: WebSocket) {
const data = JSON.stringify(message);
for (const [ws] of this.sessions) {
if (ws !== exclude) {
ws.send(data);
}
}
}
}
π₯ User Presence
Show collaborators' cursors and current selections in real-time.
Cursor Types
| Cursor | Context | Display |
|---|---|---|
| Timeline | Position on arrange view | Vertical line + name tag |
| Mixer | Currently selected channel | Channel highlight |
| Clip | Selected/editing clip | Border color + avatar |
| Drum Pad | Editing step sequencer | Row highlight |
Presence UI
// presence.tsx
function CollaboratorCursors({ peers }: { peers: Peer[] }) {
return (
<>
{peers.map(peer => (
<div
key={peer.id}
className="collaborator-cursor"
style={{
left: peer.cursor?.x,
top: peer.cursor?.y,
'--color': peer.color
}}
>
<div className="cursor-pointer" />
<span className="cursor-label">{peer.name}</span>
</div>
))}
</>
);
}
ποΈ Transport Sync
Keeping playback synchronized across all collaborators requires special handling.
Sync Strategy
- One user is designated "transport leader" (first to join or explicitly passed)
- Leader's play/stop commands are authoritative
- Followers sync to leader's position with latency compensation
- If leader disconnects, leadership transfers to next user
// transport-sync.ts
class TransportSync {
private isLeader: boolean = false;
private leaderPosition: number = 0;
private latencyEstimate: number = 50; // ms
handleTransportCommand(command: TransportCommand) {
if (this.isLeader) {
// We are leader, broadcast our command
this.broadcastTransport({
type: command.type,
position: Tone.getTransport().position,
timestamp: Date.now()
});
this.executeCommand(command);
} else {
// Request leader to execute
this.sendToLeader({ type: 'transport_request', command });
}
}
handleLeaderSync(sync: TransportSync) {
// Adjust for network latency
const adjustedPosition = sync.position + (this.latencyEstimate / 1000);
if (sync.type === 'play') {
Tone.getTransport().position = adjustedPosition;
Tone.getTransport().start();
} else if (sync.type === 'stop') {
Tone.getTransport().stop();
Tone.getTransport().position = sync.position;
}
}
}
π¬ Communication Features
Built-in Chat
// Chat messages through Durable Object
interface ChatMessage {
type: 'chat';
userId: string;
text: string;
timestamp: number;
}
// Displayed in sidebar panel
Voice Chat (Optional)
Using Cloudflare Calls for voice communication during sessions.
| Feature | MVP | v1.1 |
|---|---|---|
| Text chat | β | β |
| Voice chat | β | β |
| Screen annotations | β | β |
| Video call | β | β |
π Permissions
| Role | View | Edit | Export | Invite | Settings |
|---|---|---|---|---|---|
| Owner | β | β | β | β | β |
| Editor | β | β | β | β | β |
| Viewer | β | β | β | β | β |
Invite System
// Shareable links
https://flowstate.app/project/{id}?invite={token}
// Token validation
async function validateInvite(token: string): Promise<InviteInfo> {
const invite = await env.DB.prepare(
'SELECT * FROM invites WHERE token = ? AND expires_at > ?'
).bind(token, Date.now()).first();
if (!invite) throw new Error('Invalid or expired invite');
return {
projectId: invite.project_id,
role: invite.role,
createdBy: invite.created_by
};
}
π Session Limits
| Tier | Collaborators | Session Duration |
|---|---|---|
| Free | 2 (including owner) | 1 hour |
| Pro | 5 | Unlimited |
| Enterprise | 20 | Unlimited |
π° Cost Analysis
| Component | Cost (10K users, 1K concurrent) |
|---|---|
| Durable Objects requests | $25 (50M req @ $0.50/M) |
| Durable Objects duration | $25 (50K GB-sec) |
| WebSocket messages | Included in DO |
| Total | ~$50/mo |
Efficiency: Durable Objects hibernate when idle, so you only pay for active sessions.
π MVP Scope
MVP (v1.0)
- Single-user editing (no real-time collab)
- Project sharing via link (view only)
- Export project for offline sharing
v1.1 (Post-MVP)
- Real-time multi-user editing
- Presence indicators (cursors)
- Text chat
- Role-based permissions
v1.2
- Voice chat integration
- Version history / time travel
- Branch/merge projects
- Comments on timeline
Development Note: Real-time collaboration is complex. MVP focuses on single-user with sharing. Full collab in v1.1.