snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

5
templates/sync-cloudflare/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/*/node_modules
node_modules
.yarn/
.wrangler/
dist/

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,119 @@
# tldraw sync server
This is a production-ready backend for [tldraw sync](https://tldraw.dev/docs/sync).
- Your client-side tldraw-based app can be served from anywhere you want.
- This backend uses [Cloudflare Workers](https://developers.cloudflare.com/workers/), and will need
to be deployed to your own Cloudflare account.
- Each whiteboard is synced via
[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) to a [Cloudflare
Durable Object](https://developers.cloudflare.com/durable-objects/), which persists room state in
its built-in SQLite storage.
- Uploaded images and videos are stored in a [Cloudflare
R2](https://developers.cloudflare.com/r2/) bucket.
- Although unrelated to tldraw sync, this server also includes a component to fetch link previews
for URLs added to the canvas.
This is a minimal setup of the same system that powers multiplayer collaboration for hundreds of
thousands of rooms & users on www.tldraw.com. Because durable objects effectively create a mini
server instance for every single active room, we've never needed to worry about scale. Cloudflare
handles the tricky infrastructure work of ensuring there's only ever one instance of each room, and
making sure that every user gets connected to that instance. We've found that with this approach,
each room is able to handle about 50 simultaneous collaborators.
[![architecture](./arch.png)](https://www.tldraw.com/ro/Yb_QHJFP9syPZq1YrV3YR?v=-255,-148,2025,1265&p=page)
When a user opens a room, they connect via Workers to a durable object. Each durable object is like
its own miniature server. There's only ever one for each room, and all the users of that room
connect to it. When a user makes a change to the drawing, it's sent via a websocket connection to
the durable object for that room. The durable object applies the change to its in-memory copy of the
document, and broadcasts the change via websockets to all other connected clients. Room state is
persisted automatically to the durable object's built-in SQLite storage, so it survives restarts
and hibernation. When the last client leaves the room, the durable object will shut down.
Static assets like images and videos are too big to be synced via websockets and a durable object.
Instead, they're uploaded to workers which store them in an R2 bucket. When they're downloaded,
they're cached on cloudflare's edge network to reduce costs and make serving them faster.
## Development
To install dependencies, run `yarn`. To start a local development server, run `yarn dev`. This will
start a [`vite`](https://vitejs.dev/) dev server running both your application frontend, and the
cloudflare workers backend via the [cloudflare vite
plugin](https://developers.cloudflare.com/workers/vite-plugin/). The app & server should now be
running at http://localhost:5137.
The backend worker is under [`worker`](./worker/), and is split across several files:
- **[`worker/worker.ts`](./worker/worker.ts):** the main entrypoint to the worker, defining each
route available.
- **[`worker/TldrawDurableObject.ts`](./worker/TldrawDurableObject.ts):** the sync durable object.
An instance of this is created for every active room. This exposes a
[`TLSocketRoom`](https://tldraw.dev/reference/sync-core/TLSocketRoom) over websockets, and
persists room state to the durable object's built-in SQLite storage.
- **[`worker/assetUploads.ts`](./worker/assetUploads.ts):** uploads, downloads, and caching for
static assets like images and videos.
- **[`worker/bookmarkUnfurling.ts`](./worker/bookmarkUnfurling.ts):** extract URL metadata for bookmark shapes.
The frontend client is under [`client`](./client):
- **[`client/App.tsx`](./client/App.tsx):** the main client `<App />` component. This connects our
sync backend to the `<Tldraw />` component, wiring in assets and bookmark previews.
- **[`client/multiplayerAssetStore.tsx`](./client/multiplayerAssetStore.tsx):** how does the client
upload and retrieve assets like images & videos from the worker?
- **[`client/getBookmarkPreview.tsx`](./client/getBookmarkPreview.tsx):** how does the client fetch
bookmark previews from the worker?
## Custom shapes
To add support for custom shapes, see the [tldraw sync custom shapes docs](https://tldraw.dev/docs/sync#Custom-shapes--bindings).
## Adding cloudflare to your own repo
If you already have an app using tldraw and want to use the system in this repo, you can copy and
paste the relevant parts to your own app.
To add the server to your own app, copy the contents of the [`worker`](./worker/) folder and
[`./wrangler.toml`](./wrangler.toml) into your app. Add the dependencies from
[`package.json`](./package.json). You can run the worker using `wrangler dev` in the same folder as
`./wrangler.toml`.
To point your existing client at the server defined in this repo, copy
[`client/multiplayerAssetStore.tsx`](./client/multiplayerAssetStore.tsx) and
[`client/getBookmarkPreview.tsx`](./client/getBookmarkPreview.tsx) into your app. Then, adapt the
code from [`client/App.tsx`](./client/App.tsx) to your own app. Adapt the `/api/` URLs used in each
of these files to point at your new `wrangler dev` server.
## Deployment
To deploy this example, you'll need to create a cloudflare account and create an R2 bucket to store
uploaded images and videos. Update `bucket_name = 'tldraw-content'` in
[`wrangler.toml`](./wrangler.toml) with the name of your new bucket.
To actually deploy the app, first create a production build using `yarn build`. Then, run `yarn
wrangler deploy`. This will deploy the backend worker along with the frontend app to cloudflare.
This should give you a workers.dev URL, but you can also [configure a custom
domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/).
## License
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw-sync-cloudflare/blob/main/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,40 @@
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
// How does our server handle bookmark unfurling?
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
// we start with an empty asset record
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
meta: {},
props: {
src: url,
description: '',
image: '',
favicon: '',
title: '',
},
}
try {
// try to fetch the preview data from the server
const response = await fetch(`/api/unfurl?url=${encodeURIComponent(url)}`)
const data: any = await response.json()
// fill in our asset with whatever info we found
asset.props.description = data?.description ?? ''
asset.props.image = data?.image ?? ''
asset.props.favicon = data?.favicon ?? ''
asset.props.title = data?.title ?? ''
// carry the social image's dimensions on `meta` so embeds (e.g. Vimeo/YouTube) can size
// themselves to the content's real aspect ratio instead of staying letterboxed
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
}

View File

@@ -0,0 +1,88 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
@import url('tldraw/tldraw.css');
body {
font-family: 'Inter', sans-serif;
overscroll-behavior: none;
}
.RoomWrapper {
--background: white;
--text: hsl(0, 0%, 20%);
--gray-light: #f5f5f5;
--gray-dark: #e5e5e5;
--black-transparent-lighter: rgba(0, 0, 0, 0.15);
--black-transparent-light: rgba(0, 0, 0, 0.4);
--black-transparent-dark: rgba(0, 0, 0, 0.6);
--black-transparent-darker: rgba(0, 0, 0, 0.7);
--focus: hsl(217, 89%, 61%);
--whiteboard: hsl(240, 5%, 96%);
color-scheme: light;
}
:root:has(.tl-theme__dark) .RoomWrapper {
--background: black;
--text: hsl(0, 9%, 94%);
--gray-light: #333;
--gray-dark: #444;
--black-transparent-lighter: rgba(255, 255, 255, 0.15);
--black-transparent-light: rgba(255, 255, 255, 0.4);
--black-transparent-dark: rgba(255, 255, 255, 0.6);
--black-transparent-darker: rgba(255, 255, 255, 0.7);
--focus: hsl(217, 89%, 61%);
--whiteboard: hsl(240, 5%, 6.5%);
color-scheme: dark;
}
.RoomWrapper {
display: flex;
flex-direction: column;
position: absolute;
inset: 0px;
}
.RoomWrapper-header {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: 8px 16px;
gap: 16px;
background: var(--background);
border-bottom: 1px solid var(--gray-dark);
color: var(--text);
font-size: 14px;
}
.RoomWrapper-copy {
background: var(--black-transparent-lighter);
border: 1px solid var(--gray-dark);
border-radius: 4px;
padding: 4px 12px;
height: 24px;
cursor: pointer;
position: relative;
text-align: center;
}
.RoomWrapper-copy:has(.RoomWrapper-copied) {
color: transparent;
}
.RoomWrapper-copied {
color: var(--text);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.RoomWrapper-copy:hover {
border: 1px solid var(--black-transparent-lighter);
}
.RoomWrapper-content {
position: relative;
flex: 1;
}

View File

@@ -0,0 +1,21 @@
/**
* Safe localStorage helpers that handle cases where localStorage is not available
* or access is restricted by the browser.
*/
export function getLocalStorageItem(key: string): string | null {
try {
return localStorage.getItem(key)
} catch (error) {
console.warn('Failed to access localStorage:', error)
return null
}
}
export function setLocalStorageItem(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch (error) {
console.warn('Failed to set localStorage item:', error)
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import './index.css'
import { Room } from './pages/Room'
import { Root } from './pages/Root'
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
},
{
path: '/:roomId',
element: <Room />,
},
])
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)

