Browse Source

Merge pull request #10889 from growilabs/support/migrate-to-y-websocket

feat(yjs): migrate collaborative editing transport from y-socket.io to y-websocket
mergify[bot] 1 week ago
parent
commit
b48aef9b81

+ 5 - 0
.changeset/eight-zebras-design.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+add YJS_WEBSOCKET_BASE_PATH

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

@@ -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

+ 100 - 0
.kiro/specs/migrate-to-y-websocket/requirements.md

@@ -0,0 +1,100 @@
+# Requirements Document
+
+## 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.
+
+**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.
+
+## Requirements
+
+### Requirement 1: Document Synchronization Integrity
+
+**Objective:** As a wiki user editing collaboratively, I want all clients editing the same page to always share a single server-side Y.Doc instance, so that edits are never lost due to document desynchronization.
+
+#### Acceptance Criteria
+
+1. When multiple clients connect to the same page simultaneously, the Yjs Service shall ensure that exactly one Y.Doc instance exists on the server for that page.
+2. When a client connects while another client's document initialization is in progress, the Yjs Service shall return the same Y.Doc instance to both clients without creating a duplicate.
+3. When a client reconnects after a brief network disconnection, the Yjs Service shall synchronize the client with the existing server-side Y.Doc containing all other clients' changes.
+4. While multiple clients are editing the same page, the Yjs Service shall propagate each client's changes to all other connected clients in real time.
+5. If a client's WebSocket connection drops and reconnects, the Yjs Service shall not destroy the server-side Y.Doc while other clients remain connected.
+
+### 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.
+
+#### 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`.
+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.
+
+### Requirement 3: Authentication and Authorization
+
+**Objective:** As a system administrator, I want WebSocket connections for collaborative editing to be authenticated and authorized, so that only permitted users can access page content via the Yjs channel.
+
+#### Acceptance Criteria
+
+1. When a WebSocket upgrade request is received for collaborative editing, the Yjs Service shall authenticate the user using the existing session/passport mechanism.
+2. When an authenticated user attempts to connect to a page's Yjs document, the Yjs Service shall verify that the user has read access to that page before allowing the connection.
+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
+
+**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.
+
+#### 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`).
+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.
+
+### Requirement 5: Awareness and Presence Tracking
+
+**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+
+#### Acceptance Criteria
+
+1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
+2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
+3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
+4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider.
+
+### 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.
+
+#### 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.
+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.

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

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

+ 23 - 0
.kiro/specs/migrate-to-y-websocket/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "migrate-to-y-websocket",
+  "created_at": "2026-03-19T00:00:00.000Z",
+  "updated_at": "2026-03-24T00:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "cleanup_completed": true,
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

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

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

+ 1 - 1
apps/app/package.json

@@ -285,7 +285,7 @@
     "xss": "^1.0.15",
     "y-codemirror.next": "^0.3.5",
     "y-mongodb-provider": "^0.2.0",
-    "y-socket.io": "^1.1.3",
+    "y-websocket": "^2.0.4",
     "yjs": "^13.6.18",
     "zod": "^3.24.2"
   },

+ 12 - 0
apps/app/src/interfaces/session-config.ts

@@ -0,0 +1,12 @@
+export interface SessionConfig {
+  rolling: boolean;
+  secret: string;
+  resave: boolean;
+  saveUninitialized: boolean;
+  cookie: {
+    maxAge: number;
+  };
+  genid: (req: { path: string }) => string;
+  name?: string;
+  store?: unknown;
+}

+ 6 - 14
apps/app/src/server/crowi/index.ts

@@ -14,6 +14,7 @@ import { initializeOpenaiService } from '~/features/openai/server/services/opena
 import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
 import instanciatePageBulkExportJobCleanUpCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
 import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
+import type { SessionConfig } from '~/interfaces/session-config';
 import { startCron as startAccessTokenCron } from '~/server/service/access-token';
 import { projectRoot } from '~/server/util/project-dir-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -84,19 +85,6 @@ type CommentServiceType = any;
 type SyncPageStatusServiceType = any;
 type CrowiDevType = any;
 
