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

2
apps/bemo-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
.wrangler

View File

@@ -0,0 +1,60 @@
# @tldraw/bemo-worker
```
⠀⠀⠀⠀⠀⠀⢤⡴⠶⠷⠶⠶⠾⠷⠻⠶⠷⠿⠾⠶⠷⠿⠳⠷⠾⠾⠷⠷⠾⠾⠷⠿⠶⠿⠞⠷⠶⠷⢦⣤⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢠⡼⠟⠋⠤⣤⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⣤⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠀⠁⡀⢀⠉⠙⠠⣄⣀⠀⠀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣘⣛⣧⣤⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠐⠠⠀⣠⣤⡘⠀⠌⡈⣷⡶⠛⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠙⠉⠋⠛⠋⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⢐⣶⣶⠙⠋⠃⣴⣮⠁⣿⠀⠀⠀⠀⢠⣄⣤⣠⣄⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣄⣤⣠⣄⣠⡄⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡗⢸⠏⢁⡶⣶⡶⠏⠉⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠐⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠰⣿⡾⠁⠉⠀⣾⣿⠄⣿⠀⠀⠂⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠐⠉⡁⣰⣶⡆⠉⠉⠀⣿⠀⠀⡀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢈⠐⠀⠀⠉⡀⢀⠁⠂⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠛⣤⡼⠋⠀⠀⠀⠀⠀⠀⠀⠀⠛⢣⣤⠛⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⣏⢠⣷⣾⣾⣤⣠⡀⠂⡁⣿⠀⠀⠈⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠶⣆⣀⣀⣰⠶⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢸⣿⣿⣿⢿⣿⡃⠄⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠸⣟⣿⣻⣿⣿⡄⠀⠄⣿⠀⠀⠠⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣤⣿⣿⣤
⠀⠀⠀⠀⢸⡗⠰⣿⣿⣿⣿⣾⠂⠄⠂⣿⠀⠀⢀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣯
⠀⠀⠀⠀⢸⡏⠘⠉⣿⣿⣿⡿⠆⠀⡁⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣧
⠀⠀⠀⠀⢸⣇⠨⣿⣿⣿⣿⣿⡁⠂⠄⣿⠀⠀⠂⠸⢧⣠⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⡼⠇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⡿
⠀⠀⠀⠀⢸⡧⠘⠉⠙⠿⠻⠛⠂⢀⠂⣿⠀⠀⢀⠀⠀⢁⢈⡈⣁⢈⡁⣈⢁⡈⢁⠈⠁⠁⠈⠈⠀⠁⠈⠁⠉⠈⠁⠈⠀⠁⠀⠀⠠⣿⣤⠀⠀⠀⠀⠀⣿⡀⣸⣿
⠀⠀⠀⠀⢸⣇⣠⣢⣷⣶⣶⣦⡑⠀⠄⣿⠀⠀⠀⢰⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⠀⠀⠠⠀⠀⢀⣶⣶⠀⠀⠄⠐⠀⠐⣿⣼⠟⢣⣤⣤⠟⠁⣰⠿⠀
⠀⣀⣰⣶⠾⠛⠛⠙⠛⠉⣿⣿⡀⠐⡀⣿⠀⠀⠄⠘⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠉⠀⠀⢀⠠⠀⠘⠛⠛⠁⠀⠀⠀⠄⠈⣿⠙⠶⢧⣀⣀⣰⡾⠋⠀⠀
⣶⠋⠉⣘⣟⢿⣻⣟⡻⠻⠿⣿⠀⠄⡀⣿⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠈⠀⠀⠀⡀⠀⠀⠀⡀⠐⠀⢀⠀⣿⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀
⠛⣦⣤⣤⣤⣤⣤⣤⣤⣤⠀⢹⣟⠀⡀⣿⠀⠀⠈⢀⣀⣻⡏⠉⣇⣀⡐⠀⠀⠄⠀⠀⠂⠀⢀⠀⣠⡼⠧⣄⠀⣠⣦⠄⠀⢀⠠⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠐⠠⠀⠛⠛⡀⠄⡀⣿⠀⠀⠠⢼⣇⣀⡀⢀⣀⣸⠇⠀⠀⠀⠀⠂⠀⠠⠀⠘⠛⠒⠒⠚⠻⣿⣂⠼⡇⠀⠀⢀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠁⠂⠡⠈⡀⠄⠂⠄⣿⠀⠀⠀⠀⠀⠹⢇⣠⡏⠀⠀⠀⠀⠁⠀⡀⠐⠀⠀⠀⣠⣶⣿⣿⣷⣬⡉⠁⠀⠀⢀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠸⢷⣄⠂⣁⠂⠡⢀⠐⡈⠀⣿⠀⠀⠈⠀⠀⢂⣀⡠⠀⠀⠐⠀⠁⡀⠄⠀⠀⠀⠐⠀⣿⣿⣿⣿⣷⣿⡇⠀⠀⠠⠀⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠘⢳⣤⡄⢁⠂⠐⡀⠁⣿⠀⠀⢀⠘⠛⠛⠛⠃⠀⠘⠛⠛⠛⠛⠀⠀⠀⠁⠀⠀⠈⠻⠿⠿⠿⠋⠀⠀⢀⠀⠀⠐⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⡶⢦⣀⣄⣿⠀⠀⡀⠀⠠⠀⠀⠀⡀⠀⢀⠀⢀⠀⠀⡀⠂⠀⠈⠀⡀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠈⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠛⢻⣿⣤⡀⣁⣀⡀⣀⢂⣀⣁⣀⣈⡀⣈⣀⣀⣁⣀⡐⣀⣀⢁⡀⣂⢀⣀⢂⣀⣀⢀⢂⣀⣠⣼⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⠛⠛⠛⢻⣿⣻⣿⣛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⣻⣟⣿⣿⣿⡛⠛⠛⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⡿⣿⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣻⣿⣿⣿⣯⣷⡗⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⣿⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀⢰⡞⣿⣿⡶⣯⣽⡿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⢸⣿⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣏⣿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⡟⢩⣟⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠿⣧⣾⣿⣶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
```
The bemo worker does several things like handling bookmark unfurling, asset uploads, and helping web socket connections to rooms.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
The code in this folder is Copyright (c) 2024-present tldraw Inc. 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.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## 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).