View File

@@ -0,0 +1,30 @@
import { TLAssetStore, uniqueId } from 'tldraw'
// How does our server handle assets like images and videos?
export const multiplayerAssetStore: TLAssetStore = {
// to upload an asset, we...
async upload(_asset, file) {
// ...create a unique name & URL...
const objectName = `${uniqueId()}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
const url = `/api/uploads/${objectName}`
// ...POST it to out worker to upload it...
const response = await fetch(url, {
method: 'POST',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
// ...and return the URL to be stored with the asset record.
return { src: url }
},
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
// auth, or to serve optimized versions / sizes of the asset.
resolve(asset) {
return asset.props.src
},
}

View File

@@ -0,0 +1,83 @@
import { useSync } from '@tldraw/sync'
import { ReactNode, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Tldraw } from 'tldraw'
import { getBookmarkPreview } from '../getBookmarkPreview'
import { multiplayerAssetStore } from '../multiplayerAssetStore'
export function Room() {
const { roomId } = useParams<{ roomId: string }>()
// Create a store connected to multiplayer.
const store = useSync({
// We need to know the websockets URI...
uri: `${window.location.origin}/api/connect/${roomId}`,
// ...and how to handle static assets like images & videos
assets: multiplayerAssetStore,
})
return (
<RoomWrapper roomId={roomId}>
<Tldraw
// we can pass the connected store into the Tldraw component which will handle
// loading states & enable multiplayer UX like cursors & a presence menu
store={store}
options={{ deepLinks: true }}
onMount={(editor) => {
// when the editor is ready, we need to register our bookmark unfurling service
editor.registerExternalAssetHandler('url', getBookmarkPreview)
}}
/>
</RoomWrapper>
)
}
function RoomWrapper({ children, roomId }: { children: ReactNode; roomId?: string }) {
const [didCopy, setDidCopy] = useState(false)
useEffect(() => {
if (!didCopy) return
const timeout = setTimeout(() => setDidCopy(false), 3000)
return () => clearTimeout(timeout)
}, [didCopy])
return (
<div className="RoomWrapper">
<div className="RoomWrapper-header">
<WifiIcon />
<div>{roomId}</div>
<button
className="RoomWrapper-copy"
onClick={() => {
navigator.clipboard.writeText(window.location.href)
setDidCopy(true)
}}
aria-label="copy room link"
>
Copy link
{didCopy && <div className="RoomWrapper-copied">Copied!</div>}
</button>
</div>
<div className="RoomWrapper-content">{children}</div>
</div>
)
}
function WifiIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
width={16}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,10 @@
import { Navigate } from 'react-router-dom'
import { uniqueId } from 'tldraw'
import { getLocalStorageItem, setLocalStorageItem } from '../localStorage'
const myLocalRoomId = getLocalStorageItem('my-local-room-id') ?? 'test-room-' + uniqueId()
setLocalStorageItem('my-local-room-id', myLocalRoomId)
export function Root() {
return <Navigate to={`/${myLocalRoomId}`} />
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tldraw multiplayer template</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/client/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
{
"name": "tldraw-sync-cloudflare",
"private": true,
"version": "0.0.0",
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "MIT",
"type": "module",
"tldraw_template": {
"repo": "tldraw/tldraw-sync-cloudflare",
"cli": {
"name": "Multiplayer",
"description": "Real-time multiplayer with a self-hosted backend.",
"shortDescription": "Real-time multiplayer.",
"order": 2
},
"deploy": {
"target": "cloudflare",
"host": "multiplayer.templates.tldraw.dev"
},
"scripts": {
"lint": null
}
},
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"preview": "vite preview"
},
"dependencies": {
"@tldraw/sync": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"cloudflare-workers-unfurl": "^0.0.9",
"itty-router": "^5.0.18",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.28.2",
"tldraw": "workspace:*"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.1.2",
"typescript": "^5.8.3",
"vite": "^8.0.16",
"wrangler": "^4.75.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "./worker-configuration.d.ts"]
},
"include": ["client", "worker", "vite.config.ts"],
"references": [
{ "path": "../../packages/sync" },
{ "path": "../../packages/sync-core" },
{ "path": "../../packages/tldraw" },
{ "path": "../../packages/tlschema" }
]
}

View File

@@ -0,0 +1,10 @@
import { cloudflare } from '@cloudflare/vite-plugin'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig(() => {
return {
plugins: [cloudflare(), react()],
}
})

View File

@@ -0,0 +1,157 @@
import {
DurableObjectSqliteSyncWrapper,
type SessionStateSnapshot,
SQLiteSyncStorage,
TLSocketRoom,
} from '@tldraw/sync-core'
import {
createTLSchema,
// defaultBindingSchemas,
defaultShapeSchemas,
TLRecord,
} from '@tldraw/tlschema'
import { DurableObject } from 'cloudflare:workers'
import { AutoRouter, error, IRequest } from 'itty-router'
// add custom shapes and bindings here if needed:
const schema = createTLSchema({
shapes: { ...defaultShapeSchemas },
// bindings: { ...defaultBindingSchemas },
})
interface SocketAttachment {
sessionId: string
snapshot: SessionStateSnapshot | null
}
function getAttachment(ws: WebSocket): SocketAttachment | null {
const attachment = ws.deserializeAttachment() as SocketAttachment | null
return attachment?.sessionId ? attachment : null
}
// Each whiteboard room is hosted in a Durable Object with WebSocket Hibernation.
// https://developers.cloudflare.com/durable-objects/
//
// There's only ever one durable object instance per room. Room state is
// persisted automatically to SQLite via ctx.storage. When all clients are
// idle, the DO hibernates (freeing memory) while WebSocket connections
// stay alive at the Cloudflare layer.
export class TldrawDurableObject extends DurableObject {
private room: TLSocketRoom<TLRecord, void> | null = null
/** Map sessionId → ws so onSessionSnapshot can serialize to the right socket. */
private readonly sessionIdToWs = new Map<string, WebSocket>()
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
// Respond to ping messages at the platform level without waking the DO.
// The TLSyncClient sends {"type":"ping"} every 5s; without this, each
// ping would wake the DO from hibernation.
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair('{"type":"ping"}', '{"type":"pong"}')
)
}
private getOrCreateRoom(): TLSocketRoom<TLRecord, void> {
if (!this.room) {
const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
this.room = new TLSocketRoom<TLRecord, void>({
schema,
storage,
// Disable idle timeout since Cloudflare handles keep-alive via auto-response.
// Without this, sessions would be pruned after 20s of no "real" messages
// even though the client is still connected and being auto-ponged.
clientTimeout: Infinity,
onSessionSnapshot: (sessionId, snapshot) => {
const ws = this.sessionIdToWs.get(sessionId)
if (ws) ws.serializeAttachment({ sessionId, snapshot })
},
})
// Resume any sessions that survived hibernation
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
}
private readonly router = AutoRouter({ catch: (e) => error(e) }).get(
'/api/connect/:roomId',
(request) => this.handleConnect(request)
)
// Entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
return this.router.fetch(request)
}
// Handle new WebSocket connection requests
async handleConnect(request: IRequest) {
const sessionId = request.query.sessionId as string
if (!sessionId) return error(400, 'Missing sessionId')
// Create the websocket pair for the client
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
// Use hibernation API instead of serverWebSocket.accept()
this.ctx.acceptWebSocket(serverWebSocket)
// Store sessionId in attachment immediately so we can identify this socket
// after hibernation, before the connect handshake completes.
const attachment: SocketAttachment = { sessionId, snapshot: null }
serverWebSocket.serializeAttachment(attachment)
// Connect to the room. The first webSocketMessage from the client will
// complete the handshake and trigger debounced snapshot storage.
this.getOrCreateRoom().handleSocketConnect({ sessionId, socket: serverWebSocket })
return new Response(null, { status: 101, webSocket: clientWebSocket })
}
// --- WebSocket Hibernation API handlers ---
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const attachment = getAttachment(ws)
if (!attachment) return
this.sessionIdToWs.set(attachment.sessionId, ws)
this.getOrCreateRoom().handleSocketMessage(attachment.sessionId, message)
}
override async webSocketClose(ws: WebSocket) {
this.handleWebSocketEnd(ws, 'handleSocketClose')
}
override async webSocketError(ws: WebSocket) {
this.handleWebSocketEnd(ws, 'handleSocketError')
}
private handleWebSocketEnd(ws: WebSocket, method: 'handleSocketClose' | 'handleSocketError') {
const attachment = getAttachment(ws)
if (!attachment) return
this.sessionIdToWs.delete(attachment.sessionId)
const room = this.getOrCreateRoom()
// If the DO was hibernating, this session was never re-added to the room
// (ctx.getWebSockets() doesn't include the disconnecting socket). Resume it
// briefly so the room can broadcast presence removal to other clients.
if (attachment.snapshot && !room.getSessionSnapshot(attachment.sessionId)) {
room.handleSocketResume({
sessionId: attachment.sessionId,
socket: ws,
snapshot: attachment.snapshot,
})
}
room[method](attachment.sessionId)
}
}

View File

@@ -0,0 +1,105 @@
import { error, IRequest } from 'itty-router'
// assets are stored in the bucket under the /uploads path
function getAssetObjectName(uploadId: string) {
return `uploads/${uploadId.replace(/[^a-zA-Z0-9_-]+/g, '_')}`
}
declare global {
interface CacheStorage {
default: Cache
}
}
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
export 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 }
}
// when a user downloads an asset, we retrieve it from the bucket. we also cache the response for performance.
export async function handleAssetDownload(request: IRequest, env: Env, ctx: ExecutionContext) {
const objectName = getAssetObjectName(request.params.uploadId)
// if we have a cached response for this request (automatically handling ranges etc.), return it
const cacheKey = new Request(request.url, { headers: request.headers })
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
return cachedResponse
}
// if not, we try to fetch the asset from the bucket
const object = await env.TLDRAW_BUCKET.get(objectName, {
range: request.headers,
onlyIf: request.headers,
})
if (!object) {
return error(404)
}
// write the relevant metadata to the response headers
const headers = new Headers()
object.writeHttpMetadata(headers)
// assets are immutable, so we can cache them basically forever:
headers.set('cache-control', 'public, max-age=31536000, immutable')
headers.set('etag', object.httpEtag)
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
// cache, which isn't allowed by cloudflare.
headers.set('access-control-allow-origin', '*')
// Prevent XSS from user-uploaded SVGs (or any file served with an executable content-type).
headers.set('content-security-policy', "default-src 'none'")
headers.set('x-content-type-options', 'nosniff')
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
// need to do it ourselves.
let contentRange
if (object.range) {
if ('suffix' in object.range) {
const start = object.size - object.range.suffix
const end = object.size - 1
contentRange = `bytes ${start}-${end}/${object.size}`
} else {
const start = object.range.offset ?? 0
const end = object.range.length ? start + object.range.length - 1 : object.size - 1
if (start !== 0 || end !== object.size - 1) {
contentRange = `bytes ${start}-${end}/${object.size}`
}
}
}
if (contentRange) {
headers.set('content-range', contentRange)
}
// make sure we get the correct body/status for the response
const body = 'body' in object && object.body ? object.body : null
const status = body ? (contentRange ? 206 : 200) : 304
// we only cache complete (200) responses
if (status === 200) {
const [cacheBody, responseBody] = body!.tee()
ctx.waitUntil(caches.default.put(cacheKey, new Response(cacheBody, { headers, status })))
return new Response(responseBody, { headers, status })
}
return new Response(body, { headers, status })
}

View File

@@ -0,0 +1,37 @@
import { handleUnfurlRequest } from 'cloudflare-workers-unfurl'
import { AutoRouter, error, IRequest } from 'itty-router'
import { handleAssetDownload, handleAssetUpload } from './assetUploads'
// make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from './TldrawDurableObject'
// we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because
// we're hosting the worker separately to the client. you should restrict this to your own domain.
const router = AutoRouter<IRequest, [env: Env, ctx: ExecutionContext]>({
catch: (e) => {
console.error(e)
return error(e)
},
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.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 })
})
// assets can be uploaded to the bucket under /uploads:
.post('/api/uploads/:uploadId', handleAssetUpload)
// they can be retrieved from the bucket too:
.get('/api/uploads/:uploadId', handleAssetDownload)
// bookmarks need to extract metadata from pasted URLs:
.get('/api/unfurl', handleUnfurlRequest)
.all('*', () => {
return new Response('Not found', { status: 404 })
})
export default {
fetch: router.fetch,
}

View File

@@ -0,0 +1,35 @@
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"
# Set up the durable object used for each tldraw room
[durable_objects]
bindings = [{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" }]
# Durable objects require migrations to create/modify/delete them
# Using new_sqlite_classes for SQLite-backed storage
[[migrations]]
tag = "v1"
new_sqlite_classes = ["TldrawDurableObject"]
# We store asset uploads (images, videos) in an R2 bucket.
# Room data is stored in Durable Object SQLite storage.
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'multiplayer-template'
preview_bucket_name = 'multiplayer-template-preview'