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
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:
5
templates/sync-cloudflare/.gitignore
vendored
Normal file
5
templates/sync-cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/*/node_modules
|
||||
node_modules
|
||||
.yarn/
|
||||
.wrangler/
|
||||
dist/
|
||||
9
templates/sync-cloudflare/LICENSE.md
Normal file
9
templates/sync-cloudflare/LICENSE.md
Normal 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.
|
||||
119
templates/sync-cloudflare/README.md
Normal file
119
templates/sync-cloudflare/README.md
Normal 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.
|
||||
|
||||
[](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).
|
||||
40
templates/sync-cloudflare/client/getBookmarkPreview.tsx
Normal file
40
templates/sync-cloudflare/client/getBookmarkPreview.tsx
Normal 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
|
||||
}
|
||||
88
templates/sync-cloudflare/client/index.css
Normal file
88
templates/sync-cloudflare/client/index.css
Normal 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;
|
||||
}
|
||||
21
templates/sync-cloudflare/client/localStorage.ts
Normal file
21
templates/sync-cloudflare/client/localStorage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
23
templates/sync-cloudflare/client/main.tsx
Normal file
23
templates/sync-cloudflare/client/main.tsx
Normal 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>
|
||||
)
|
||||
30
templates/sync-cloudflare/client/multiplayerAssetStore.tsx
Normal file
30
templates/sync-cloudflare/client/multiplayerAssetStore.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
83
templates/sync-cloudflare/client/pages/Room.tsx
Normal file
83
templates/sync-cloudflare/client/pages/Room.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
templates/sync-cloudflare/client/pages/Root.tsx
Normal file
10
templates/sync-cloudflare/client/pages/Root.tsx
Normal 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}`} />
|
||||
}
|
||||
1
templates/sync-cloudflare/client/vite-env.d.ts
vendored
Normal file
1
templates/sync-cloudflare/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
templates/sync-cloudflare/index.html
Normal file
13
templates/sync-cloudflare/index.html
Normal 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>
|
||||
55
templates/sync-cloudflare/package.json
Normal file
55
templates/sync-cloudflare/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
templates/sync-cloudflare/public/favicon.ico
Normal file
BIN
templates/sync-cloudflare/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
27
templates/sync-cloudflare/tsconfig.json
Normal file
27
templates/sync-cloudflare/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
10
templates/sync-cloudflare/vite.config.ts
Normal file
10
templates/sync-cloudflare/vite.config.ts
Normal 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()],
|
||||
}
|
||||
})
|
||||
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal file
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
105
templates/sync-cloudflare/worker/assetUploads.ts
Normal file
105
templates/sync-cloudflare/worker/assetUploads.ts
Normal 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 })
|
||||
}
|
||||
37
templates/sync-cloudflare/worker/worker.ts
Normal file
37
templates/sync-cloudflare/worker/worker.ts
Normal 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,
|
||||
}
|
||||
35
templates/sync-cloudflare/wrangler.toml
Normal file
35
templates/sync-cloudflare/wrangler.toml
Normal 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'
|
||||
Reference in New Issue
Block a user