View File

@@ -0,0 +1,42 @@
{
"name": "@tldraw/bemo-worker",
"description": "tldraw infinite canvas SDK (merge server).",
"version": "0.0.0",
"private": true,
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"main": "./src/worker.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"dev": "yarn run -T tsx ../../internal/scripts/workers/dev.ts",
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"check-bundle-size": "yarn run -T tsx ../../internal/scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"dependencies": {
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/sync-core": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"@tldraw/worker-shared": "workspace:*",
"itty-router": "^5.0.18",
"react": "^19.2.1",
"react-dom": "^19.2.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260302.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"esbuild": "^0.28.0",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^7.0.2",
"wrangler": "^4.75.0"
}
}

View File

@@ -0,0 +1,266 @@
import { AnalyticsEngineDataset } from '@cloudflare/workers-types'
import {
InMemorySyncStorage,
RoomSnapshot,
TLSocketRoom,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
} from '@tldraw/sync-core'
import { TLRecord } from '@tldraw/tlschema'
import { ExecutionQueue, throttle } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { createSentry, parseRequestQuery } from '@tldraw/worker-shared'
import { DurableObject } from 'cloudflare:workers'
import { IRequest, Router } from 'itty-router'
import { makePermissiveSchema } from './makePermissiveSchema'
import { Environment } from './types'
const connectRequestQuery = T.object({
sessionId: T.string,
storeId: T.string.optional(),
})
interface AnalyticsEvent {
type: 'connect' | 'send_message' | 'receive_message'
origin: string
sessionId: string
slug: string
}
export class BemoDO extends DurableObject<Environment> {
r2: R2Bucket
_slug: string | null = null
analytics?: AnalyticsEngineDataset
writeEvent({ type, origin, sessionId, slug }: AnalyticsEvent) {
this.analytics?.writeDataPoint({
blobs: [type, origin, slug, sessionId],
})
}
constructor(
public state: DurableObjectState,
env: Environment
) {
super(state, env)
this.r2 = env.BEMO_BUCKET
this.analytics = env.BEMO_ANALYTICS
state.blockConcurrencyWhile(async () => {
this._slug = ((await this.state.storage.get('slug')) ?? null) as string | null
})
}
private reportError(e: unknown, request?: Request) {
const sentry = createSentry(this.ctx, this.env, request)
console.error(e)
// eslint-disable-next-line @typescript-eslint/no-deprecated
sentry?.captureException(e)
}
private readonly router = Router()
.all('/connect/:slug', async (req) => {
if (!this._slug) {
await this.state.blockConcurrencyWhile(async () => {
await this.state.storage.put('slug', req.params.slug)
this._slug = req.params.slug
})
}
return this.handleConnect(req)
})
.all('*', async () => new Response('Not found', { status: 404 }))
override async fetch(request: Request): Promise<Response> {
try {
return await this.router.fetch(request)
} catch (error) {
this.reportError(error, request)
return new Response('Something went wrong', {
status: 500,
statusText: 'Internal Server Error',
})
}
}
async handleConnect(req: IRequest) {
// extract query params from request, should include instanceId
const { sessionId } = parseRequestQuery(req, connectRequestQuery)
// Create the websocket pair for the client
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
serverWebSocket.accept()
const origin = req.headers.get('origin') ?? 'unknown'
try {
const room = await this.getRoom()
// Don't connect if we're already at max connections
if (room.getNumActiveSessions() >= MAX_CONNECTIONS) {
// TODO: this is not handled on the client, it just gets stuck in a loading state.
// With hibernatable sockets it should be fine to send a .close() event here.
// but we should really handle unknown errors better on the client.
return new Response('Room is full', {
status: 403,
})
}
// all good
room.handleSocketConnect({
sessionId,
socket: serverWebSocket,
meta: { origin },
})
this.writeEvent({
type: 'connect',
origin,
sessionId,
slug: this.getSlug(),
})
return new Response(null, { status: 101, webSocket: clientWebSocket })
} catch (e) {
if (e === ROOM_NOT_FOUND) {
serverWebSocket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.NOT_FOUND)
return new Response(null, { status: 101, webSocket: clientWebSocket })
}
throw e
}
}
// For TLSyncRoom
_room: Promise<TLSocketRoom<TLRecord, { origin: string }>> | null = null
getSlug() {
if (!this._slug) {
throw new Error('slug must be present')
}
return this._slug
}
getRoom() {
const slug = this.getSlug()
if (!this._room) {
this._room = this.loadFromDatabase(slug).then((result) => {
// create the storage layer that holds the document state
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: result.type === 'room_found' ? result.snapshot : undefined,
onChange: () => {
this.triggerPersistSchedule()
},
})
return new TLSocketRoom<TLRecord, { origin: string }>({
schema: makePermissiveSchema(),
storage,
onAfterReceiveMessage: ({ sessionId, meta }) => {
this.writeEvent({
type: 'receive_message',
origin: meta.origin,
sessionId,
slug,
})
},
onBeforeSendMessage: ({ sessionId, meta }) => {
this.writeEvent({
type: 'send_message',
origin: meta.origin,
sessionId,
slug,
})
},
onSessionRemoved: async (room, args) => {
if (args.numSessionsRemaining > 0) return
if (!this._room) return
try {
await this.persistToDatabase()
} catch {
// already logged
}
this._room = null
room.close()
},
})
})
}
return this._room
}
triggerPersistSchedule = throttle(() => {
this.schedulePersist()
}, 2000)
async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
try {
const key = getR2KeyForSlug(persistenceKey)
// when loading, prefer to fetch documents from the bucket
const roomFromBucket = await this.r2.get(key)
if (roomFromBucket) {
return { type: 'room_found', snapshot: await roomFromBucket.json() }
}
return { type: 'room_not_found' }
} catch (error) {
console.error('failed to fetch doc', persistenceKey, error)
return { type: 'error', error: error as Error }
}
}
_lastPersistedClock: number | null = null
executionQueue = new ExecutionQueue()
// Save the room to supabase
async persistToDatabase() {
this.executionQueue
.push(async () => {
// check whether the worker was woken up to persist after having gone to sleep
if (!this._room) return
const slug = this.getSlug()
const room = await this.getRoom()
const clock = room.getCurrentDocumentClock()
if (this._lastPersistedClock === clock) return
const snapshot = JSON.stringify(room.storage.getSnapshot?.())
const key = getR2KeyForSlug(slug)
await Promise.all([this.r2.put(key, snapshot)])
this._lastPersistedClock = clock
// use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop
// just in case there's any possibility of setting up a neverending queue
})
.catch((e) => {
this.reportError(e)
})
}
async schedulePersist() {
const existing = await this.state.storage.getAlarm()
if (!existing) {
this.state.storage.setAlarm(PERSIST_INTERVAL_MS)
}
}
// Will be called automatically when the alarm ticks.
override async alarm() {
this.persistToDatabase()
}
}
function getR2KeyForSlug(persistenceKey: string) {
return `rooms/${persistenceKey}`
}
const ROOM_NOT_FOUND = new Error('Room not found')
const MAX_CONNECTIONS = 30
const PERSIST_INTERVAL_MS = 5000
type DBLoadResult =
| {
type: 'error'
error?: Error | undefined
}
| {
type: 'room_found'
snapshot: RoomSnapshot
}
| {
type: 'room_not_found'
}

