Real-time collaborative code editing with Monaco, Yjs CRDTs, and room-based permissions.
collab_docs provides persistent, multi-user document editing. Documents are attached to chat rooms or standalone, edited with the Monaco code editor in the browser, and synchronized in real-time using Yjs CRDTs over Socket.IO. The server maintains in-memory Yjs documents with periodic persistence to SQLite. A full permission system controls access at owner/editor/viewer/room-member levels.
collab_docs.doh.yaml
├── collab_docs_server (nodejs)
│ ├── Yjs document lifecycle (in-memory + periodic persistence)
│ ├── Socket.IO sync protocol
│ ├── Permission system (4 groups)
│ ├── REST API (CRUD + page routes)
│ └── Programmatic API (createDoc, readDocContent, etc.)
└── collab_docs_client (browser)
├── CollabDocManager (full-page doc list + editor)
├── CollabDocManagerToolbar (compact toolbar for chat rooms)
├── CollabDocManagerEditor (Monaco + Yjs binding)
└── collab_shared (transport provider)
Depends on: express_router, user_host, collab_shared (browser)
Loaded by: chat.doh.yaml
Depended on by: collab_richtext
| File | Purpose |
|---|---|
collab_docs.doh.yaml |
Package manifest |
collab_docs_server.js |
Server: Yjs lifecycle, permissions, routes, socket events (~1061 lines) |
collab_docs_client.js |
Client: Monaco editor, doc manager UI (~1830 lines) |
collab_docs.css |
Editor and manager styles |
| Constant | Value | Description |
|---|---|---|
PERSIST_DEBOUNCE_MS |
3000 |
Debounce interval for saving Yjs state to DB |
GC_TIMEOUT_MS |
300000 (5 min) |
Unload in-memory docs with no active clients |
| Table | Type | Purpose |
|---|---|---|
collab_docs.documents |
Idea | Document metadata |
collab_docs.doc_state |
Idea | Yjs CRDT state (base64 encoded) |
Document metadata shape:
{
id: string, // UUID
title: string,
room_id: string | null, // Chat room ID or null for standalone
owner: string, // Username
editors: string[], // Usernames with write access
viewers: string[], // Usernames with read-only access
type: string, // 'plaintext' | 'richtext'
language: string, // Monaco language ID ('markdown', 'javascript', etc.)
created_at: number,
updated_at: number
}
Document state shape:
{
id: string, // Document ID (matches documents table)
state: string // Base64-encoded Yjs state
}
| Context | Condition |
|---|---|
collab_doc |
ctx?.documentId exists |
| Group | Condition | Permissions | Description |
|---|---|---|---|
collab_doc_owner |
2-arg: user.username === ctx.owner |
read:collab_doc, write:collab_doc, delete:collab_doc, share:collab_doc |
Document creator/owner |
collab_doc_editor |
2-arg: ctx.editors?.includes(user.username) |
read:collab_doc, write:collab_doc |
Explicitly shared editors |
collab_doc_viewer |
2-arg: ctx.viewers?.includes(user.username) |
read:collab_doc |
Read-only viewers |
collab_doc_room_member |
2-arg: room membership check | read:collab_doc, write:collab_doc |
All members of the document's room |
{ documentId, owner, editors, viewers, roomMembers }Doh.permit(user, action, context) against groups| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/collab/docs |
Yes | Create document |
| GET | /api/collab/docs |
Yes | List documents (optional room_id filter) |
| GET | /api/collab/docs/:id |
Yes | Get document by ID |
| PUT | /api/collab/docs/:id |
Yes | Update metadata |
| DELETE | /api/collab/docs/:id |
Yes | Delete document |
POST /api/collab/docs
// Request
{ title: string, room_id?: string, editors?: string[], viewers?: string[], type?: string, language?: string }
// Response
{ doc: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at } }
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /docs |
Yes | Document manager page |
| GET | /collab/:id |
Yes | Fullscreen editor page |
| Event | Direction | Payload | Response |
|---|---|---|---|
collab:doc:open |
Client → Server | { doc_id } |
cb({ doc_meta, readOnly, syncStep1, syncStep2, awarenessStates, clientCount }) |
collab:doc:create |
Client → Server | { title, room_id?, language?, type?, content? } |
cb({ doc }) |
collab:doc:list |
Client → Server | { room_id? } |
cb({ docs[] }) |
collab:doc:update_meta |
Client → Server | { doc_id, updates: { title?, language?, editors?, viewers? } } |
cb({ doc }) |
collab:doc:delete |
Client → Server | { doc_id } |
cb({ success }) |
collab:doc:close |
Client → Server | { doc_id } |
-- |
collab:doc:open response detail:
{
doc_meta: { id, title, room_id, owner, editors, viewers, type, language, created_at, updated_at },
readOnly: boolean, // User has read-only access
syncStep1: number[], // Yjs state vector (Uint8Array as array)
syncStep2: number[], // Full document state
awarenessStates: number[], // Remote cursor/presence states
clientCount: number // Active editors
}
| Event | Direction | Payload | Description |
|---|---|---|---|
collab:doc:sync |
Bidirectional | { doc_id, message: number[] } |
Yjs sync protocol messages |
collab:doc:awareness |
Bidirectional | { doc_id, message: number[] } |
Cursor/presence updates |
| Event | Payload | Trigger |
|---|---|---|
collab:doc:client_count |
{ doc_id, count } |
Client joins/leaves document |
collab:doc:meta_updated |
{ doc } |
Metadata changed |
collab:doc:deleted |
{ doc_id } |
Document deleted |
| Event | Direction | Payload |
|---|---|---|
collab:docs:join_lobby |
Client → Server | -- |
collab:docs:leave_lobby |
Client → Server | -- |
For real-time document list updates on the /docs manager page.
IMPORTANT: Always use socket.emit('collab:doc:list', ...) for listing documents, not Doh.ajaxPromise(). The socket handler routes correctly to the list operation; ajaxPromise may trigger creation instead.
Exported via Doh.Globals.CollabDocs for use by other modules (e.g., bot integrations):
Doh.Module('my_module', ['collab_docs_server'], function() {
const docs = Doh.Globals.CollabDocs;
// Create a document
const doc = await docs.createDoc({
title: 'Meeting Notes',
content: '# Notes\n',
room_id: 'room-123',
owner: 'alice'
});
// Read/write content
const content = docs.readDocContent(doc.id);
const previous = docs.writeDocContent(doc.id, '# Updated\n');
// Undo/redo
docs.undoDocEdit(doc.id, 'system');
docs.redoDocEdit(doc.id, 'system');
// Query
const roomDocs = docs.listRoomDocs('room-123');
const single = docs.getDocById(doc.id);
const canEdit = docs.canAccess(user, doc, 'write');
});
| Function | Params | Returns | Description |
|---|---|---|---|
getDocById(docId) |
string | doc | null |
Fetch document metadata |
canAccess(user, doc, action) |
user, doc, string | boolean | Permission check |
createDoc(opts) |
{ title, content?, room_id?, owner } |
doc | Create document with optional content |
readDocContent(docId) |
string | string | null |
Extract text content from Yjs doc |
writeDocContent(docId, content, origin?) |
string, string, any | string | Replace content, returns previous |
undoDocEdit(docId, origin) |
string, any | boolean | Undo last edit |
redoDocEdit(docId, origin) |
string, any | boolean | Redo last undo |
listRoomDocs(roomId) |
string | doc[] |
All docs attached to a room |
| Pattern | Parent | Purpose |
|---|---|---|
CollabDocManager |
html |
Full-page document list with create/delete/open |
CollabDocManagerToolbar |
html |
Compact toolbar with doc tiles (used in chat room headers) |
CollabDocManagerEditor |
html |
Monaco editor with Yjs binding, language selector, title editing |
CollabDocEditor |
html |
Standalone editor wrapper |
CollabDocToolbar |
html |
Editor toolbar with title, language, share, delete, fullscreen |
CollabDocStatusBar |
html |
Sync state indicator, collaborator count, connection status |
CollabDocList |
html |
Document listing with type icons and active editor count |
CollabDocTile |
html |
Individual document tile in toolbar |
When a user enters a room:
chat_client.js calls docToolbar.loadDocs()collab:doc:list with room_idOn server startup, a "Lobby Welcome" document is automatically created if it doesn't exist:
lobby-welcome-doclobbysystemAll authenticated users can read and edit the lobby document.
/docs — verify document list loads/collab/<doc-id> — verify fullscreen editor loads