🎯 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

  1. One user is designated "transport leader" (first to join or explicitly passed)
  2. Leader's play/stop commands are authoritative
  3. Followers sync to leader's position with latency compensation
  4. 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.