View File

@@ -0,0 +1,49 @@
import {
TLBaseBinding,
TLBaseShape,
TLSchema,
bindingIdValidator,
createTLSchema,
opacityValidator,
parentIdValidator,
shapeIdValidator,
} from '@tldraw/tlschema'
import { T } from '@tldraw/validate'
export function makePermissiveSchema(): TLSchema {
const schema = createTLSchema()
const shapeValidator = T.object<TLBaseShape<any, any>>({
id: shapeIdValidator,
typeName: T.literal('shape'),
x: T.number,
y: T.number,
rotation: T.number,
index: T.indexKey,
parentId: parentIdValidator,
type: T.string,
isLocked: T.boolean,
opacity: opacityValidator,
props: T.jsonValue as any,
meta: T.jsonValue as any,
}) as (typeof schema)['types']['shape']['validator']
const shapeType = schema.getType('shape')
// @ts-expect-error
shapeType.validator = shapeValidator
const bindingValidator = T.object<TLBaseBinding<any, any>>({
id: bindingIdValidator,
typeName: T.literal('binding'),
type: T.string,
fromId: shapeIdValidator,
toId: shapeIdValidator,
props: T.jsonValue as any,
meta: T.jsonValue as any,
}) as (typeof schema)['types']['binding']['validator']
const bindingType = schema.getType('binding')
// @ts-expect-error
bindingType.validator = bindingValidator
return schema
}

