|
|
@@ -0,0 +1,450 @@
|
|
|
+# Design Document: migrate-to-y-websocket
|
|
|
+
|
|
|
+## Overview
|
|
|
+
|
|
|
+**Purpose**: This feature replaces the `y-socket.io` Yjs transport layer with `y-websocket` to eliminate a critical race condition in document initialization that causes collaborative editing clients to permanently desynchronize.
|
|
|
+
|
|
|
+**Users**: All GROWI users who use real-time collaborative page editing. System operators benefit from switching to an actively maintained library.
|
|
|
+
|
|
|
+**Impact**: Replaces the internal transport layer for Yjs document synchronization. External behavior (editor UI, awareness indicators, draft detection, save flow) remains unchanged. Socket.IO continues to serve non-Yjs real-time events.
|
|
|
+
|
|
|
+### Goals
|
|
|
+- Eliminate the `initDocument` race condition that causes client desynchronization
|
|
|
+- Maintain all existing collaborative editing functionality (sync, awareness, persistence, draft detection)
|
|
|
+- Use `y-websocket@2.x` which is compatible with the current `yjs@^13` stack
|
|
|
+- Coexist with the existing Socket.IO infrastructure without disruption
|
|
|
+
|
|
|
+### Non-Goals
|
|
|
+- Upgrading to yjs v14 (separate future effort)
|
|
|
+- Changing the Yjs document model, CodeMirror integration, or page save/revision logic
|
|
|
+- Migrating Socket.IO-based UI events (page room broadcasts) to WebSocket
|
|
|
+- Changing the `yjs-writings` MongoDB collection schema or data format
|
|
|
+
|
|
|
+## Architecture
|
|
|
+
|
|
|
+### Existing Architecture Analysis
|
|
|
+
|
|
|
+The current system uses two transport layers on the same HTTP server:
|
|
|
+
|
|
|
+1. **Socket.IO** (`/socket.io/` path): Handles general app events (page join/leave, notifications) and Yjs document sync via y-socket.io's dynamic namespaces (`/yjs|{pageId}`)
|
|
|
+2. **Express HTTP** (all other paths): REST API, SSR pages
|
|
|
+
|
|
|
+y-socket.io creates Socket.IO namespaces dynamically for each page's Yjs document. Authentication piggybacks on Socket.IO's middleware chain (express-session + passport). The `YjsService` singleton wraps `YSocketIO` and integrates persistence, access control, and awareness event bridging.
|
|
|
+
|
|
|
+**Key constraint**: Socket.IO rooms (`page:{pageId}`) are used by non-editor UI components to receive awareness state size updates and draft status notifications. This Socket.IO room broadcast mechanism must be preserved.
|
|
|
+
|
|
|
+### Architecture Pattern & Boundary Map
|
|
|
+
|
|
|
+```mermaid
|
|
|
+graph 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
|
|
|
+```
|
|
|
+
|
|
|
+**Architecture Integration**:
|
|
|
+- **Selected pattern**: Replace Socket.IO-based Yjs transport with native WebSocket, keeping Socket.IO for non-Yjs events
|
|
|
+- **Domain boundaries**: YjsService encapsulates all Yjs document management; Socket.IO only receives bridged awareness events
|
|
|
+- **Existing patterns preserved**: Singleton YjsService, MongoDB persistence via y-mongodb-provider, session-based authentication
|
|
|
+- **New components rationale**: WebSocket upgrade handler needed because raw ws does not have Socket.IO's middleware chain
|
|
|
+- **Steering compliance**: Server-client boundary enforced; `ws` already in dependencies
|
|
|
+
|
|
|
+### Technology Stack
|
|
|
+
|
|
|
+| Layer | Choice / Version | Role in Feature | Notes |
|
|
|
+|-------|------------------|-----------------|-------|
|
|
|
+| Client Provider | `y-websocket@^2.0.4` (WebsocketProvider) | Yjs document sync over WebSocket | Replaces `y-socket.io` SocketIOProvider; yjs v13 compatible |
|
|
|
+| Server WebSocket | `ws@^8.17.1` (WebSocket.Server) | Native WebSocket server for Yjs | Already installed; `noServer: true` mode for HTTP upgrade sharing |
|
|
|
+| Server Yjs Utils | `y-websocket@^2.0.4` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` | Bundled server utilities; atomic document management |
|
|
|
+| Persistence | `y-mongodb-provider` (existing) | Yjs document persistence to MongoDB | No changes; same `yjs-writings` collection |
|
|
|
+| Event Bridge | Socket.IO `io` instance (existing) | Awareness state broadcasting to page rooms | Bridged from y-websocket awareness events |
|
|
|
+| Auth | express-session + passport (existing) | WebSocket upgrade authentication | Cookie-based session parsing on upgrade request |
|
|
|
+
|
|
|
+## System Flows
|
|
|
+
|
|
|
+### Client Connection Flow
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+Key decisions: Authentication happens before `handleUpgrade`, so unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern — no race condition window.
|
|
|
+
|
|
|
+### Document Lifecycle
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+## Requirements Traceability
|
|
|
+
|
|
|
+| Requirement | Summary | Components | Interfaces | Flows |
|
|
|
+|-------------|---------|------------|------------|-------|
|
|
|
+| 1.1, 1.2 | Single Y.Doc per page | DocumentManager | getYDoc | Connection Flow |
|
|
|
+| 1.3, 1.4, 1.5 | Sync integrity on reconnect | DocumentManager, WebsocketProvider | setupWSConnection | Connection Flow |
|
|
|
+| 2.1 | y-websocket server transport | YjsService, DocumentManager | setupWSConnection, setPersistence | Connection Flow |
|
|
|
+| 2.2 | y-websocket client provider | WebsocketProvider (use-collaborative-editor-mode) | WebsocketProvider constructor | Connection Flow |
|
|
|
+| 2.3 | Coexist with Socket.IO | UpgradeRouter | server.on upgrade | Connection Flow |
|
|
|
+| 2.4 | resyncInterval | WebsocketProvider | resyncInterval option | — |
|
|
|
+| 3.1, 3.2, 3.3 | Auth on upgrade | UpgradeHandler | authenticateUpgrade, checkPageAccess | Connection Flow |
|
|
|
+| 3.4 | Guest access | UpgradeHandler | checkPageAccess | Connection Flow |
|
|
|
+| 4.1, 4.2 | MongoDB persistence | PersistenceAdapter | bindState, writeState | Document Lifecycle |
|
|
|
+| 4.3 | Load before sync | PersistenceAdapter | bindState | Connection Flow |
|
|
|
+| 4.4 | Persist updates | PersistenceAdapter | doc.on update | — |
|
|
|
+| 4.5 | Flush on disconnect | PersistenceAdapter | writeState | Document Lifecycle |
|
|
|
+| 5.1 | Client awareness broadcast | WebsocketProvider | awareness.setLocalStateField | — |
|
|
|
+| 5.2, 5.3 | Awareness bridge to Socket.IO | AwarenessBridge | awareness.on update, io.in.emit | — |
|
|
|
+| 5.4 | Display editor list | use-collaborative-editor-mode | awareness.on update | — |
|
|
|
+| 6.1, 6.2 | YDoc status API | YjsService | getYDocStatus, getCurrentYdoc | — |
|
|
|
+| 6.3 | Sync on document load | YjsService | contentInitializor / bindState | Connection Flow |
|
|
|
+| 6.4 | Force sync API | YjsService | syncWithTheLatestRevisionForce | — |
|
|
|
+| 7.1, 7.2 | Dev environment | ViteDevConfig | — | — |
|
|
|
+| 8.1, 8.2, 8.3 | Dependency cleanup | package.json changes | — | — |
|
|
|
+
|
|
|
+## Components and Interfaces
|
|
|
+
|
|
|
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|
|
|
+|-----------|-------------|--------|-------------|-----------------|-----------|
|
|
|
+| YjsService | Server / Service | Orchestrates Yjs document lifecycle, exposes public API | 1.1-1.5, 2.1, 6.1-6.4 | ws (P0), y-websocket/bin/utils (P0), MongodbPersistence (P0) | Service |
|
|
|
+| UpgradeHandler | Server / Auth | Authenticates and authorizes WebSocket upgrade requests | 3.1-3.4, 2.3 | express-session (P0), passport (P0), Page model (P0) | Service |
|
|
|
+| guardSocket | Server / Util | Prevents socket closure by other upgrade handlers during async auth | 2.3 | — | Utility |
|
|
|
+| PersistenceAdapter | Server / Data | Bridges MongodbPersistence to y-websocket persistence interface; handles sync-on-load and awareness registration | 4.1-4.5, 6.3, 5.2, 5.3 | MongodbPersistence (P0), syncYDoc (P0), Socket.IO io (P1) | Service, Event |
|
|
|
+| AwarenessBridge | Server / Events | Bridges y-websocket awareness events to Socket.IO rooms | 5.2, 5.3 | Socket.IO io (P0), docs Map (P1) | Event |
|
|
|
+| use-collaborative-editor-mode | Client / Hook | Manages WebsocketProvider lifecycle and awareness | 2.2, 2.4, 5.1, 5.4 | y-websocket (P0), yjs (P0) | State |
|
|
|
+| ViteDevConfig | Dev / Config | Configures dev server WebSocket proxy/setup | 7.1, 7.2 | — | — |
|
|
|
+
|
|
|
+### Server / Service Layer
|
|
|
+
|
|
|
+#### YjsService
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration |
|
|
|
+| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 6.1, 6.2, 6.3, 6.4 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map
|
|
|
+- Initializes persistence and content initialization via y-websocket's `setPersistence` and `setContentInitializor`
|
|
|
+- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler)
|
|
|
+- Exposes the same public interface as the current `IYjsService` for downstream consumers
|
|
|
+- Must attach to the existing `httpServer` without interfering with Socket.IO's upgrade handling
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: crowi/index.ts — initialization (P0)
|
|
|
+- Inbound: PageService, API routes — getYDocStatus, syncWithTheLatestRevisionForce (P0)
|
|
|
+- Outbound: UpgradeHandler — authentication (P0)
|
|
|
+- Outbound: PersistenceAdapter — document persistence (P0)
|
|
|
+- Outbound: AwarenessBridge — awareness event fan-out (P1)
|
|
|
+- External: y-websocket `bin/utils` — getYDoc, setupWSConnection, docs, WSSharedDoc (P0)
|
|
|
+- External: ws — WebSocket.Server (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface IYjsService {
|
|
|
+ getYDocStatus(pageId: string): Promise<YDocStatus>;
|
|
|
+ syncWithTheLatestRevisionForce(
|
|
|
+ pageId: string,
|
|
|
+ editingMarkdownLength?: number,
|
|
|
+ ): Promise<SyncLatestRevisionBody>;
|
|
|
+ getCurrentYdoc(pageId: string): Y.Doc | undefined;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- Preconditions: Service initialized with httpServer and io instances
|
|
|
+- Postconditions: Public API behavior identical to current implementation
|
|
|
+- Invariants: At most one Y.Doc per pageId in the docs Map at any time
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Constructor changes: Accept `httpServer: http.Server` and `io: Server` instead of just `io: Server`
|
|
|
+- Replace `new YSocketIO(io)` with `new WebSocket.Server({ noServer: true })` + y-websocket utils setup
|
|
|
+- Replace `ysocketio.documents.get(pageId)` with `docs.get(pageId)` from y-websocket utils
|
|
|
+- Replace `ysocketio['persistence'] = ...` with `setPersistence(...)` public API
|
|
|
+- Do NOT use `setContentInitializor` — instead, place sync-on-load logic (`syncYDoc`) inside `bindState` after persisted state is applied, to guarantee correct ordering (persistence load → YDocStatus check → syncYDoc)
|
|
|
+- Use `httpServer.on('upgrade', ...)` with path check for `/yjs/`
|
|
|
+- **CRITICAL**: Socket.IO server must be configured with `destroyUpgrade: false` to prevent engine.io from destroying non-Socket.IO upgrade requests. Without this, engine.io's default behavior kills `/yjs/` WebSocket handshakes after a 1-second timeout. Set via `new Server(httpServer, { destroyUpgrade: false })` in `socket-io.ts`.
|
|
|
+- Socket.IO's internal upgrade handling for `/socket.io/` is not affected because Socket.IO only intercepts its own path
|
|
|
+
|
|
|
+#### UpgradeHandler
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Authenticates WebSocket upgrade requests using session cookies and verifies page access |
|
|
|
+| Requirements | 3.1, 3.2, 3.3, 3.4 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Runs express-session and passport middleware against the raw upgrade request via `runMiddleware` helper
|
|
|
+- Checks page access using `Page.isAccessiblePageByViewer`
|
|
|
+- Extracts `pageId` from the URL path (`/yjs/{pageId}`)
|
|
|
+- Writes HTTP error responses for unauthorized requests (`writeErrorResponse`) — does NOT close the socket; socket lifecycle is managed by the caller (YjsService) to work correctly with `guardSocket`
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: YjsService — called on upgrade event (P0)
|
|
|
+- Outbound: express-session + passport — session/user deserialization (P0)
|
|
|
+- Outbound: Page model — access check (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+type AuthenticatedRequest = IncomingMessage & {
|
|
|
+ user?: IUserHasId;
|
|
|
+};
|
|
|
+
|
|
|
+type UpgradeResult =
|
|
|
+ | { authorized: true; request: AuthenticatedRequest; pageId: string }
|
|
|
+ | { authorized: false; statusCode: number };
|
|
|
+
|
|
|
+// Factory function — returns an async handler
|
|
|
+const createUpgradeHandler = (sessionConfig: SessionConfig) =>
|
|
|
+ async (request, socket, head): Promise<UpgradeResult> => { ... };
|
|
|
+```
|
|
|
+
|
|
|
+- Preconditions: Request has valid URL matching `/yjs/{pageId}`
|
|
|
+- Postconditions: Returns authorized result with deserialized user and pageId, or rejection with HTTP error written to socket
|
|
|
+- Invariants: Never calls `wss.handleUpgrade` for unauthorized requests; never calls `socket.destroy()` (caller responsibility)
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Uses `runMiddleware` helper to execute Connect-style middleware (express-session, passport.initialize, passport.session) against raw `IncomingMessage` with a stub `ServerResponse`
|
|
|
+- `writeErrorResponse` writes HTTP status line only — socket cleanup is deferred to the caller so that `guardSocket` (see below) can intercept `socket.destroy()` during async auth
|
|
|
+- Guest access: if `user` is undefined but page allows guest access, proceed with authorization
|
|
|
+
|
|
|
+#### guardSocket
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Prevents other synchronous upgrade handlers from closing the socket during async auth |
|
|
|
+| Requirements | 2.3 (coexistence with other servers) |
|
|
|
+
|
|
|
+**Why this exists**: Node.js EventEmitter fires all `upgrade` listeners synchronously. When the Yjs handler (async) yields at its first `await`, Next.js's `NextCustomServer.upgradeHandler` runs and calls `socket.end()` for paths it does not recognize. This destroys the socket before Yjs auth completes. `prependListener` cannot solve this because it only changes listener order — it cannot prevent subsequent listeners from executing.
|
|
|
+
|
|
|
+**How it works**: Temporarily replaces `socket.end()` and `socket.destroy()` with no-ops before the first `await`. After auth completes, calls `restore()` to reinstate the original methods, then proceeds with either `wss.handleUpgrade` (success) or `socket.destroy()` (failure).
|
|
|
+
|
|
|
+```typescript
|
|
|
+const guard = guardSocket(socket);
|
|
|
+const result = await handleUpgrade(request, socket, head);
|
|
|
+guard.restore();
|
|
|
+```
|
|
|
+
|
|
|
+**Test coverage**: `guard-socket.spec.ts` verifies that a hostile upgrade handler calling `socket.end()` does not prevent WebSocket connection establishment.
|
|
|
+
|
|
|
+#### PersistenceAdapter
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Adapts the existing MongodbPersistence to y-websocket's persistence interface |
|
|
|
+| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Implements the y-websocket persistence interface (`bindState`, `writeState`)
|
|
|
+- Loads persisted Y.Doc state from MongoDB on document creation
|
|
|
+- After applying persisted state, determines YDocStatus and calls `syncYDoc` to synchronize with the latest revision — this guarantees correct ordering (persistence first, then sync)
|
|
|
+- Persists incremental updates on every document change
|
|
|
+- Registers awareness event listeners for the AwarenessBridge on document creation
|
|
|
+- Flushes document state on last-client disconnect
|
|
|
+- Maintains the `updatedAt` metadata for draft detection
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: y-websocket utils — called on document lifecycle events (P0)
|
|
|
+- Outbound: MongodbPersistence (extended y-mongodb-provider) — data access (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface YWebsocketPersistence {
|
|
|
+ bindState: (docName: string, ydoc: Y.Doc) => void;
|
|
|
+ writeState: (docName: string, ydoc: Y.Doc) => Promise<void>;
|
|
|
+ provider: MongodbPersistence;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- Preconditions: MongoDB connection established, `yjs-writings` collection accessible
|
|
|
+- Postconditions: Document state persisted; `updatedAt` metadata updated
|
|
|
+- Invariants: Same persistence behavior as current `createMongoDBPersistence`
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Extends the current `createMongoDBPersistence` pattern with additional responsibilities: after applying persisted state, `bindState` also runs `syncYDoc` and registers the awareness event bridge
|
|
|
+- This consolidation into `bindState` is intentional: y-websocket does NOT await `contentInitializor` or `bindState`, but within `bindState` itself the ordering is guaranteed (load → sync → awareness registration)
|
|
|
+- The `doc.on('update', ...)` handler for incremental persistence remains unchanged
|
|
|
+- Accepts `io` (Socket.IO server) and `syncYDoc` as dependencies via closure or factory parameters
|
|
|
+
|
|
|
+### Server / Events Layer
|
|
|
+
|
|
|
+#### AwarenessBridge
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Bridges y-websocket per-document awareness events to Socket.IO room broadcasts |
|
|
|
+| Requirements | 5.2, 5.3 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Listens to awareness update events on each WSSharedDoc
|
|
|
+- Emits `YjsAwarenessStateSizeUpdated` to the page's Socket.IO room on awareness changes
|
|
|
+- Emits `YjsHasYdocsNewerThanLatestRevisionUpdated` when the last editor disconnects
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: y-websocket document awareness — awareness update events (P0)
|
|
|
+- Outbound: Socket.IO io instance — room broadcast (P0)
|
|
|
+
|
|
|
+**Contracts**: Event [x]
|
|
|
+
|
|
|
+##### Event Contract
|
|
|
+- Published events (to Socket.IO rooms):
|
|
|
+ - `YjsAwarenessStateSizeUpdated` with `awarenessStateSize: number`
|
|
|
+ - `YjsHasYdocsNewerThanLatestRevisionUpdated` with `hasNewerYdocs: boolean`
|
|
|
+- Subscribed events (from y-websocket):
|
|
|
+ - `WSSharedDoc.awareness.on('update', ...)` — per-document awareness changes
|
|
|
+- Ordering: Best-effort delivery via Socket.IO; eventual consistency acceptable
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Awareness listener is registered inside `bindState` of the PersistenceAdapter (not in `setContentInitializor`), ensuring it runs after persistence is loaded
|
|
|
+- In y-websocket, awareness state count is `doc.awareness.getStates().size` (same API as y-socket.io's `doc.awareness.states.size`)
|
|
|
+- When awareness size drops to 0 (last editor leaves), check YDoc status and emit draft notification
|
|
|
+
|
|
|
+### Client / Hook Layer
|
|
|
+
|
|
|
+#### use-collaborative-editor-mode
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions |
|
|
|
+| Requirements | 2.2, 2.4, 5.1, 5.4 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Creates `WebsocketProvider` with the correct WebSocket URL and room name
|
|
|
+- Sets local awareness state with editor metadata (name, avatar, color)
|
|
|
+- Handles provider lifecycle (create on mount, destroy on unmount/deps change)
|
|
|
+- Provides CodeMirror extensions (yCollab, yUndoManagerKeymap) bound to the active Y.Doc
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Outbound: WebSocket server at `/yjs/{pageId}` — document sync (P0)
|
|
|
+- External: y-websocket `WebsocketProvider` — client provider (P0)
|
|
|
+- External: y-codemirror.next — CodeMirror binding (P0)
|
|
|
+
|
|
|
+**Contracts**: State [x]
|
|
|
+
|
|
|
+##### State Management
|
|
|
+- State model: `provider: WebsocketProvider | undefined` (local React state)
|
|
|
+- Persistence: None (provider is ephemeral, tied to component lifecycle)
|
|
|
+- Concurrency: Single provider per page; cleanup on deps change prevents duplicates
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Replace `SocketIOProvider` import with `WebsocketProvider` from `y-websocket`
|
|
|
+- Construct WebSocket URL: `${wsProtocol}//${window.location.host}/yjs` where `wsProtocol` is `wss:` or `ws:` based on `window.location.protocol`
|
|
|
+- Room name: `pageId` (same as current)
|
|
|
+- Options mapping: `autoConnect: true` → `connect: true`; `resyncInterval: 3000` unchanged
|
|
|
+- Awareness API is identical (`provider.awareness.setLocalStateField`, `.on('update', ...)`)
|
|
|
+- Event API mapping: `.on('sync', handler)` is the same
|
|
|
+
|
|
|
+### Dev / Config Layer
|
|
|
+
|
|
|
+#### ViteDevConfig
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Configures Vite dev server to support y-websocket collaborative editing |
|
|
|
+| Requirements | 7.1, 7.2 |
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- Replace `YSocketIO` import with y-websocket server utils (`setupWSConnection`, `getYDoc`)
|
|
|
+- Create `ws.WebSocketServer` in Vite's `configureServer` hook
|
|
|
+- Handle WebSocket upgrade on dev server's `httpServer`
|
|
|
+
|
|
|
+## Data Models
|
|
|
+
|
|
|
+No changes to data models. The `yjs-writings` MongoDB collection schema, indexes, and the `MongodbPersistence` extended class remain unchanged. The persistence interface (`bindState` / `writeState`) is compatible between y-socket.io and y-websocket.
|
|
|
+
|
|
|
+## Error Handling
|
|
|
+
|
|
|
+### Error Strategy
|
|
|
+
|
|
|
+| Error Type | Scenario | Response |
|
|
|
+|------------|----------|----------|
|
|
|
+| Auth Failure | Invalid/expired session cookie | 401 Unauthorized on upgrade, socket destroyed |
|
|
|
+| Access Denied | User lacks page access | 403 Forbidden on upgrade, socket destroyed |
|
|
|
+| Persistence Error | MongoDB read failure in bindState | Log error, serve empty doc (clients will sync from each other) |
|
|
|
+| WebSocket Close | Client network failure | Automatic reconnect with exponential backoff (built into WebsocketProvider) |
|
|
|
+| Document Not Found | getCurrentYdoc for non-active doc | Return undefined (existing behavior) |
|
|
|
+
|
|
|
+### Monitoring
|
|
|
+
|
|
|
+- Log WebSocket upgrade auth failures at `warn` level
|
|
|
+- Log document lifecycle events (create, destroy) at `debug` level
|
|
|
+- Log persistence errors at `error` level
|
|
|
+- Existing Socket.IO event monitoring unchanged
|
|
|
+
|
|
|
+## Security Notes
|
|
|
+
|
|
|
+- Auth check happens in the HTTP upgrade handler BEFORE WebSocket connection is established — unauthorized clients never receive any Yjs data
|
|
|
+- Uses same session mechanism as the rest of GROWI; no new attack surface
|