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,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)
}
}

View 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 })
}

View 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,
}