···11+// For format details, see https://aka.ms/devcontainer.json. For config options, see the
22+// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
33+{
44+ "name": "Node.js",
55+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
66+ "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
77+88+ // Features to add to the dev container. More info: https://containers.dev/features.
99+ // "features": {},
1010+1111+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
1212+ "forwardPorts": [5700,5800]
1313+1414+ // Use 'postCreateCommand' to run commands after the container is created.
1515+ // "postCreateCommand": "yarn install",
1616+1717+ // Configure tool-specific properties.
1818+ // "customizations": {},
1919+2020+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
2121+ // "remoteUser": "root"
2222+}
···11+# tldraw sync, simple Node/Bun server example
22+33+This is a simple example of a backend for [tldraw sync](https://tldraw.dev/docs/sync) with a Node or Bun server.
44+55+Run `yarn dev-node` or `yarn dev-bun` in this folder to start the server + client.
66+77+For a production-ready example specific to Cloudflare, see /templates/sync-cloudflare.
88+99+## License
1010+1111+This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/apps/simple-server-example/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
1212+1313+## Trademarks
1414+1515+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.
1616+1717+## Distributions
1818+1919+You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
2020+2121+## Contribution
2222+2323+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).
2424+2525+## Community
2626+2727+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).
2828+2929+## Contact
3030+3131+Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
+14
compose.yaml
···11+services:
22+ tldraw:
33+ image: tldraw
44+ build: .
55+ ports:
66+ - 5757:5757
77+ - 5858:5858
88+ ## behind a reverse proxy the backend URL needs to be set so the client code
99+ ## knows where to communicate to
1010+ ##
1111+ ## URL should lead to port 5858 on this container
1212+ ##
1313+ # environment:
1414+ # - VITE_WORKER_URL=mydomain-ws.com
···11+import { useSync } from '@tldraw/sync'
22+import {
33+ AssetRecordType,
44+ getHashForString,
55+ TLAssetStore,
66+ TLBookmarkAsset,
77+ Tldraw,
88+ uniqueId,
99+} from 'tldraw'
1010+1111+const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? 'http://localhost:5858';
1212+1313+// In this example, the room ID is hard-coded. You can set this however you like though.
1414+const roomId = 'test-room'
1515+1616+function App() {
1717+ // Create a store connected to multiplayer.
1818+ const store = useSync({
1919+ // We need to know the websocket's URI...
2020+ uri: `${WORKER_URL}/connect/${roomId}`,
2121+ // ...and how to handle static assets like images & videos
2222+ assets: multiplayerAssets,
2323+ })
2424+2525+ return (
2626+ <div style={{ position: 'fixed', inset: 0 }}>
2727+ <Tldraw
2828+ // we can pass the connected store into the Tldraw component which will handle
2929+ // loading states & enable multiplayer UX like cursors & a presence menu
3030+ store={store}
3131+ onMount={(editor) => {
3232+ // @ts-expect-error
3333+ window.editor = editor
3434+ // when the editor is ready, we need to register out bookmark unfurling service
3535+ editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
3636+ }}
3737+ />
3838+ </div>
3939+ )
4040+}
4141+4242+// How does our server handle assets like images and videos?
4343+const multiplayerAssets: TLAssetStore = {
4444+ // to upload an asset, we prefix it with a unique id, POST it to our worker, and return the URL
4545+ async upload(_asset, file) {
4646+ const id = uniqueId()
4747+4848+ const objectName = `${id}-${file.name}`
4949+ const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}`
5050+5151+ const response = await fetch(url, {
5252+ method: 'PUT',
5353+ body: file,
5454+ })
5555+5656+ if (!response.ok) {
5757+ throw new Error(`Failed to upload asset: ${response.statusText}`)
5858+ }
5959+6060+ return { src: url }
6161+ },
6262+ // to retrieve an asset, we can just use the same URL. you could customize this to add extra
6363+ // auth, or to serve optimized versions / sizes of the asset.
6464+ resolve(asset) {
6565+ return asset.props.src
6666+ },
6767+}
6868+6969+// How does our server handle bookmark unfurling?
7070+async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
7171+ const asset: TLBookmarkAsset = {
7272+ id: AssetRecordType.createId(getHashForString(url)),
7373+ typeName: 'asset',
7474+ type: 'bookmark',
7575+ meta: {},
7676+ props: {
7777+ src: url,
7878+ description: '',
7979+ image: '',
8080+ favicon: '',
8181+ title: '',
8282+ },
8383+ }
8484+8585+ try {
8686+ const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
8787+ const data = await response.json()
8888+8989+ asset.props.description = data?.description ?? ''
9090+ asset.props.image = data?.image ?? ''
9191+ asset.props.favicon = data?.favicon ?? ''
9292+ asset.props.title = data?.title ?? ''
9393+ } catch (e) {
9494+ console.error(e)
9595+ }
9696+9797+ return asset
9898+}
9999+100100+export default App
···11+<!doctype html>
22+<html lang="en">
33+ <head>
44+ <meta charset="UTF-8" />
55+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
66+ <title>tldraw node/bun server example</title>
77+ </head>
88+ <body>
99+ <div id="root"></div>
1010+ <script type="module" src="./main.tsx"></script>
1111+ <noscript>You need to enable JavaScript to run tldraw. ✌️</noscript>
1212+ </body>
1313+</html>
+10
src/client/main.tsx
···11+import React from 'react'
22+import ReactDOM from 'react-dom/client'
33+import App from './App'
44+import './index.css'
55+66+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
77+ <React.StrictMode>
88+ <App />
99+ </React.StrictMode>
1010+)
+16
src/client/vite-env.d.ts
···11+/// <reference types="vite/client" />
22+33+interface ViteTypeOptions {
44+ // By adding this line, you can make the type of ImportMetaEnv strict
55+ // to disallow unknown keys.
66+ // strictImportMetaEnv: unknown
77+}
88+99+interface ImportMetaEnv {
1010+ readonly VITE_WORKER_URL: string
1111+ // more env variables...
1212+}
1313+1414+interface ImportMeta {
1515+ readonly env: ImportMetaEnv
1616+}
+15
src/server/assets.ts
···11+import { mkdir, readFile, writeFile } from 'fs/promises'
22+import { join, resolve } from 'path'
33+import { Readable } from 'stream'
44+55+// We are just using the filesystem to store assets
66+const DIR = resolve('./.assets')
77+88+export async function storeAsset(id: string, stream: Readable) {
99+ await mkdir(DIR, { recursive: true })
1010+ await writeFile(join(DIR, id), stream)
1111+}
1212+1313+export async function loadAsset(id: string) {
1414+ return await readFile(join(DIR, id))
1515+}
+90
src/server/rooms.ts
···11+import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core'
22+import { mkdir, readFile, writeFile } from 'fs/promises'
33+import { join } from 'path'
44+55+// For this example we're just saving data to the local filesystem
66+const DIR = './.rooms'
77+async function readSnapshotIfExists(roomId: string) {
88+ try {
99+ const data = await readFile(join(DIR, roomId))
1010+ return JSON.parse(data.toString()) ?? undefined
1111+ } catch {
1212+ return undefined
1313+ }
1414+}
1515+async function saveSnapshot(roomId: string, snapshot: RoomSnapshot) {
1616+ await mkdir(DIR, { recursive: true })
1717+ await writeFile(join(DIR, roomId), JSON.stringify(snapshot))
1818+}
1919+2020+// We'll keep an in-memory map of rooms and their data
2121+interface RoomState {
2222+ room: TLSocketRoom<any, void>
2323+ id: string
2424+ needsPersist: boolean
2525+}
2626+const rooms = new Map<string, RoomState>()
2727+2828+// Very simple mutex using promise chaining, to avoid race conditions
2929+// when loading rooms. In production you probably want one mutex per room
3030+// to avoid unnecessary blocking!
3131+let mutex = Promise.resolve<null | Error>(null)
3232+3333+export async function makeOrLoadRoom(roomId: string) {
3434+ mutex = mutex
3535+ .then(async () => {
3636+ if (rooms.has(roomId)) {
3737+ const roomState = await rooms.get(roomId)!
3838+ if (!roomState.room.isClosed()) {
3939+ return null // all good
4040+ }
4141+ }
4242+ console.log('loading room', roomId)
4343+ const initialSnapshot = await readSnapshotIfExists(roomId)
4444+4545+ const roomState: RoomState = {
4646+ needsPersist: false,
4747+ id: roomId,
4848+ room: new TLSocketRoom({
4949+ initialSnapshot,
5050+ onSessionRemoved(room, args) {
5151+ console.log('client disconnected', args.sessionId, roomId)
5252+ if (args.numSessionsRemaining === 0) {
5353+ console.log('closing room', roomId)
5454+ room.close()
5555+ }
5656+ },
5757+ onDataChange() {
5858+ roomState.needsPersist = true
5959+ },
6060+ }),
6161+ }
6262+ rooms.set(roomId, roomState)
6363+ return null // all good
6464+ })
6565+ .catch((error) => {
6666+ // return errors as normal values to avoid stopping the mutex chain
6767+ return error
6868+ })
6969+7070+ const err = await mutex
7171+ if (err) throw err
7272+ return rooms.get(roomId)!.room
7373+}
7474+7575+// Do persistence on a regular interval.
7676+// In production you probably want a smarter system with throttling.
7777+setInterval(() => {
7878+ for (const roomState of rooms.values()) {
7979+ if (roomState.needsPersist) {
8080+ // persist room
8181+ roomState.needsPersist = false
8282+ console.log('saving snapshot', roomState.id)
8383+ saveSnapshot(roomState.id, roomState.room.getCurrentSnapshot())
8484+ }
8585+ if (roomState.room.isClosed()) {
8686+ console.log('deleting room', roomState.id)
8787+ rooms.delete(roomState.id)
8888+ }
8989+ }
9090+}, 2000)
+93
src/server/server.bun.ts
···11+import { TLSocketRoom } from '@tldraw/sync-core'
22+import { IRequest, Router, RouterType, cors, json } from 'itty-router'
33+import { loadAsset, storeAsset } from './assets'
44+import { makeOrLoadRoom } from './rooms'
55+import { unfurl } from './unfurl'
66+const PORT = 5858
77+88+// For this example we use Bun.serve and a basic router to handle requests
99+// To keep things simple we're skipping normal production concerns like rate limiting and input validation.
1010+1111+const { corsify, preflight } = cors({ origin: '*' })
1212+1313+const router: RouterType<IRequest, any, any> = Router()
1414+ .all('*', preflight)
1515+1616+ // This is the main entrypoint for the multiplayer sync
1717+ .get('/connect/:roomId', async (req) => {
1818+ // The roomId comes from the URL pathname
1919+ const roomId = req.params.roomId
2020+ // The sessionId is passed from the client as a query param,
2121+ // you need to extract it and pass it to the room.
2222+ const sessionId = req.query.sessionId
2323+ // Now we pass the params to the upgrade function so that
2424+ // when the socket connects, it can be associated with the correct room
2525+ // and session.
2626+ server.upgrade(req, { data: { roomId, sessionId } })
2727+ return new Response(null, { status: 101 })
2828+ })
2929+3030+ // To enable blob storage for assets, we add a simple endpoint supporting PUT and GET requests
3131+ .put('/uploads/:id', async (req) => {
3232+ const id = (req.params as any).id as string
3333+ await storeAsset(id, (await req.blob()).stream() as any)
3434+ return json({ ok: true })
3535+ })
3636+ .get('/uploads/:id', async (req) => {
3737+ const id = (req.params as any).id as string
3838+ return new Response(await loadAsset(id))
3939+ })
4040+4141+ // To enable unfurling of bookmarks, we add a simple endpoint that takes a URL query param
4242+ .get('/unfurl', async (req) => {
4343+ const url = (req.query as any).url as string
4444+ return json(await unfurl(url))
4545+ })
4646+ .all('*', () => {
4747+ new Response('Not found', { status: 404 })
4848+ })
4949+5050+const server = Bun.serve<
5151+ { room?: TLSocketRoom<any, void>; sessionId: string; roomId: string },
5252+ null
5353+>({
5454+ port: PORT,
5555+ fetch(req) {
5656+ try {
5757+ return router.fetch(req).then(corsify)
5858+ } catch (e) {
5959+ console.error(e)
6060+ return new Response('Something went wrong', {
6161+ status: 500,
6262+ })
6363+ }
6464+ },
6565+ websocket: {
6666+ async open(socket) {
6767+ // get the params extracted from the URL in the GET /connect/:roomId handler above
6868+ const { sessionId, roomId } = socket.data
6969+7070+ // Here we make or get an existing instance of TLSocketRoom for the given roomId
7171+ const room = await makeOrLoadRoom(roomId)
7272+ // and finally connect the socket to the room
7373+ room.handleSocketConnect({ sessionId, socket })
7474+ // store the room on the socket so we can access it easily later
7575+ socket.data.room = room
7676+ },
7777+ async message(ws, message) {
7878+ // pass the message along to the room
7979+ ws.data.room?.handleSocketMessage(ws.data.sessionId, message)
8080+ },
8181+ drain(ws) {
8282+ // If the socket was was overloaded with backpressure, let's just close it
8383+ // and let the client reconnect and send all the data again.
8484+ ws.close()
8585+ },
8686+ close(ws) {
8787+ // let the room know the socket has closed
8888+ ws.data.room?.handleSocketClose(ws.data.sessionId)
8989+ },
9090+ },
9191+})
9292+9393+console.log(`Listening on localhost:${server.port}`)
+80
src/server/server.node.ts
···11+import cors from '@fastify/cors'
22+import websocketPlugin from '@fastify/websocket'
33+import fastify from 'fastify'
44+import type { RawData } from 'ws'
55+import { loadAsset, storeAsset } from './assets'
66+import { makeOrLoadRoom } from './rooms'
77+import { unfurl } from './unfurl'
88+99+const PORT = 5858
1010+1111+// For this example we use a simple fastify server with the official websocket plugin
1212+// To keep things simple we're skipping normal production concerns like rate limiting and input validation.
1313+const app = fastify()
1414+app.register(websocketPlugin)
1515+app.register(cors, { origin: '*' })
1616+1717+app.register(async (app) => {
1818+ // This is the main entrypoint for the multiplayer sync
1919+ app.get('/connect/:roomId', { websocket: true }, async (socket, req) => {
2020+ // The roomId comes from the URL pathname
2121+ const roomId = (req.params as any).roomId as string
2222+ // The sessionId is passed from the client as a query param,
2323+ // you need to extract it and pass it to the room.
2424+ const sessionId = (req.query as any)?.['sessionId'] as string
2525+2626+ // At least one message handler needs to
2727+ // be attached before doing any kind of async work
2828+ // https://github.com/fastify/fastify-websocket?tab=readme-ov-file#attaching-event-handlers
2929+ // We collect messages that came in before the room was loaded, and re-emit them
3030+ // after the room is loaded.
3131+ const caughtMessages: RawData[] = []
3232+3333+ const collectMessagesListener = (message: RawData) => {
3434+ caughtMessages.push(message)
3535+ }
3636+3737+ socket.on('message', collectMessagesListener)
3838+3939+ // Here we make or get an existing instance of TLSocketRoom for the given roomId
4040+ const room = await makeOrLoadRoom(roomId)
4141+ // and finally connect the socket to the room
4242+ room.handleSocketConnect({ sessionId, socket })
4343+4444+ socket.off('message', collectMessagesListener)
4545+4646+ // Finally, we replay any caught messages so the room can process them
4747+ for (const message of caughtMessages) {
4848+ socket.emit('message', message)
4949+ }
5050+ })
5151+5252+ // To enable blob storage for assets, we add a simple endpoint supporting PUT and GET requests
5353+ // But first we need to allow all content types with no parsing, so we can handle raw data
5454+ app.addContentTypeParser('*', (_, __, done) => done(null))
5555+ app.put('/uploads/:id', {}, async (req, res) => {
5656+ const id = (req.params as any).id as string
5757+ await storeAsset(id, req.raw)
5858+ res.send({ ok: true })
5959+ })
6060+ app.get('/uploads/:id', async (req, res) => {
6161+ const id = (req.params as any).id as string
6262+ const data = await loadAsset(id)
6363+ res.send(data)
6464+ })
6565+6666+ // To enable unfurling of bookmarks, we add a simple endpoint that takes a URL query param
6767+ app.get('/unfurl', async (req, res) => {
6868+ const url = (req.query as any).url as string
6969+ res.send(await unfurl(url))
7070+ })
7171+})
7272+7373+app.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
7474+ if (err) {
7575+ console.error(err)
7676+ process.exit(1)
7777+ }
7878+7979+ console.log(`Server started on port ${PORT}`)
8080+})