Features

Real-time Collaboration

Enable multiple users to edit content simultaneously with live presence
Status: Stable - Complete and ready for production use

Add real-time collaboration to your Nuxt Crouton applications with the @fyit/crouton-collab package. Built on Yjs CRDTs for conflict-free synchronization, it supports rich text editing, flow graphs, and any collaborative data structure.

Quick Start

Installation

pnpm add @fyit/crouton-collab

Configuration

Add the layer to your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@fyit/crouton',
    '@fyit/crouton-collab'
  ]
})

Basic Usage

<script setup>
// For rich text editors (pages)
const { ydoc, yxmlFragment, connected, users } = useCollabEditor({
  roomId: 'page-123',
  roomType: 'page'
})

// For flow graphs
const { ymap, data, connected, users } = useCollabSync({
  roomId: 'flow-123',
  roomType: 'flow',
  structure: 'map'
})
</script>

<template>
  <div>
    <CollabIndicator
      :connected="connected"
      :synced="true"
      :users="users"
    />
    <!-- Your editor content -->
  </div>
</template>

Composables

useCollabEditor

Ready-to-use setup for TipTap collaborative editing.

const {
  // Connection state
  connected,      // Whether WebSocket is connected
  synced,         // Whether initial sync is complete
  error,          // Connection error, if any

  // Yjs for TipTap
  ydoc,           // Y.Doc instance
  yxmlFragment,   // Y.XmlFragment for content

  // Presence
  user,           // Current user
  users,          // All users in room
  otherUsers,     // Other users (excluding self)

  // For TipTap extensions
  provider,       // { awareness: { setLocalStateField, ... } }

  // Actions
  connect,        // Manually connect
  disconnect,     // Manually disconnect
  updateCursor,   // Update cursor position
  updateSelection // Update text selection
} = useCollabEditor({
  roomId: 'page-123',
  roomType: 'page',        // default
  field: 'content',        // default
  user: { name: 'Alice', color: '#ff0000' }
})

TipTap Integration:

import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'

const editor = useEditor({
  extensions: [
    Collaboration.configure({
      document: ydoc,
      field: 'content'
    }),
    CollaborationCursor.configure({
      provider,
      user: { name: 'Alice', color: '#ff0000' }
    })
  ]
})

useCollabSync

General-purpose Yjs synchronization for any data structure.

const {
  // Connection state
  connected, synced, error,

  // Yjs structures (one populated based on structure option)
  ymap,           // Y.Map | null
  yarray,         // Y.Array | null
  yxmlFragment,   // Y.XmlFragment | null
  ytext,          // Y.Text | null

  // Reactive data
  data,           // Ref<Record<string, unknown>> - for Y.Map
  arrayData,      // Ref<unknown[]> - for Y.Array

  // Users in room
  users,

  // Actions
  connect, disconnect
} = useCollabSync({
  roomId: 'room-123',
  roomType: 'flow',
  structure: 'map',       // 'map' | 'array' | 'xmlFragment' | 'text'
  structureName: 'nodes'  // optional, defaults to roomType
})

useCollabPresence

Track cursor positions, selections, and presence.

const {
  user,               // Current user
  users,              // All users
  otherUsers,         // Other users (excluding self)

  // Actions
  updateCursor,       // (cursor) => void
  updateSelection,    // (selection) => void
  selectNode,         // (nodeId) => void
  updateGhostNode,    // (ghostNode) => void

  // Utilities
  getUsersSelectingNode,  // (nodeId) => CollabAwarenessState[]
  getUserColor,           // (userId) => string
  getNodePresenceStyle    // (nodeId) => { boxShadow?, borderColor? }
} = useCollabPresence({
  connection,         // from useCollabConnection
  user: { name: 'Alice' }
})

useCollabRoomUsers

Poll room users via HTTP (for global presence in lists).

const {
  users,          // All users in room
  otherUsers,     // Excluding current user
  count,          // Total count
  otherCount,     // Other users count
  loading,
  error,
  isPolling,

  // Actions
  refresh,        // Manual refresh
  startPolling,   // Start polling
  stopPolling     // Stop polling
} = useCollabRoomUsers({
  roomId: 'page-123',
  roomType: 'page',
  pollInterval: 5000,     // default: 5 seconds
  currentUserId: user?.id,
  excludeSelf: true,      // default
  immediate: true         // default: start on mount
})

Components

CollabStatus

Connection status indicator with colored dot.

<CollabStatus
  :connected="connected"
  :synced="synced"
  :error="error"
  :show-label="true"
/>
PropTypeDefaultDescription
connectedbooleanrequiredWebSocket connected
syncedbooleanrequiredInitial sync complete
errorError | nullnullConnection error
showLabelbooleantrueShow text label

Status Colors:

  • 🟢 Green: synced and ready
  • 🟡 Yellow (pulsing): connecting or syncing
  • 🔴 Red: error
  • ⚪ Gray: disconnected

CollabPresence

Stacked user avatars with overflow indicator.

<CollabPresence
  :users="otherUsers"
  :max-visible="5"
  size="sm"
  :show-tooltip="true"
/>
PropTypeDefaultDescription
usersCollabAwarenessState[]requiredUsers to display
maxVisiblenumber5Max avatars before +N
size'xs' | 'sm' | 'md''sm'Avatar size
showTooltipbooleantrueShow name on hover

CollabIndicator

Combined status + presence for toolbars.

<CollabIndicator
  :connected="connected"
  :synced="synced"
  :error="error"
  :users="otherUsers"
  :max-visible-users="3"
/>

