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 (~1146 lines) |
collab_docs_client.js |
Client: Monaco editor, doc manager UI (~1859 lines) |
collab_docs.css |
Editor and manager styles |
| Constant | Value | Description |
|---|---|---|
PERSIST_DEBOUNCE_MS |
5000 (5s) |
Debounce interval for saving Yjs state to DB |
GC_TIMEOUT_MS |
300000 (5 min) |
Unload in-memory docs with no active clients |
These are compile-time constants, not pod-configurable.
| 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 (Y.encodeStateAsUpdate output)
}
| 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) OR room member/lobby wildcard |
read:collab_doc |
Read-only viewers and room members (fallback read access) |
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 groupsThe collab_doc_room_member group grants read and write to any authenticated user who is a member of the room to which the document is attached (doc.room_id). Room membership is resolved at permit-check time by querying chat.room_members for the room.
The condition checks ctx.roomMembers?.includes(user.username). For the lobby room, roomMembers is the special wildcard array ['*'], and the condition treats this as matching all authenticated users — so all authenticated users can read and write lobby documents.
// collab_doc_room_member condition (simplified)
condition: (user, ctx) => {
if (ctx?.roomMembers?.includes('*')) return true; // lobby wildcard
if (ctx?.roomMembers?.includes(user?.username)) return true;
return false;
}
This means:
room_id) have no room member group — only explicit editors/viewers and the owner.| 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 (also feeds CollabDocStore in chat) |
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.
When a client opens a document (collab:doc:open) and the document is not already in the activeDocs map:
Y.Doc and Awareness are created in memory.collab_docs.doc_state and applies it to the Yjs document.ydoc.on('update', ...) listener is registered to trigger persistence debouncing on every edit.activeDocs as { ydoc, awareness, clients: Map, persistTimer, gcTimer }.Every Yjs update triggers schedulePersist(docId). This debounces by PERSIST_DEBOUNCE_MS (5 seconds): if an update arrives within 5 seconds of the last scheduled save, the timer resets. When the timer fires, persistDocState runs:
Y.encodeStateAsUpdate(ydoc) serializes the full document.collab_docs.doc_state via ReplaceIntoDB.updated_at timestamp in collab_docs.documents is updated.Awareness state (cursors/presence) is never persisted. It is in-memory only and cleared when the document is garbage collected.
When a client closes a document (collab:doc:close) or disconnects, the client is removed from the document's clients map and scheduleGC(docId) is called. The GC timer fires after GC_TIMEOUT_MS (5 minutes). If the document still has no clients at that point:
persistDocState is called immediately to save any unsaved edits.ydoc.destroy() releases the Yjs document from memory.activeDocs.If any new client opens the document before the GC timer fires, the timer is cancelled.
The GC cycle in summary:
client opens doc → cancel GC timer, add to clients
client closes doc → remove from clients, start 5-min GC timer
5 min passes → persist final state, destroy ydoc, remove from activeDocs
next client opens → load from DB, create new ydoc
On server startup, ensureLobbyDocExists() checks for a document with ID lobby-welcome-doc. If it does not exist, it is created automatically with:
| Field | Value |
|---|---|
id |
'lobby-welcome-doc' |
title |
'Lobby Welcome' |
room_id |
'lobby' |
owner |
'system' |
editors |
[] (empty — room membership provides access) |
viewers |
[] |
type |
'plaintext' |
language |
'markdown' |
The initial Yjs content is pre-populated with a welcome markdown message. Because the document's room_id is 'lobby', the collab_doc_room_member group applies with the wildcard ['*'] room members array, granting all authenticated users read and write access. No explicit editor assignment is needed.
The system user (owner: 'system') is not a real account and cannot authenticate. Only room membership grants access to this document.
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 |
|---|---|---|---|
activeDocs |
-- | Map<string, entry> |
Live in-memory document registry (read-only access) |
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, type?, language? } |
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_id/docs — verify document list loads/collab/<doc-id> — verify fullscreen editor loadslobby-welcome-doc without being explicitly listed as editor