# Y.js、WebSocket、Reactでリアルタイム共同編集エディタを構築

リアルタイムコラボレーションは、現代のWebアプリケーションにとって不可欠な機能になっています。GoogleドキュメントからFigmaまで、ユーザーはコンフリクトなしに同時にコンテンツを編集できることを期待しています。このチュートリアルでは、Y.js(CRDTライブラリ)、リアルタイム同期のためのWebSocket、リッチテキスト編集体験のためのReactTipTapを使用して、完全に機能する共同編集テキストエディタをゼロから構築します。

構築するもの

  • 複数のユーザーが同時に編集できるリッチテキストエディタ
  • リアルタイムのカーソルと選択の共有(Awareness)
  • クライアント間でドキュメント状態を同期するWebSocketサーバー
  • 自動再接続によるオフラインサポート
  • ファイルシステムへのドキュメント永続化
  • 前提条件

  • Node.js 18以上とnpm/pnpm
  • React、TypeScript、WebSocketの基本知識
  • リッチテキストエディタの知識があると役立ちますが、必須ではありません
  • CRDTとY.jsの理解

    CRDT(Conflict-free Replicated Data Types)は、異なるレプリカで独立して更新でき、常に一貫した状態にマージされるデータ構造です — 調整は不要です。Y.jsはJavaScriptで最も人気のあるCRDT実装で、以下を提供します:

  • Y.Text — フォーマット属性付きの共同編集テキスト
  • Y.Array — 共有順序付きリスト
  • Y.Map — 共有キーバリューマップ
  • Y.XmlFragment — 共有XML/HTMLツリー(TipTap/ProseMirrorで使用)
  • Googleドキュメントで使用されているOperational Transformation(OT)とは異なり、CRDTはコンフリクトを解決するために中央サーバーを必要としません。すべてのピアが自動的に同じ状態に収束します。

    ステップ1:プロジェクトセットアップ

    モノレポ構造でプロジェクトを作成します:

    mkdir collab-editor && cd collab-editor
    

    mkdir client server

    # Reactクライアントの初期化

    cd client

    npm create vite@latest . -- --template react-ts

    npm install

    # 共同編集の依存関係をインストール

    npm install yjs y-websocket @tiptap/react @tiptap/starter-kit \

    @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor \

    @tiptap/pm

    # サーバーの初期化

    cd ../server

    npm init -y

    npm install ws yjs y-protocols lib0

    npm install -D typescript @types/ws tsx

    サーバー用のtsconfig.jsonを追加:

    {
    

    "compilerOptions": {

    "target": "ES2022",

    "module": "ESNext",

    "moduleResolution": "bundler",

    "outDir": "dist",

    "strict": true,

    "esModuleInterop": true

    },

    "include": ["src"]

    }

    ステップ2:WebSocketサーバーの構築

    server/src/index.tsを作成します:

    import { WebSocketServer, WebSocket } from 'ws';
    

    import * as Y from 'yjs';

    import { encoding, decoding } from 'lib0';

    import fs from 'fs';

    import path from 'path';

    const PORT = 4444;

    const PERSISTENCE_DIR = './docs';

    // y-websocketプロトコルに一致するメッセージタイプ

    const messageSync = 0;

    const messageAwareness = 1;

    interface Room {

    doc: Y.Doc;

    clients: Set<WebSocket>;

    awareness: Map<number, any>;

    }

    const rooms = new Map<string, Room>();

    // 永続化ディレクトリの確認

    if (!fs.existsSync(PERSISTENCE_DIR)) {

    fs.mkdirSync(PERSISTENCE_DIR, { recursive: true });

    }

    function getRoom(roomName: string): Room {

    if (rooms.has(roomName)) return rooms.get(roomName)!;

    const doc = new Y.Doc();

    // 永続化された状態があれば読み込み

    const filePath = path.join(PERSISTENCE_DIR, ${roomName}.yjs);

    if (fs.existsSync(filePath)) {

    const persistedState = fs.readFileSync(filePath);

    Y.applyUpdate(doc, new Uint8Array(persistedState));

    console.log(永続化されたドキュメントを読み込みました: ${roomName});

    }

    // 更新ごとに永続化(本番ではデバウンス)

    let persistTimeout: NodeJS.Timeout | null = null;

    doc.on('update', () => {

    if (persistTimeout) clearTimeout(persistTimeout);

    persistTimeout = setTimeout(() => {

    const state = Y.encodeStateAsUpdate(doc);

    fs.writeFileSync(filePath, Buffer.from(state));

    console.log(ドキュメントを永続化しました: ${roomName});

    }, 1000);

    });

    const room: Room = {

    doc,

    clients: new Set(),

    awareness: new Map(),

    };

    rooms.set(roomName, room);

    return room;

    }

    function broadcastToRoom(

    room: Room,

    message: Uint8Array,

    exclude?: WebSocket

    ) {

    room.clients.forEach((client) => {

    if (client !== exclude && client.readyState === WebSocket.OPEN) {

    client.send(message);

    }

    });

    }

    const wss = new WebSocketServer({ port: PORT });

    wss.on('connection', (ws, req) => {

    const roomName = req.url?.slice(1) || 'default';

    const room = getRoom(roomName);

    room.clients.add(ws);

    console.log(

    クライアントがルーム "${roomName}" に接続 (${room.clients.size} クライアント)

    );

    // 現在のドキュメント状態をsync step 1として送信

    const encoder = encoding.createEncoder();

    encoding.writeVarUint(encoder, messageSync);

    const sv = Y.encodeStateVector(room.doc);

    encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdate(room.doc, sv));

    ws.send(encoding.toUint8Array(encoder));

    // 既存のawareness状態を送信

    room.awareness.forEach((state, clientId) => {

    const awarenessEncoder = encoding.createEncoder();

    encoding.writeVarUint(awarenessEncoder, messageAwareness);

    encoding.writeVarString(

    awarenessEncoder,

    JSON.stringify({ clientId, state })

    );

    ws.send(encoding.toUint8Array(awarenessEncoder));

    });

    ws.on('message', (data: Buffer) => {

    const message = new Uint8Array(data);

    const decoder = decoding.createDecoder(message);

    const msgType = decoding.readVarUint(decoder);

    switch (msgType) {

    case messageSync: {

    const update = decoding.readVarUint8Array(decoder);

    Y.applyUpdate(room.doc, update);

    broadcastToRoom(room, message, ws);

    break;

    }

    case messageAwareness: {

    const awarenessStr = decoding.readVarString(decoder);

    const { clientId, state } = JSON.parse(awarenessStr);

    room.awareness.set(clientId, state);

    broadcastToRoom(room, message, ws);

    break;

    }

    }

    });

    ws.on('close', () => {

    room.clients.delete(ws);

    console.log(

    クライアントがルーム "${roomName}" から切断 (${room.clients.size} クライアント)

    );

    if (room.clients.size === 0) {

    setTimeout(() => {

    if (room.clients.size === 0) {

    rooms.delete(roomName);

    console.log(ルーム "${roomName}" をクリーンアップしました);

    }

    }, 30000);

    }

    });

    });

    console.log(WebSocketサーバーが ws://localhost:${PORT} で起動しました);

    このサーバーはドキュメントルームを管理し、状態をディスクに永続化し、クライアント間で更新をブロードキャストします。

    ステップ3:y-websocketプロバイダーの使用(簡略化アプローチ)

    本番環境では、上記のカスタムサーバーの代わりに組み込みのy-websocketプロバイダーを使用します:

    # 組み込みのy-websocketサーバーを起動
    

    npx y-websocket --port 4444

    または永続化付きのカスタムサーバーを作成:

    import { setupWSConnection } from 'y-websocket/bin/utils';
    

    import { WebSocketServer } from 'ws';

    import http from 'http';

    const server = http.createServer();

    const wss = new WebSocketServer({ server });

    wss.on('connection', (ws, req) => {

    setupWSConnection(ws, req, {

    docName: req.url?.slice(1) || 'default',

    gc: true,

    });

    });

    server.listen(4444, () => {

    console.log('y-websocketサーバーがポート4444で起動しました');

    });

    ステップ4:共同編集エディタコンポーネントの構築

    client/src/CollaborativeEditor.tsxを作成します:

    import { useEditor, EditorContent } from '@tiptap/react';
    

    import StarterKit from '@tiptap/starter-kit';

    import Collaboration from '@tiptap/extension-collaboration';

    import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

    import * as Y from 'yjs';

    import { WebsocketProvider } from 'y-websocket';

    import { useEffect, useMemo, useState } from 'react';

    const colors = [

    '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',

    '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',

    ];

    const getRandomColor = () => colors[Math.floor(Math.random() * colors.length)];

    const getRandomName = () => {

    const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'];

    return names[Math.floor(Math.random() * names.length)];

    };

    interface CollaborativeEditorProps {

    roomName: string;

    }

    export default function CollaborativeEditor({ roomName }: CollaborativeEditorProps) {

    const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');

    const [userCount, setUserCount] = useState(0);

    const { ydoc, provider } = useMemo(() => {

    const ydoc = new Y.Doc();

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider };

    }, [roomName]);

    useEffect(() => {

    const onStatus = ({ status: s }: { status: string }) => {

    setStatus(s as any);

    };

    provider.on('status', onStatus);

    const onAwarenessChange = () => {

    setUserCount(provider.awareness.getStates().size);

    };

    provider.awareness.on('change', onAwarenessChange);

    return () => {

    provider.off('status', onStatus);

    provider.awareness.off('change', onAwarenessChange);

    provider.destroy();

    ydoc.destroy();

    };

    }, [provider, ydoc]);

    const editor = useEditor({

    extensions: [

    StarterKit.configure({ history: false }),

    Collaboration.configure({ document: ydoc }),

    CollaborationCursor.configure({

    provider,

    user: provider.awareness.getLocalState()?.user,

    }),

    ],

    editorProps: {

    attributes: {

    class: 'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-6',

    },

    },

    }, [ydoc, provider]);

    return (

    <div className="collaborative-editor">

    <div className="flex items-center justify-between p-3 bg-gray-100 rounded-t-lg border border-b-0">

    <div className="flex items-center gap-2">

    <div className={w-2 h-2 rounded-full ${

    status === 'connected' ? 'bg-green-500'

    : status === 'connecting' ? 'bg-yellow-500'

    : 'bg-red-500'

    }} />

    <span className="text-sm text-gray-600 capitalize">{status}</span>

    </div>

    <span className="text-sm text-gray-500">

    {userCount} ユーザーがオンライン

    </span>

    </div>

    {editor && (

    <div className="flex gap-1 p-2 border border-b-0 bg-white">

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBold().run()}

    active={editor.isActive('bold')}

    >B</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleItalic().run()}

    active={editor.isActive('italic')}

    >I</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}

    active={editor.isActive('heading', { level: 1 })}

    >H1</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}

    active={editor.isActive('heading', { level: 2 })}

    >H2</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBulletList().run()}

    active={editor.isActive('bulletList')}

    >• リスト</ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleCodeBlock().run()}

    active={editor.isActive('codeBlock')}

    >コード</ToolbarButton>

    </div>

    )}

    <div className="border rounded-b-lg">

    <EditorContent editor={editor} />

    </div>

    </div>

    );

    }

    function ToolbarButton({ onClick, active, children }: {

    onClick: () => void; active: boolean; children: React.ReactNode;

    }) {

    return (

    <button

    onClick={onClick}

    className={px-3 py-1 rounded text-sm font-medium transition-colors ${

    active ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100 text-gray-700'

    }}

    >{children}</button>

    );

    }

    ステップ5:共同編集カーソルのスタイリング

    client/src/editor.cssを作成してリモートカーソルをスタイリングします:

    .collaboration-cursor__caret {
    

    border-left: 2px solid;

    border-right: none;

    margin-left: -1px;

    margin-right: -1px;

    pointer-events: none;

    position: relative;

    word-break: normal;

    }

    .collaboration-cursor__label {

    border-radius: 3px 3px 3px 0;

    color: white;

    font-size: 11px;

    font-weight: 600;

    left: -1px;

    line-height: 1;

    padding: 2px 6px;

    position: absolute;

    top: -1.4em;

    user-select: none;

    white-space: nowrap;

    }

    ステップ6:アプリの統合

    client/src/App.tsxを更新します:

    import { useState } from 'react';
    

    import CollaborativeEditor from './CollaborativeEditor';

    import './editor.css';

    export default function App() {

    const [roomName, setRoomName] = useState('my-document');

    const [joined, setJoined] = useState(false);

    if (!joined) {

    return (

    <div className="min-h-screen flex items-center justify-center bg-gray-50">

    <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">

    <h1 className="text-2xl font-bold mb-6 text-center">

    共同編集エディタ

    </h1>

    <input

    type="text"

    value={roomName}

    onChange={(e) => setRoomName(e.target.value)}

    placeholder="ルーム名を入力..."

    className="w-full px-4 py-2 border rounded-lg mb-4"

    />

    <button

    onClick={() => setJoined(true)}

    className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700"

    >

    ルームに参加

    </button>

    </div>

    </div>

    );

    }

    return (

    <div className="min-h-screen bg-gray-50 p-8">

    <div className="max-w-4xl mx-auto">

    <h1 className="text-2xl font-bold mb-6">ルーム: {roomName}</h1>

    <CollaborativeEditor roomName={roomName} />

    </div>

    </div>

    );

    }

    ステップ7:オフラインサポートの追加

    Y.jsはネイティブにオフライン編集をサポートしています。IndexedDB永続化を追加して、ブラウザの更新後も変更を保持します:

    cd client
    

    npm install y-indexeddb

    CollaborativeEditor.tsxのプロバイダーセットアップを更新:

    import { IndexeddbPersistence } from 'y-indexeddb';
    
    

    const { ydoc, provider, indexeddbProvider } = useMemo(() => {

    const ydoc = new Y.Doc();

    const indexeddbProvider = new IndexeddbPersistence(roomName, ydoc);

    indexeddbProvider.on('synced', () => {

    console.log('IndexedDBから読み込みました');

    });

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider, indexeddbProvider };

    }, [roomName]);

    ユーザーがオフラインになっても編集を続行できます。再接続時にY.jsが自動的にすべての変更をマージします。

    ステップ8:Y.UndoManagerによるUndo/Redo

    Y.jsはコラボレーション対応のUndo/Redoを提供します。現在のユーザーの変更のみが元に戻されます:

    import { UndoManager } from 'yjs';
    
    

    const type = ydoc.getXmlFragment('default');

    const undoManager = new UndoManager(type, {

    trackedOrigins: new Set([null]),

    });

    ステップ9:本番デプロイ

    サーバーのDocker化

    FROM node:20-alpine
    

    WORKDIR /app

    COPY package*.json ./

    RUN npm ci --production

    COPY dist/ ./dist/

    EXPOSE 4444

    VOLUME /app/docs

    CMD ["node", "dist/index.js"]

    Docker Compose

    version: '3.8'
    

    services:

    server:

    build: ./server

    ports:

    - '4444:4444'

    volumes:

    - doc-data:/app/docs

    restart: unless-stopped

    client:

    build: ./client

    ports:

    - '3000:80'

    depends_on:

    - server

    volumes:

    doc-data:

    Nginx リバースプロキシ

    upstream ws_backend {
    

    server localhost:4444;

    }

    server {

    listen 443 ssl;

    server_name collab.yourdomain.com;

    location /ws {

    proxy_pass http://ws_backend;

    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;

    proxy_set_header Connection "upgrade";

    proxy_read_timeout 86400;

    }

    location / {

    root /var/www/collab-editor;

    try_files $uri $uri/ /index.html;

    }

    }

    ステップ10:スケーリングの考慮事項

    単一サーバーを超えたスケーリングのために:

    1. Redis Pub/Sub: y-redisを使用して複数サーバーインスタンス間でドキュメント更新を同期

    2. データベース永続化: ファイル永続化をPostgreSQLまたはMongoDBに置き換え

    3. 認証: WebSocketのconnectionハンドラーでJWT検証を追加

    4. レート制限: 悪用を防ぐためにクライアントごとの更新頻度を制限

    まとめ

    このチュートリアルで、以下の機能を持つ完全なリアルタイム共同編集エディタを構築しました:

    | 機能 | 技術 |

    |------|------|

    | CRDTドキュメントモデル | Y.js |

    | リアルタイム同期 | WebSocket + y-websocket |

    | リッチテキストエディタ | TipTap (ProseMirror) |

    | 共有カーソル | Awarenessプロトコル |

    | オフラインサポート | y-indexeddb |

    | 永続化 | ファイルシステム / LevelDB |

    | Undo/Redo | Y.UndoManager |

    Y.jsにより、共同編集は驚くほど手軽に実現できます。CRDTアプローチはコンフリクト解決の複雑さを排除し、プロバイダーのエコシステム(WebSocket、WebRTC、IndexedDB)がデプロイ方法に柔軟性を与えます。

    参考資料

  • Y.js ドキュメント
  • TipTap コラボレーションガイド
  • CRDT入門 by Martin Kleppmann
  • y-websocket GitHub