资讯动态

基于 Cloudflare Workers 与 Durable Objects 搭建 tldraw 实时多人同步后端(sync-cloudflare 模板全解析)

发布时间:2026/9/10 21:41:26 来源:尧图企业网站定制
基于 Cloudflare Workers 与 Durable Objects 搭建 tldraw 实时多人同步后端sync-cloudflare 模板全解析【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw导读本篇文章以仓库中templates/sync-cloudflare模板为核心完整讲解如何基于 Cloudflare Workers、Durable Objects 与 R2 对象存储为 tldraw 应用搭建一套生产可用的实时多人协作后端。这套模板是 www.tldraw.com 线上多人协作系统的最小化复刻每个白板房间由一个独立的 Durable Object 承载通过 WebSocket 实时同步绘图数据并借助 R2 存储图片/视频等静态资产还附带书签链接预览的抓取能力。读完本文你将掌握tldraw 同步后端的整体架构与数据流、Worker 路由与 Durable Object 的关键实现、客户端如何通过useSync与TLAssetStore接入多人协作、如何把整套系统迁移到自己的仓库并部署到 Cloudflare。1. 模板概览一次搞清 tldraw sync 后端的构成templates/sync-cloudflare是一个生产就绪production-ready的 tldraw 同步后端模板。它的核心设计思想是前端应用可以托管在任何地方后端则完全构建在 Cloudflare 云产品之上。从 README.md 可以提炼出以下关键事实后端基于 Cloudflare Workers需要部署到你自己的 Cloudflare 账户中每个白板房间通过 WebSocket 连接到一个 Cloudflare Durable Object房间状态持久化在该 Durable Object 内置的 SQLite 存储中上传的图片与视频存放在 Cloudflare R2 存储桶中除同步外服务器还包含一个与 tldraw 同步无关的附加组件为画布中粘贴的 URL 抓取链接预览bookmark unfurling。这套系统正是 www.tldraw.com 线上多人协作所使用系统的最小化版本。其扩展性的关键在于 Durable Objects 的架构特性每个活跃房间都会隐式地创建一台迷你服务器实例因此官方无需担心规模问题——Cloudflare 负责保证每个房间只有一个实例并确保所有用户都能连接到该实例。根据模板 README 的描述在这种方案下每个房间大约可以承载 50 位同时协作者。1.1 目录结构速览模板的源码结构非常清晰后端与前端分离templates/sync-cloudflare/ ├── worker/ │ ├── worker.ts # Worker 主入口定义所有路由 │ ├── TldrawDurableObject.ts # 同步 Durable Object每个房间一个实例 │ └── assetUploads.ts # 静态资产图片/视频的上传、下载与缓存 ├── client/ │ ├── main.tsx # 前端入口react-router 路由 │ ├── multiplayerAssetStore.tsx # 客户端资产上传/获取的 TLAssetStore 实现 │ ├── getBookmarkPreview.tsx # 客户端书签预览获取逻辑 │ └── pages/ │ ├── Root.tsx # 根路由生成/跳转房间 ID │ └── Room.tsx # 房间页面接入 useSync 与 Tldraw 组件 ├── wrangler.toml # Cloudflare 部署配置 ├── package.json # 依赖与脚本 ├── vite.config.ts # Vite Cloudflare Vite 插件配置 └── arch.png # 架构示意图2. 架构与数据流每个房间一台迷你服务器模板 README 中给出了架构图arch.png直观展示了系统两条核心数据通路房间同步room sync与图片/视频资产image/video assets。2.1 房间同步通路当用户打开一个房间时完整的数据流是这样的连接浏览器客户端通过 Workers 连接到对应房间的 Durable Object。每个 Durable Object 就像一台独立的迷你服务器每个房间只有一个且该房间的所有用户都连接到它变更广播用户对画布做出修改后修改内容经由 WebSocket 连接发送给该房间的 Durable Object应用与同步Durable Object 将变更应用到它内存中的文档副本并通过 WebSocket 把变更广播给房间内的所有其他已连接客户端自动持久化房间状态会自动持久化到 Durable Object 内置的 SQLite 存储中因此可以安然度过重启与休眠hibernation周期销毁当最后一个客户端离开房间时该 Durable Object 会自行关闭。2.2 资产通路图片和视频这类静态资产体积太大不适合通过 WebSocket 与 Durable Object 同步。因此上传资产被上传到 Worker由 Worker 存入 R2 存储桶下载与缓存资产下载时会在 Cloudflare 边缘网络edge network上缓存既降低了成本也加快了服务速度。2.3 规模化的秘密这套架构之所以不用操心扩展根源在于 Durable Objects 的语义每个房间有且仅有一个实例由idFromName(roomId)确定性派生所有用户都连向同一个实例天然的按房间隔离意味着并发房间再多也只是多部署几台迷你服务器的问题。Cloudflare 接管了每个房间只有一个实例以及让每个用户都连到该实例的底层基础设施工作。3. Worker 主入口路由与房间寻址后端入口位于 worker/worker.ts它使用 itty-router 做路由分发并导出了同步 Durable Object 供 Cloudflare 注册export { TldrawDurableObject } from ./TldrawDurableObject const router AutoRouterIRequest, [env: Env, ctx: ExecutionContext]({ catch: (e) { console.error(e) return error(e) }, }) // 请求 /connect 会被路由到 Durable Object处理实时 WebSocket 同步 .get(/api/connect/:roomId, (request, env) { const id env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) const room env.TLDRAW_DURABLE_OBJECT.get(id) return room.fetch(request.url, { headers: request.headers, body: request.body }) }) // 资产可以上传到 /uploads 路径下的存储桶 .post(/api/uploads/:uploadId, handleAssetUpload) // 也可以从存储桶取回 .get(/api/uploads/:uploadId, handleAssetDownload) // 书签需要从粘贴的 URL 中提取元数据 .get(/api/unfurl, handleUnfurlRequest) .all(*, () { return new Response(Not found, { status: 404 }) }) export default { fetch: router.fetch, }关键点逐一拆解/api/connect/:roomId同步连接的入口。env.TLDRAW_DURABLE_OBJECT.idFromName(roomId)根据房间名确定性生成 Durable Object ID——同一房间名永远解析到同一实例这正是每个房间一台服务器的基石。随后把请求原样转发给该 Durable Object 的fetch。/api/uploads/:uploadId资产的 POST上传与 GET下载路由具体逻辑在 worker/assetUploads.ts。/api/unfurl书签链接预览由cloudflare-workers-unfurl包的handleUnfurlRequest实现。模板默认开启 CORS因为 Worker 与客户端是分开托管的README 明确提醒生产环境应把 CORS 限制为你自己的域名。4. TldrawDurableObject房间同步的核心实现worker/TldrawDurableObject.ts 是整套系统的心脏。它的职责是用TLSocketRoom来自tldraw/sync-core包对外暴露一个 WebSocket 房间并把房间状态持久化到 Durable Object 内置的 SQLite 存储。4.1 房间的创建内存态 SQLite 持久化房间实例按需懒创建创建时完成 schema 配置、存储层装配与事件回调注册private getOrCreateRoom(): TLSocketRoomTLRecord, void { if (!this.room) { const sql new DurableObjectSqliteSyncWrapper(this.ctx.storage) const storage new SQLiteSyncStorageTLRecord({ sql }) this.room new TLSocketRoomTLRecord, void({ schema, storage, // 关闭空闲超时Cloudflare 通过自动应答维持心跳 clientTimeout: Infinity, onSessionSnapshot: (sessionId, snapshot) { const ws this.sessionIdToWs.get(sessionId) if (ws) ws.serializeAttachment({ sessionId, snapshot }) }, }) // 恢复休眠前幸存的会话 for (const ws of this.ctx.getWebSockets()) { const attachment getAttachment(ws) if (!attachment?.snapshot) continue this.room.handleSocketResume({ sessionId: attachment.sessionId, socket: ws, snapshot: attachment.snapshot, }) } } return this.room }存储DurableObjectSqliteSyncWrapper把 Durable Object 的ctx.storage包装成 SQLite 接口SQLiteSyncStorage则是 tldraw 的TLSocketRoom需要的持久化实现。房间状态因此自动写入 DO 的 SQLite 存储在重启与休眠后仍然存活。clientTimeout: Infinity这是一个精妙的细节。Cloudflare 在平台层用自动应答处理 keep-alive所以如果保留默认的超时逻辑会话会在 20 秒无真实消息后被误判为离线剪除——即便客户端其实一直在线并被自动 pong。因此这里显式禁用超时。休眠恢复DO 休眠期间 WebSocket 由 Cloudflare 平台层保活DO 被唤醒后通过ctx.getWebSockets()取回存活的连接用 attachment 中保存的snapshot调用handleSocketResume恢复会话。4.2 WebSocket 握手与 Hibernation API构造函数里有一处关键配置——平台级 ping 自动应答constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) // TLSyncClient 每 5 秒发送 {type:ping}若不处理每次 ping 都会把 DO 从休眠中唤醒 this.ctx.setWebSocketAutoResponse( new WebSocketRequestResponsePair({type:ping}, {type:pong}) ) }tldraw 客户端TLSyncClient每 5 秒发送一次{type:ping}。setWebSocketAutoResponse让 Cloudflare 在平台层直接回pongDO 实例根本不会被唤醒从而显著降低休眠场景下的资源开销。handleConnect处理新的 WebSocket 连接请求async handleConnect(request: IRequest) { const sessionId request.query.sessionId as string if (!sessionId) return error(400, Missing sessionId) const { 0: clientWebSocket, 1: serverWebSocket } new WebSocketPair() // 使用休眠 API 而非 serverWebSocket.accept() this.ctx.acceptWebSocket(serverWebSocket) const attachment: SocketAttachment { sessionId, snapshot: null } serverWebSocket.serializeAttachment(attachment) this.getOrCreateRoom().handleSocketConnect({ sessionId, socket: serverWebSocket }) return new Response(null, { status: 101, webSocket: clientWebSocket }) }要点必须校验sessionId查询参数缺失时返回 400采用WebSocket Hibernation API用ctx.acceptWebSocket(serverWebSocket)替代手动accept()连接不活跃时 DO 可以休眠在握手完成前就把sessionId写入 socket attachment确保 DO 休眠恢复后仍能识别每个 socket客户端收到的 101 Switching Protocols 响应通过webSocket: clientWebSocket返回。消息、关闭、错误三个生命周期钩子都做了适配webSocketMessage中用sessionIdToWs映射记录当前 socket供onSessionSnapshot序列化快照回写并把消息交给房间处理webSocketClose/webSocketError则统一走handleWebSocketEnd——其中还有一个优雅的细节如果 DO 此前在休眠断开的会话从未被重新加入房间ctx.getWebSockets()不包含正在断开的 socket则先短暂handleSocketResume恢复它让房间能向其他客户端广播 presence 移除再执行handleSocketClose/handleSocketError。4.3 Schema自定义形状的扩展点TldrawDurableObject.ts顶部用createTLSchema构造同步 schemaconst schema createTLSchema({ shapes: { ...defaultShapeSchemas }, // bindings: { ...defaultBindingSchemas }, })默认只启用 tldraw 的内置形状 schema注释中预留了自定义形状与绑定bindings的扩展点。README 也提示要支持自定义形状参见 tldraw 官方 sync 文档中的 Custom shapes bindings 章节。5. 资产上传与下载R2 边缘缓存的完整实现worker/assetUploads.ts 实现了图片/视频等静态资产的上传、下载与缓存共三个关键函数。5.1 上传handleAssetUploadexport async function handleAssetUpload(request: IRequest, env: Env) { const objectName getAssetObjectName(request.params.uploadId) const contentType request.headers.get(content-type) ?? if (!contentType.startsWith(image/) !contentType.startsWith(video/)) { return error(400, Invalid content type) } if (await env.TLDRAW_BUCKET.head(objectName)) { return error(409, Upload already exists) } await env.TLDRAW_BUCKET.put(objectName, request.body, { httpMetadata: request.headers, }) return { ok: true } }安全与幂等细节只允许image/*与video/*类型其余返回 400用head检查同名对象是否已存在存在则返回 409 防止重复上传对象名统一经过getAssetObjectName清洗——uploadId.replace(/[^a-zA-Z0-9_-]/g, _)确保只保留安全字符防止路径穿越。5.2 下载与缓存handleAssetDownload下载逻辑展示了生产级缓存策略export async function handleAssetDownload(request: IRequest, env: Env, ctx: ExecutionContext) { const objectName getAssetObjectName(request.params.uploadId) // 命中缓存直接返回自动处理 Range 等请求头 const cacheKey new Request(request.url, { headers: request.headers }) const cachedResponse await caches.default.match(cacheKey) if (cachedResponse) return cachedResponse // 未命中则从存储桶取回支持 Range 请求 const object await env.TLDRAW_BUCKET.get(objectName, { range: request.headers, onlyIf: request.headers, }) if (!object) return error(404) const headers new Headers() object.writeHttpMetadata(headers) // 资产不可变几乎可以永久缓存 headers.set(cache-control, public, max-age31536000, immutable) headers.set(etag, object.httpEtag) // 允许所有客户端访问在这里设置 CORS避免缓存响应再被追加 CORS 头Cloudflare 不允许 headers.set(access-control-allow-origin, *) // 防止用户上传的 SVG 等可执行类型造成 XSS headers.set(content-security-policy, default-src none) headers.set(x-content-type-options, nosniff) // ...content-range 计算略见源码值得注意的工程细节不可变缓存资产对象名带唯一 ID内容一旦写入不再变更因此cache-control设为public, max-age31536000, immutable一年Range 支持透传range/onlyIf请求头到 R2并手动计算content-rangeCloudflare 的writeHttpMetadata不会自动写该头实现视频拖动进度等场景的字节范围请求状态码相应为 206缓存写入只缓存完整的 200 响应用body.tee()分流通过ctx.waitUntil异步写入caches.default不阻塞用户响应安全响应头content-security-policy: default-src none与x-content-type-options: nosniff用于防御用户上传 SVG 等可执行内容引发的 XSS缓存键使用包含请求头的完整 Request 作为缓存键确保 Range 语义正确。6. 客户端接入从 useSync 到 TLAssetStore前端入口在 client/main.tsx用 react-router 定义两条路由/交给Root/:roomId交给Room。client/pages/Root.tsx 会在本地存储localStorage里记住一个房间 ID不存在则用uniqueId()生成然后重定向到该房间。6.1 房间页useSync 连接实时协作client/pages/Room.tsx 是核心export function Room() { const { roomId } useParams{ roomId: string }() // 创建一个连接到多人服务器的 store const store useSync({ // 我们需要知道 WebSocket 地址... uri: ${window.location.origin}/api/connect/${roomId}, // ...以及如何处理图片、视频等静态资产 assets: multiplayerAssetStore, }) return ( RoomWrapper roomId{roomId} Tldraw store{store} options{{ deepLinks: true }} onMount{(editor) { // 编辑器就绪后注册书签链接预览服务 editor.registerExternalAssetHandler(url, getBookmarkPreview) }} / /RoomWrapper ) }useSync来自tldraw/sync包负责建立并维护与/api/connect/:roomId的 WebSocket 连接返回一个已连接多人服务器的 store把 store 传给Tldraw store{store}后组件会自动处理加载状态并启用多人游标、presence 菜单等多人 UXoptions{{ deepLinks: true }}让编辑器支持深链deep link导航onMount中通过editor.registerExternalAssetHandler(url, getBookmarkPreview)注册外部 URL 资产处理器把画布中粘贴的链接转成带预览的书签形状。RoomWrapper还提供了房间页顶栏显示当前 roomId、提供复制房间链接按钮复制成功短暂显示 Copied!。6.2 资产存取multiplayerAssetStoreclient/multiplayerAssetStore.tsx 实现了 tldraw 的TLAssetStore接口回答客户端如何向 Worker 上传、取回资产export const multiplayerAssetStore: TLAssetStore { async upload(_asset, file) { // 生成唯一对象名与 URL const objectName ${uniqueId()}-${file.name}.replace(/[^a-zA-Z0-9.]/g, -) const url /api/uploads/${objectName} // POST 给 Worker 完成上传 const response await fetch(url, { method: POST, body: file }) if (!response.ok) { throw new Error(Failed to upload asset: ${response.statusText}) } // 返回 URL随资产记录一并存储 return { src: url } }, resolve(asset) { // 取回资产直接用同一 URL return asset.props.src }, }upload用uniqueId()拼出唯一的对象名并 POST 到/api/uploads/{name}成功后把 URL 存进资产记录resolve直接返回该 URL 作为下载地址。模板注释也提示resolve是定制鉴权、或按需返回优化尺寸/版本的理想接入点。6.3 书签预览getBookmarkPreviewclient/getBookmarkPreview.tsx 实现客户端一侧的书签 unfurlingexport async function getBookmarkPreview({ url }: { url: string }): PromiseTLAsset { // 先用空资产记录占位 const asset: TLBookmarkAsset { id: AssetRecordType.createId(getHashForString(url)), typeName: asset, type: bookmark, meta: {}, props: { src: url, description: , image: , favicon: , title: }, } try { // 向服务器请求预览数据 const response await fetch(/api/unfurl?url${encodeURIComponent(url)}) const data: any await response.json() asset.props.description data?.description ?? asset.props.image data?.image ?? asset.props.favicon data?.favicon ?? asset.props.title data?.title ?? // 把社交图尺寸放到 meta 上让嵌入内容如 Vimeo/YouTube按真实宽高比排版 if (typeof data?.imageWidth number) asset.meta.imageWidth data.imageWidth if (typeof data?.imageHeight number) asset.meta.imageHeight data.imageHeight } catch (e) { console.error(e) } return asset }细节用getHashForString(url)派生确定性资产 ID元数据标题、描述、图片、favicon缺失时优雅回退为空字符串尤其贴心的是把社交预览图的宽高写入meta让 Vimeo/YouTube 这类嵌入形状能按内容的真实宽高比排版而不是被 letterbox 留黑边。7. 部署配置wrangler.toml 逐项解读模板根目录的 wrangler.toml 是 Cloudflare 侧的完整配置name multiplayer-template main worker/worker.ts compatibility_date 2025-05-08 compatibility_flags [nodejs_compat] upload_source_maps true preview_urls true [[routes]] pattern multiplayer.templates.tldraw.dev custom_domain true [observability] enabled true [assets] not_found_handling single-page-application directory ./dist/client # 为每个 tldraw 房间设置的 Durable Object [durable_objects] bindings [{ name TLDRAW_DURABLE_OBJECT, class_name TldrawDurableObject }] # Durable Objects 需要 migration 来创建/修改/删除 # 使用 new_sqlite_classes 以启用 SQLite 存储 [[migrations]] tag v1 new_sqlite_classes [TldrawDurableObject] # 资产上传图片、视频存放在 R2 存储桶中 [[r2_buckets]] binding TLDRAW_BUCKET bucket_name multiplayer-template preview_bucket_name multiplayer-template-preview逐项说明main worker/worker.tsWorker 入口文件即上文解析的路由主入口compatibility_date与compatibility_flags指定 Cloudflare Workers 兼容性日期与nodejs_compat标志node 兼容 API 需要[assets]把./dist/client构建产物作为静态资源托管not_found_handling single-page-application让未知路径回退到前端 SPA 入口——前端与后端一次部署、同域同源因此客户端里的/api/...相对路径可以直接命中 Worker 路由[durable_objects]注册TLDRAW_DURABLE_OBJECT绑定映射到TldrawDurableObject类供 Worker 中env.TLDRAW_DURABLE_OBJECT.idFromName(...)使用[[migrations]]Durable Object 必须通过 migration 创建/修改/删除这里用new_sqlite_classes声明 SQLite 支持的类tag v1[[r2_buckets]]绑定TLDRAW_BUCKET到指定 R2 桶含 preview 桶。依赖方面package.json 中关键依赖为tldraw/sync客户端useSync、tldraw/sync-core服务端TLSocketRoom、tldraw/tlschemaschema 构造、cloudflare-workers-unfurl链接预览、itty-router路由、wrangler与cloudflare/vite-plugin本地开发与部署。开发脚本为devvite --host、buildtsc vite build、previewvite preview。8. 开发与部署从本地跑起来到上线8.1 本地开发按 README.md 的说明两步即可启动yarn # 安装依赖 yarn dev # 启动本地开发服务器yarn dev会启动一个 Vite 开发服务器同时运行你的应用前端与 Cloudflare Workers 后端——后端通过 Cloudflare 官方 Vite 插件cloudflare/vite-plugin加载。随后访问http://localhost:5137即可看到应用与服务器同时运行。8.2 部署到 Cloudflare部署前需要准备注册一个 Cloudflare 账户创建一个 R2 存储桶用于存放上传的图片与视频把 wrangler.toml 中的bucket_name multiplayer-template改成你自己桶的名字README 中示例为tldraw-content。然后执行yarn build # 先构建生产版本 yarn wrangler deploy # 部署后端 Worker 与前端应用部署完成后会得到一个*.workers.dev的 URL当然你也可以在 Cloudflare 控制台配置自定义域名模板本身已在[[routes]]中声明了multiplayer.templates.tldraw.dev的自定义域名示例。9. 迁移到自己仓库把服务器与客户端接入现有应用如果你已经有一个基于 tldraw 的应用想复用这套系统README 给出了清晰的搬运指南。9.1 后端部分把worker/文件夹的全部内容复制到你的应用中复制根目录的 wrangler.toml从 package.json 中把对应依赖加入你的项目在wrangler.toml所在目录运行wrangler dev即可本地启动 Worker。9.2 前端部分复制 client/multiplayerAssetStore.tsx 与 client/getBookmarkPreview.tsx 到你的应用中参照 client/pages/Room.tsx 中的写法把useSyncTldraw store{store}的接入代码适配到你自己的应用修改上述文件中用到的/api/前缀 URL指向你新启动的wrangler dev服务器地址。若你的应用需要自定义形状custom shapes除在服务端 TldrawDurableObject.ts 的createTLSchema中注册外还需要在客户端同步相同的 schema 定义详见官方 sync 文档的 Custom shapes bindings 章节。10. 关键配置速查表配置项位置说明同步路由/api/connect/:roomIdworker/worker.tsWebSocket 连接入口按房间名寻址 Durable Object资产上传/下载/api/uploads/:uploadIdworker/worker.tsR2 资产的 POST/GET 路由书签预览/api/unfurlworker/worker.tsURL 元数据抓取Durable Object 绑定wrangler.tomlTLDRAW_DURABLE_OBJECT→TldrawDurableObjectSQLite 迁移wrangler.tomlnew_sqlite_classes [TldrawDurableObject]R2 桶绑定wrangler.tomlTLDRAW_BUCKET部署前需改名客户端多人接入client/pages/Room.tsxuseSyncstore传入Tldraw客户端资产存取client/multiplayerAssetStore.tsx实现TLAssetStore接口客户端书签预览client/getBookmarkPreview.tsxregisterExternalAssetHandler(url, ...)注册11. 总结与下一步这套 sync-cloudflare 模板展示了一条清晰的路径用 Cloudflare Workers Durable Objects R2 三件套在云原生边缘平台上跑起 tldraw 实时多人协作。其核心工程智慧在于——利用 Durable Objects每个房间一个确定性实例的语义自动获得水平扩展能力用 SQLite 内置存储免去额外数据库运维用边缘缓存与平台级心跳应答把成本与延迟压到最低。如果你打算在生产环境使用建议重点关注三处自定义一是将 Worker 的 CORS 收窄到自己的域名二是按业务需求改造multiplayerAssetStore的resolve鉴权、压缩版本三是在createTLSchema中注册你自定义的形状与绑定 schema。模板中的 worker/TldrawDurableObject.ts、worker/assetUploads.ts 与 client/pages/Room.tsx 是理解整套系统的最佳起点配合 wrangler.toml 即可在数分钟内把一套可扩展的 tldraw 多人协作后端跑起来。【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. Worlds best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价