Skip to content

Commit b75b206

Browse files
authored
Initial commit
0 parents  commit b75b206

25 files changed

+5817
-0
lines changed

.eslintrc.cjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
env: { browser: true, es2020: true },
3+
extends: [
4+
'eslint:recommended',
5+
'plugin:@typescript-eslint/recommended',
6+
'plugin:react-hooks/recommended',
7+
],
8+
parser: '@typescript-eslint/parser',
9+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10+
plugins: ['react-refresh'],
11+
rules: {
12+
'react-refresh/only-export-components': 'warn',
13+
},
14+
}

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/*/node_modules
2+
node_modules
3+
.yarn/
4+
.wrangler/
5+
dist/

LICENSE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) 2024 tldraw Inc.
4+
5+
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:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
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.

README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# tldraw sync server
2+
3+
This is a production-ready backend for [tldraw sync](https://tldraw.dev/docs/sync).
4+
5+
- Your client-side tldraw-based app can be served from anywhere you want.
6+
- This backend uses [Cloudflare Workers](https://developers.cloudflare.com/workers/), and will need
7+
to be deployed to your own Cloudflare account.
8+
- Each whiteboard is synced via
9+
[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) to a [Cloudflare
10+
Durable Object](https://developers.cloudflare.com/durable-objects/).
11+
- Whiteboards and any uploaded images/videos are stored in a [Cloudflare
12+
R2](https://developers.cloudflare.com/r2/) bucket.
13+
- Although unreliated to tldraw sync, this server also includes a component to fetch link previews
14+
for URLs added to the canvas.
15+
This is a minimal setup of the same system that powers multiplayer collaboration for hundreds of
16+
thousands of rooms & users on www.tldraw.com. Because durable objects effectively create a mini
17+
server instance for every single active room, we've never needed to worry about scale. Cloudflare
18+
handles the tricky infrastructure work of ensuring there's only ever one instance of each room, and
19+
making sure that every user gets connected to that instance. We've found that with this approach,
20+
each room is able to handle about 30 simultaneous collaborators.
21+
22+
## Overview
23+
24+
[![architecture](./arch.png)](https://www.tldraw.com/ro/Yb_QHJFP9syPZq1YrV3YR?v=-255,-148,2025,1265&p=page)
25+
26+
When a user opens a room, they connect via Workers to a durable object. Each durable object is like
27+
its own miniature server. There's only ever one for each room, and all the users of that room
28+
connect to it. When a user makes a change to the drawing, it's sent via a websocket connection to
29+
the durable object for that room. The durable object applies the change to its in-memory copy of the
30+
document, and broadcasts the change via websockets to all other connected clients. On a regular
31+
schedule, the durable object persists its contents to an R2 bucket. When the last client leaves the
32+
room, the durable object will shut down.
33+
34+
Static assets like images and videos are too big to be synced via websockets and a durable object.
35+
Instead, they're uploaded to workers which store them in the same R2 bucket as the rooms. When
36+
they're downloaded, they're cached on cloudflare's edge network to reduce costs and make serving
37+
them faster.
38+
39+
## Development
40+
41+
To install dependencies, run `yarn`. To start a local development server, run `yarn dev`. This will
42+
start a [`vite`](https://vitejs.dev/) dev server for the frontend of your application, and a
43+
[`wrangler`](https://developers.cloudflare.com/workers/wrangler/) dev server for your workers
44+
backend. The app should now be running at http://localhost:5137 (and the server at
45+
http://localhost:5172).
46+
47+
The backend worker is under [`worker`](./worker/), and is split across several files:
48+
49+
- **[`worker/worker.ts`](./worker/worker.ts):** the main entrypoint to the worker, defining each
50+
route available.
51+
- **[`worker/TldrawDurableObject.ts`](./worker/TldrawDurableObject.ts):** the sync durable object.
52+
An instance of this is created for every active room. This exposes a
53+
[`TLSocketRoom`](https://tldraw.dev/reference/sync-core/TLSocketRoom) over websockets, and
54+
periodically saves room data to R2.
55+
- **[`worker/assetUploads.ts`](./worker/assetUploads.ts):** uploads, downloads, and caching for
56+
static assets like images and videos.
57+
- **[`worker/bookmarkUnfurling.ts`](./worker/bookmarkUnfurling.ts):** extract URL metadata for bookmark shapes.
58+
59+
The frontend client is under [`client`](./client):
60+
61+
- **[`client/App.tsx`](./client/App.tsx):** the main client `<App />` component. This connects our
62+
sync backend to the `<Tldraw />` component, wiring in assets and bookmark previews.
63+
- **[`client/multiplayerAssetStore.tsx`](./client/multiplayerAssetStore.tsx):** how does the client
64+
upload and retrieve assets like images & videos from the worker?
65+
- **[`client/getBookmarkPreview.tsx`](./client/getBookmarkPreview.tsx):** how does the client fetch
66+
bookmark previews from the worker?
67+
68+
## Custom shapes
69+
70+
To add support for custom shapes, see the [tldraw sync custom shapes
71+
docs](https://tldraw.dev/docs/sync#Custom-shapes--bindings).
72+
73+
## Adding cloudflare to your own repo
74+
75+
If you already have an app using tldraw and want to use the system in this repo, you can copy and
76+
paste the relevant parts to your own app.
77+
78+
To point your existing client at the server defined in this repo, copy
79+
[`client/multiplayerAssetStore.tsx`](./client/multiplayerAssetStore.tsx) and
80+
[`client/getBookmarkPreview.tsx`](./client/getBookmarkPreview.tsx) into your app. Then, adapt the
81+
code from [`client/App.tsx`](./client/App.tsx) to your own app. When you call `useSync`, you'll need
82+
to pass it a URL. In development, that's `http://localhost:5172/connect/some-room-id`. We use an
83+
environment variable set in [`./vite.config.ts`](./vite.config.ts) to set the server URL.
84+
85+
To add the server to your own app, copy the contents of the [`worker`](./worker/) folder and
86+
[`./wrangler.toml`](./wrangler.toml) into your app. Add the dependencies from
87+
[`package.json`](./package.json). If you're using TypeScript, you'll also need to adapt
88+
`tsconfig.worker.json` for your own project. You can run the worker using `wrangler dev` in the same
89+
folder as `./wrangler.toml`.
90+
91+
## Deployment
92+
93+
To deploy this example, you'll need to create a cloudflare account and create an R2 bucket to store
94+
your data. Update `bucket_name = 'tldraw-content'` in [`wrangler.toml`](./wrangler.toml) with the
95+
name of your new bucket.
96+
97+
Run `wrangler deploy` to deploy your backend. This should give you a workers.dev URL, but you can
98+
also [configure a custom
99+
domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/).
100+
101+
Finally, deploy your client HTML & JavaScript. Create a production build with
102+
`TLDRAW_WORKER_URL=https://your.workers.domain.com yarn build`. Publish the resulting build (in
103+
`dist/`) on a host of your choosing - we use [Vercel](https://vercel.com).
104+
105+
When you visit your published client, it should connect to your cloudflare workers domain and sync
106+
your document across devices.
107+
108+
## License
109+
110+
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).
111+
112+
## Trademarks
113+
114+
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.
115+
116+
## Distributions
117+
118+
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
119+
120+
## Contribution
121+
122+
Please see our [contributing guide](https://github.com/tldraw/tldraw/blob/main/CONTRIBUTING.md). Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
123+
124+
## Community
125+
126+
Have questions, comments or feedback? [Join our discord](https://discord.gg/rhsyWMUJxd) or [start a discussion](https://github.com/tldraw/tldraw/discussions/new). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
127+
128+
## Contact
129+
130+
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

arch.png

460 KB
Loading

client/App.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useSync } from '@tldraw/sync'
2+
import { Tldraw } from 'tldraw'
3+
import { getBookmarkPreview } from './getBookmarkPreview'
4+
import { multiplayerAssetStore } from './multiplayerAssetStore'
5+
6+
// Where is our worker located? Configure this in `vite.config.ts`
7+
const WORKER_URL = process.env.TLDRAW_WORKER_URL
8+
9+
// In this example, the room ID is hard-coded. You can set this however you like though.
10+
const roomId = 'test-room'
11+
12+
function App() {
13+
// Create a store connected to multiplayer.
14+
const store = useSync({
15+
// We need to know the websockets URI...
16+
uri: `${WORKER_URL}/connect/${roomId}`,
17+
// ...and how to handle static assets like images & videos
18+
assets: multiplayerAssetStore,
19+
})
20+
21+
return (
22+
<div style={{ position: 'fixed', inset: 0 }}>
23+
<Tldraw
24+
// we can pass the connected store into the Tldraw component which will handle
25+
// loading states & enable multiplayer UX like cursors & a presence menu
26+
store={store}
27+
onMount={(editor) => {
28+
// when the editor is ready, we need to register our bookmark unfurling service
29+
editor.registerExternalAssetHandler('url', getBookmarkPreview)
30+
}}
31+
/>
32+
</div>
33+
)
34+
}
35+
36+
export default App

client/getBookmarkPreview.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
2+
3+
// How does our server handle bookmark unfurling?
4+
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
5+
// we start with an empty asset record
6+
const asset: TLBookmarkAsset = {
7+
id: AssetRecordType.createId(getHashForString(url)),
8+
typeName: 'asset',
9+
type: 'bookmark',
10+
meta: {},
11+
props: {
12+
src: url,
13+
description: '',
14+
image: '',
15+
favicon: '',
16+
title: '',
17+
},
18+
}
19+
20+
try {
21+
// try to fetch the preview data from the server
22+
const response = await fetch(
23+
`${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
24+
)
25+
const data = await response.json()
26+
27+
// fill in our asset with whatever info we found
28+
asset.props.description = data?.description ?? ''
29+
asset.props.image = data?.image ?? ''
30+
asset.props.favicon = data?.favicon ?? ''
31+
asset.props.title = data?.title ?? ''
32+
} catch (e) {
33+
console.error(e)
34+
}
35+
36+
return asset
37+
}

client/index.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700@400;700&display=swap');
2+
@import url('tldraw/tldraw.css');
3+
4+
body {
5+
font-family: 'Inter', sans-serif;
6+
overscroll-behavior: none;
7+
}

client/main.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react'
2+
import ReactDOM from 'react-dom/client'
3+
import App from './App.tsx'
4+
import './index.css'
5+
6+
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7+
<React.StrictMode>
8+
<App />
9+
</React.StrictMode>
10+
)

client/multiplayerAssetStore.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { TLAssetStore, uniqueId } from 'tldraw'
2+
3+
const WORKER_URL = process.env.TLDRAW_WORKER_URL
4+
5+
// How does our server handle assets like images and videos?
6+
export const multiplayerAssetStore: TLAssetStore = {
7+
// to upload an asset, we...
8+
async upload(_asset, file) {
9+
// ...create a unique name & URL...
10+
const id = uniqueId()
11+
const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
12+
const url = `${WORKER_URL}/uploads/${objectName}`
13+
14+
// ...POST it to out worker to upload it...
15+
const response = await fetch(url, {
16+
method: 'POST',
17+
body: file,
18+
})
19+
20+
if (!response.ok) {
21+
throw new Error(`Failed to upload asset: ${response.statusText}`)
22+
}
23+
24+
// ...and return the URL to be stored with the asset record.
25+
return url
26+
},
27+
28+
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
29+
// auth, or to serve optimized versions / sizes of the asset.
30+
resolve(asset) {
31+
return asset.props.src
32+
},
33+
}

0 commit comments

Comments
 (0)