Grove

Room Management

Scoped collaborative spaces with participant limits, cursor tracking, typing indicators, and lifecycle hooks.

Rooms extend the Presence & Awareness system with structured collaborative spaces. While presence tracks "who is here," rooms answer "who is working on what" with participant limits, cursor tracking, typing indicators, and lifecycle hooks.

Overview

A room is a named collaborative space scoped to a document, canvas, or arbitrary context. Rooms provide:

  • Participant limits to control concurrency (e.g., max 5 editors)
  • Cursor tracking with typed positions for canvas, text, and code editors
  • Typing indicators per participant
  • Metadata per room and per participant
  • Lifecycle hooks for authorization, logging, and side effects
  • Document room helpers that tie rooms to specific table records

Rooms are built on top of presence and use the same TTL-based cleanup and SSE broadcast infrastructure.

Server Setup (Go)

Forge Integration

Rooms are enabled automatically when presence is enabled:

app.Use(groveext.New(
    groveext.WithDriver(pgdb),
    groveext.WithCRDT(plugin, hook.Scope{Tables: []string{"documents"}}),
    groveext.WithSyncController(
        crdt.WithPresenceEnabled(true),
        crdt.WithPresenceTTL(30 * time.Second),
    ),
))

This registers the room HTTP endpoints alongside the existing sync and presence routes.

Standalone Server

ctrl := crdt.NewSyncController(plugin,
    crdt.WithPresenceEnabled(true),
    crdt.WithPresenceTTL(30 * time.Second),
)
defer ctrl.Close()

handler := crdt.NewHTTPHandler(ctrl)
http.Handle("/sync/", handler)

RoomManager

The RoomManager handles room CRUD, join/leave operations, and participant tracking. Access it from the SyncController:

rm := ctrl.RoomManager()

Creating Rooms

room, err := rm.CreateRoom(ctx, "design-review", crdt.RoomOptions{
    MaxParticipants: 10,
    Metadata:        map[string]any{"project": "grove", "team": "platform"},
})

RoomOptions

FieldTypeDescription
MaxParticipantsintMaximum number of participants (0 = unlimited)
Metadatamap[string]anyArbitrary room metadata

Joining Rooms

err := rm.JoinRoom(ctx, "design-review", crdt.ParticipantData{
    Name:     "Alice",
    Color:    "#3b82f6",
    Avatar:   "https://example.com/alice.png",
    Cursor:   &crdt.CursorPosition{Type: "canvas", X: 120, Y: 340},
    IsTyping: false,
    Status:   "active",
})

Join returns an error if the room is full or the participant is already in the room.

Leaving Rooms

err := rm.LeaveRoom(ctx, "design-review", "node-1")

Listing Rooms

rooms, err := rm.ListRooms(ctx)

Getting Room Details

room, err := rm.GetRoom(ctx, "design-review")
// room.ID, room.Participants, room.Metadata, room.CreatedAt

Deleting Rooms

err := rm.DeleteRoom(ctx, "design-review")

Cursor Tracking

Cursors are typed to support different editor contexts:

type CursorPosition struct {
    Type   string  `json:"type"`   // "canvas", "text", "code"
    X      float64 `json:"x"`      // Canvas: pixel X position
    Y      float64 `json:"y"`      // Canvas: pixel Y position
    Line   int     `json:"line"`   // Text/Code: line number
    Column int     `json:"column"` // Text/Code: column number
    Offset int     `json:"offset"` // Text: character offset from start
}

Canvas Cursor

For design tools and whiteboards where position is absolute:

cursor := &crdt.CursorPosition{
    Type: "canvas",
    X:    450.5,
    Y:    230.0,
}

Text Cursor

For rich text editors with line and column:

cursor := &crdt.CursorPosition{
    Type:   "text",
    Line:   12,
    Column: 35,
    Offset: 482,
}

Code Cursor

For code editors with line and column:

cursor := &crdt.CursorPosition{
    Type:   "code",
    Line:   42,
    Column: 18,
}

ParticipantData

Each participant carries metadata visible to other participants:

type ParticipantData struct {
    Name     string          `json:"name"`
    Color    string          `json:"color"`
    Avatar   string          `json:"avatar,omitempty"`
    Cursor   *CursorPosition `json:"cursor,omitempty"`
    IsTyping bool            `json:"isTyping"`
    Status   string          `json:"status,omitempty"`   // "active", "idle", "away"
    Extra    json.RawMessage `json:"extra,omitempty"`     // Arbitrary extra data
}

Update participant data after joining:

err := rm.UpdateParticipant(ctx, "design-review", "node-1", crdt.ParticipantData{
    Name:     "Alice",
    Color:    "#3b82f6",
    Cursor:   &crdt.CursorPosition{Type: "canvas", X: 200, Y: 150},
    IsTyping: true,
})

Document Room Helpers

Convenience functions tie rooms to specific documents. The room ID is constructed as "table:pk":

// Create a room for a specific document.
room, err := rm.CreateDocumentRoom(ctx, "documents", "doc-1", crdt.RoomOptions{
    MaxParticipants: 5,
})
// Room ID: "documents:doc-1"

// Join a document room.
err = rm.JoinDocumentRoom(ctx, "documents", "doc-1", crdt.ParticipantData{
    Name:  "Alice",
    Color: "#3b82f6",
})

// Get document room details.
room, err = rm.GetDocumentRoom(ctx, "documents", "doc-1")

Room Lifecycle Hooks

Register hooks to intercept room events for authorization, logging, or side effects:

RoomHook

type RoomHook struct {
    OnCreate func(ctx context.Context, roomID string, opts RoomOptions) error
    OnDelete func(ctx context.Context, roomID string)
    OnJoin   func(ctx context.Context, roomID string, nodeID string, data ParticipantData) error
    OnLeave  func(ctx context.Context, roomID string, nodeID string)
}

Registering Hooks

ctrl.AddRoomHook(crdt.RoomHook{
    OnJoin: func(ctx context.Context, roomID string, nodeID string, data crdt.ParticipantData) error {
        // Authorization: only allow users with "editor" role
        if !hasEditorRole(ctx) {
            return fmt.Errorf("unauthorized: editor role required")
        }
        log.Printf("User %s joined room %s", data.Name, roomID)
        return nil
    },
    OnLeave: func(ctx context.Context, roomID string, nodeID string) {
        log.Printf("Node %s left room %s", nodeID, roomID)
    },
    OnCreate: func(ctx context.Context, roomID string, opts crdt.RoomOptions) error {
        log.Printf("Room %s created with max %d participants", roomID, opts.MaxParticipants)
        return nil
    },
})

RoomInterceptor

For more advanced interception, implement the RoomInterceptor interface as a plugin:

type RoomInterceptor interface {
    BeforeJoinRoom(ctx context.Context, roomID, nodeID string, data *ParticipantData) error
    AfterJoinRoom(ctx context.Context, roomID, nodeID string, data ParticipantData)
    BeforeLeaveRoom(ctx context.Context, roomID, nodeID string) error
    AfterLeaveRoom(ctx context.Context, roomID, nodeID string)
}

HTTP API Reference

MethodPathDescription
POST/sync/roomsCreate a room
GET/sync/roomsList all rooms
GET/sync/rooms/:idGet room details and participants
DELETE/sync/rooms/:idDelete a room
POST/sync/rooms/:id/joinJoin a room
POST/sync/rooms/:id/leaveLeave a room
PUT/sync/rooms/:id/participantUpdate participant data

Create Room

POST /sync/rooms

{
  "room_id": "design-review",
  "options": {
    "max_participants": 10,
    "metadata": { "project": "grove" }
  }
}

Join Room

POST /sync/rooms/:id/join

{
  "node_id": "browser-1",
  "data": {
    "name": "Alice",
    "color": "#3b82f6",
    "cursor": { "type": "canvas", "x": 0, "y": 0 }
  }
}

Update Participant

PUT /sync/rooms/:id/participant

{
  "node_id": "browser-1",
  "data": {
    "name": "Alice",
    "color": "#3b82f6",
    "cursor": { "type": "canvas", "x": 200, "y": 150 },
    "isTyping": true
  }
}

TypeScript Client

RoomClient

import { RoomClient } from "@grove-js/crdt";

const rooms = new RoomClient({
    baseURL: "/api/sync",
    nodeID: "browser-1",
    headers: { Authorization: "Bearer <token>" },
});

