Просмотр исходного кода

rewrite spec for collaborative editor

Yuki Takei 1 неделя назад
Родитель
Сommit
e788418b45

+ 268 - 0
.kiro/specs/collaborative-editor/design.md

@@ -0,0 +1,268 @@
+# Design Document: collaborative-editor
+
+## Overview
+
+**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).
+
+### Goals
+- Guarantee a single server-side Y.Doc per page — no split-brain desynchronization
+- Provide real-time bidirectional sync for all connected editors
+- Authenticate and authorize WebSocket connections using existing session infrastructure
+- Persist draft state to MongoDB for durability across reconnections and restarts
+- Bridge awareness/presence events to non-editor UI via Socket.IO rooms
+
+### Non-Goals
+- Changing the Yjs document model, CodeMirror integration, or page save/revision logic
+- Migrating Socket.IO-based UI events to WebSocket
+- Changing the `yjs-writings` MongoDB collection schema or data format
+
+## Architecture
+
+### Architecture Diagram
+
+```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
+```
+
+**Key architectural properties**:
+- **Dual transport**: WebSocket for Yjs sync (`/yjs/{pageId}`), Socket.IO for UI events (`/socket.io/`)
+- **Singleton YjsService**: Encapsulates all Yjs document management
+- **Atomic document creation**: `map.setIfUndefined` from lib0 — synchronous get-or-create, no race condition window
+- **Session-based auth**: Cookie parsed from HTTP upgrade request, same session store as Express
+
+### Technology Stack
+
+| 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 |
+
+## 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
+```
+
+Authentication happens before `handleUpgrade` — unauthorized connections never reach the Yjs layer. Document creation uses `getYDoc`'s atomic `map.setIfUndefined` pattern.
+
+### 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
+```
+
+## Components and Interfaces
+
+| 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 |
+
+### YjsService
+
+**Intent**: Manages Yjs document lifecycle, WebSocket server setup, and public API for page save/status integration.
+
+**Responsibilities**:
+- Owns the `ws.WebSocketServer` instance and the y-websocket `docs` Map
+- Initializes persistence via y-websocket's `setPersistence`
+- Registers the HTTP `upgrade` handler (delegating auth to UpgradeHandler)
+- Exposes the same public interface as `IYjsService` for downstream consumers
+
+**Service Interface**:
+
+```typescript
+interface IYjsService {
+  getYDocStatus(pageId: string): Promise<YDocStatus>;
+  syncWithTheLatestRevisionForce(
+    pageId: string,
+    editingMarkdownLength?: number,
+  ): Promise<SyncLatestRevisionBody>;
+  getCurrentYdoc(pageId: string): Y.Doc | undefined;
+}
+```
+
+- Constructor accepts `httpServer: http.Server` and `io: Server`
+- Uses `WebSocket.Server({ noServer: true })` + y-websocket utils
+- Uses `httpServer.on('upgrade', ...)` with path check for `/yjs/`
+- **CRITICAL**: Socket.IO server must set `destroyUpgrade: false` to prevent engine.io from destroying non-Socket.IO upgrade requests
+
+### UpgradeHandler
+
+**Intent**: Authenticates WebSocket upgrade requests using session cookies and verifies page access.
+
+**Interface**:
+
+```typescript
+type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+```
+
+- Runs express-session and passport middleware via `runMiddleware` helper against raw `IncomingMessage`
+- `writeErrorResponse` writes HTTP status line only — socket cleanup deferred to caller (works with `guardSocket`)
+- Guest access: if `user` is undefined but page allows guest access, authorization proceeds
+
+### guardSocket
+
+**Intent**: 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.
+
+```typescript
+const guard = guardSocket(socket);
+const result = await handleUpgrade(request, socket, head);
+guard.restore();
+```
+
+### PersistenceAdapter
+
+**Intent**: Adapts MongodbPersistence to y-websocket's persistence interface (`bindState`, `writeState`).
+
+**Interface**:
+
+```typescript
+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 bridge
+- `writeState`: Flushes document state to MongoDB on last-client disconnect
+- Ordering within `bindState` is guaranteed (persistence load → sync → awareness registration)
+
+### AwarenessBridge
+
+**Intent**: Bridges y-websocket per-document awareness events to Socket.IO room broadcasts.
+
+**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
+
+### use-collaborative-editor-mode (Client Hook)
+
+**Intent**: Manages WebsocketProvider lifecycle, awareness state, and CodeMirror extensions.
+
+**Key details**:
+- WebSocket URL: `${wsProtocol}//${window.location.host}/yjs`, room name: `pageId`
+- Options: `connect: true`, `resyncInterval: 3000`
+- Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)`
+- All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations
+
+## Data Models
+
+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 Handling
+
+| 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 |
+
+## Requirements Traceability
+
+| 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 |

+ 14 - 35
.kiro/specs/migrate-to-y-websocket/requirements.md → .kiro/specs/collaborative-editor/requirements.md

@@ -2,13 +2,11 @@
 
 ## Introduction
 
-GROWI's collaborative editing system currently uses `y-socket.io` (v1.1.3) as the Yjs transport layer for real-time document synchronization. A critical race condition in `y-socket.io`'s `initDocument()` method causes clients to occasionally split into isolated Y.Doc instances on the server, resulting in permanent desynchronization until browser reload. The `y-socket.io` library has been unmaintained since September 2023.
-
-This specification defines the requirements for migrating the collaborative editing transport layer from `y-socket.io` to `y-websocket`, the official Yjs WebSocket provider maintained by the Yjs core team. The migration resolves the document initialization race condition while maintaining all existing collaborative editing functionality.
+GROWI provides real-time collaborative editing powered by Yjs, allowing multiple users to simultaneously edit the same wiki page with automatic conflict resolution. The collaborative editing system uses `y-websocket` as the Yjs transport layer over native WebSocket, with MongoDB persistence for draft state and Socket.IO bridging for awareness/presence events to non-editor UI components.
 
 **Scope**: Server-side Yjs document management, client-side Yjs provider, WebSocket authentication, MongoDB persistence integration, and awareness/presence tracking.
 
-**Out of Scope**: Changes to the Yjs document model itself, CodeMirror editor integration, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events.
+**Out of Scope**: The Yjs document model itself, CodeMirror editor integration details, page save/revision logic, or the global Socket.IO infrastructure used for non-Yjs events.
 
 ## Requirements
 
@@ -26,14 +24,14 @@ This specification defines the requirements for migrating the collaborative edit
 
 ### Requirement 2: WebSocket Transport Layer
 
-**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket instead of y-socket.io, so that the system benefits from active maintenance and the race-condition-free document initialization pattern.
+**Objective:** As a system operator, I want the collaborative editing transport to use y-websocket over native WebSocket, so that the system benefits from active maintenance and atomic document initialization.
 
 #### Acceptance Criteria
 
-1. The Yjs Service shall use `y-websocket` (or `@y/websocket-server`) as the server-side Yjs transport, replacing `y-socket.io`.
-2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider, replacing `y-socket.io`'s `SocketIOProvider`.
+1. The Yjs Service shall use `y-websocket` server utilities as the server-side Yjs transport.
+2. The Editor Client shall use `y-websocket`'s `WebsocketProvider` as the client-side Yjs provider.
 3. The WebSocket server shall coexist with the existing Socket.IO server on the same HTTP server instance without port conflicts.
-4. The Yjs Service shall support the `resyncInterval` capability (periodic state re-synchronization) to recover from any missed updates.
+4. The Yjs Service shall support `resyncInterval` (periodic state re-synchronization) to recover from any missed updates.
 
 ### Requirement 3: Authentication and Authorization
 
@@ -46,14 +44,14 @@ This specification defines the requirements for migrating the collaborative edit
 3. If an unauthenticated or unauthorized WebSocket upgrade request is received, the Yjs Service shall reject the connection with an appropriate HTTP error status.
 4. Where guest access is enabled for a page, the Yjs Service shall allow guest users to connect to that page's collaborative editing session.
 
-### Requirement 4: MongoDB Persistence Compatibility
+### Requirement 4: MongoDB Persistence
 
-**Objective:** As a system operator, I want the Yjs persistence layer to continue using the existing MongoDB storage, so that no data migration is required and existing drafts are preserved.
+**Objective:** As a system operator, I want the Yjs persistence layer to use MongoDB storage, so that draft state is preserved across server restarts and client reconnections.
 
 #### Acceptance Criteria
 
-1. The Yjs Service shall continue to use the `yjs-writings` MongoDB collection for document persistence.
-2. The Yjs Service shall maintain compatibility with the existing `MongodbPersistence` implementation (extended `y-mongodb-provider`).
+1. The Yjs Service shall use the `yjs-writings` MongoDB collection for document persistence.
+2. The Yjs Service shall use the `MongodbPersistence` implementation (extended `y-mongodb-provider`).
 3. When a Y.Doc is loaded from persistence, the Yjs Service shall apply the persisted state before sending sync messages to connecting clients.
 4. When a Y.Doc receives updates, the Yjs Service shall persist each update to MongoDB with an `updatedAt` timestamp.
 5. When all clients disconnect from a document, the Yjs Service shall flush the document state to MongoDB before destroying the in-memory instance.
@@ -71,30 +69,11 @@ This specification defines the requirements for migrating the collaborative edit
 
 ### Requirement 6: YDoc Status and Sync Integration
 
-**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to continue functioning, so that draft detection, save operations, and revision synchronization work correctly.
+**Objective:** As a system component, I want the YDoc status detection and force-sync mechanisms to function correctly, so that draft detection, save operations, and revision synchronization work as expected.
 
 #### Acceptance Criteria
 
-1. The Yjs Service shall continue to expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED).
-2. The Yjs Service shall continue to expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists.
+1. The Yjs Service shall expose `getYDocStatus(pageId)` returning the correct status (ISOLATED, NEW, DRAFT, SYNCED, OUTDATED).
+2. The Yjs Service shall expose `getCurrentYdoc(pageId)` returning the in-memory Y.Doc instance if one exists.
 3. When a Y.Doc is loaded from persistence (within `bindState`), the Yjs Service shall call `syncYDoc` to synchronize the document with the latest revision based on YDoc status.
-4. The Yjs Service shall continue to expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization.
-
-### Requirement 7: Development Environment Support
-
-**Objective:** As a developer, I want the collaborative editing to work in the local development environment, so that I can develop and test collaborative features.
-
-#### Acceptance Criteria
-
-1. The Vite dev server configuration (`packages/editor/vite.config.ts`) shall support the y-websocket-based collaborative editing setup.
-2. When running `turbo run dev`, the WebSocket endpoint for collaborative editing shall be available alongside the existing Socket.IO endpoints.
-
-### Requirement 8: Dependency Cleanup
-
-**Objective:** As a maintainer, I want the y-socket.io dependency to be completely removed after migration, so that the codebase has no dead dependencies.
-
-#### Acceptance Criteria
-
-1. When the migration is complete, the `y-socket.io` package shall be removed from all `package.json` files in the monorepo.
-2. The system shall have no remaining imports or type references to `y-socket.io` modules.
-3. The `y-websocket` (and/or `@y/websocket-server`) package shall be listed in the appropriate `dependencies` or `devDependencies` section based on the Turbopack externalisation rule.
+4. The Yjs Service shall expose `syncWithTheLatestRevisionForce(pageId)` for API-triggered force synchronization.

+ 69 - 0
.kiro/specs/collaborative-editor/research.md

@@ -0,0 +1,69 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `collaborative-editor`
+- **Key Findings**:
+  - y-websocket uses atomic `map.setIfUndefined` for document creation — eliminates TOCTOU race conditions
+  - `y-websocket@2.x` bundles both client and server utils with `yjs@^13` compatibility
+  - `ws` package already installed in GROWI; Express HTTP server supports adding WebSocket upgrade alongside Socket.IO
+
+## Design Decisions
+
+### Decision: Use y-websocket@2.x for both client and server
+
+- **Context**: Need yjs v13 compatibility on both client and server sides
+- **Alternatives Considered**:
+  1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed
+  2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
+  3. y-websocket@2.x for everything — simplest path, proven code
+- **Selected**: Option 3 — `y-websocket@2.x`
+- **Rationale**: Minimizes custom code, proven server utils, compatible with yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens
+- **Trade-offs**: Miss v3 SyncStatus feature, but `sync` event + `resyncInterval` meets all requirements
+- **Follow-up**: Plan separate yjs v14 migration, then upgrade to y-websocket v3 + @y/websocket-server
+
+### Decision: WebSocket path prefix `/yjs/`
+
+- **Context**: Need URL pattern that doesn't conflict with Socket.IO
+- **Selected**: `/yjs/{pageId}`
+- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes
+
+### Decision: Session-based authentication on WebSocket upgrade
+
+- **Context**: Must authenticate WebSocket connections without Socket.IO middleware
+- **Selected**: Parse session cookie from HTTP upgrade request, deserialize user from session store
+- **Rationale**: Reuses existing session infrastructure — same cookie, same store, same passport serialization
+- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout
+
+### Decision: Keep Socket.IO for awareness event fan-out
+
+- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components
+- **Selected**: Continue using Socket.IO `io.in(roomName).emit()` for awareness events, bridging from y-websocket awareness
+- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope
+
+## Critical Implementation Constraints
+
+### engine.io `destroyUpgrade` setting
+
+Socket.IO's engine.io v6 defaults `destroyUpgrade: true` in its `attach()` method. This causes engine.io to destroy all non-Socket.IO upgrade requests after a 1-second timeout. The Socket.IO server **must** be configured with `destroyUpgrade: false` to allow `/yjs/` WebSocket handshakes to succeed.
+
+### Next.js upgradeHandler race condition (guardSocket pattern)
+
+Next.js's `NextCustomServer.upgradeHandler` registers an `upgrade` listener on the HTTP server. When the Yjs async handler yields at its first `await`, Next.js's synchronous handler runs and calls `socket.end()` for unrecognized paths. The `guardSocket` pattern temporarily replaces `socket.end()`/`socket.destroy()` with no-ops before the first `await`, restoring them after auth completes.
+
+- `prependListener` cannot solve this — it only changes listener order, cannot prevent subsequent listeners from executing
+- Removing Next.js's listener is fragile and breaks HMR
+- Synchronous auth is impossible (requires async MongoDB/session store queries)
+
+### React render-phase violation in use-collaborative-editor-mode
+
+Provider creation and awareness event handlers must be placed **outside** `setProvider(() => { ... })` functional state updaters. If inside, `awareness.setLocalStateField()` triggers synchronous awareness events that update other components during render. All side effects go in the `useEffect` body; `setProvider(_provider)` is called with a plain value.
+
+### y-websocket bindState ordering
+
+y-websocket does NOT await `bindState` before sending sync messages. However, within `bindState` itself, the ordering is guaranteed: persistence load → YDocStatus check → syncYDoc → awareness registration. This consolidation is intentional.
+
+## References
+- [y-websocket GitHub](https://github.com/yjs/y-websocket)
+- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) (yjs v14, future migration target)
+- [ws npm](https://www.npmjs.com/package/ws)
+- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider)

+ 2 - 3
.kiro/specs/migrate-to-y-websocket/spec.json → .kiro/specs/collaborative-editor/spec.json

@@ -1,10 +1,9 @@
 {
-  "feature_name": "migrate-to-y-websocket",
+  "feature_name": "collaborative-editor",
   "created_at": "2026-03-19T00:00:00.000Z",
   "updated_at": "2026-03-24T00:00:00.000Z",
   "language": "en",
-  "phase": "implementation-complete",
-  "cleanup_completed": true,
+  "phase": "active",
   "approvals": {
     "requirements": {
       "generated": true,

+ 3 - 0
.kiro/specs/collaborative-editor/tasks.md

@@ -0,0 +1,3 @@
+# Implementation Plan
+
+No pending tasks. Use `/kiro:spec-tasks collaborative-editor` to generate tasks for new work.

+ 0 - 450
.kiro/specs/migrate-to-y-websocket/design.md

@@ -1,450 +0,0 @@
-# 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

+ 0 - 158
.kiro/specs/migrate-to-y-websocket/research.md

@@ -1,158 +0,0 @@
-# Research & Design Decisions
-
-## Summary
-- **Feature**: `migrate-to-y-websocket`
-- **Discovery Scope**: Complex Integration
-- **Key Findings**:
-  - y-socket.io's `initDocument()` has a TOCTOU race condition due to `await` between Map get and set — y-websocket uses atomic `map.setIfUndefined` which eliminates this class of bug
-  - `@y/websocket-server@0.1.5` requires `yjs@^14` (incompatible), but `y-websocket@2.0.4` bundles server utils with `yjs@^13` support
-  - The `ws` package is already installed in GROWI (`ws@^8.17.1`); Express HTTP server supports adding a WebSocket upgrade handler alongside Socket.IO
-
-## Research Log
-
-### y-socket.io Race Condition Root Cause
-- **Context**: Clients occasionally desynchronize — some see edits, others don't
-- **Sources Consulted**: `node_modules/y-socket.io/dist/server/server.js` (minified source)
-- **Findings**:
-  - `initDocument()` does `_documents.get(name)`, then `await persistence.bindState(name, doc)`, then `_documents.set(name, doc)`
-  - The `await` yields to the event loop, allowing a concurrent `initDocument` call to create a second Y.Doc for the same name
-  - Each socket's sync listeners are bound to the doc returned by its `initDocument` call
-  - Namespace-level broadcasts partially mask the issue, but resync intervals and disconnect handlers operate on the wrong doc instance
-- **Implications**: The only fix is replacing the transport layer; patching y-socket.io is fragile since the library is unmaintained
-
-### y-websocket Document Initialization Safety
-- **Context**: Verify y-websocket does not have the same race condition
-- **Sources Consulted**: `@y/websocket-server/src/utils.js`, y-websocket v2 `bin/utils.cjs`
-- **Findings**:
-  - Uses `map.setIfUndefined(docs, docname, () => { ... })` from lib0 — synchronous atomic get-or-create
-  - Document is registered in the Map before any async operation (persistence, contentInitializor)
-  - `persistence.bindState` is called but NOT awaited inline — the document is already in the Map
-  - Concurrent connections calling `getYDoc` with the same name receive the same WSSharedDoc instance
-- **Implications**: The primary race condition is eliminated by design
-
-### y-websocket Package Version Compatibility
-- **Context**: Choose correct package versions for GROWI's yjs v13 stack
-- **Sources Consulted**: npm registry for y-websocket, @y/websocket-server
-- **Findings**:
-  - `y-websocket@3.0.0` (Apr 2025): Client-only, peers on `yjs@^13.5.6` — compatible
-  - `y-websocket@3.0.0`: Removed server utils (moved to separate package)
-  - `@y/websocket-server@0.1.5` (Feb 2026): Requires `yjs@^14.0.0-7` — **incompatible**
-  - `y-websocket@2.0.4` (Jul 2024): Includes both client and server utils (`./bin/utils`), peers on `yjs@^13.5.6` — compatible
-  - `y-websocket@2.1.0` (Dec 2024): Also includes server utils, peers on `yjs@^13.5.6` — compatible
-- **Implications**: Must use y-websocket v2.x for server utils, or vendor/adapt server code from @y/websocket-server
-
-### HTTP Server and WebSocket Coexistence
-- **Context**: Determine how to add raw WebSocket alongside existing Socket.IO
-- **Sources Consulted**: `apps/app/src/server/crowi/index.ts`, `apps/app/src/server/service/socket-io/socket-io.ts`, `engine.io@6.6.5/build/server.js`
-- **Findings**:
-  - HTTP server created via `http.createServer(app)` at crowi/index.ts:582
-  - Socket.IO attaches to this server and handles its own `upgrade` events for `/socket.io/` path
-  - `ws@^8.17.1` already installed in apps/app
-  - WebSocket.Server with `noServer: true` can coexist by handling `upgrade` events for a different path prefix
-  - **CRITICAL**: engine.io v6 defaults `destroyUpgrade: true` in its `attach()` method (server.js:657). This causes engine.io to destroy all non-Socket.IO upgrade requests after `destroyUpgradeTimeout` (default 1000ms). Without setting `destroyUpgrade: false`, any WebSocket upgrade to `/yjs/` is silently killed by engine.io before the Yjs handler can complete the handshake.
-  - The `destroyUpgrade` option must be passed via `new Server(httpServer, { destroyUpgrade: false })` in the Socket.IO constructor, which forwards it to `engine.io.attach(server, opts)`.
-- **Implications**: Safe to add `ws.Server` with path prefix `/yjs/` on the same HTTP server, **provided `destroyUpgrade: false` is set on the Socket.IO server**
-
-### Authentication for Raw WebSocket
-- **Context**: y-socket.io piggybacks on Socket.IO middleware for session/passport; raw WebSocket needs custom auth
-- **Sources Consulted**: `apps/app/src/server/crowi/express-init.js`, `apps/app/src/server/service/socket-io/socket-io.ts`
-- **Findings**:
-  - Socket.IO attaches session middleware via `io.engine.use(expressSession(...))`
-  - Express session uses cookie-based session ID (`connect.sid` or configured name)
-  - Raw WebSocket `upgrade` request carries the same HTTP cookies
-  - Can reconstruct session by: (1) parsing cookie from upgrade request, (2) loading session from store (Redis or MongoDB)
-  - Passport user is stored in `req.session.passport.user`, deserialized via `passport.deserializeUser`
-- **Implications**: Authentication requires manual session parsing in the `upgrade` handler, but uses the same session store and cookie
-
-## Architecture Pattern Evaluation
-
-| Option | Description | Strengths | Risks / Limitations | Notes |
-|--------|-------------|-----------|---------------------|-------|
-| A: y-websocket@2.x (client + server) | Use v2.x which bundles both client and server utils | Single package, proven server code, yjs v13 compatible | Older client (missing v3 SyncStatus), v2 may stop receiving updates | Simplest migration path |
-| B: y-websocket@3.x client + custom server | Use v3 client + inline server adapter based on @y/websocket-server | Latest client features, full control over server code | Must maintain custom server code (~200 lines) | Recommended if v3 features needed |
-| C: y-websocket@3.x + @y/websocket-server | Use v3 client + official server package | Official packages for both sides | Requires yjs v14 upgrade (breaking change) | Too risky for this migration scope |
-
-## Design Decisions
-
-### Decision: Use y-websocket@2.x for both client and server
-
-- **Context**: Need yjs v13 compatibility on both client and server sides
-- **Alternatives Considered**:
-  1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed now
-  2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
-  3. y-websocket@2.x for everything — simplest path, proven code
-- **Selected Approach**: Option A — use `y-websocket@2.x` (specifically 2.0.4 or 2.1.0)
-- **Rationale**: Minimizes custom code, proven server utils, compatible with existing yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens separately
-- **Trade-offs**: Miss v3 SyncStatus feature, but current `sync` event + `resyncInterval` meets all requirements
-- **Follow-up**: Plan separate yjs v14 migration in future, then upgrade to y-websocket v3 + @y/websocket-server
-
-### Decision: WebSocket path prefix `/yjs/`
-
-- **Context**: Need URL pattern for Yjs WebSocket connections that doesn't conflict with Socket.IO
-- **Alternatives Considered**:
-  1. `/yjs/{pageId}` — clean, matches existing `/yjs|{pageId}` pattern semantics
-  2. `/ws/yjs/{pageId}` — more explicit WebSocket prefix
-  3. `/api/v3/yjs/{pageId}` — matches API convention
-- **Selected Approach**: `/yjs/{pageId}` path prefix
-- **Rationale**: Simple, semantic, no conflict with Socket.IO's `/socket.io/` path or Express routes
-- **Trade-offs**: None significant
-
-### Decision: Session-based authentication on WebSocket upgrade
-
-- **Context**: Must authenticate WebSocket connections without Socket.IO middleware
-- **Alternatives Considered**:
-  1. Parse session cookie from upgrade request, load session from store — reuses existing session infrastructure
-  2. Token-based auth via query params — simpler but requires generating/managing tokens
-  3. Separate auth endpoint + upgrade — adds complexity
-- **Selected Approach**: Parse session cookie from the HTTP upgrade request and deserialize the user from the session store
-- **Rationale**: Reuses existing session infrastructure (same cookie, same store, same passport serialization), no client-side auth changes needed
-- **Trade-offs**: Couples to express-session internals, but GROWI already has this coupling throughout
-
-### Decision: Keep Socket.IO for awareness event fan-out
-
-- **Context**: GROWI uses Socket.IO rooms (`page:{pageId}`) to broadcast awareness updates to non-editor components (page viewers, sidebar, etc.)
-- **Selected Approach**: Continue using Socket.IO `io.in(roomName).emit()` for awareness size events and draft status notifications. Hook into y-websocket's per-document awareness events and bridge to Socket.IO.
-- **Rationale**: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope
-- **Trade-offs**: Two transport layers (WebSocket for Yjs sync, Socket.IO for UI events) — acceptable given the separation of concerns
-
-## Risks & Mitigations
-- **Risk**: y-websocket server `persistence.bindState` is not awaited before first sync → client may briefly see empty doc
-  - **Mitigation**: Override `setupWSConnection` to await `doc.whenInitialized` before sending sync step 1, or ensure `bindState` completes fast (MongoDB read is typically <50ms)
-- **Risk**: engine.io `destroyUpgrade` kills non-Socket.IO WebSocket upgrades
-  - **Mitigation**: Set `destroyUpgrade: false` in Socket.IO server options. Without this, engine.io's `attach()` registers an `upgrade` listener that destroys any upgrade request not matching the Socket.IO path after a 1-second timeout, causing `/yjs/` WebSocket handshakes to fail silently (connection reset with no HTTP response).
-  - **Discovered during**: Implementation validation — `curl` WebSocket upgrade to `/yjs/{pageId}` returned "Empty reply from server"
-- **Risk**: Socket.IO and ws competing for HTTP upgrade events
-  - **Mitigation**: Socket.IO only handles `/socket.io/` path; register ws handler for `/yjs/` path with explicit path check before `handleUpgrade`. Combined with `destroyUpgrade: false`, non-Socket.IO upgrades are left untouched by engine.io.
-- **Risk**: Session cookie parsing edge cases (SameSite, Secure flags, proxy headers)
-  - **Mitigation**: Reuse existing express-session cookie parser and session store; test with the same proxy configuration
-- **Risk**: Document cleanup race when last client disconnects and a new client immediately connects
-  - **Mitigation**: y-websocket's `getYDoc` atomic pattern handles this — new client gets a fresh doc if cleanup completed, or the existing doc if not yet cleaned up
-
-## Implementation Discoveries
-
-### Next.js NextCustomServer.upgradeHandler Race Condition
-- **Context**: WebSocket connections to `/yjs/{pageId}` failed with "could not establish connection" in dev mode
-- **Root Cause**: Next.js's `NextCustomServer.upgradeHandler` (in `next/dist/server/lib/router-server.js:657`) registers an `upgrade` listener on the HTTP server. When the Yjs async handler yields at `await handleUpgrade(...)`, Next.js's synchronous handler runs and calls `socket.end()` for paths it does not recognize.
-- **Evidence**: Stack trace confirmed `socket.end()` called from `NextCustomServer.upgradeHandler` during the 6ms async auth gap
-- **Solution**: `guardSocket` pattern — temporarily replace `socket.end()`/`socket.destroy()` with no-ops before the first `await`, restore after auth completes
-- **Why alternatives don't work**:
-  - `httpServer.prependListener('upgrade', ...)` — only changes listener order, cannot prevent subsequent listeners from executing
-  - Removing Next.js's listener — fragile, breaks HMR
-  - Synchronous auth — impossible (requires async MongoDB/session store queries)
-- **Test**: `guard-socket.spec.ts` reproduces the scenario with a hostile upgrade handler
-
-### React Render-Phase Violation in use-collaborative-editor-mode
-- **Context**: `Cannot update a component (EditingUsers) while rendering a different component (Y)` warning
-- **Root Cause**: Provider creation and awareness event handlers were inside `setProvider(() => { ... })` — a functional state updater that React may call during rendering. `awareness.setLocalStateField()` triggered synchronous awareness events, which called `onEditorsUpdated()`, updating `EditingUsers` state during render.
-- **Solution**: Moved all side effects (provider creation, awareness setup, event handler registration) out of the `setProvider` updater into the `useEffect` body. `setProvider(_provider)` is called with a plain value after setup completes.
-
-### writeErrorResponse Pattern (Socket Lifecycle Separation)
-- **Context**: `rejectUpgrade` originally called both `socket.write()` and `socket.destroy()`, but during `guardSocket` protection, `destroy` was a no-op — creating confusing dual-destroy semantics
-- **Solution**: Renamed to `writeErrorResponse` with only `socket.write()`. Socket cleanup (`destroy`) is exclusively managed by the caller (`yjs.ts`) after `guard.restore()`, ensuring correct behavior regardless of guard state.
-
-## References
-- [y-websocket GitHub](https://github.com/yjs/y-websocket) — official Yjs WebSocket provider
-- [y-websocket-server GitHub](https://github.com/yjs/y-websocket-server) — server-side utilities (yjs v14)
-- [y-socket.io npm](https://www.npmjs.com/package/y-socket.io) — current library (unmaintained since Sep 2023)
-- [ws npm](https://www.npmjs.com/package/ws) — WebSocket implementation for Node.js
-- [y-mongodb-provider](https://github.com/MaxNoetzold/y-mongodb-provider) — MongoDB persistence for Yjs

+ 0 - 102
.kiro/specs/migrate-to-y-websocket/tasks.md

@@ -1,102 +0,0 @@
-# Implementation Plan
-
-- [x] 1. Add y-websocket dependency and adapt persistence layer
-- [x] 1.1 (P) Add y-websocket package to apps/app and packages/editor
-  - Add `y-websocket@^2.0.4` to both `apps/app/package.json` and `packages/editor/package.json`
-  - Classify as `dependencies` in apps/app (server-side `bin/utils` is used at runtime) and verify Turbopack externalisation after build
-  - Run `pnpm install` to update lockfile
-  - _Requirements: 2.1, 8.3_
-
-- [x] 1.2 (P) Adapt the MongoDB persistence layer to the y-websocket persistence interface
-  - Update `create-mongodb-persistence.ts` to return an object matching y-websocket's `setPersistence` shape (`bindState`, `writeState`, `provider`)
-  - The `bindState` implementation extends the current logic: load persisted Y.Doc, compute diff, store update, apply persisted state, register incremental update handler with `updatedAt` metadata
-  - After applying persisted state within `bindState`, determine `YDocStatus` and call `syncYDoc` to synchronize with the latest revision — this guarantees correct ordering (persistence load completes before sync runs)
-  - Also within `bindState`, register the awareness event listener on the document for the AwarenessBridge (emit awareness state size and draft status to Socket.IO rooms)
-  - Accept `io` (Socket.IO server instance) and sync dependencies via factory parameters
-  - The `writeState` implementation calls `flushDocument` — same as current
-  - Update the `Persistence` type import from y-websocket's `bin/utils` instead of y-socket.io
-  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 6.3, 5.2, 5.3_
-
-- [x] 2. Implement WebSocket upgrade authentication handler
-- [x] 2.1 Create the upgrade handler that authenticates WebSocket connections using session cookies
-  - Parse the `cookie` header from the HTTP upgrade request to extract the session ID
-  - Load the session from the session store (Redis or MongoDB, matching GROWI's express-session configuration)
-  - Deserialize the user from the session via passport's `deserializeUser`
-  - Extract `pageId` from the URL path (`/yjs/{pageId}`)
-  - Verify page access using `Page.isAccessiblePageByViewer(pageId, user)`
-  - Allow guest access when the page permits it and the user is null
-  - Reject unauthorized requests with `401 Unauthorized` or `403 Forbidden` by writing HTTP response headers and destroying the socket — before `handleUpgrade` is called
-  - Attach the authenticated user to the request object for downstream use
-  - _Requirements: 3.1, 3.2, 3.3, 3.4_
-
-- [x] 3. Rewrite YjsService to use y-websocket server utilities
-- [x] 3.1 Replace YSocketIO with ws.WebSocketServer and y-websocket document management
-  - Change the constructor to accept both `httpServer` and `io` (instead of only `io`)
-  - Create a `WebSocket.Server` with `noServer: true` mode
-  - Call y-websocket's `setPersistence` with the adapted persistence layer from task 1.2
-  - Register the HTTP `upgrade` event handler on `httpServer`, routing requests with path prefix `/yjs/` to the upgrade handler from task 2.1, then to `wss.handleUpgrade`, and finally to y-websocket's `setupWSConnection` with the extracted `pageId` as `docName`
-  - Ensure Socket.IO's upgrade handling for `/socket.io/` is not affected by checking the URL path before intercepting
-  - **Set `destroyUpgrade: false`** on the Socket.IO server (`socket-io.ts`) to prevent engine.io from destroying non-Socket.IO upgrade requests (discovered during validation: engine.io v6 defaults `destroyUpgrade: true`, silently killing `/yjs/` WebSocket handshakes)
-  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.3_
-
-- [x] 3.2 Integrate document status API and force-sync
-  - Replace `ysocketio.documents.get(pageId)` with y-websocket's `docs.get(pageId)` for `getCurrentYdoc` and `syncWithTheLatestRevisionForce`
-  - Preserve all public API behavior of `IYjsService` (getYDocStatus, getCurrentYdoc, syncWithTheLatestRevisionForce)
-  - Update `sync-ydoc.ts` type imports: change `Document` from y-socket.io to y-websocket's `WSSharedDoc` (or `Y.Doc`)
-  - Note: sync-on-load (`syncYDoc`) and awareness bridging are handled inside `bindState` of the PersistenceAdapter (task 1.2), not via `setContentInitializor`
-  - _Requirements: 6.1, 6.2, 6.3, 6.4_
-
-- [x] 4. Update server initialization flow
-- [x] 4.1 Pass httpServer to YjsService initialization
-  - Update `initializeYjsService` to accept both `httpServer` and `io` parameters
-  - Update the call site in `crowi/index.ts` to pass `httpServer` alongside `socketIoService.io`
-  - Verify the initialization order: httpServer created → Socket.IO attached → YjsService initialized with both references
-  - _Requirements: 2.3_
-
-- [x] 5. Migrate client-side provider to WebsocketProvider
-- [x] 5.1 (P) Replace SocketIOProvider with WebsocketProvider in the collaborative editor hook
-  - Change the import from `y-socket.io` to `y-websocket`
-  - Construct the WebSocket URL dynamically: use `wss://` when the page is served over HTTPS, `ws://` otherwise, appending `/yjs` as the base path
-  - Use `pageId` as the `roomname` parameter (same as current)
-  - Map options: `autoConnect: true` → `connect: true`; keep `resyncInterval: 3000`
-  - Awareness API calls remain identical: `provider.awareness.setLocalStateField`, `.getStates()`, `.on('update', ...)`
-  - Sync event listener remains identical: `provider.on('sync', handler)`
-  - Lifecycle cleanup remains identical: `provider.disconnect()`, `provider.destroy()`
-  - _Requirements: 2.2, 2.4, 5.1, 5.4_
-
-- [x] 6. Update Vite dev server configuration
-- [x] 6.1 (P) Configure the packages/editor Vite dev server to use y-websocket
-  - Replace the `YSocketIO` import with y-websocket server utilities (`setupWSConnection`, `setPersistence`)
-  - Create a `WebSocket.Server` with `noServer: true` in Vite's `configureServer` hook
-  - Handle WebSocket upgrade events on the dev server's `httpServer` for the `/yjs/` path prefix
-  - Ensure the Vite HMR WebSocket and the Yjs WebSocket do not conflict (different paths)
-  - _Requirements: 7.1, 7.2_
-
-- [x] 7. Remove y-socket.io and finalize dependencies
-- [x] 7.1 Remove all y-socket.io references from the codebase
-  - Remove `y-socket.io` from `apps/app/package.json` and `packages/editor/package.json`
-  - Verify no remaining imports or type references to `y-socket.io` modules across the monorepo
-  - Run `pnpm install` to update the lockfile
-  - Verify `y-websocket` is classified correctly (`dependencies` vs `devDependencies`) by checking Turbopack externalisation: run `turbo run build --filter @growi/app` and check `apps/app/.next/node_modules/` for y-websocket
-  - _Requirements: 8.1, 8.2, 8.3_
-
-- [x] 8. Integration and concurrency tests
-- [x] 8.1 Add integration tests for the WebSocket connection and sync flow
-  - Test the full connection flow: WebSocket upgrade → authentication → document creation → sync step 1/2
-  - Test multi-client sync: two clients connect to the same page, verify both receive each other's edits via the same server-side Y.Doc
-  - Test reconnection: client disconnects and reconnects, verify it receives updates that occurred during disconnection
-  - Test persistence round-trip: document persisted when all clients disconnect, state restored when a new client connects
-  - _Requirements: 1.3, 1.4, 4.3, 4.5_
-
-- [x] 8.2 Add concurrency tests for document initialization safety
-  - Test simultaneous connections: multiple clients connect to the same page at the exact same time, verify that exactly one Y.Doc instance exists on the server (the core race condition fix)
-  - Test disconnect-during-connect: one client disconnects while another is connecting, verify no document corruption or data loss
-  - _Requirements: 1.1, 1.2, 1.5_
-
-- [x] 8.3 Add unit tests for the upgrade authentication handler
-  - Test valid session cookie → user deserialized → page access granted → upgrade proceeds
-  - Test expired/invalid session → 401 response → socket destroyed
-  - Test valid user but no page access → 403 response → socket destroyed
-  - Test guest user with guest-accessible page → upgrade proceeds
-  - Test missing or malformed URL path → rejection
-  - _Requirements: 3.1, 3.2, 3.3, 3.4_