首页
/ tldraw 同步实战:用 Node.js + SQLite 构建自定义多人在线白板后端(simple-server-example 全解析)

tldraw 同步实战:用 Node.js + SQLite 构建自定义多人在线白板后端(simple-server-example 全解析)

2026-09-09 13:59:13作者:戚魁泉Nursing

导读

本文以 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/corsbetter-sqlite3itty-routerunfurl.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 的效果。

提示:yarntsx 均通过仓库根部的 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 三层抽象:NodeSqliteWrapperSQLiteSyncStorageTLSocketRoom

从源码结构看,@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 体现了典型的按需加载 + 引用计数式回收策略:

  1. rooms Map 缓存活跃房间,重复连接直接复用现有实例;
  2. 每个房间对应一个独立的 SQLite 数据库文件 .rooms/<roomId>.db,首次访问时创建;
  3. 通过 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 清洗

sanitizeRoomIdroomId 中除字母、数字、_- 之外的字符全部替换为 _防止路径穿越(例如 ../../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))
}

注意两个容易被忽略但很重要的细节:

  1. app.addContentTypeParser('*', ...):必须让 Fastify 对任意 Content-Type 都不做解析,才能把上传请求体当作原始二进制流写入文件;
  2. 响应头 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)
}}

七、客户端接线:useSyncTLAssetStore

7.1 useSync:一行代码接入多人同步

客户端核心是 @tldraw/sync 包导出的 React Hook useSyncApp.tsx):

const WORKER_URL = `http://localhost:5858`
const roomId = 'test-room'   // 示例中硬编码,生产环境可按需生成

const store = useSync({
	uri: `${WORKER_URL}/connect/${roomId}`,
	assets: multiplayerAssets,
})
  • uri:WebSocket 地址,roomId 拼在路径中,sessionIduseSync 内部作为查询参数自动附带;
  • assets:实现 TLAssetStore 的对象,定义资产上传与解析方式(见下);
  • 返回的 store 可直接传给 <Tldraw store={store}>,组件会自行处理加载状态,并自动启用多人功能(光标、在线状态、选择同步等)。

useSync.ts 的源码可以看出,它内部会创建 TLSyncClient 管理连接状态机,并对外暴露 RemoteTLStoreWithStatus(含 statusstoreerror),同时提供 users(自定义用户信息与解析)、getUserPresence(自定义广播的在线状态)、onCustomMessageReceived(自定义消息通道)等高级配置项,本文示例只用到了最基础的 uriassets

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 已防路径穿越 补充房间白名单/访问控制
端口与地址 硬编码 5858server.ts)与 5757vite.config.mts 改为环境变量配置

若你的目标是 Cloudflare 边缘部署,请直接参考仓库中的 templates/sync-cloudflare 模板,它利用 Durable Objects 实现跨实例一致的房间同步与存储。

九、小结

通过 templates/simple-server-example 可以完整掌握 tldraw sync 服务端的接入模式:

  1. 同步端点:用任意 WebSocket 框架暴露 /connect/:roomId,把 socket 交给 TLSocketRoom.handleSocketConnect
  2. 持久化NodeSqliteWrapper + SQLiteSyncStorage 把房间状态落盘到 SQLite,房间按需加载、空房自动回收;
  3. 资产通道:独立的 PUT/GET 端点承载图片视频,TLAssetStore 定义客户端侧的对应协议;
  4. 书签卡片/unfurl 端点 + registerExternalAssetHandler('url', ...) 实现链接元数据展开。

这套分层(存储可替换、协议与传输解耦)的设计正是 tldraw sync 的核心价值所在:开发者只需实现少量接口,就能在任何后端技术上获得与官方服务一致的多人实时协作体验。

相关资源

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395