-
Notifications
You must be signed in to change notification settings - Fork 63
feat: support collaborative editing #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
705234b
d150a23
b9e0c36
c9a8417
ce6bd90
ad461c1
7008890
925507b
e10a950
89c358a
5b4f89e
55d4b32
382c642
b39453d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| enable-pre-post-scripts=true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,11 @@ | |
| "pre-commit": "lint-staged", | ||
| "commit-msg": "node verifyCommit.js" | ||
| }, | ||
| "pnpm": { | ||
| "patchedDependencies": { | ||
| "[email protected]": "patches/[email protected]" | ||
| } | ||
| }, | ||
| "lint-staged": { | ||
| "*.ts": [ | ||
| "eslint --fix", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| node_modules | ||
| npm-debug.log | ||
| .git | ||
| .gitignore | ||
| README.md | ||
| .nyc_output | ||
| coverage | ||
| .DS_Store | ||
| dist | ||
| tmp | ||
| dbDir |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,6 @@ | ||||||
| HOST=0.0.0.0 | ||||||
| PORT=1234 | ||||||
| MONGODB_URL=mongodb://admin:[email protected]:27017/?authSource=admin | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use placeholder credentials in .env.example, not real/default values. The hardcoded MongoDB credentials ( Apply this diff to use placeholder credentials: -MONGODB_URL=mongodb://admin:[email protected]:27017/?authSource=admin
+MONGODB_URL=mongodb://<username>:<password>@127.0.0.1:27017/?authSource=adminOptionally, add a comment above this line documenting how to set up credentials: +# MongoDB connection URL - configure with your credentials
+MONGODB_URL=mongodb://<username>:<password>@127.0.0.1:27017/?authSource=admin📝 Committable suggestion
Suggested change
🧰 Tools🪛 dotenv-linter (4.0.0)[warning] 3-3: [UnorderedKey] The MONGODB_URL key should go before the PORT key (UnorderedKey) 🤖 Prompt for AI Agents |
||||||
| MONGODB_DB=tinyeditor | ||||||
| MONGODB_COLLECTION=documents | ||||||
| GC=true | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| FROM node:22-alpine | ||
|
|
||
| WORKDIR /app | ||
| COPY package.json ./ | ||
| RUN npm install --no-package-lock && npm install pm2@latest -g | ||
| COPY . . | ||
|
|
||
| RUN npm run build | ||
| CMD [ "pm2-runtime", "start", "ecosystem.config.cjs" ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # TinyEditor 协同编辑后端服务 | ||
|
|
||
| 基于 Yjs 和 WebSocket 的实时协同编辑后端服务,支持多用户实时协作编辑,使用 MongoDB 进行文档持久化。 | ||
|
|
||
| ## 快速开始 | ||
|
|
||
| ### 环境变量配置 | ||
|
|
||
| ### Docker 容器化部署(推荐) | ||
|
|
||
| 1. 拉取 Docker 镜像,使用 Docker Compose 一键启动: | ||
|
|
||
| ```bash | ||
| docker pull yinlin124/collaborative-editor-backend:latest | ||
| ``` | ||
|
|
||
| 2. 创建 `docker-compose.yml` 文件,内容如下: | ||
|
|
||
| ```yaml | ||
| services: | ||
| mongodb: | ||
| image: mongo:latest | ||
| container_name: yjs-mongodb | ||
| restart: always | ||
| ports: | ||
| - '27017:27017' | ||
| environment: | ||
| MONGO_INITDB_ROOT_USERNAME: admin | ||
| MONGO_INITDB_ROOT_PASSWORD: admin!123 | ||
| volumes: | ||
| - mongodb_data:/data/db | ||
|
|
||
| websocket-server: | ||
| image: yinlin124/collaborative-editor-backend:latest | ||
| container_name: yjs-websocket-server | ||
| restart: always | ||
| ports: | ||
| - '${PORT:-1234}:${PORT:-1234}' | ||
| env_file: | ||
| - .env | ||
| depends_on: | ||
| - mongodb | ||
|
|
||
| volumes: | ||
| mongodb_data: | ||
| ``` | ||
|
|
||
| 3. 在项目根目录下新建 `.env` 文件: | ||
|
|
||
| ```env | ||
| HOST=0.0.0.0 | ||
| PORT=1234 | ||
| MONGODB_URL=mongodb://admin:admin!123@mongodb:27017/?authSource=admin | ||
| MONGODB_DB=tinyeditor | ||
| MONGODB_COLLECTION=documents | ||
| GC=true | ||
| ``` | ||
|
|
||
| 可参照下方表格进行配置 `.env` 文件 | ||
| | 变量名 | 必需 | 默认值 | 说明 | | ||
| | -------------------- | ---- | ------ | -------------------------------------------------------------- | | ||
| | `HOST` | ✅ | - | 服务器监听地址 | | ||
| | `PORT` | ✅ | - | WebSocket 服务端口 | | ||
| | `MONGODB_URL` | ✅ | - | MongoDB 连接字符串 | | ||
| | `MONGODB_DB` | ✅ | - | MongoDB 数据库名称 | | ||
| | `MONGODB_COLLECTION` | ✅ | - | MongoDB 集合名称 | | ||
| | `GC` | ❌ | `true` | 是否启用 Yjs 垃圾回收 | | ||
|
|
||
| 4. 在项目根目录下运行 `docker-compose` 启动容器: | ||
|
|
||
| ```bash | ||
| docker compose up | ||
| ``` | ||
|
|
||
| ### 本地部署 | ||
|
|
||
| 启动 mongodb | ||
|
|
||
| ```bash | ||
| docker run -d \ | ||
| --name mongodb \ | ||
| -p 27017:27017 \ | ||
| -e MONGO_INITDB_ROOT_USERNAME=admin \ | ||
| -e MONGO_INITDB_ROOT_PASSWORD="admin!123" \ | ||
| -v mongodb_data:/data/db \ | ||
| mongo:latest | ||
| ``` | ||
|
|
||
| 修改 `.env MongoDB URL` | ||
|
|
||
| ```bash | ||
| MONGODB_URL=mongodb://admin:admin!123@localhost:27017/?authSource=admin | ||
| ``` | ||
|
|
||
| 启动本地服务器 | ||
|
|
||
| ```bash | ||
| npm install -g pm2 | ||
| npm install | ||
| npm start | ||
| ``` | ||
|
|
||
| ## 前端配置 | ||
|
|
||
| (非完整前端配置主要参考 provider 部分) | ||
|
|
||
| ```javascript | ||
| import TinyEditor from '@opentiny/fluent-editor' | ||
|
|
||
| const editor = new TinyEditor('#editor', { | ||
| theme: 'snow', | ||
| modules: { | ||
| collaboration: { | ||
| provider: { | ||
| type: 'websocket', | ||
| options: { | ||
| serverUrl: 'ws://localhost:1234', | ||
| roomName: 'my-document', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }) | ||
| ``` | ||
|
|
||
| ## 开发指南 | ||
|
|
||
| ### MongoDB 持久化拓展 | ||
|
|
||
| 当前项目在 [`src/persistence/mongo.ts`](./src/persistence/mongo.ts) 类实现 MongoDB 持久化,基于 [`y-mongodb-provider`](https://github.com/MaxNoetzold/y-mongodb-provider) 库。 | ||
|
|
||
| 需要拓展当前持久化能力时,可参考 API 文档:[y-mongodb-provider API](https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#api) | ||
|
|
||
| ### 自定义持久化接口 | ||
|
|
||
| 要支持其他数据库(如 PostgreSQL、Redis 等),需要实现 `Persistence` 接口 | ||
|
|
||
| | 方法名 | 参数 | 返回值 | 说明 | | ||
| | ------------ | --------------------------------- | --------------- | -------------------------------------------- | | ||
| | `bindState` | `docName: string`<br>`doc: Y.Doc` | `Promise<void>` | 文档初始化时调用,加载历史状态并设置实时同步 | | ||
| | `writeState` | `docName: string`<br>`doc: Y.Doc` | `Promise<void>` | 手动保存文档状态(可选使用) | | ||
| | `close` | - | `Promise<void>` | 服务器关闭时调用,清理资源 | | ||
|
|
||
| ### 更多社区持久化支持 | ||
|
|
||
| [`adapter for Yjs`](https://github.com/search?q=adapter%20for%20Yjs&type=repositories): | ||
|
|
||
| - [y-mongodb-provider](https://github.com/yjs/y-mongodb-provider) | ||
| - [y-redis](https://github.com/yjs/y-redis) | ||
| - [y-postgres](https://github.com/MaxNoetzold/y-postgresql) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| services: | ||
| mongodb: | ||
| image: mongo:latest | ||
| container_name: yjs-mongodb | ||
| restart: always | ||
| ports: | ||
| - '27017:27017' | ||
| environment: | ||
| MONGO_INITDB_ROOT_USERNAME: admin | ||
| MONGO_INITDB_ROOT_PASSWORD: admin!123 | ||
| volumes: | ||
| - mongodb_data:/data/db | ||
|
|
||
| websocket-server: | ||
| image: yinlin124/collaborative-editor-backend:latest | ||
| container_name: yjs-websocket-server | ||
| restart: always | ||
| ports: | ||
| - '${PORT:-1234}:${PORT:-1234}' | ||
| environment: | ||
| HOST: ${HOST:-0.0.0.0} # 设置后端监听的网络接口 | ||
| PORT: ${PORT:-1234} # 默认 1234 端口,可以使用环境变量修改 | ||
| MONGODB_URL: ${MONGODB_URL:-mongodb://admin:admin!123@mongodb:27017/?authSource=admin} # 如果你使用自己的 mongodb 服务需要修改此项 | ||
| MONGODB_DB: ${MONGODB_DB:-tinyeditor} # 数据库名称 | ||
| MONGODB_COLLECTION: ${MONGODB_COLLECTION:-documents} # 集合名称 | ||
| depends_on: | ||
| - mongodb | ||
|
|
||
| volumes: | ||
| mongodb_data: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| const fs = require('node:fs') | ||
| const path = require('node:path') | ||
|
|
||
| const logDir = path.resolve(__dirname, 'log') | ||
| if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }) | ||
|
|
||
| const envFile = path.resolve(__dirname, '.env') | ||
| const hasEnvFile = fs.existsSync(envFile) | ||
| const nodeArgs = hasEnvFile ? '--env-file=.env --no-warnings' : '--no-warnings' | ||
|
|
||
| module.exports = { | ||
| apps: [{ | ||
| name: 'collaborative-editor-backend', | ||
| script: './dist/server.js', | ||
| node_args: nodeArgs, | ||
| instances: 1, | ||
| log_file: path.join(logDir, 'app.log'), | ||
| error_file: path.join(logDir, 'error.log'), | ||
| log_date_format: 'YYYY-MM-DD HH:mm:ss Z', | ||
| watch: false, | ||
| max_restarts: 10, | ||
| }], | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "name": "collaborative-editor-backend", | ||
| "type": "module", | ||
| "version": "0.0.1", | ||
| "scripts": { | ||
| "dev": "vite-node src/server.ts", | ||
| "start": "npm run build && pm2 start ecosystem.config.cjs", | ||
| "stop": "pm2 stop ecosystem.config.cjs", | ||
| "build": "vite build" | ||
| }, | ||
| "dependencies": { | ||
| "@y/protocols": "^1.0.6-1", | ||
| "lib0": "^0.2.102", | ||
| "mongodb": "^6.0.0", | ||
| "ws": "^8.0.0", | ||
| "y-mongodb-provider": "0.2.1", | ||
| "yjs": "^13.6.15" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/glob": "^9.0.0", | ||
| "@types/node": "^24.3.0", | ||
| "@types/ws": "^8.18.1", | ||
| "glob": "^11.0.3", | ||
| "typescript": "^5.7.9", | ||
| "vite": "^7.1.3", | ||
| "vite-node": "^3.2.4" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export const HOST = process.env.HOST! | ||
| export const PORT = Number.parseInt(process.env.PORT!) | ||
|
|
||
| export const MONGODB_URL = process.env.MONGODB_URL! as string | ||
| export const MONGODB_DB = process.env.MONGODB_DB! as string | ||
| export const MONGODB_COLLECTION = process.env.MONGODB_COLLECTION! as string | ||
|
|
||
| export const GC_ENABLED = (process.env.GC !== 'false' && process.env.GC !== '0') as boolean | ||
|
Comment on lines
+1
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden env handling: validate, default sensibly, and fail fast with clear errors. Non‑null assertions hide misconfig at compile time and crash later; Apply: +function requireEnv(name: string): string {
+ const v = process.env[name]
+ if (!v) throw new Error(`Missing required env: ${name}`)
+ return v
+}
+
-export const HOST = process.env.HOST!
-export const PORT = Number.parseInt(process.env.PORT!)
+export const HOST = requireEnv('HOST')
+const PORT_STR = requireEnv('PORT')
+export const PORT = Number.parseInt(PORT_STR, 10)
+if (!Number.isFinite(PORT) || PORT <= 0) {
+ throw new Error(`Invalid PORT: ${PORT_STR}`)
+}
-export const MONGODB_URL = process.env.MONGODB_URL! as string
-export const MONGODB_DB = process.env.MONGODB_DB! as string
-export const MONGODB_COLLECTION = process.env.MONGODB_COLLECTION! as string
+export const MONGODB_URL = requireEnv('MONGODB_URL')
+export const MONGODB_DB = requireEnv('MONGODB_DB')
+export const MONGODB_COLLECTION = requireEnv('MONGODB_COLLECTION')
-export const GC_ENABLED = (process.env.GC !== 'false' && process.env.GC !== '0') as boolean
+// Default GC to true when unset; allow explicit off with 'false' or '0'
+export const GC_ENABLED =
+ process.env.GC === undefined ? true : !(process.env.GC === 'false' || process.env.GC === '0')🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import type * as Y from 'yjs' | ||
|
|
||
| export interface Persistence { | ||
| connect: () => Promise<void> | ||
| bindState: (docName: string, doc: Y.Doc) => Promise<void> | ||
| writeState: (docName: string, doc: Y.Doc) => Promise<void> | ||
| close?: () => Promise<void> | ||
| } | ||
|
|
||
| let persistence: Persistence | null = null | ||
|
|
||
| export function setPersistence(p: Persistence | null) { | ||
| persistence = p | ||
| } | ||
|
|
||
| export const getPersistence = () => persistence |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||||
| import type { Db } from 'mongodb' | ||||||||||||||
| import type { Persistence } from './index.ts' | ||||||||||||||
| import { MongoClient } from 'mongodb' | ||||||||||||||
| import { MongodbPersistence } from 'y-mongodb-provider' | ||||||||||||||
| import * as Y from 'yjs' | ||||||||||||||
| import { MONGODB_COLLECTION, MONGODB_DB, MONGODB_URL } from '../env.ts' | ||||||||||||||
|
|
||||||||||||||
| interface MongoConnectionObj { | ||||||||||||||
| client: MongoClient | ||||||||||||||
| db: Db | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export class MongoPersistence implements Persistence { | ||||||||||||||
| private mongodbPersistence: MongodbPersistence | ||||||||||||||
| private client: MongoClient | ||||||||||||||
| private connected = false | ||||||||||||||
|
|
||||||||||||||
| constructor() { | ||||||||||||||
| if (!MONGODB_URL) throw new Error('缺少必需的环境变量: MONGODB_URL') | ||||||||||||||
| if (!MONGODB_DB) throw new Error('缺少必需的环境变量: MONGODB_DB') | ||||||||||||||
| if (!MONGODB_COLLECTION) throw new Error('缺少必需的环境变量: MONGODB_COLLECTION') | ||||||||||||||
|
Comment on lines
+19
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use English for error messages. The error messages are in Chinese, which may not be accessible to all developers working on this codebase. Consider using English for consistency with the rest of the codebase. Apply this diff to use English error messages: - if (!MONGODB_URL) throw new Error('缺少必需的环境变量: MONGODB_URL')
- if (!MONGODB_DB) throw new Error('缺少必需的环境变量: MONGODB_DB')
- if (!MONGODB_COLLECTION) throw new Error('缺少必需的环境变量: MONGODB_COLLECTION')
+ if (!MONGODB_URL) throw new Error('Missing required environment variable: MONGODB_URL')
+ if (!MONGODB_DB) throw new Error('Missing required environment variable: MONGODB_DB')
+ if (!MONGODB_COLLECTION) throw new Error('Missing required environment variable: MONGODB_COLLECTION')📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| this.client = new MongoClient(MONGODB_URL, { | ||||||||||||||
| connectTimeoutMS: 5000, | ||||||||||||||
| serverSelectionTimeoutMS: 5000, | ||||||||||||||
| socketTimeoutMS: 5000, | ||||||||||||||
| }) | ||||||||||||||
| const db = this.client.db(MONGODB_DB) | ||||||||||||||
| const connectionObj: MongoConnectionObj = { client: this.client, db } | ||||||||||||||
|
|
||||||||||||||
| this.mongodbPersistence = new MongodbPersistence(connectionObj, { | ||||||||||||||
| collectionName: MONGODB_COLLECTION, | ||||||||||||||
| flushSize: 50, | ||||||||||||||
| multipleCollections: true, | ||||||||||||||
| }) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async connect() { | ||||||||||||||
| if (!this.connected) { | ||||||||||||||
| await this.client.connect() | ||||||||||||||
| this.connected = true | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async bindState(docName: string, ydoc: Y.Doc) { | ||||||||||||||
| await this.connect() | ||||||||||||||
|
|
||||||||||||||
| const persistedYDoc = await this.mongodbPersistence.getYDoc(docName) | ||||||||||||||
|
|
||||||||||||||
| const newUpdates = Y.encodeStateAsUpdate(ydoc) | ||||||||||||||
| if (newUpdates.byteLength > 0) { | ||||||||||||||
| this.mongodbPersistence.storeUpdate(docName, newUpdates) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYDoc)) | ||||||||||||||
|
|
||||||||||||||
| ydoc.on('update', (update: Uint8Array) => { | ||||||||||||||
| this.mongodbPersistence.storeUpdate(docName, update) | ||||||||||||||
| }) | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+45
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical issues in bindState implementation. The
Apply this diff to address these issues: async bindState(docName: string, ydoc: Y.Doc) {
- await this.connect()
+ try {
+ await this.connect()
- const persistedYDoc = await this.mongodbPersistence.getYDoc(docName)
+ const persistedYDoc = await this.mongodbPersistence.getYDoc(docName)
- const newUpdates = Y.encodeStateAsUpdate(ydoc)
- if (newUpdates.byteLength > 0) {
- this.mongodbPersistence.storeUpdate(docName, newUpdates)
- }
+ // Apply persisted state first
+ Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYDoc))
- Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYDoc))
+ // Then store any new local updates
+ const newUpdates = Y.encodeStateAsUpdate(ydoc)
+ if (newUpdates.byteLength > 0) {
+ await this.mongodbPersistence.storeUpdate(docName, newUpdates)
+ }
- ydoc.on('update', (update: Uint8Array) => {
- this.mongodbPersistence.storeUpdate(docName, update)
- })
+ // Store the update handler reference for cleanup
+ const updateHandler = (update: Uint8Array) => {
+ this.mongodbPersistence.storeUpdate(docName, update)
+ }
+ ydoc.on('update', updateHandler)
+
+ // TODO: Consider storing handlers in a Map for cleanup in close()
+ } catch (error) {
+ console.error(`Failed to bind state for document ${docName}:`, error)
+ throw error
+ }
}
|
||||||||||||||
|
|
||||||||||||||
| async writeState(docName: string, ydoc: Y.Doc) { | ||||||||||||||
| return Promise.resolve() | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async close() { | ||||||||||||||
| this.mongodbPersistence.destroy() | ||||||||||||||
| await this.client.close() | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Broken link fragment in All Contributors badge.
The link fragment
#contributors-does not match the corresponding heading. The document contains## ✨ 贡献者(line 69), which would generate a different ID depending on the markdown processor. The link will fail to navigate to the intended section.Either update the link fragment to match the heading ID or add an English heading alias:
Or alternatively, add an English anchor heading before the Chinese one:
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
5-5: Link fragments should be valid
(MD051, link-fragments)
🤖 Prompt for AI Agents