CollabEditingBadge

Shows "X editing" badge on collection list items.

<CollabEditingBadge
  room-id="page-123"
  room-type="page"
  :current-user-id="currentUser?.id"
  :poll-interval="5000"
  size="xs"
  :show-avatars="true"
/>
PropTypeDefaultDescription
roomIdstringrequiredRoom ID to check
roomTypestring'page'Room type
currentUserIdstring-Exclude self from count
pollIntervalnumber5000Poll interval in ms
size'xs' | 'sm' | 'md''xs'Badge size
showAvatarsbooleantrueShow avatars on hover

CollabCursors

Remote cursor overlay for canvas/editor content.

<div class="relative">
  <CollabCursors
    :users="otherUsers"
    :show-labels="true"
    :offset-x="0"
    :offset-y="0"
  />
  <!-- Your content here -->
</div>

Global Presence in Lists

Show "X people editing" badges in collection lists:

<CroutonCollection
  collection="pages"
  :rows="pages"
  :show-collab-presence="true"
/>

<!-- With custom configuration -->
<CroutonCollection
  collection="pages"
  :rows="pages"
  :show-collab-presence="{
    roomType: 'page',
    currentUserId: currentUser?.id,
    pollInterval: 10000,
    getRoomId: (row, collection) => `${collection}-${row.id}`
  }"
/>

Architecture

The collaboration package uses a layered architecture:

┌─────────────────────────────────────────────────────┐
│                     Clients                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐              │
│  │ User A  │  │ User B  │  │ User C  │              │
│  └────┬────┘  └────┬────┘  └────┬────┘              │
│       └────────────┼────────────┘                    │
│                    │ WebSocket                       │
│                    ▼                                 │
│  ┌───────────────────────────────────────────────┐  │
│  │           /api/collab/[roomId]/ws             │  │
│  │           ?type=page|flow|document            │  │
│  └───────────────────────────────────────────────┘  │
│                    │                                 │
│                    ▼                                 │
│  ┌───────────────────────────────────────────────┐  │
│  │              CollabRoom DO                    │  │
│  │  ┌─────────┐  ┌──────────┐  ┌──────────────┐ │  │
│  │  │  Y.Doc  │  │ Sessions │  │  Awareness   │ │  │
│  │  │ (CRDT)  │  │(WebSockets)│  │(Presence)  │ │  │
│  │  └────┬────┘  └──────────┘  └──────────────┘ │  │
│  └───────┼───────────────────────────────────────┘  │
│          │                                           │
│          ▼                                           │
│  ┌───────────────────────────────────────────────┐  │
│  │              D1: yjs_collab_states            │  │
│  │  room_type │ room_id │ state │ version        │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘

Room Types

TypeUse CaseYjs Structure
pageTipTap editor contentY.XmlFragment
flowNode graphsY.Map
documentPlain textY.Text
genericCustomAny

Cloudflare Configuration

wrangler.toml

[[durable_objects.bindings]]
name = "COLLAB_ROOMS"
class_name = "CollabRoom"

[[migrations]]
tag = "collab-v1"
new_classes = ["CollabRoom"]

D1 Migration

npx wrangler d1 execute <DB_NAME> \
  --file=./packages/nuxt-crouton-collab/server/database/migrations/0001_yjs_collab_states.sql

WebSocket Protocol

Binary Messages

Raw Uint8Array containing Yjs update data.

JSON Messages

// Awareness update
{
  type: 'awareness',
  userId: 'user-123',
  state: {
    user: { id: 'user-123', name: 'Alice', color: '#ff0000' },
    cursor: { x: 100, y: 200 },
    selection: { anchor: 10, head: 20 }
  }
}

// Ping/Pong for connection health
{ type: 'ping' }
{ type: 'pong' }

Types Reference

interface CollabUser {
  id: string
  name: string
  color: string
}

interface CollabAwarenessState {
  user: CollabUser
  cursor: { x: number; y: number } | null
  selection?: { anchor: number; head: number } | null
  selectedNodeId?: string | null
  ghostNode?: { id: string; position: { x: number; y: number } } | null
  [key: string]: unknown  // Extensible
}

interface CollabConnectionState {
  connected: boolean
  synced: boolean
  error: Error | null
}

type CollabStructure = 'map' | 'array' | 'xmlFragment' | 'text'

API Endpoints

EndpointMethodDescription
/api/collab/[roomId]/ws?type=XGETWebSocket upgrade
/api/collab/[roomId]/users?type=XGETGet current users

Users Endpoint Response

{
  "users": [
    {
      "user": { "id": "user-123", "name": "Alice", "color": "#ff0000" },
      "cursor": { "x": 100, "y": 200 }
    }
  ],
  "count": 1
}

Best Practices

  1. Use appropriate room types - Match the room type to your data structure
  2. Exclude self from counts - Use currentUserId to exclude the current user
  3. Throttle awareness updates - Don't send cursor updates on every mouse move
  4. Handle disconnections gracefully - Show status indicators to users
  5. Clean up on unmount - The composables handle this automatically

Troubleshooting

Connection Issues

  1. Check browser WebSocket tab in DevTools
  2. Verify type and roomId query params
  3. Check Cloudflare Durable Object logs
  4. Verify D1 migration was run

Debug Room Users

# Via curl (when server is running)
curl https://your-app.com/api/collab/room-123/users?type=page

Common Errors

ErrorCauseSolution
WebSocket connection failedServer not runningCheck server logs
Room not foundMissing D1 migrationRun migration SQL
User not syncingWrong room typeVerify type parameter