await rooms.create("design-session", { maxParticipants: 10 });
await rooms.join("design-session", {
    name: "Alice",
    color: "#3b82f6",
    cursor: { type: "canvas", x: 0, y: 0 },
});

// Update cursor on mouse move.
await rooms.updateParticipant("design-session", {
    cursor: { type: "canvas", x: 200, y: 150 },
});

// List all rooms.
const allRooms = await rooms.list();

// Leave on cleanup.
await rooms.leave("design-session");

React Hooks

useRoom

import { useRoom } from "@grove-js/crdt/react";

function CollabCanvas({ roomId }: { roomId: string }) {
    const { participants, join, leave, updateMyData } = useRoom(roomId);

    useEffect(() => {
        join({ name: "Alice", color: "#3b82f6" });
        return () => { leave(); };
    }, []);

    const handleMouseMove = (e: React.MouseEvent) => {
        updateMyData({
            cursor: { type: "canvas", x: e.clientX, y: e.clientY },
        });
    };

    return (
        <div onMouseMove={handleMouseMove}>
            {participants.map((p) => (
                <Cursor key={p.nodeId} data={p.data} />
            ))}
        </div>
    );
}

useDocumentRoom

import { useDocumentRoom } from "@grove-js/crdt/react";

function DocumentEditor({ docId }: { docId: string }) {
    const { participants, join, leave, updateMyData } = useDocumentRoom("documents", docId);

    useEffect(() => {
        join({ name: "Alice", color: "#3b82f6" });
        return () => { leave(); };
    }, []);

    return (
        <div>
            <div className="active-editors">
                {participants.map((p) => (
                    <span key={p.nodeId} style={{ color: p.data.color }}>
                        {p.data.name}
                        {p.data.isTyping && " (typing...)"}
                    </span>
                ))}
            </div>
        </div>
    );
}

useRoomList

import { useRoomList } from "@grove-js/crdt/react";

function RoomBrowser() {
    const { rooms, loading, refresh } = useRoomList();

    if (loading) return <div>Loading rooms...</div>;

    return (
        <ul>
            {rooms.map((room) => (
                <li key={room.id}>
                    {room.id} — {room.participantCount}/{room.maxParticipants} participants
                </li>
            ))}
            <button onClick={refresh}>Refresh</button>
        </ul>
    );
}

Example: Collaborative Document Editor with Cursors

A complete example combining rooms, cursors, and document editing:

import { CRDTProvider, useDocument, useDocumentRoom } from "@grove-js/crdt/react";

function App() {
    return (
        <CRDTProvider config={{
            baseURL: "/api/sync",
            nodeID: `browser-${crypto.randomUUID()}`,
            tables: ["documents"],
        }}>
            <CollaborativeEditor docId="doc-1" />
        </CRDTProvider>
    );
}

function CollaborativeEditor({ docId }: { docId: string }) {
    const { data, update } = useDocument("documents", docId);
    const { participants, join, leave, updateMyData } = useDocumentRoom("documents", docId);

    useEffect(() => {
        join({ name: "Alice", color: "#3b82f6" });
        return () => { leave(); };
    }, []);

    const handleKeyDown = () => {
        updateMyData({ isTyping: true });
    };

    const handleBlur = () => {
        updateMyData({ isTyping: false, cursor: null });
    };

    const handleSelectionChange = () => {
        const sel = window.getSelection();
        if (sel && sel.rangeCount > 0) {
            updateMyData({
                cursor: {
                    type: "text",
                    offset: sel.anchorOffset,
                    line: 0,
                    column: sel.anchorOffset,
                },
            });
        }
    };

    return (
        <div>
            <div className="collaborators">
                {participants.map((p) => (
                    <span
                        key={p.nodeId}
                        className="collaborator-badge"
                        style={{ backgroundColor: p.data.color }}
                    >
                        {p.data.name}
                        {p.data.isTyping && " ..."}
                    </span>
                ))}
            </div>
            <textarea
                value={data?.body ?? ""}
                onChange={(e) => update("body", e.target.value)}
                onKeyDown={handleKeyDown}
                onBlur={handleBlur}
                onSelect={handleSelectionChange}
            />
        </div>
    );
}

On this page