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, custom transport example
This is a simple example of a backend for [tldraw sync](https://tldraw.dev/docs/sync) hosted on Node, with a custom socket.io transport instead of our default websocket-based one.
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,47 @@
{
"name": "@tldraw/socketio-server-example",
"description": "tldraw infinite canvas SDK (example socket.io 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": {
"@tldraw/sync": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.10.0",
"express": "^5.1.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.28.2",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"tldraw": "workspace:*",
"unfurl.js": "^6.4.0",
"vite": "^8.0.16"
}
}

View File

@@ -0,0 +1,176 @@
import { TLPersistentClientSocket, TLSocketStatusChangeEvent, useSync } from '@tldraw/sync'
import { useCallback } from 'react'
import { io, Socket } from 'socket.io-client'
import {
AssetRecordType,
getHashForString,
TLAssetStore,
TLBookmarkAsset,
Tldraw,
TLRecord,
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 a connection to the server:
connect: useCallback((query) => {
const socket = io(WORKER_URL, {
query: { ...query, roomId },
})
return socketIoToTldrawSocket(socket)
}, []),
// ...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>
)
}
// Helper function to convert Socket.IO to TLPersistentClientSocket
function socketIoToTldrawSocket(ioSocket: Socket): TLPersistentClientSocket<TLRecord> {
const statusChangeListeners = new Set<(event: TLSocketStatusChangeEvent) => void>()
const tldrawSocket: TLPersistentClientSocket<TLRecord> = {
connectionStatus: 'offline',
sendMessage: (message) => {
console.log('📤 Sending:', message)
ioSocket.emit('tldraw-message', JSON.stringify(message))
},
onReceiveMessage: (callback) => {
// Listen for tldraw sync protocol messages
const handler = (message: any) => {
console.log('📥 Received:', message)
callback(message)
}
ioSocket.on('tldraw-message', handler)
// Return cleanup function
return () => {
ioSocket.off('tldraw-message', handler)
}
},
onStatusChange: (callback) => {
statusChangeListeners.add(callback)
return () => {
statusChangeListeners.delete(callback)
}
},
restart: () => {
console.log('🔄 Restarting Socket.IO connection...')
ioSocket.disconnect()
ioSocket.connect()
},
close: () => {
ioSocket.off('connect', connectHandler)
ioSocket.off('disconnect', disconnectHandler)
ioSocket.off('connect_error', errorHandler)
clearTimeout(initialStatusTimeout)
ioSocket.disconnect()
},
}
// Map Socket.IO events to TLPersistentClientSocket status
const setStatus = (event: TLSocketStatusChangeEvent) => {
tldrawSocket.connectionStatus = event.status
statusChangeListeners.forEach((cb) => cb(event))
}
const connectHandler = () => setStatus({ status: 'online' })
const disconnectHandler = () => setStatus({ status: 'offline' })
const errorHandler = (error: any) =>
setStatus({ status: 'error', reason: error.message || 'Connection error' })
ioSocket.on('connect', connectHandler)
ioSocket.on('disconnect', disconnectHandler)
ioSocket.on('connect_error', errorHandler)
// Set initial status
const initialStatusTimeout = setTimeout(() => {
if (ioSocket.connected) connectHandler()
}, 0)
return tldrawSocket
}
// 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/bun 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,48 @@
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,139 @@
import { createServer } from 'http'
import { WebSocketMinimal } from '@tldraw/sync-core'
import express from 'express'
import { Server } from 'socket.io'
import { loadAsset, storeAsset } from './assets'
import { makeOrLoadRoom } from './rooms'
import { unfurl } from './unfurl'
const PORT = 5858
// For this example we use Socket.IO with Express
// To keep things simple we're skipping normal production concerns like rate limiting and input validation.
// Create Express app and HTTP server for Socket.IO
const app = express()
const server = createServer(app)
// Enable CORS and parse JSON
app.use(express.json())
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
next()
})
export interface ServerToClientMessage {
'tldraw-message': (message: string) => void
}
export interface ClientToServerMessage {
'tldraw-message': (message: string) => void
}
// Create Socket.IO server
const io = new Server<ClientToServerMessage, ServerToClientMessage>(server, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
})
// This is the main entrypoint for the multiplayer sync
io.on('connection', async (socket) => {
console.log('Socket.IO client connected:', socket.id)
// The roomId and sessionId are passed from the client as query params,
// you need to extract them and pass them to the room.
const sessionId = socket.handshake.query.sessionId as string
const roomId = socket.handshake.query.roomId as string
if (!sessionId || !roomId) {
console.log('Missing required parameters, disconnecting:', socket.id)
socket.disconnect()
return
}
console.log('Connecting to room:', roomId, 'with session:', sessionId)
try {
// Here we make or get an existing instance of TLSocketRoom for the given roomId
const room = makeOrLoadRoom(roomId)
// Create a socket adapter for TLSocketRoom
const socketAdapter: WebSocketMinimal = {
send: (message) => {
socket.emit('tldraw-message', JSON.parse(message))
},
close: () => {
socket.disconnect()
},
get readyState() {
return socket.connected ? 1 : 3 // 1 = OPEN, 3 = CLOSED
},
}
// and finally connect the socket to the room
room.handleSocketConnect({ sessionId, socket: socketAdapter })
// Handle tldraw sync messages
socket.on('tldraw-message', (message) => {
// Ensure message is a string - Socket.IO might send it as an object or buffer
room.handleSocketMessage(sessionId, message)
})
// Handle disconnect
socket.on('disconnect', () => {
console.log('Socket.IO client disconnected:', socket.id)
})
} catch (error) {
console.error('Error setting up room connection:', error)
socket.disconnect()
}
})
// To enable blob storage for assets, we add simple endpoints supporting PUT and GET requests
app.put('/uploads/:id', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
try {
const id = req.params.id
await storeAsset(id, req.body)
res.json({ ok: true })
} catch (error) {
console.error('Asset upload error:', error)
res.status(500).json({ error: 'Upload failed' })
}
})
app.get('/uploads/:id', async (req, res) => {
try {
const id = req.params.id
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)
} catch (error) {
console.error('Asset download error:', error)
res.status(404).json({ error: 'Asset not found' })
}
})
// To enable unfurling of bookmarks, we add a simple endpoint that takes a URL query param
app.get('/unfurl', async (req, res) => {
try {
const url = req.query.url as string
if (!url) {
return res.status(400).json({ error: 'URL parameter required' })
}
const result = await unfurl(url)
return res.json(result)
} catch (error) {
console.error('Unfurl error:', error)
return res.status(500).json({ error: 'Unfurl failed' })
}
})
// Start server
server.listen(PORT, () => {
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'],
},
}))