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:
176
templates/socketio-server-example/src/client/App.tsx
Normal file
176
templates/socketio-server-example/src/client/App.tsx
Normal 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
|
||||
7
templates/socketio-server-example/src/client/index.css
Normal file
7
templates/socketio-server-example/src/client/index.css
Normal 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;
|
||||
}
|
||||
13
templates/socketio-server-example/src/client/index.html
Normal file
13
templates/socketio-server-example/src/client/index.html
Normal 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>
|
||||
10
templates/socketio-server-example/src/client/main.tsx
Normal file
10
templates/socketio-server-example/src/client/main.tsx
Normal 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>
|
||||
)
|
||||
1
templates/socketio-server-example/src/client/vite-env.d.ts
vendored
Normal file
1
templates/socketio-server-example/src/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
15
templates/socketio-server-example/src/server/assets.ts
Normal file
15
templates/socketio-server-example/src/server/assets.ts
Normal 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))
|
||||
}
|
||||
48
templates/socketio-server-example/src/server/rooms.ts
Normal file
48
templates/socketio-server-example/src/server/rooms.ts
Normal 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
|
||||
}
|
||||
139
templates/socketio-server-example/src/server/server.ts
Normal file
139
templates/socketio-server-example/src/server/server.ts
Normal 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}`)
|
||||
})
|
||||
14
templates/socketio-server-example/src/server/unfurl.ts
Normal file
14
templates/socketio-server-example/src/server/unfurl.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user