tldraw 同步实战:用 Node.js + SQLite 构建自定义多人在线白板后端(simple-server-example 全解析)
导读
本文以 tldraw 仓库中的 templates/simple-server-example 为蓝本,完整讲解如何用 Node.js 自行搭建 tldraw 多人同步(tldraw sync)后端:包括 Fastify + WebSocket 的实时同步端点、基于 SQLiteSyncStorage 的房间数据持久化、图片/视频等静态资源的上传下载、以及链接书签的 URL 展开(unfurl)服务。读完本文,你将能理解 tldraw 同步协议的服务端接入原理,并具备把该示例扩展为生产级自托管协同白板服务的能力。
一、示例总览:一个"麻雀虽小、五脏俱全"的同步后端
templates/simple-server-example 是一个完整的可运行示例,包含服务端(Node.js + Fastify)与客户端(React + Vite)两部分,目录结构如下:
templates/simple-server-example/
├── src/
│ ├── client/ # 前端 React 应用
│ │ ├── App.tsx # useSync 连接 + 资源处理
│ │ ├── main.tsx # React 挂载入口
│ │ ├── index.css / index.html / vite-env.d.ts
│ └── server/ # Node 服务端
│ ├── server.ts # Fastify HTTP + WebSocket 路由
│ ├── rooms.ts # 房间生命周期与 SQLite 持久化
│ ├── assets.ts # 图片/视频等静态资源的文件系统存储
│ └── unfurl.ts # 书签链接元数据展开
├── package.json
├── tsconfig.json
└── vite.config.mts
其设计意图非常明确:tldraw SDK 只负责客户端渲染与交互,服务端需要自行提供三件事——WebSocket 同步端点(承载多人实时协作)、资产(asset)存储(图片、视频等大文件)、书签 unfurl 服务。该示例用最少的依赖(fastify、@fastify/websocket、@fastify/cors、better-sqlite3、itty-router、unfurl.js)把这三件事全部落地,见 package.json。
与它相对的是 Cloudflare 专用方案 templates/sync-cloudflare,后者针对边缘运行时做了优化;如果你要部署在 Cloudflare Workers 上,应优先参考那个模板。本示例则面向任意 Node.js 环境,理解成本更低,是学习同步协议接入的最佳起点。
二、一键启动:两条命令跑起前后端
在仓库根目录(monorepo,使用 Yarn workspaces)下执行:
# 进入示例目录
cd templates/simple-server-example
# 同时启动服务端(端口 5858)与客户端(Vite,端口 5757)
yarn dev
yarn dev 实际并行执行两条命令(见 package.json):
"dev": "concurrently -n server,client -c red,blue \"yarn dev-server\" \"yarn dev-client\"",
"dev-server": "yarn run -T tsx watch ./src/server/server.ts",
"dev-client": "vite dev --host"
- 服务端:
tsx watch直接运行 TypeScript 源码,支持文件变更热重启,监听端口5858(定义于 server.ts); - 客户端:Vite dev server,端口
5757(见 vite.config.mts),--host允许局域网访问,便于多设备联调。
启动后浏览器打开 Vite 提供的地址(如 http://localhost:5757),即可看到一块可多人协作的画布。用两个浏览器窗口打开同一地址,光标、图形、选区会实时互相同步——这就是 tldraw sync 的效果。
提示:
yarn与tsx均通过仓库根部的 workspace 解析(yarn run -T),因此无需单独安装全局依赖。
三、服务端核心:WebSocket 同步端点
tldraw sync 的接入协议由 @tldraw/sync-core 包提供,服务端只需实现一个 WebSocket 端点。示例中使用 Fastify 的官方 WebSocket 插件(server.ts):
const app = fastify()
app.register(websocketPlugin)
app.register(cors, { origin: '*' })
app.register(async (app) => {
// 这是多人同步的主入口
app.get('/connect/:roomId', { websocket: true }, async (socket, req) => {
// roomId 来自 URL 路径
const roomId = (req.params as any).roomId as string
// sessionId 由客户端以查询参数传入,必须提取并交给 room
const sessionId = (req.query as any)?.['sessionId'] as string
// 在异步加载 room 之前,必须至少挂载一个消息处理器
// 这里先把提前到达的消息缓存起来,等 room 就绪后再重放
const caughtMessages: RawData[] = []
const collectMessagesListener = (message: RawData) => {
caughtMessages.push(message)
}
socket.on('message', collectMessagesListener)
// 获取(或创建)该 roomId 对应的 TLSocketRoom 实例
const room = makeOrLoadRoom(roomId)
// 将 socket 接入 room
room.handleSocketConnect({ sessionId, socket })
socket.off('message', collectMessagesListener)
// 重放缓存的消息,让 room 正常处理
for (const message of caughtMessages) {
socket.emit('message', message)
}
})
// ... 资产上传下载与 unfurl 路由见下文
})
这段代码揭示了一个重要的实现细节:连接建立后、room 异步加载完成前,客户端可能已经发来消息。因此示例在 makeOrLoadRoom(内部会同步打开 SQLite 数据库,属于异步前的工作)之前就挂上 collectMessagesListener 收集消息,待 room.handleSocketConnect 完成后再逐条重放,保证不丢包。代码注释特别给出了这一模式的出处(fastify-websocket 关于先挂事件处理器的约定),这是把 WebSocket 接入同步协议时最容易踩的坑。
四、房间管理:TLSocketRoom + SQLite 持久化
服务端的同步核心是 @tldraw/sync-core 导出的 TLSocketRoom——它管理某个房间内所有连接的会话、处理协议消息、并把状态读写委托给传入的 storage。示例将其与 SQLite 组合(rooms.ts):
import { NodeSqliteWrapper, SQLiteSyncStorage, TLSocketRoom } from '@tldraw/sync-core'
import Database from 'better-sqlite3'
// 房间数据保存在本地文件系统的 SQLite 数据库中
const DIR = './.rooms'
mkdirSync(DIR, { recursive: true })
// 清洗 roomId,防止路径穿越攻击
function sanitizeRoomId(roomId: string): string {
return roomId.replace(/[^a-zA-Z0-9_-]/g, '_')
}
// 内存中维护活跃房间的映射
const rooms = new Map<string, TLSocketRoom<any, void>>()
export function makeOrLoadRoom(roomId: string): TLSocketRoom<any, void> {
roomId = sanitizeRoomId(roomId)
const existing = rooms.get(roomId)
if (existing && !existing.isClosed()) {
return existing
}
console.log('loading room', roomId)
// 打开数据库,文件不存在则自动创建
const db = new Database(join(DIR, `${roomId}.db`))
const sql = new NodeSqliteWrapper(db)
const storage = new SQLiteSyncStorage({ sql })
const room = new TLSocketRoom({
storage,
onSessionRemoved(room, args) {
console.log('client disconnected', args.sessionId, roomId)
if (args.numSessionsRemaining === 0) {
console.log('closing room', roomId)
room.close()
db.close()
rooms.delete(roomId)
}
},
})
rooms.set(roomId, room)
return room
}
4.1 三层抽象:NodeSqliteWrapper → SQLiteSyncStorage → TLSocketRoom
从源码结构看,@tldraw/sync-core 对存储做了分层抽象,职责分明:
NodeSqliteWrapper:把better-sqlite3的数据库实例包装成 sync-core 期望的Sqlite接口(提供run/get/all等方法),使 sync-core 与具体 SQLite 驱动解耦。这样在 Cloudflare 场景下可以替换为基于 Durable Objects 的包装器,而同步逻辑无需改动;SQLiteSyncStorage:基于包装器实现 sync-core 定义的存储接口,负责房间数据的持久化读写(文档/时钟/快照等表结构由它内部维护);TLSocketRoom:同步协议的高层门面,负责会话接入、消息分发、冲突解决与状态广播。客户端连上后调用room.handleSocketConnect({ sessionId, socket })即完成接入。
4.2 房间生命周期与懒卸载
makeOrLoadRoom 体现了典型的按需加载 + 引用计数式回收策略:
roomsMap 缓存活跃房间,重复连接直接复用现有实例;- 每个房间对应一个独立的 SQLite 数据库文件
.rooms/<roomId>.db,首次访问时创建; - 通过
onSessionRemoved回调感知断连:当numSessionsRemaining === 0(最后一个客户端离开)时主动room.close()并关闭数据库、从 Map 删除,避免内存与文件句柄泄漏。
因此房间数据是持久化的:所有客户端断开后,.rooms 目录下的数据库文件仍保留着画布内容;下次有人进入同一 roomId 时,数据会从 SQLite 完整恢复。这也正是原文档所说"Room data is automatically persisted to SQLite databases in the .rooms directory using SQLiteSyncStorage"的底层机制。
4.3 安全细节:roomId 清洗
sanitizeRoomId 把 roomId 中除字母、数字、_、- 之外的字符全部替换为 _,防止路径穿越(例如 ../../etc 之类的输入导致写入非预期路径)。任何把 roomId 直接拼进文件路径的生产实现都必须做类似的清洗,这是示例中值得直接借鉴的安全实践。
五、静态资源(Asset)存储:图片、视频的上传与下载
tldraw 画布中插入的图片、视频等大文件不会塞进同步协议(它们体积大、且不需要实时协作语义),而是走独立的 HTTP 存储通道。服务端提供两个端点(server.ts):
// 允许所有 Content-Type 且不做解析,以便处理原始二进制流
app.addContentTypeParser('*', (_, __, done) => done(null))
app.put('/uploads/:id', {}, async (req, res) => {
const id = (req.params as any).id as string
await storeAsset(id, req.raw)
res.send({ ok: true })
})
app.get('/uploads/:id', async (req, res) => {
const id = (req.params as any).id as string
const data = await loadAsset(id)
// 防止用户上传的 SVG 造成 XSS
res.header('Content-Security-Policy', "default-src 'none'")
res.header('X-Content-Type-Options', 'nosniff')
res.send(data)
})
底层存储实现很简单——直接写文件系统(assets.ts):
// 仅用文件系统存储资产
const DIR = resolve('./.assets')
export async function storeAsset(id: string, stream: Readable) {
await mkdir(DIR, { recursive: true })
await writeFile(join(DIR, id), stream)
}
export async function loadAsset(id: string) {
return await readFile(join(DIR, id))
}
注意两个容易被忽略但很重要的细节:
app.addContentTypeParser('*', ...):必须让 Fastify 对任意 Content-Type 都不做解析,才能把上传请求体当作原始二进制流写入文件;- 响应头
Content-Security-Policy: default-src 'none'与X-Content-Type-Options: nosniff:防止用户上传的 SVG 等文件在浏览器中被当作可执行脚本,是资产服务必须防御的 XSS 向量。
生产环境中可以把 storeAsset / loadAsset 替换为对象存储(S3、R2、OSS)的实现,接口签名保持不变即可,这正是分层设计的好处。
六、书签展开(Unfurl):粘贴链接自动生成卡片
当用户向画布粘贴一个 URL 时,tldraw 会调用客户端注册的 registerExternalAssetHandler('url', ...),服务端通过 unfurl 获取网页标题、描述、缩略图等信息生成书签卡片。
服务端端点(server.ts):
app.get('/unfurl', async (req, res) => {
const url = (req.query as any).url as string
res.send(await unfurl(url))
})
实现基于 unfurl.js 库(unfurl.ts),从 Open Graph 与 Twitter Card 元数据中提取字段,并优先取 open_graph.images[0],其次回退到 twitter_card.images[0]:
export async function unfurl(url: string) {
const { title, description, open_graph, twitter_card, favicon } = await _unfurl.unfurl(url)
const image = open_graph?.images?.[0]?.url || twitter_card?.images?.[0]?.url
return { title, description, image, favicon }
}
客户端侧(App.tsx)在编辑器挂载后注册处理器,先构造一个空的 bookmark asset,再请求服务端填充元数据,即使请求失败也不影响链接本身的插入:
onMount={(editor) => {
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
}}
七、客户端接线:useSync 与 TLAssetStore
7.1 useSync:一行代码接入多人同步
客户端核心是 @tldraw/sync 包导出的 React Hook useSync(App.tsx):
const WORKER_URL = `http://localhost:5858`
const roomId = 'test-room' // 示例中硬编码,生产环境可按需生成
const store = useSync({
uri: `${WORKER_URL}/connect/${roomId}`,
assets: multiplayerAssets,
})
uri:WebSocket 地址,roomId拼在路径中,sessionId由useSync内部作为查询参数自动附带;assets:实现TLAssetStore的对象,定义资产上传与解析方式(见下);- 返回的
store可直接传给<Tldraw store={store}>,组件会自行处理加载状态,并自动启用多人功能(光标、在线状态、选择同步等)。
从 useSync.ts 的源码可以看出,它内部会创建 TLSyncClient 管理连接状态机,并对外暴露 RemoteTLStoreWithStatus(含 status、store、error),同时提供 users(自定义用户信息与解析)、getUserPresence(自定义广播的在线状态)、onCustomMessageReceived(自定义消息通道)等高级配置项,本文示例只用到了最基础的 uri 与 assets。
7.2 TLAssetStore:资产的客户端上传/解析协议
const multiplayerAssets: TLAssetStore = {
// 上传:为文件加唯一前缀,PUT 到服务端,返回可访问 URL
async upload(_asset, file) {
const objectName = `${uniqueId()}-${file.name}`
const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}`
const response = await fetch(url, { method: 'PUT', body: file })
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
return { src: url }
},
// 解析:直接返回存储的 src。可按需扩展鉴权、压缩/多尺寸版本
resolve(asset) {
return asset.props.src
},
}
两点值得说明:
upload在文件名前拼接uniqueId()防止不同用户上传同名文件互相覆盖;resolve是读取路径,示例直接返回src;生产环境可在这里注入签名 URL、防盗链校验或返回压缩后的缩略图版本。
八、从示例到生产:可落地的改造清单
原 README 明确指出该示例"skipping normal production concerns like rate limiting and input validation"(见 server.ts 注释)。从示例走向生产环境时,建议按以下清单改造:
| 关注点 | 示例现状 | 生产建议 |
|---|---|---|
| 鉴权 | 无,cors origin: '*' 全开放 |
接入登录体系;在 /connect/:roomId 校验 sessionId 对应的用户身份 |
| 限流 | 无 | 对连接数、消息频率、上传体积做限流 |
| 资产存储 | 本地文件系统 .assets/ |
替换为 S3 / R2 / OSS 等对象存储 |
| 部署形态 | 单机 Node 进程、内存 Map 缓存房间 | 多实例部署时需引入共享房间路由(如按 roomId 一致性哈希)或用 Durable Objects 等托管方案 |
| 入口校验 | sanitizeRoomId 已防路径穿越 |
补充房间白名单/访问控制 |
| 端口与地址 | 硬编码 5858(server.ts)与 5757(vite.config.mts) |
改为环境变量配置 |
若你的目标是 Cloudflare 边缘部署,请直接参考仓库中的 templates/sync-cloudflare 模板,它利用 Durable Objects 实现跨实例一致的房间同步与存储。
九、小结
通过 templates/simple-server-example 可以完整掌握 tldraw sync 服务端的接入模式:
- 同步端点:用任意 WebSocket 框架暴露
/connect/:roomId,把 socket 交给TLSocketRoom.handleSocketConnect; - 持久化:
NodeSqliteWrapper+SQLiteSyncStorage把房间状态落盘到 SQLite,房间按需加载、空房自动回收; - 资产通道:独立的 PUT/GET 端点承载图片视频,
TLAssetStore定义客户端侧的对应协议; - 书签卡片:
/unfurl端点 +registerExternalAssetHandler('url', ...)实现链接元数据展开。
这套分层(存储可替换、协议与传输解耦)的设计正是 tldraw sync 的核心价值所在:开发者只需实现少量接口,就能在任何后端技术上获得与官方服务一致的多人实时协作体验。
相关资源
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00