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:
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal file
157
templates/sync-cloudflare/worker/TldrawDurableObject.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
105
templates/sync-cloudflare/worker/assetUploads.ts
Normal file
105
templates/sync-cloudflare/worker/assetUploads.ts
Normal 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 })
|
||||
}
|
||||
37
templates/sync-cloudflare/worker/worker.ts
Normal file
37
templates/sync-cloudflare/worker/worker.ts
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user