-interface SessionConfig {
-  rolling: boolean;
-  secret: string;
-  resave: boolean;
-  saveUninitialized: boolean;
-  cookie: {
-    maxAge: number;
-  };
-  genid: (req: { path: string }) => string;
-  name?: string;
-  store?: unknown;
-}
-
 interface CrowiEvents {
   user: UserEvent;
   page: PageEventType;
@@ -588,7 +576,11 @@ class Crowi {
     this.socketIoService.attachServer(httpServer);
 
     // Initialization YjsService
-    initializeYjsService(this.socketIoService.io);
+    initializeYjsService(
+      httpServer,
+      this.socketIoService.io,
+      this.sessionConfig,
+    );
 
     await this.autoInstall();
 

+ 3 - 0
apps/app/src/server/service/socket-io/socket-io.ts

@@ -43,6 +43,9 @@ export class SocketIoService {
   async attachServer(server) {
     this.io = new Server(server, {
       serveClient: false,
+      // Allow non-Socket.IO WebSocket upgrade requests (e.g. /yjs/) to pass through
+      // without being destroyed by engine.io's default timeout handler
+      destroyUpgrade: false,
     });
 
     // create namespace for admin

+ 61 - 20
apps/app/src/server/service/yjs/create-mongodb-persistence.ts

@@ -1,23 +1,38 @@
-import type { Persistence } from 'y-socket.io/dist/server';
+import { YDocStatus } from '@growi/core/dist/consts';
+import type { Server } from 'socket.io';
+import type { WSSharedDoc, YWebsocketPersistence } from 'y-websocket/bin/utils';
 import * as Y from 'yjs';
 
+import { SocketEventName } from '~/interfaces/websocket';
+import {
+  getRoomNameWithId,
+  RoomPrefix,
+} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 
 import type { MongodbPersistence } from './extended/mongodb-persistence';
+import type { syncYDoc as syncYDocType } from './sync-ydoc';
 
 const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence');
 
+type GetYDocStatus = (pageId: string) => Promise<YDocStatus>;
+
 /**
- * Based on the example by https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#an-other-example
- * @param mdb
- * @returns
+ * Creates a y-websocket compatible persistence layer backed by MongoDB.
+ *
+ * bindState also handles:
+ * - sync-on-load (syncYDoc) after persisted state is applied
+ * - awareness event bridge to Socket.IO rooms
  */
 export const createMongoDBPersistence = (
   mdb: MongodbPersistence,
-): Persistence => {
-  const persistece: Persistence = {
+  io: Server,
+  syncYDoc: typeof syncYDocType,
+  getYDocStatus: GetYDocStatus,
+): YWebsocketPersistence => {
+  const persistence: YWebsocketPersistence = {
     provider: mdb,
-    bindState: async (docName, ydoc) => {
+    bindState: async (docName: string, ydoc: WSSharedDoc) => {
       logger.debug('bindState', { docName });
 
       const persistedYdoc = await mdb.getYDoc(docName);
@@ -27,12 +42,7 @@ export const createMongoDBPersistence = (
       const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector);
 
       // store the new data in db (if there is any: empty update is an array of 0s)
-      if (
-        diff.reduce(
-          (previousValue, currentValue) => previousValue + currentValue,
-          0,
-        ) > 0
-      ) {
+      if (diff.some((b) => b !== 0)) {
         mdb.storeUpdate(docName, diff);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       }
@@ -40,23 +50,54 @@ export const createMongoDBPersistence = (
       // send the persisted data to clients
       Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
 
+      // cleanup some memory
+      persistedYdoc.destroy();
+
+      // sync with the latest revision after persisted state is applied
+      const ydocStatus = await getYDocStatus(docName);
+      syncYDoc(mdb, ydoc, { ydocStatus });
+
       // store updates of the document in db
-      ydoc.on('update', async (update) => {
+      ydoc.on('update', (update: Uint8Array) => {
         mdb.storeUpdate(docName, update);
         mdb.setTypedMeta(docName, 'updatedAt', Date.now());
       });
 
-      // cleanup some memory
-      persistedYdoc.destroy();
+      // register awareness event bridge to Socket.IO rooms
+      // Only emit when the awareness state size actually changes (cursor moves
+      // and other updates fire frequently but don't change the user count)
+      let lastEmittedSize = -1;
+      ydoc.awareness.on('update', async () => {
+        const pageId = docName;
+        const awarenessStateSize = ydoc.awareness.getStates().size;
+
+        if (awarenessStateSize !== lastEmittedSize) {
+          lastEmittedSize = awarenessStateSize;
+          io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+            SocketEventName.YjsAwarenessStateSizeUpdated,
+            awarenessStateSize,
+          );
+        }
+
+        // emit draft status when last user leaves
+        if (awarenessStateSize === 0) {
+          const status = await getYDocStatus(pageId);
+          const hasYdocsNewerThanLatestRevision =
+            status === YDocStatus.DRAFT || status === YDocStatus.ISOLATED;
+
+          io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
+            SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+            hasYdocsNewerThanLatestRevision,
+          );
+        }
+      });
     },
-    writeState: async (docName) => {
+    writeState: async (docName: string) => {
       logger.debug('writeState', { docName });
-      // This is called when all connections to the document are closed.
-
       // flush document on close to have the smallest possible database
       await mdb.flushDocument(docName);
     },
   };
 
-  return persistece;
+  return persistence;
 };

+ 159 - 0
apps/app/src/server/service/yjs/guard-socket.spec.ts

@@ -0,0 +1,159 @@
+import http from 'node:http';
+import WebSocket, { WebSocketServer } from 'ws';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
+
+import { guardSocket } from './guard-socket';
+
+/**
+ * Creates a test server where:
+ * 1. The Yjs upgrade handler guards the socket and awaits before completing
+ * 2. A hostile handler (simulating Next.js) calls socket.end() for /yjs/ paths
+ */
+const createServerWithHostileHandler = (): {
+  server: http.Server;
+  wss: WebSocketServer;
+} => {
+  const server = http.createServer();
+  const wss = new WebSocketServer({ noServer: true });
+
+  // Yjs handler (registered first — same order as production)
+  server.on('upgrade', async (request, socket, head) => {
+    const url = request.url ?? '';
+    if (!url.startsWith('/yjs/')) return;
+
+    const pageId = url.slice('/yjs/'.length).split('?')[0];
+
+    const guard = guardSocket(socket);
+
+    try {
+      // Simulate async auth delay
+      await new Promise((resolve) => setTimeout(resolve, 10));
+
+      guard.restore();
+
+      wss.handleUpgrade(request, socket, head, (ws) => {
+        wss.emit('connection', ws, request);
+        setupWSConnection(ws, request, { docName: pageId });
+      });
+    } catch {
+      guard.restore();
+      socket.destroy();
+    }
+  });
+
+  // Hostile handler (registered second — simulates Next.js upgradeHandler)
+  server.on('upgrade', (_request, socket) => {
+    socket.end();
+  });
+
+  return { server, wss };
+};
+
+const connectClient = (port: number, pageId: string): Promise<WebSocket> => {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`ws://127.0.0.1:${port}/yjs/${pageId}`);
+    ws.binaryType = 'arraybuffer';
+    ws.on('open', () => resolve(ws));
+    ws.on('error', reject);
+  });
+};
+
+describe('guardSocket — protection against hostile upgrade handlers', () => {
+  let server: http.Server;
+  let wss: WebSocketServer;
+  let port: number;
+
+  beforeAll(async () => {
+    setPersistence(null);
+
+    const testServer = createServerWithHostileHandler();
+    server = testServer.server;
+    wss = testServer.wss;
+
+    await new Promise<void>((resolve) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (addr && typeof addr === 'object') {
+          port = addr.port;
+        }
+        resolve();
+      });
+    });
+  });
+
+  afterAll(async () => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+
+    await new Promise<void>((resolve) => {
+      wss.close(() => {
+        server.close(() => resolve());
+      });
+    });
+  });
+
+  afterEach(() => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+  });
+
+  it('should establish WebSocket connection even when a hostile handler calls socket.end()', async () => {
+    const pageId = 'guard-test-001';
+
+    const ws = await connectClient(port, pageId);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const serverDoc = docs.get(pageId);
+    expect(serverDoc).toBeDefined();
+    assert(serverDoc !== undefined);
+    expect(serverDoc.conns.size).toBe(1);
+
+    ws.close();
+  });
+
+  it('should handle multiple concurrent connections with hostile handler', async () => {
+    const pageId = 'guard-test-002';
+
+    const connections = await Promise.all([
+      connectClient(port, pageId),
+      connectClient(port, pageId),
+    ]);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    const serverDoc = docs.get(pageId);
+    expect(serverDoc).toBeDefined();
+    assert(serverDoc !== undefined);
+    expect(serverDoc.conns.size).toBe(2);
+
+    for (const ws of connections) {
+      ws.close();
+    }
+  });
+
+  it('should allow normal close after guard is restored', async () => {
+    const pageId = 'guard-test-003';
+
+    const ws = await connectClient(port, pageId);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    // Connection succeeds, meaning socket.end/destroy were properly
+    // guarded during async auth and restored before wss.handleUpgrade
+    expect(ws.readyState).toBe(WebSocket.OPEN);
+
+    ws.close();
+    await new Promise((resolve) => setTimeout(resolve, 50));
+
+    // After close, the server doc should have removed the connection
+    const serverDoc = docs.get(pageId);
+    if (serverDoc) {
+      expect(serverDoc.conns.size).toBe(0);
+    }
+  });
+});

+ 30 - 0
apps/app/src/server/service/yjs/guard-socket.ts

@@ -0,0 +1,30 @@
+import type { Duplex } from 'node:stream';
+
+type SocketGuard = {
+  restore: () => void;
+};
+
+/**
+ * Temporarily replaces socket.end() and socket.destroy() with no-ops.
+ *
+ * This prevents other synchronous `upgrade` event listeners (e.g. Next.js's
+ * NextCustomServer.upgradeHandler) from closing the socket while an async
+ * handler is awaiting authentication.
+ *
+ * Call `restore()` on the returned object to reinstate the original methods
+ * before performing the actual WebSocket handshake or cleanup.
+ */
+export const guardSocket = (socket: Duplex): SocketGuard => {
+  const origEnd = socket.end.bind(socket);
+  const origDestroy = socket.destroy.bind(socket);
+
+  socket.end = () => socket;
+  socket.destroy = () => socket;
+
+  return {
+    restore: () => {
+      socket.end = origEnd;
+      socket.destroy = origDestroy;
+    },
+  };
+};

+ 2 - 2
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -1,6 +1,6 @@
 import { Origin, YDocStatus } from '@growi/core';
 import type { Delta } from '@growi/editor';
-import type { Document } from 'y-socket.io/dist/server';
+import type { WSSharedDoc } from 'y-websocket/bin/utils';
 
 import loggerFactory from '~/utils/logger';
 
@@ -22,7 +22,7 @@ type Context = {
  */
 export const syncYDoc = async (
   mdb: MongodbPersistence,
-  doc: Document,
+  doc: WSSharedDoc,
   context: true | Context,
 ): Promise<void> => {
   const pageId = doc.name;

+ 177 - 0
apps/app/src/server/service/yjs/upgrade-handler.spec.ts

@@ -0,0 +1,177 @@
+import type { IncomingMessage } from 'node:http';
+import type { Duplex } from 'node:stream';
+import type { IUserHasId } from '@growi/core';
+import { mock } from 'vitest-mock-extended';
+
+import { createUpgradeHandler } from './upgrade-handler';
+
+type AuthenticatedIncomingMessage = IncomingMessage & { user?: IUserHasId };
+
+interface MockSocket {
+  write: ReturnType<typeof vi.fn>;
+  destroy: ReturnType<typeof vi.fn>;
+}
+
+const { isAccessibleMock } = vi.hoisted(() => ({
+  isAccessibleMock: vi.fn(),
+}));
+
+vi.mock('mongoose', () => ({
+  default: {
+    model: () => ({ isAccessiblePageByViewer: isAccessibleMock }),
+  },
+}));
+
+const { sessionMiddlewareMock } = vi.hoisted(() => ({
+  sessionMiddlewareMock: vi.fn(
+    (_req: unknown, _res: unknown, next: () => void) => next(),
+  ),
+}));
+
+vi.mock('express-session', () => ({
+  default: () => sessionMiddlewareMock,
+}));
+
+vi.mock('passport', () => ({
+  default: {
+    initialize: () => (_req: unknown, _res: unknown, next: () => void) =>
+      next(),
+    session: () => (_req: unknown, _res: unknown, next: () => void) => next(),
+  },
+}));
+
+const sessionConfig = {
+  rolling: true,
+  secret: 'test-secret',
+  resave: false,
+  saveUninitialized: true,
+  cookie: { maxAge: 86400000 },
+  genid: () => 'test-session-id',
+};
+
+const createMockRequest = (
+  url: string,
+  user?: IUserHasId,
+): AuthenticatedIncomingMessage => {
+  const req = mock<AuthenticatedIncomingMessage>();
+  req.url = url;
+  req.headers = { cookie: 'connect.sid=test-session' };
+  req.user = user;
+  return req;
+};
+
+const createMockSocket = (): Duplex & MockSocket => {
+  return {
+    write: vi.fn().mockReturnValue(true),
+    destroy: vi.fn(),
+  } as unknown as Duplex & MockSocket;
+};
+
+describe('UpgradeHandler', () => {
+  const handleUpgrade = createUpgradeHandler(sessionConfig);
+
+  it('should authorize a valid user with page access', async () => {
+    isAccessibleMock.mockResolvedValue(true);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011', {
+      _id: 'user1',
+      name: 'Test User',
+    } as unknown as IUserHasId);
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(true);
+    if (result.authorized) {
+      expect(result.pageId).toBe('507f1f77bcf86cd799439011');
+    }
+  });
+
+  it('should reject with 400 for missing/malformed URL path', async () => {
+    const request = createMockRequest('/invalid/path');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(400);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('400'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should reject with 403 when user has no page access', async () => {
+    isAccessibleMock.mockResolvedValue(false);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011', {
+      _id: 'user1',
+      name: 'Test User',
+    } as unknown as IUserHasId);
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(403);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('403'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should reject with 401 when unauthenticated user has no page access', async () => {
+    isAccessibleMock.mockResolvedValue(false);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(401);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+
+  it('should allow guest user when page allows guest access', async () => {
+    isAccessibleMock.mockResolvedValue(true);
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(true);
+    if (result.authorized) {
+      expect(result.pageId).toBe('507f1f77bcf86cd799439011');
+    }
+  });
+
+  it('should reject with 401 when session middleware fails', async () => {
+    sessionMiddlewareMock.mockImplementationOnce(
+      (_req: unknown, _res: unknown, next: (err?: unknown) => void) =>
+        next(new Error('session store unavailable')),
+    );
+
+    const request = createMockRequest('/yjs/507f1f77bcf86cd799439011');
+    const socket = createMockSocket();
+    const head = Buffer.alloc(0);
+
+    const result = await handleUpgrade(request, socket, head);
+
+    expect(result.authorized).toBe(false);
+    if (!result.authorized) {
+      expect(result.statusCode).toBe(401);
+    }
+    expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401'));
+    expect(socket.destroy).not.toHaveBeenCalled();
+  });
+});

+ 131 - 0
apps/app/src/server/service/yjs/upgrade-handler.ts

@@ -0,0 +1,131 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
+import expressSession from 'express-session';
+import type { IncomingMessage, ServerResponse } from 'http';
+import mongoose from 'mongoose';
+import passport from 'passport';
+import type { Duplex } from 'stream';
+
+import type { SessionConfig } from '~/interfaces/session-config';
+import loggerFactory from '~/utils/logger';
+
+import type { PageModel } from '../../models/page';
+
+const logger = loggerFactory('growi:service:yjs:upgrade-handler');
+
+type AuthenticatedRequest = IncomingMessage & {
+  user?: IUserHasId;
+};
+
+/**
+ * Connect-style middleware that operates on raw Node.js HTTP types.
+ * Express middleware (express-session, passport) is compatible because
+ * express.Request extends IncomingMessage and express.Response extends ServerResponse.
+ */
+type ConnectMiddleware = (
+  req: IncomingMessage,
+  res: ServerResponse,
+  next: (err?: unknown) => void,
+) => void;
+
+/**
+ * Run a Connect-style middleware against a raw IncomingMessage.
+ * Safe for express-session, passport.initialize(), and passport.session() which
+ * only read/write `req` properties and call `next()` — they never write to `res`.
+ */
+const runMiddleware = (
+  middleware: ConnectMiddleware,
+  req: IncomingMessage,
+): Promise<void> =>
+  new Promise((resolve, reject) => {
+    const stubRes = {} as ServerResponse;
+    middleware(req, stubRes, (err?: unknown) => {
+      if (err) return reject(err);
+      resolve();
+    });
+  });
+
+/**
+ * Extracts pageId from upgrade request URL.
+ * Expected format: /yjs/{pageId}
+ */
+const pageIdPattern = new RegExp(`^${YJS_WEBSOCKET_BASE_PATH}/([a-f0-9]{24})`);
+const extractPageId = (url: string | undefined): string | null => {
+  if (url == null) return null;
+  const match = url.match(pageIdPattern);
+  return match?.[1] ?? null;
+};
+
+/**
+ * Writes an HTTP error response to the socket.
+ * Does NOT close the socket — the caller (yjs.ts) manages socket lifecycle
+ * so that guardSocket can safely intercept end/destroy during async auth.
+ */
+const writeErrorResponse = (
+  socket: Duplex,
+  statusCode: number,
+  message: string,
+): void => {
+  socket.write(`HTTP/1.1 ${statusCode} ${message}\r\n\r\n`);
+};
+
+export type UpgradeResult =
+  | { authorized: true; request: AuthenticatedRequest; pageId: string }
+  | { authorized: false; statusCode: number };
+
+/**
+ * Creates an upgrade handler that authenticates WebSocket connections
+ * using the existing express-session + passport mechanism.
+ */
+export const createUpgradeHandler = (sessionConfig: SessionConfig) => {
+  const sessionMiddleware = expressSession(sessionConfig as any);
+  const passportInit = passport.initialize();
+  const passportSession = passport.session();
+
+  return async (
+    request: IncomingMessage,
+    socket: Duplex,
+    _head: Buffer,
+  ): Promise<UpgradeResult> => {
+    const pageId = extractPageId(request.url);
+    if (pageId == null) {
+      logger.warn('Invalid URL path for Yjs upgrade', { url: request.url });
+      writeErrorResponse(socket, 400, 'Bad Request');
+      return { authorized: false, statusCode: 400 };
+    }
+
+    try {
+      // Run session + passport middleware chain
+      await runMiddleware(sessionMiddleware as ConnectMiddleware, request);
+      await runMiddleware(passportInit as ConnectMiddleware, request);
+      await runMiddleware(passportSession as ConnectMiddleware, request);
+    } catch (err) {
+      logger.warn('Session/passport middleware failed on upgrade', { err });
+      writeErrorResponse(socket, 401, 'Unauthorized');
+      return { authorized: false, statusCode: 401 };
+    }
+
+    const user = (request as AuthenticatedRequest).user ?? null;
+
+    // Check page access
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
+
+    if (!isAccessible) {
+      const statusCode = user == null ? 401 : 403;
+      const message = user == null ? 'Unauthorized' : 'Forbidden';
+      logger.warn(`Yjs upgrade rejected: ${message}`, {
+        pageId,
+        userId: user?._id,
+      });
+      writeErrorResponse(socket, statusCode, message);
+      return { authorized: false, statusCode };
+    }
+
+    return {
+      authorized: true,
+      request: request as AuthenticatedRequest,
+      pageId,
+    };
+  };
+};

+ 205 - 0
apps/app/src/server/service/yjs/websocket-connection.integ.ts

@@ -0,0 +1,205 @@
+import http from 'node:http';
+import WebSocket, { WebSocketServer } from 'ws';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
+
+/**
+ * Creates a minimal HTTP + y-websocket server for testing.
+ * No authentication — pure document sync testing.
+ */
+const createTestServer = (): { server: http.Server; wss: WebSocketServer } => {
+  const server = http.createServer();
+  const wss = new WebSocketServer({ noServer: true });
+
+  server.on('upgrade', (request, socket, head) => {
+    const url = request.url ?? '';
+    if (!url.startsWith('/yjs/')) return;
+    const pageId = url.slice('/yjs/'.length).split('?')[0];
+
+    wss.handleUpgrade(request, socket, head, (ws) => {
+      wss.emit('connection', ws, request);
+      setupWSConnection(ws, request, { docName: pageId });
+    });
+  });
+
+  return { server, wss };
+};
+
+/**
+ * Connects a WebSocket client and waits for the connection to open.
+ */
+const connectClient = (port: number, pageId: string): Promise<WebSocket> => {
+  return new Promise((resolve, reject) => {
+    const ws = new WebSocket(`ws://127.0.0.1:${port}/yjs/${pageId}`);
+    ws.binaryType = 'arraybuffer';
+    ws.on('open', () => resolve(ws));
+    ws.on('error', reject);
+  });
+};
+
+/**
+ * Waits for a WebSocket to fully close.
+ */
+const waitForClose = (ws: WebSocket): Promise<void> => {
+  return new Promise((resolve) => {
+    if (ws.readyState === WebSocket.CLOSED) return resolve();
+    ws.on('close', () => resolve());
+  });
+};
+
+describe('WebSocket Connection and Sync Flow', () => {
+  let server: http.Server;
+  let wss: WebSocketServer;
+  let port: number;
+
+  beforeAll(async () => {
+    setPersistence(null);
+
+    const testServer = createTestServer();
+    server = testServer.server;
+    wss = testServer.wss;
+
+    await new Promise<void>((resolve) => {
+      server.listen(0, '127.0.0.1', () => {
+        const addr = server.address();
+        if (addr && typeof addr === 'object') {
+          port = addr.port;
+        }
+        resolve();
+      });
+    });
+  });
+
+  afterAll(async () => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+
+    await new Promise<void>((resolve) => {
+      wss.close(() => {
+        server.close(() => resolve());
+      });
+    });
+  });
+
+  afterEach(() => {
+    for (const [name, doc] of docs) {
+      doc.destroy();
+      docs.delete(name);
+    }
+  });
+
+  describe('Connection and sync flow', () => {
+    it('should create a server-side Y.Doc on first client connection', async () => {
+      const pageId = 'test-page-sync-001';
+
+      const ws = await connectClient(port, pageId);
+
+      // Wait for setupWSConnection to register the doc
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.name).toBe(pageId);
+      expect(serverDoc.conns.size).toBe(1);
+
+      ws.close();
+    });
+
+    it('should register multiple clients on the same server-side Y.Doc', async () => {
+      const pageId = 'test-page-multi-001';
+
+      const ws1 = await connectClient(port, pageId);
+      const ws2 = await connectClient(port, pageId);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(2);
+
+      ws1.close();
+      ws2.close();
+    });
+
+    it('should keep the server doc alive when one client disconnects', async () => {
+      const pageId = 'test-page-reconnect-001';
+
+      const ws1 = await connectClient(port, pageId);
+      const ws2 = await connectClient(port, pageId);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Disconnect client 1
+      ws1.close();
+      await waitForClose(ws1);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Server doc should still exist with client 2
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(1);
+
+      ws2.close();
+    });
+  });
+
+  describe('Concurrency — single Y.Doc per page', () => {
+    it('should create exactly one Y.Doc for simultaneous connections', async () => {
+      const pageId = 'test-page-concurrent-001';
+
+      // Connect multiple clients simultaneously
+      const connections = await Promise.all([
+        connectClient(port, pageId),
+        connectClient(port, pageId),
+        connectClient(port, pageId),
+      ]);
+
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Verify single Y.Doc instance
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      expect(serverDoc.conns.size).toBe(3);
+
+      // Only one doc for this page
+      const matchingDocs = Array.from(docs.values()).filter(
+        (d) => d.name === pageId,
+      );
+      expect(matchingDocs).toHaveLength(1);
+
+      for (const ws of connections) {
+        ws.close();
+      }
+    });
+
+    it('should handle disconnect during connect without document corruption', async () => {
+      const pageId = 'test-page-disconnect-001';
+
+      // Client 1 connects
+      const ws1 = await connectClient(port, pageId);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Write to server doc directly
+      const serverDoc = docs.get(pageId);
+      assert(serverDoc !== undefined);
+      serverDoc.getText('codemirror').insert(0, 'Hello World');
+
+      // Client 2 connects and immediately disconnects
+      const ws2 = await connectClient(port, pageId);
+      ws2.close();
+      await waitForClose(ws2);
+      await new Promise((resolve) => setTimeout(resolve, 50));
+
+      // Server doc should still exist with client 1
+      const docAfter = docs.get(pageId);
+      assert(docAfter !== undefined);
+      expect(docAfter.conns.size).toBe(1);
+
+      // Text should be intact
+      expect(docAfter.getText('codemirror').toString()).toBe('Hello World');
+
+      ws1.close();
+    });
+  });
+});

+ 39 - 0
apps/app/src/server/service/yjs/y-websocket-server.d.ts

@@ -0,0 +1,39 @@
+declare module 'y-websocket/bin/utils' {
+  import type { IncomingMessage } from 'http';
+  import type { WebSocket } from 'ws';
+  import type { Awareness } from 'y-protocols/awareness';
+  import * as Y from 'yjs';
+
+  export class WSSharedDoc extends Y.Doc {
+    name: string;
+    conns: Map<WebSocket, Set<number>>;
+    awareness: Awareness;
+    whenInitialized: Promise<void>;
+    constructor(name: string);
+  }
+
+  export interface YWebsocketPersistence {
+    bindState: (docName: string, ydoc: WSSharedDoc) => void;
+    writeState: (docName: string, ydoc: WSSharedDoc) => Promise<void>;
+    provider: unknown;
+  }
+
+  export function setPersistence(
+    persistence: YWebsocketPersistence | null,
+  ): void;
+  export function getPersistence(): YWebsocketPersistence | null;
+
+  export const docs: Map<string, WSSharedDoc>;
+
+  export function getYDoc(docname: string, gc?: boolean): WSSharedDoc;
+
+  export function setupWSConnection(
+    conn: WebSocket,
+    req: IncomingMessage,
+    opts?: { docName?: string; gc?: boolean },
+  ): void;
+
+  export function setContentInitializor(
+    f: (ydoc: Y.Doc) => Promise<void>,
+  ): void;
+}

+ 25 - 10
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -1,3 +1,4 @@
+import http from 'node:http';
 import { YDocStatus } from '@growi/core/dist/consts';
 import { Types } from 'mongoose';
 import type { Server } from 'socket.io';
@@ -8,11 +9,15 @@ import type { MongodbPersistence } from './extended/mongodb-persistence';
 import type { IYjsService } from './yjs';
 import { getYjsService, initializeYjsService } from './yjs';
 
-vi.mock('y-socket.io/dist/server', () => {
-  const YSocketIO = vi.fn();
-  YSocketIO.prototype.on = vi.fn();
-  YSocketIO.prototype.initialize = vi.fn();
-  return { YSocketIO };
+vi.mock('y-websocket/bin/utils', () => {
+  const docs = new Map();
+  return {
+    docs,
+    setPersistence: vi.fn(),
+    setupWSConnection: vi.fn(),
+    getYDoc: vi.fn(),
+    setContentInitializor: vi.fn(),
+  };
 });
 
 vi.mock('../revision/normalize-latest-revision-if-broken', () => ({
@@ -30,16 +35,25 @@ describe('YjsService', () => {
   describe('getYDocStatus()', () => {
     beforeAll(() => {
       const ioMock = mock<Server>();
+      const httpServer = http.createServer();
+      const sessionConfig = {
+        rolling: true,
+        secret: 'test-secret',
+        resave: false,
+        saveUninitialized: true,
+        cookie: { maxAge: 86400000 },
+        genid: () => 'test-session-id',
+      };
 
       // initialize
-      initializeYjsService(ioMock);
+      initializeYjsService(httpServer, ioMock, sessionConfig);
     });
 
-    afterAll(async () => {
-      // flush revisions
+    afterEach(async () => {
       await Revision.deleteMany({});
+    });
 
-      // flush yjs-writings
+    afterAll(async () => {
       const yjsService = getYjsService();
       const privateMdb = getPrivateMdbInstance(yjsService);
       try {
@@ -48,7 +62,8 @@ describe('YjsService', () => {
         // Ignore errors that can occur due to async index creation:
         // - 26: NamespaceNotFound (collection not yet created)
         // - 276: IndexBuildAborted (cleanup during index creation)
-        if (error.code !== 26 && error.code !== 276) {
+        const code = (error as { code?: number }).code;
+        if (code !== 26 && code !== 276) {
           throw error;
         }
       }

+ 72 - 93
apps/app/src/server/service/yjs/yjs.ts

@@ -1,49 +1,47 @@
-import type { IPage, IUserHasId } from '@growi/core';
-import { YDocStatus } from '@growi/core/dist/consts';
-import type { IncomingMessage } from 'http';
+import type http from 'node:http';
+import { YDocStatus, YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import mongoose from 'mongoose';
 import type { Server } from 'socket.io';
-import type { Document } from 'y-socket.io/dist/server';
-import { type Document as Ydoc, YSocketIO } from 'y-socket.io/dist/server';
+import { WebSocketServer } from 'ws';
+import type { WSSharedDoc } from 'y-websocket/bin/utils';
+import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
 
-import { SocketEventName } from '~/interfaces/websocket';
+import type { SessionConfig } from '~/interfaces/session-config';
 import type { SyncLatestRevisionBody } from '~/interfaces/yjs';
-import {
-  getRoomNameWithId,
-  RoomPrefix,
-} from '~/server/service/socket-io/helper';
 import loggerFactory from '~/utils/logger';
 
-import type { PageModel } from '../../models/page';
 import { Revision } from '../../models/revision';
 import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken';
 import { createIndexes } from './create-indexes';
 import { createMongoDBPersistence } from './create-mongodb-persistence';
 import { MongodbPersistence } from './extended/mongodb-persistence';
+import { guardSocket } from './guard-socket';
 import { syncYDoc } from './sync-ydoc';
+import { createUpgradeHandler } from './upgrade-handler';
 
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+const YJS_PATH_PREFIX = `${YJS_WEBSOCKET_BASE_PATH}/`;
 
 const logger = loggerFactory('growi:service:yjs');
 
-type RequestWithUser = IncomingMessage & { user: IUserHasId };
-
 export interface IYjsService {
   getYDocStatus(pageId: string): Promise<YDocStatus>;
   syncWithTheLatestRevisionForce(
     pageId: string,
     editingMarkdownLength?: number,
   ): Promise<SyncLatestRevisionBody>;
-  getCurrentYdoc(pageId: string): Ydoc | undefined;
+  getCurrentYdoc(pageId: string): WSSharedDoc | undefined;
 }
 
 class YjsService implements IYjsService {
-  private ysocketio: YSocketIO;
-
   private mdb: MongodbPersistence;
 
-  constructor(io: Server) {
+  constructor(
+    httpServer: http.Server,
+    io: Server,
+    sessionConfig: SessionConfig,
+  ) {
     const mdb = new MongodbPersistence(
       {
         // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any'
@@ -57,80 +55,62 @@ class YjsService implements IYjsService {
     );
     this.mdb = mdb;
 
-    // initialize YSocketIO
-    const ysocketio = new YSocketIO(io);
-    this.injectPersistence(ysocketio, mdb);
-    ysocketio.initialize();
-    this.ysocketio = ysocketio;
-
     // create indexes
     createIndexes(MONGODB_PERSISTENCE_COLLECTION_NAME);
 
-    // register middlewares
-    this.registerAccessiblePageChecker(ysocketio);
-
-    ysocketio.on('document-loaded', async (doc: Document) => {
-      const pageId = doc.name;
-
-      const ydocStatus = await this.getYDocStatus(pageId);
-
-      syncYDoc(mdb, doc, { ydocStatus });
-    });
-
-    ysocketio.on('awareness-update', async (doc: Document) => {
-      const pageId = doc.name;
-
-      if (pageId == null) return;
-
-      const awarenessStateSize = doc.awareness.states.size;
+    // setup y-websocket persistence (includes awareness bridge and sync-on-load)
+    const persistence = createMongoDBPersistence(mdb, io, syncYDoc, (pageId) =>
+      this.getYDocStatus(pageId),
+    );
+    setPersistence(persistence);
 
-      // Triggered when awareness changes
-      io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
-        SocketEventName.YjsAwarenessStateSizeUpdated,
-        awarenessStateSize,
-      );
+    // setup WebSocket server
+    const wss = new WebSocketServer({ noServer: true });
+    const handleUpgrade = createUpgradeHandler(sessionConfig);
 
-      // Triggered when the last user leaves the editor
-      if (awarenessStateSize === 0) {
-        const ydocStatus = await this.getYDocStatus(pageId);
-        const hasYdocsNewerThanLatestRevision =
-          ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED;
+    httpServer.on('upgrade', async (request, socket, head) => {
+      const url = request.url ?? '';
 
-        io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit(
-          SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
-          hasYdocsNewerThanLatestRevision,
-        );
+      // Only handle /yjs/ paths; let Socket.IO and others pass through
+      if (!url.startsWith(YJS_PATH_PREFIX)) {
+        return;
       }
-    });
-  }
-
-  private injectPersistence(
-    ysocketio: YSocketIO,
-    mdb: MongodbPersistence,
-  ): void {
-    const persistece = createMongoDBPersistence(mdb);
-
-    // foce set to private property
-    // biome-ignore lint/complexity/useLiteralKeys: ignore
-    ysocketio['persistence'] = persistece;
-  }
-
-  private registerAccessiblePageChecker(ysocketio: YSocketIO): void {
-    // check accessible page
-    ysocketio.nsp?.use(async (socket, next) => {
-      // extract page id from namespace
-      const pageId = socket.nsp.name.replace(/\/yjs\|/, '');
-      const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService
 
-      const Page = mongoose.model<IPage, PageModel>('Page');
-      const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
-
-      if (!isAccessible) {
-        return next(new Error('Forbidden'));
+      // Guard the socket against being closed by other upgrade handlers
+      // (e.g. Next.js's NextCustomServer.upgradeHandler) that run synchronously
+      // after this async handler yields at the first await.
+      const guard = guardSocket(socket);
+
+      try {
+        const result = await handleUpgrade(request, socket, head);
+
+        // Restore original socket methods now that all synchronous
+        // upgrade handlers have finished
+        guard.restore();
+
+        if (!result.authorized) {
+          // rejectUpgrade already wrote the HTTP error response but
+          // socket.destroy() was a no-op during the guard; clean up now
+          socket.destroy();
+          return;
+        }
+
+        wss.handleUpgrade(result.request, socket, head, (ws) => {
+          wss.emit('connection', ws, result.request);
+          setupWSConnection(ws, result.request, { docName: result.pageId });
+        });
+      } catch (err) {
+        guard.restore();
+
+        logger.error('Yjs upgrade handler failed unexpectedly', { url, err });
+        if (socket.writable) {
+          socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
+        }
+        socket.destroy();
       }
-
-      return next();
     });
+
+    logger.info('YjsService initialized with y-websocket');
   }
 
   public async getYDocStatus(pageId: string): Promise<YDocStatus> {
@@ -187,14 +167,14 @@ class YjsService implements IYjsService {
     pageId: string,
     editingMarkdownLength?: number,
   ): Promise<SyncLatestRevisionBody> {
-    const doc = this.ysocketio.documents.get(pageId);
+    const doc = docs.get(pageId);
 
     if (doc == null) {
       return { synced: false };
     }
 
-    const ytextLength = doc?.getText('codemirror').length;
-    syncYDoc(this.mdb, doc, true);
+    const ytextLength = doc.getText('codemirror').length;
+    await syncYDoc(this.mdb, doc, true);
 
     return {
       synced: true,
@@ -205,24 +185,23 @@ class YjsService implements IYjsService {
     };
   }
 
-  public getCurrentYdoc(pageId: string): Ydoc | undefined {
-    const currentYdoc = this.ysocketio.documents.get(pageId);
-    return currentYdoc;
+  public getCurrentYdoc(pageId: string): WSSharedDoc | undefined {
+    return docs.get(pageId);
   }
 }
 
 let _instance: YjsService;
 
-export const initializeYjsService = (io: Server): void => {
+export const initializeYjsService = (
+  httpServer: http.Server,
+  io: Server,
+  sessionConfig: SessionConfig,
+): void => {
   if (_instance != null) {
     throw new Error('YjsService is already initialized');
   }
 
-  if (io == null) {
-    throw new Error("'io' is required if initialize YjsService");
-  }
-
-  _instance = new YjsService(io);
+  _instance = new YjsService(httpServer, io, sessionConfig);
 };
 
 export const getYjsService = (): YjsService => {

+ 5 - 0
packages/core/src/consts/ydoc-status.ts

@@ -13,3 +13,8 @@ export const YDocStatus = {
   ISOLATED: 'isolated',
 } as const;
 export type YDocStatus = (typeof YDocStatus)[keyof typeof YDocStatus];
+
+/**
+ * The base path for Yjs WebSocket connections.
+ */
+export const YJS_WEBSOCKET_BASE_PATH = '/yjs';

+ 1 - 1
packages/editor/package.json

@@ -72,7 +72,7 @@
     "swr": "^2.3.2",
     "ts-deepmerge": "^6.2.0",
     "y-codemirror.next": "^0.3.5",
-    "y-socket.io": "^1.1.3",
+    "y-websocket": "^2.0.4",
     "yjs": "^13.6.19"
   }
 }

+ 59 - 73
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -1,8 +1,9 @@
 import { useEffect, useState } from 'react';
 import { keymap } from '@codemirror/view';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
-import { SocketIOProvider } from 'y-socket.io';
+import { WebsocketProvider } from 'y-websocket';
 import * as Y from 'yjs';
 
 import { userColor } from '../../consts';
@@ -30,7 +31,7 @@ export const useCollaborativeEditorMode = (
       useSecondary: reviewMode,
     }) ?? {};
 
-  const [provider, setProvider] = useState<SocketIOProvider>();
+  const [provider, setProvider] = useState<WebsocketProvider>();
 
   // reset editors
   useEffect(() => {
@@ -40,85 +41,70 @@ export const useCollaborativeEditorMode = (
 
   // Setup provider
   useEffect(() => {
-    let _provider: SocketIOProvider | undefined;
-    let providerSyncHandler: (isSync: boolean) => void;
-    let updateAwarenessHandler: (update: {
+    if (!isEnabled || pageId == null || primaryDoc == null) {
+      setProvider(undefined);
+      return;
+    }
+
+    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const serverUrl = `${wsProtocol}//${window.location.host}${YJS_WEBSOCKET_BASE_PATH}`;
+
+    const _provider = new WebsocketProvider(serverUrl, pageId, primaryDoc, {
+      connect: true,
+      resyncInterval: 3000,
+    });
+
+    const userLocalState: EditingClient = {
+      clientId: primaryDoc.clientID,
+      name: user?.name ?? `Guest User ${Math.floor(Math.random() * 100)}`,
+      userId: user?._id,
+      username: user?.username,
+      imageUrlCached: user?.imageUrlCached,
+      color: userColor.color,
+      colorLight: userColor.light,
+    };
+
+    const { awareness } = _provider;
+    awareness.setLocalStateField('editors', userLocalState);
+
+    const emitEditorList = () => {
+      if (onEditorsUpdated == null) return;
+      const clientList: EditingClient[] = Array.from(
+        awareness.getStates().values(),
+        (value) => value.editors,
+      );
+      if (Array.isArray(clientList)) {
+        onEditorsUpdated(clientList);
+      }
+    };
+
+    const providerSyncHandler = (isSync: boolean) => {
+      if (isSync) emitEditorList();
+    };
+
+    _provider.on('sync', providerSyncHandler);
+
+    const updateAwarenessHandler = (update: {
       added: number[];
       updated: number[];
       removed: number[];
-    }) => void;
-
-    setProvider(() => {
-      if (!isEnabled || pageId == null || primaryDoc == null) {
-        return undefined;
+    }) => {
+      for (const clientId of update.removed) {
+        awareness.getStates().delete(clientId);
       }
+      emitEditorList();
+    };
 
-      _provider = new SocketIOProvider('/', pageId, primaryDoc, {
-        autoConnect: true,
-        resyncInterval: 3000,
-      });
+    awareness.on('update', updateAwarenessHandler);
 
-      const userLocalState: EditingClient = {
-        clientId: primaryDoc.clientID,
-        name: user?.name ?? `Guest User ${Math.floor(Math.random() * 100)}`,
-        userId: user?._id,
-        username: user?.username,
-        imageUrlCached: user?.imageUrlCached,
-        color: userColor.color,
-        colorLight: userColor.light,
-      };
-
-      const { awareness } = _provider;
-      awareness.setLocalStateField('editors', userLocalState);
-
-      providerSyncHandler = (isSync: boolean) => {
-        if (isSync && onEditorsUpdated != null) {
-          const clientList: EditingClient[] = Array.from(
-            awareness.getStates().values(),
-            (value) => value.editors,
-          );
-          if (Array.isArray(clientList)) {
-            onEditorsUpdated(clientList);
-          }
-        }
-      };
-
-      _provider.on('sync', providerSyncHandler);
-
-      // update args type see: SocketIOProvider.Awareness.awarenessUpdate
-      updateAwarenessHandler = (update: {
-        added: number[];
-        updated: number[];
-        removed: number[];
-      }) => {
-        // remove the states of disconnected clients
-        update.removed.forEach((clientId) => {
-          awareness.states.delete(clientId);
-        });
-
-        // update editor list
-        if (onEditorsUpdated != null) {
-          const clientList: EditingClient[] = Array.from(
-            awareness.states.values(),
-            (value) => value.editors,
-          );
-          if (Array.isArray(clientList)) {
-            onEditorsUpdated(clientList);
-          }
-        }
-      };
-
-      awareness.on('update', updateAwarenessHandler);
-
-      return _provider;
-    });
+    setProvider(_provider);
 
     return () => {
-      _provider?.awareness.setLocalState(null);
-      _provider?.awareness.off('update', updateAwarenessHandler);
-      _provider?.off('sync', providerSyncHandler);
-      _provider?.disconnect();
-      _provider?.destroy();
+      _provider.awareness.setLocalState(null);
+      _provider.awareness.off('update', updateAwarenessHandler);
+      _provider.off('sync', providerSyncHandler);
+      _provider.disconnect();
+      _provider.destroy();
     };
   }, [isEnabled, primaryDoc, onEditorsUpdated, pageId, user]);
 

+ 21 - 17
packages/editor/vite.config.ts

@@ -1,12 +1,13 @@
 import path from 'node:path';
+import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import react from '@vitejs/plugin-react';
 import glob from 'glob';
 import { nodeExternals } from 'rollup-plugin-node-externals';
-import { Server } from 'socket.io';
 import type { Plugin } from 'vite';
 import { defineConfig } from 'vite';
 import dts from 'vite-plugin-dts';
-import { YSocketIO } from 'y-socket.io/dist/server';
+
+const YJS_PATH_PREFIX = `${YJS_WEBSOCKET_BASE_PATH}/`;
 
 const excludeFiles = [
   '**/components/playground/*',
@@ -14,27 +15,30 @@ const excludeFiles = [
   '**/vite-env.d.ts',
 ];
 
-const devSocketIOPlugin = (): Plugin => ({
-  name: 'dev-socket-io',
+const devWebSocketPlugin = (): Plugin => ({
+  name: 'dev-y-websocket',
   apply: 'serve',
   configureServer(server) {
     if (!server.httpServer) return;
 
-    // setup socket.io
-    const io = new Server(server.httpServer);
-    io.on('connection', (socket) => {
-      // biome-ignore lint/suspicious/noConsole: Allow to use
-      console.log('Client connected');
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const { setupWSConnection } = require('y-websocket/bin/utils');
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const { WebSocketServer } = require('ws');
+
+    const wss = new WebSocketServer({ noServer: true });
+
+    server.httpServer.on('upgrade', (request, socket, head) => {
+      const url = request.url ?? '';
+      if (!url.startsWith(YJS_PATH_PREFIX)) return;
 
-      socket.on('disconnect', () => {
-        // biome-ignore lint/suspicious/noConsole: Allow to use
-        console.log('Client disconnected');
+      const pageId = url.slice(YJS_PATH_PREFIX.length).split('?')[0];
+
+      wss.handleUpgrade(request, socket, head, (ws) => {
+        wss.emit('connection', ws, request);
+        setupWSConnection(ws, request, { docName: pageId });
       });
     });
-
-    // setup y-socket.io
-    const ysocketio = new YSocketIO(io);
-    ysocketio.initialize();
   },
 });
 
@@ -42,7 +46,7 @@ const devSocketIOPlugin = (): Plugin => ({
 export default defineConfig({
   plugins: [
     react(),
-    devSocketIOPlugin(),
+    devWebSocketPlugin(),
     dts({
       entryRoot: 'src',
       exclude: [...excludeFiles],

File diff suppressed because it is too large
+ 216 - 201
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff