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

View File

@@ -0,0 +1,3 @@
.rooms/
.assets/
.yarn

View File

@@ -0,0 +1,33 @@
# tldraw sync, simple Node server example
This is a simple example of a backend for [tldraw sync](https://tldraw.dev/docs/sync) with a Node server.
Run `yarn dev` in this folder to start the server + client.
Room data is automatically persisted to SQLite databases in the `.rooms` directory using `SQLiteSyncStorage`.
For a production-ready example specific to Cloudflare, see /templates/sync-cloudflare.
## License
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).
## 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,49 @@
{
"name": "@tldraw/simple-server-example",
"description": "tldraw infinite canvas SDK (example node server).",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"license": "MIT",
"main": "./src/server/server.ts",
"scripts": {
"dev": "concurrently -n server,client -c red,blue \"yarn dev-server\" \"yarn dev-client\"",
"dev-server": "yarn run -T tsx watch ./src/server/server.ts",
"dev-client": "vite dev --host",
"test-ci": "echo 'No tests yet'",
"test": "yarn run -T vitest run --passWithNoTests",
"test-coverage": "lazy inherit",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^4.17.21",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"concurrently": "^9.1.2",
"lazyrepo": "0.0.0-alpha.27",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/websocket": "^11.0.0",
"@tldraw/sync": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.1",
"itty-router": "^5.0.18",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.28.2",
"tldraw": "workspace:*",
"unfurl.js": "^6.4.0",
"vite": "^8.0.16",
"ws": "^8.18.0"
}
}

View File

@@ -0,0 +1,98 @@
import { useSync } from '@tldraw/sync'
import {
AssetRecordType,
getHashForString,
TLAssetStore,
TLBookmarkAsset,
Tldraw,
uniqueId,
} from 'tldraw'
const WORKER_URL = `http://localhost:5858`
// In this example, the room ID is hard-coded. You can set this however you like though.
const roomId = 'test-room'
function App() {
// Create a store connected to multiplayer.
const store = useSync({
// We need to know the websocket's URI...
uri: `${WORKER_URL}/connect/${roomId}`,
// ...and how to handle static assets like images & videos
assets: multiplayerAssets,
})
return (
<div style={{ position: 'fixed', inset: 0 }}>
<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}
onMount={(editor) => {
// @ts-expect-error
window.editor = editor
// when the editor is ready, we need to register out bookmark unfurling service
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
}}
/>
</div>
)
}
// How does our server handle assets like images and videos?
const multiplayerAssets: TLAssetStore = {
// to upload an asset, we prefix it with a unique id, POST it to our worker, and return the URL
async upload(_asset, file) {
const objectName = `${uniqueId()}-${file.name}`
const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}`
const response = await fetch(url, {
method: 'PUT',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
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
},
}
// How does our server handle bookmark unfurling?
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
meta: {},
props: {
src: url,
description: '',
image: '',
favicon: '',
title: '',
},
}
try {
const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
const data = await response.json()
asset.props.description = data?.description ?? ''
asset.props.image = data?.image ?? ''
asset.props.favicon = data?.favicon ?? ''
asset.props.title = data?.title ?? ''
} catch (e) {
console.error(e)
}
return asset
}
export default App

View File

@@ -0,0 +1,7 @@
@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;
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>tldraw node server example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
<noscript>You need to enable JavaScript to run tldraw. ✌️</noscript>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

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

View File

@@ -0,0 +1,15 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { Readable } from 'stream'
// We are just using the filesystem to store assets
const DIR = resolve('./.assets')
export async function storeAsset(id: string, stream: Readable) {
await mkdir(DIR, { recursive: true })
await writeFile(join(DIR, id), stream)
}
export async function loadAsset(id: string) {
return await readFile(join(DIR, id))
}

View File

@@ -0,0 +1,47 @@
import { mkdirSync } from 'fs'
import { join } from 'path'
import { NodeSqliteWrapper, SQLiteSyncStorage, TLSocketRoom } from '@tldraw/sync-core'
import Database from 'better-sqlite3'
// For this example we're saving data to a SQLite database on the local filesystem
const DIR = './.rooms'
mkdirSync(DIR, { recursive: true })
// Sanitize roomId to prevent path traversal attacks
function sanitizeRoomId(roomId: string): string {
return roomId.replace(/[^a-zA-Z0-9_-]/g, '_')
}
// We'll keep an in-memory map of active rooms
const rooms = new Map<string, TLSocketRoom<any, void>>()
export function makeOrLoadRoom(roomId: string): TLSocketRoom<any, void> {
roomId = sanitizeRoomId(roomId)
const existing = rooms.get(roomId)
if (existing && !existing.isClosed()) {
return existing
}
console.log('loading room', roomId)
// Open the database - file is created if it doesn't exist
const db = new Database(join(DIR, `${roomId}.db`))
const sql = new NodeSqliteWrapper(db)
const storage = new SQLiteSyncStorage({ sql })
const room = new TLSocketRoom({
storage,
onSessionRemoved(room, args) {
console.log('client disconnected', args.sessionId, roomId)
if (args.numSessionsRemaining === 0) {
console.log('closing room', roomId)
room.close()
db.close()
rooms.delete(roomId)
}
},
})
rooms.set(roomId, room)
return room
}

View File

@@ -0,0 +1,83 @@
import cors from '@fastify/cors'
import websocketPlugin from '@fastify/websocket'
import fastify from 'fastify'
import type { RawData } from 'ws'
import { loadAsset, storeAsset } from './assets'
import { makeOrLoadRoom } from './rooms'
import { unfurl } from './unfurl'
const PORT = 5858
// For this example we use a simple fastify server with the official websocket plugin
// To keep things simple we're skipping normal production concerns like rate limiting and input validation.
const app = fastify()
app.register(websocketPlugin)
app.register(cors, { origin: '*' })
app.register(async (app) => {
// This is the main entrypoint for the multiplayer sync
app.get('/connect/:roomId', { websocket: true }, async (socket, req) => {
// The roomId comes from the URL pathname
const roomId = (req.params as any).roomId as string
// The sessionId is passed from the client as a query param,
// you need to extract it and pass it to the room.
const sessionId = (req.query as any)?.['sessionId'] as string
// At least one message handler needs to
// be attached before doing any kind of async work
// https://github.com/fastify/fastify-websocket?tab=readme-ov-file#attaching-event-handlers
// We collect messages that came in before the room was loaded, and re-emit them
// after the room is loaded.
const caughtMessages: RawData[] = []
const collectMessagesListener = (message: RawData) => {
caughtMessages.push(message)
}
socket.on('message', collectMessagesListener)
// Here we make or get an existing instance of TLSocketRoom for the given roomId
const room = makeOrLoadRoom(roomId)
// and finally connect the socket to the room
room.handleSocketConnect({ sessionId, socket })
socket.off('message', collectMessagesListener)
// Finally, we replay any caught messages so the room can process them
for (const message of caughtMessages) {
socket.emit('message', message)
}
})
// To enable blob storage for assets, we add a simple endpoint supporting PUT and GET requests
// But first we need to allow all content types with no parsing, so we can handle raw data
app.addContentTypeParser('*', (_, __, done) => done(null))
app.put('/uploads/:id', {}, async (req, res) => {
const id = (req.params as any).id as string
await storeAsset(id, req.raw)
res.send({ ok: true })
})
app.get('/uploads/:id', async (req, res) => {
const id = (req.params as any).id as string
const data = await loadAsset(id)
// Prevent XSS from user-uploaded SVGs
res.header('Content-Security-Policy', "default-src 'none'")
res.header('X-Content-Type-Options', 'nosniff')
res.send(data)
})
// To enable unfurling of bookmarks, we add a simple endpoint that takes a URL query param
app.get('/unfurl', async (req, res) => {
const url = (req.query as any).url as string
res.send(await unfurl(url))
})
})
app.listen({ port: PORT }, (err) => {
if (err) {
console.error(err)
process.exit(1)
}
console.log(`Server started on port ${PORT}`)
})

View File

@@ -0,0 +1,14 @@
import _unfurl from 'unfurl.js'
export async function unfurl(url: string) {
const { title, description, open_graph, twitter_card, favicon } = await _unfurl.unfurl(url)
const image = open_graph?.images?.[0]?.url || twitter_card?.images?.[0]?.url
return {
title,
description,
image,
favicon,
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src", "scripts", "vite.config.mts"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false, "types": ["node"] },
"references": [
{ "path": "../../packages/sync" },
{ "path": "../../packages/sync-core" },
{ "path": "../../packages/tldraw" }
]
}

View File

@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'
export default defineConfig(() => ({
plugins: [react()],
root: path.join(__dirname, 'src/client'),
publicDir: path.join(__dirname, 'public'),
server: {
port: 5757,
},
optimizeDeps: {
exclude: ['@tldraw/assets'],
},
}))