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:
2
apps/bemo-worker/.gitignore
vendored
Normal file
2
apps/bemo-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
60
apps/bemo-worker/README.md
Normal file
60
apps/bemo-worker/README.md
Normal 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).
|
||||
42
apps/bemo-worker/package.json
Normal file
42
apps/bemo-worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
266
apps/bemo-worker/src/BemoDO.ts
Normal file
266
apps/bemo-worker/src/BemoDO.ts
Normal 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'
|
||||
}
|
||||
49
apps/bemo-worker/src/makePermissiveSchema.ts
Normal file
49
apps/bemo-worker/src/makePermissiveSchema.ts
Normal 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
|
||||
}
|
||||
17
apps/bemo-worker/src/types.ts
Normal file
17
apps/bemo-worker/src/types.ts
Normal 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
|
||||
}
|
||||
5
apps/bemo-worker/src/worker.test.ts
Normal file
5
apps/bemo-worker/src/worker.test.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
describe('worker', () => {
|
||||
it('works', () => {
|
||||
// blank test to make ci happy
|
||||
})
|
||||
})
|
||||
91
apps/bemo-worker/src/worker.ts
Normal file
91
apps/bemo-worker/src/worker.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
15
apps/bemo-worker/tsconfig.json
Normal file
15
apps/bemo-worker/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
15
apps/bemo-worker/vitest.config.ts
Normal file
15
apps/bemo-worker/vitest.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
109
apps/bemo-worker/wrangler.toml
Normal file
109
apps/bemo-worker/wrangler.toml
Normal 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"
|
||||
Reference in New Issue
Block a user