research.md 4.4 KB

Research & Design Decisions

Summary

  • Feature: collaborative-editor
  • Key Findings:
    • y-websocket uses atomic map.setIfUndefined for document creation — eliminates TOCTOU race conditions
    • y-websocket@2.x bundles both client and server utils with yjs@^13 compatibility
    • ws package already installed in GROWI; Express HTTP server supports adding WebSocket upgrade alongside Socket.IO

Design Decisions

Decision: Use y-websocket@2.x for both client and server

  • Context: Need yjs v13 compatibility on both client and server sides
  • Alternatives Considered:
    1. y-websocket@3.x client + custom server — more work, v3 SyncStatus not needed
    2. y-websocket@3.x + @y/websocket-server — requires yjs v14 migration (out of scope)
    3. y-websocket@2.x for everything — simplest path, proven code
  • Selected: Option 3 — y-websocket@2.x
  • Rationale: Minimizes custom code, proven server utils, compatible with yjs v13, clear upgrade path to v3 + @y/websocket-server when yjs v14 migration happens
  • Trade-offs: Miss v3 SyncStatus feature, but sync event + resyncInterval meets all requirements
  • Follow-up: Plan separate yjs v14 migration, then upgrade to y-websocket v3 + @y/websocket-server

Decision: WebSocket path prefix /yjs/

  • Context: Need URL pattern that doesn't conflict with Socket.IO
  • Selected: /yjs/{pageId}
  • Rationale: Simple, semantic, no conflict with Socket.IO's /socket.io/ path or Express routes

Decision: Session-based authentication on WebSocket upgrade

  • Context: Must authenticate WebSocket connections without Socket.IO middleware
  • Selected: Parse session cookie from HTTP upgrade request, deserialize user from session store
  • Rationale: Reuses existing session infrastructure — same cookie, same store, same passport serialization
  • Trade-offs: Couples to express-session internals, but GROWI already has this coupling throughout

Decision: Keep Socket.IO for awareness event fan-out

  • Context: GROWI uses Socket.IO rooms (page:{pageId}) to broadcast awareness updates to non-editor components
  • Selected: Continue using Socket.IO io.in(roomName).emit() for awareness events, bridging from y-websocket awareness
  • Rationale: Non-editor UI components already listen on Socket.IO rooms; changing this is out of scope

Critical Implementation Constraints

engine.io destroyUpgrade setting

Socket.IO's engine.io v6 defaults destroyUpgrade: true in its attach() method. This causes engine.io to destroy all non-Socket.IO upgrade requests after a 1-second timeout. The Socket.IO server must be configured with destroyUpgrade: false to allow /yjs/ WebSocket handshakes to succeed.

Next.js upgradeHandler race condition (guardSocket pattern)

Next.js's NextCustomServer.upgradeHandler registers an upgrade listener on the HTTP server. When the Yjs async handler yields at its first await, Next.js's synchronous handler runs and calls socket.end() for unrecognized paths. The guardSocket pattern temporarily replaces socket.end()/socket.destroy() with no-ops before the first await, restoring them after auth completes.

  • prependListener cannot solve this — it only changes listener order, cannot prevent subsequent listeners from executing
  • Removing Next.js's listener is fragile and breaks HMR
  • Synchronous auth is impossible (requires async MongoDB/session store queries)

React render-phase violation in use-collaborative-editor-mode

Provider creation and awareness event handlers must be placed outside setProvider(() => { ... }) functional state updaters. If inside, awareness.setLocalStateField() triggers synchronous awareness events that update other components during render. All side effects go in the useEffect body; setProvider(_provider) is called with a plain value.

y-websocket bindState ordering

y-websocket does NOT await bindState before sending sync messages. However, within bindState itself, the ordering is guaranteed: persistence load → YDocStatus check → syncYDoc → awareness registration. This consolidation is intentional.

References