Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
enable-pre-post-scripts=true
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# TinyEditor

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->

[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-)

<!-- ALL-CONTRIBUTORS-BADGE:END -->

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/opentiny/tiny-editor)
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# TinyEditor 富文本编辑器

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->

[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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:

- [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-)
+ [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#-贡献者)

Or alternatively, add an English anchor heading before the Chinese one:

+ ## Contributors {#contributors-}
+ 
  ## ✨ 贡献者

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

5-5: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for AI Agents
In README.zh-CN.md around line 5, the All Contributors badge links to fragment
"#contributors-" which doesn't match the actual heading ID for "## ✨ 贡献者"; fix
this by updating the badge link fragment to the correct generated ID (for
example "#✨-贡献者" or the actual slug produced by your markdown processor) or add
an English anchor heading such as "## Contributors" immediately before the
Chinese heading so the existing "#contributors" fragment will resolve; ensure
the chosen fragment exactly matches the heading slug used by your renderer.


<!-- ALL-CONTRIBUTORS-BADGE:END -->

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/opentiny/tiny-editor)
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/collaborative-editing-backend/.dockerignore
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
6 changes: 6 additions & 0 deletions packages/collaborative-editing-backend/.env.example
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use placeholder credentials in .env.example, not real/default values.

The hardcoded MongoDB credentials (admin:admin) should be replaced with placeholders to make it clear these are examples and must be configured before use. Even for local development, storing credentials (even simple ones) in example files can pose security risks.

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=admin

Optionally, 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
MONGODB_URL=mongodb://admin:admin@127.0.0.1:27017/?authSource=admin
MONGODB_URL=mongodb://<username>:<password>@127.0.0.1:27017/?authSource=admin
🧰 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
In packages/collaborative-editing-backend/.env.example around line 3, replace
the hardcoded MongoDB credentials ("admin:admin") with placeholder values and
update the example to indicate these must be configured by the user; change the
URL to use non-sensitive placeholders for username, password, host and port and
add a one-line comment above explaining this is an example, where to set real
credentials (e.g., in a local .env or secret manager) and that the placeholders
must be replaced before running.

MONGODB_DB=tinyeditor
MONGODB_COLLECTION=documents
GC=true
9 changes: 9 additions & 0 deletions packages/collaborative-editing-backend/Dockerfile
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" ]
150 changes: 150 additions & 0 deletions packages/collaborative-editing-backend/README.md
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)
30 changes: 30 additions & 0 deletions packages/collaborative-editing-backend/docker-compose.yml
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:
23 changes: 23 additions & 0 deletions packages/collaborative-editing-backend/ecosystem.config.cjs
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,
}],
}
28 changes: 28 additions & 0 deletions packages/collaborative-editing-backend/package.json
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"
}
}
8 changes: 8 additions & 0 deletions packages/collaborative-editing-backend/src/env.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden env handling: validate, default sensibly, and fail fast with clear errors.

Non‑null assertions hide misconfig at compile time and crash later; GC undefined currently evaluates to true.

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
In packages/collaborative-editing-backend/src/env.ts around lines 1 to 8, remove
non-null assertions and add explicit validation and sensible defaults: set HOST
default to '0.0.0.0' and parse PORT with Number.parseInt, defaulting to 3000 and
throwing a clear Error if supplied PORT is not a number; require MONGODB_URL,
MONGODB_DB, and MONGODB_COLLECTION and throw descriptive Errors if any are
missing; parse GC strictly so only values 'true' or '1' enable it (default
false), and export typed constants without using the ! operator so
misconfiguration fails fast with clear messages.

16 changes: 16 additions & 0 deletions packages/collaborative-editing-backend/src/persistence/index.ts
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
70 changes: 70 additions & 0 deletions packages/collaborative-editing-backend/src/persistence/mongo.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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')
🤖 Prompt for AI Agents
In packages/collaborative-editing-backend/src/persistence/mongo.ts around lines
19 to 21, the thrown Error messages are in Chinese; replace them with English
messages for consistency (e.g. "Missing required environment variable:
MONGODB_URL", "Missing required environment variable: MONGODB_DB", "Missing
required environment variable: MONGODB_COLLECTION"). Update the three throw
statements accordingly so they use these English strings while keeping the same
error types and behavior.


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical issues in bindState implementation.

The bindState method has several critical problems:

  1. Data loss risk (Line 52): storeUpdate is called without await, creating a fire-and-forget pattern that could lose data if the operation fails or if the process terminates before completion.

  2. Logic ordering issue (Lines 50-53): New local updates are stored before loading and applying the persisted state. This could create conflicts or inconsistent state, as the local updates should typically be reconciled after loading the persisted state.

  3. Memory leak (Line 57-59): The update event handler is registered but never removed, causing a memory leak if bindState is called multiple times or if documents are frequently created/destroyed.

  4. Missing error handling: No try-catch blocks to handle potential failures from MongoDB operations or Y.js encoding/decoding.

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
+    }
  }

Committable suggestion skipped: line range outside the PR's diff.


async writeState(docName: string, ydoc: Y.Doc) {
return Promise.resolve()
}

async close() {
this.mongodbPersistence.destroy()
await this.client.close()
}
}
Loading
Loading