View File

@@ -0,0 +1,17 @@
import { AnalyticsEngineDataset } from '@cloudflare/workers-types'
import { BemoDO } from './BemoDO'
export interface Environment {
// bindings
BEMO_DO: DurableObjectNamespace<BemoDO>
BEMO_BUCKET: R2Bucket
CF_VERSION_METADATA: WorkerVersionMetadata
// environment variables
TLDRAW_ENV: string | undefined
SENTRY_DSN: string | undefined
IS_LOCAL: string | undefined
WORKER_NAME: string | undefined
BEMO_ANALYTICS?: AnalyticsEngineDataset
}

View File

@@ -0,0 +1,5 @@
describe('worker', () => {
it('works', () => {
// blank test to make ci happy
})
})

View File

@@ -0,0 +1,91 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
import {
handleApiRequest,
handleExtractBookmarkMetadataRequest,
handleUserAssetGet,
handleUserAssetUpload,
notFound,
type R2BucketLike,
} from '@tldraw/worker-shared'
import { WorkerEntrypoint } from 'cloudflare:workers'
import { Router, cors } from 'itty-router'
import { Environment } from './types'
export { BemoDO } from './BemoDO'
const { preflight, corsify } = cors({ origin: '*' })
export default class Worker extends WorkerEntrypoint<Environment> {
private readonly router = Router()
.all('*', preflight)
.get('/uploads/:objectName', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetGet({
request,
bucket,
objectName: `asset-uploads/${request.params.objectName}`,
context: this.ctx,
})
})
.post('/uploads/:objectName', async (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetUpload({
headers: request.headers,
body: request.body,
bucket,
objectName: `asset-uploads/${request.params.objectName}`,
})
})
.get('/bookmarks/unfurl', (request) => {
// legacy route: extract metadata without saving image
return handleExtractBookmarkMetadataRequest({ request })
})
.post('/bookmarks/unfurl', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleExtractBookmarkMetadataRequest({
request,
uploadImage: async (headers, body, objectName) => {
const response = await handleUserAssetUpload({
body,
headers,
bucket,
objectName: `bookmark-assets/${objectName}`,
})
if (!response.ok) throw new Error('Failed to upload image')
const requestUrl = new URL(request.url)
return `${requestUrl.origin}/bookmarks/assets/${objectName}`
},
})
})
.get('/bookmarks/assets/:objectName', (request) => {
const bucket = this.env.BEMO_BUCKET as R2BucketLike
return handleUserAssetGet({
request,
bucket,
objectName: `bookmark-assets/${request.params.objectName}`,
context: this.ctx,
})
})
.get('/connect/:slug', (request) => {
const slug = request.params.slug
if (!slug) return new Response('Not found', { status: 404 })
// Set up the durable object for this room
const id = this.env.BEMO_DO.idFromName(slug)
return this.env.BEMO_DO.get(id).fetch(request)
})
.all('*', notFound)
override async fetch(request: Request): Promise<Response> {
return handleApiRequest({
router: this.router,
request,
env: this.env,
ctx: this.ctx,
after: corsify,
})
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src", "scripts"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false },
"references": [
{ "path": "../../packages/dotcom-shared" },
{ "path": "../../packages/store" },
{ "path": "../../packages/sync-core" },
{ "path": "../../packages/tlschema" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/validate" },
{ "path": "../../packages/worker-shared" }
]
}

View File

@@ -0,0 +1,15 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// bemo-worker is a Node.js/Cloudflare Worker environment
export default mergeConfig(baseConfig, {
test: {
// Use node environment instead of jsdom for worker apps
environment: 'node',
// Module name mapping for path aliases
alias: {
'^~(.*)$': './src/$1',
},
},
})

View File

@@ -0,0 +1,109 @@
main = "src/worker.ts"
compatibility_date = "2024-06-20"
upload_source_maps = true
[dev]
port = 8989
ip = "0.0.0.0"
# these migrations are append-only. you can't change them. if you do need to change something, do so
# by creating new migrations
[[migrations]]
tag = "v1" # Should be unique for each entry
new_classes = ["BemoDO"]
#################### Environment names ####################
# dev should never actually get deployed anywhere
[env.dev]
name = "dev-bemo"
# we don't have a hard-coded name for preview. we instead have to generate it at build time and append it to this file.
# staging is the same as a preview on main:
[env.staging]
name = "canary-bemo"
routes = [{ pattern = "canary-demo.tldraw.xyz", custom_domain = true }]
# production gets the proper name
[env.production]
name = "production-bemo"
routes = [{ pattern = "demo.tldraw.xyz", custom_domain = true }]
#################### Durable objects ####################
# durable objects have the same configuration in all environments:
[durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.dev.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.preview.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.staging.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
[env.production.durable_objects]
bindings = [{ name = "BEMO_DO", class_name = "BemoDO" }]
#################### R2 bucket ####################
# in dev, we write to the preview bucket and need a `preview_bucket_name`
[[env.dev.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
preview_bucket_name = 'sync-demo-preview'
# in preview and staging we write to the preview bucket
[[env.preview.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
[[env.staging.r2_buckets]]
binding = 'BEMO_BUCKET'
bucket_name = 'sync-demo-preview'
# in production, we write to the main bucket
[[env.production.r2_buckets]]
binding = "BEMO_BUCKET"
bucket_name = "sync-demo"
#################### Version metadata ####################
[version_metadata]
binding = "CF_VERSION_METADATA"
[env.dev.version_metadata]
binding = "CF_VERSION_METADATA"
[env.preview.version_metadata]
binding = "CF_VERSION_METADATA"
[env.staging.version_metadata]
binding = "CF_VERSION_METADATA"
[env.production.version_metadata]
binding = "CF_VERSION_METADATA"
#################### Analytics engine ####################
# analytics engine has the same configuration in all environments:
[[analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS"
[[env.dev.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_DEV"
[[env.preview.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_PREVIEW"
[[env.staging.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS_STAGING"
[[env.production.analytics_engine_datasets]]
binding = "BEMO_ANALYTICS"
dataset = "BEMO_ANALYTICS"