Purpose: Real-time collaborative editing in GROWI, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution via Yjs CRDT.
Users: All GROWI users who use real-time collaborative page editing. System operators manage the WebSocket and persistence infrastructure.
Impact: Yjs document synchronization over native WebSocket (y-websocket), with Socket.IO continuing to serve non-Yjs real-time events (page room broadcasts, notifications).
yjs-writings MongoDB collection schema or data formatgraph TB
subgraph Client
CM[CodeMirror Editor]
WP[WebsocketProvider]
GS[Global Socket.IO Client]
end
subgraph Server
subgraph HTTP Server
Express[Express App]
SIO[Socket.IO Server]
WSS[WebSocket Server - ws]
end
subgraph YjsService
UpgradeHandler[Upgrade Handler - Auth]
ConnHandler[Connection Handler]
DocManager[Document Manager - getYDoc]
AwarenessBridge[Awareness Bridge]
end
MDB[(MongoDB - yjs-writings)]
SessionStore[(Session Store)]
end
CM --> WP
WP -->|ws path yjs pageId| WSS
GS -->|socket.io| SIO
WSS -->|upgrade auth| UpgradeHandler
UpgradeHandler -->|parse cookie| SessionStore
WSS -->|connection| ConnHandler
ConnHandler --> DocManager
DocManager --> MDB
AwarenessBridge -->|io.in room .emit| SIO
DocManager -->|awareness events| AwarenessBridge
Key architectural properties:
/yjs/{pageId}), Socket.IO for UI events (/socket.io/)map.setIfUndefined from lib0 — synchronous get-or-create, no race condition window| Layer | Choice / Version | Role |
|---|---|---|
| Client Provider | y-websocket@^2.x (WebsocketProvider) |
Yjs document sync over WebSocket |
| Server WebSocket | ws@^8.x (WebSocket.Server) |
Native WebSocket server, noServer: true mode |
| Server Yjs Utils | y-websocket@^2.x (bin/utils) |
setupWSConnection, getYDoc, WSSharedDoc |
| Persistence | y-mongodb-provider (extended) |
Yjs document persistence to yjs-writings collection |
| Event Bridge | Socket.IO io instance |
Awareness state broadcasting to page rooms |
| Auth | express-session + passport | WebSocket upgrade authentication via cookie |
sequenceDiagram
participant C as Client Browser
participant WSS as WebSocket Server
participant UH as Upgrade Handler
participant SS as Session Store
participant DM as Document Manager
participant MDB as MongoDB
C->>WSS: HTTP Upgrade GET /yjs/pageId
WSS->>UH: upgrade event
UH->>SS: Parse cookie, load session
SS-->>UH: Session with user
UH->>UH: Check page access
alt Unauthorized
UH-->>C: 401/403, destroy socket
else Authorized
UH->>WSS: handleUpgrade
WSS->>DM: setupWSConnection
DM->>DM: getYDoc - atomic get or create
alt New document
DM->>MDB: bindState - load persisted state
MDB-->>DM: Y.Doc state
end
DM-->>C: Sync Step 1 - state vector
C-->>DM: Sync Step 2 - diff
DM-->>C: Awareness states
end
Authentication happens before handleUpgrade — unauthorized connections never reach the Yjs layer. Document creation uses getYDoc's atomic map.setIfUndefined pattern.
stateDiagram-v2
[*] --> Created: First client connects
Created --> Active: bindState completes
Active --> Active: Clients connect/disconnect
Active --> Flushing: Last client disconnects
Flushing --> [*]: writeState completes, doc destroyed
Flushing --> Active: New client connects before destroy
| Component | Layer | Intent | Key Dependencies |
|---|---|---|---|
| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | ws, y-websocket/bin/utils, MongodbPersistence |
| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | express-session, passport, Page model |
| guardSocket | Server / Util | Prevents socket closure by other upgrade handlers during async auth | — |
| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface | MongodbPersistence, syncYDoc, Socket.IO io |
| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | Socket.IO io |
| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | y-websocket, yjs |
Intent: Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration.
Responsibilities:
ws.WebSocketServer instance and the y-websocket docs MapsetPersistenceupgrade handler (delegating auth to UpgradeHandler)IYjsService for downstream consumersService Interface:
interface IYjsService {
getYDocStatus(pageId: string): Promise<YDocStatus>;
syncWithTheLatestRevisionForce(
pageId: string,
editingMarkdownLength?: number,
): Promise<SyncLatestRevisionBody>;
getCurrentYdoc(pageId: string): Y.Doc | undefined;
}
httpServer: http.Server and io: ServerWebSocket.Server({ noServer: true }) + y-websocket utilshttpServer.on('upgrade', ...) with path check for /yjs/destroyUpgrade: false to prevent engine.io from destroying non-Socket.IO upgrade requestsIntent: Authenticates WebSocket upgrade requests using session cookies and verifies page access.
Interface:
type UpgradeResult =
| { authorized: true; request: AuthenticatedRequest; pageId: string }
| { authorized: false; statusCode: number };
runMiddleware helper against raw IncomingMessagewriteErrorResponse writes HTTP status line only — socket cleanup deferred to caller (works with guardSocket)user is undefined but page allows guest access, authorization proceedsIntent: Prevents other synchronous upgrade handlers from closing the socket during async auth.
Why this exists: Node.js EventEmitter fires all upgrade listeners synchronously. When the Yjs async handler yields at its first await, Next.js's NextCustomServer.upgradeHandler runs and calls socket.end() for unrecognized paths. This destroys the socket before Yjs auth completes.
How it works: Temporarily replaces socket.end() and socket.destroy() with no-ops before the first await. After auth completes, restore() reinstates the original methods.
const guard = guardSocket(socket);
const result = await handleUpgrade(request, socket, head);
guard.restore();
Intent: Adapts MongodbPersistence to y-websocket's persistence interface (bindState, writeState).
Interface:
interface YWebsocketPersistence {
bindState: (docName: string, ydoc: Y.Doc) => void;
writeState: (docName: string, ydoc: Y.Doc) => Promise<void>;
provider: MongodbPersistence;
}
Key behavior:
bindState: Loads persisted state → determines YDocStatus → calls syncYDoc → registers awareness event bridgewriteState: Flushes document state to MongoDB on last-client disconnectbindState is guaranteed (persistence load → sync → awareness registration)Intent: Bridges y-websocket per-document awareness events to Socket.IO room broadcasts.
Published events (to Socket.IO rooms):
YjsAwarenessStateSizeUpdated with awarenessStateSize: numberYjsHasYdocsNewerThanLatestRevisionUpdated with hasNewerYdocs: booleanSubscribed events (from y-websocket):
WSSharedDoc.awareness.on('update', ...) — per-document awareness changesIntent: Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions.
Key details:
${wsProtocol}//${window.location.host}/yjs, room name: pageIdconnect: true, resyncInterval: 3000provider.awareness.setLocalStateField, .on('update', ...)No custom data models. Uses the existing yjs-writings MongoDB collection via MongodbPersistence (extended y-mongodb-provider). Collection schema, indexes, and persistence interface (bindState / writeState) are unchanged.
| Error Type | Scenario | Response |
|---|---|---|
| Auth Failure | Invalid/expired session cookie | 401 on upgrade, socket destroyed |
| Access Denied | User lacks page access | 403 on upgrade, socket destroyed |
| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients sync from each other) |
| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (WebsocketProvider built-in) |
| Document Not Found | getCurrentYdoc for non-active doc | Return undefined |
| Requirement | Summary | Components |
|---|---|---|
| 1.1, 1.2 | Single Y.Doc per page | DocumentManager (getYDoc atomic pattern) |
| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider |
| 2.1, 2.2 | y-websocket transport | YjsService, use-collaborative-editor-mode |
| 2.3 | Coexist with Socket.IO | UpgradeHandler, guardSocket |
| 2.4 | resyncInterval | WebsocketProvider |
| 3.1-3.4 | Auth on upgrade | UpgradeHandler |
| 4.1-4.5 | MongoDB persistence | PersistenceAdapter |
| 5.1-5.4 | Awareness and presence | AwarenessBridge, use-collaborative-editor-mode |
| 6.1-6.4 | YDoc status and sync | YjsService |