# Collab — Yjs 协作服务器 Node.js (CommonJS) 实时协作服务器,在内存中维护 Yjs 文档,将更新持久化到 Redis,并通过 Redis Pub/Sub 或 RabbitMQ 通知下游消费者脏文档。 ## 快速启动 ```bash npm install npm start ``` 服务器监听 `PORT`(默认 `1234`)。 ## 架构 ``` server.js ├── src/http/server.js — 原生 Node HTTP 服务器(无 Express) │ └── src/http/router.js — 路由:/health, /livez, /readyz, /api/active-rooms, /internal/yjs/* ├── src/ws-gateway.js — WebSocket 升级处理器,从 URL 路径提取房间名 ├── src/ws-utils.js — Yjs 同步/感知协议,WSSharedDoc,连接生命周期 ├── src/doc-loader.js — 加载文档:Redis 列表 → gRPC 快照 → gRPC 内容(级联) ├── src/persistence.js — 绑定 Y.Doc 'update' 事件 → Redis 列表追加 + 脏文档追踪 ├── src/redis/ — Redis 客户端,房间追踪,文档缓冲区列表 ├── src/grpc/ — gRPC 客户端连接后端(DocumentService, SystemService) ├── src/mq.js — 脏文档发布者(Redis Pub/Sub 和/或 RabbitMQ) ├── src/config.js — 所有配置来自 process.env(无 .env 加载器) ├── src/key.js — Redis 键模式 ├── src/bootstrap.js — 监听 + 初始化 Redis/gRPC └── src/lifecycle.js — 优雅停机(SIGTERM/SIGINT) ``` ## 连接流程 1. 客户端通过 WebSocket 连接到 `/{roomName}`(例如 `/doc-{docId}`) 2. `ws-gateway.js` 从 URL 路径提取房间名 3. `ws-utils.js` 为房间加载或创建 `WSSharedDoc` 4. `doc-loader.js` 尝试加载已有状态: - Redis 列表缓冲区 → gRPC `GetDocumentYjsSnapshot` → gRPC `GetDocumentContent` → 空文档 5. `persistence.js` 绑定到 Y.Doc 的 `update` 事件 6. 更新被追加到 Redis 列表并追踪用于脏文档通知 ## 持久化模型 - 每次文档更新追加到 Redis 列表:`collab:yjs:doc:updates:{docId}` - 达到 `DOC_UPDATE_COMPACT_THRESHOLD`(默认 1000)次更新后,列表压缩为单个状态更新 - 文档在 Redis 集合中标记为脏:`collab:yjs:dirty:doc` - 达到 `DIRTY_DOC_NOTIFY_THRESHOLD`(默认 200)次更新后,通过 MQ 发布脏文档事件 ## 脏文档通知 脏文档事件通知下游消费者(snapshot-worker)文档已被修改。 MQ 后端由 `DIRTY_DOC_MQ` 环境变量控制: - `redis` — Redis Pub/Sub(默认,无消费者组) - `rabbitmq` — RabbitMQ 带消费者组 - `both` — 同时发布到两者 载荷格式: ```json { "doc_id": "document-id", "ts": 1234567890 } ``` 主题:`DIRTY_DOC_TOPIC`(默认 `collab.dirty_docs`) ## HTTP 路由 健康探针: - `GET /health` — 整体健康状态 - `GET /livez` — 存活探针 - `GET /readyz` — 就绪探针(drain 期间失败) API: - `GET /api/active-rooms` — 列出活跃的 WebSocket 房间 内部接口(snapshot-worker 使用): - `GET /internal/yjs/doc-snapshot/{docId}?type={type}` — 获取文档快照 - `GET /internal/yjs/doc/{docId}` — 获取文档的二进制 Yjs 更新 ## WebSocket 协议 房间名从 WebSocket URL 路径提取。对于 `/doc-{docId}`,文档 ID 为 `{docId}`。 协议消息: - 同步消息(Yjs 同步协议) - 感知消息(光标位置、用户存在状态) 当房间的最后一个连接关闭时,文档从内存中移除。 ## Proto 代码生成 `src/gen/` 包含从父仓库的 `proto/yoresee_doc/v1/yoresee_doc.proto` 生成的 gRPC 存根。 此目录已**gitignore**。生产环境中,`Dockerfile.prod` 在构建时生成存根。开发环境从父仓库执行: ```bash bash deploy/script/gen_proto.sh ``` ## 环境变量 | 变量 | 默认值 | 描述 | |---|---|---| | `PORT` | `1234` | HTTP 监听端口 | | `REDIS_HOST` | `redis` | Redis 主机名 | | `REDIS_PORT` | `6379` | Redis 端口 | | `REDIS_PASSWORD` | `''` | Redis 密码 | | `REDIS_DB` | `0` | Redis DB 索引 | | `BACKEND_ADDR` | `backend:9090` | 后端 gRPC 地址 | | `DIRTY_DOC_MQ` | `redis` | MQ 类型:`redis`、`rabbitmq` 或 `both` | | `DIRTY_DOC_TOPIC` | `collab.dirty_docs` | 脏文档发布/订阅主题 | | `RABBITMQ_URL` | `''` | RabbitMQ 连接 URL(空 = 禁用) | | `INTERNAL_RPC_KEY` | `''` | gRPC 元数据认证键(`x-internal-key`) | | `DIRTY_DOC_NOTIFY_THRESHOLD` | `200` | 发布脏文档事件前的更新次数 | | `DOC_UPDATES_TTL` | `259200` | 文档更新计数器的 TTL(秒) | | `DOC_UPDATE_COMPACT_THRESHOLD` | `1000` | 压缩 Redis 列表前的更新次数 | | `GC` | `true` | Yjs 垃圾回收(`false`/`0` 禁用) | | `BACKEND_SNAPSHOT_TIMEOUT_MS` | `3000` | 后端快照 gRPC 调用超时 | ## Redis 键 在 `src/key.js` 中定义: - `collab:yjs:doc:updates:{docId}` — 文档更新列表 - `collab:room:doc-{docId}` — 房间元数据 - `collab:yjs:dirty:doc` — 脏文档集合 ## 优雅停机 收到 `SIGTERM` 或 `SIGINT` 时: 1. 服务器停止接受新连接 2. 就绪探针返回 `not_ready` 3. 关闭现有 WebSocket 连接 4. 关闭 MQ 连接 5. 关闭 Redis 连接 6. 进程退出(10 秒超时后强制退出)