Initial bemo worker setup (#4017)

Sets up preview deploys etc. for bemo worker.

There's enough going on here that I wanted to make it its own PR. I'll
rework david's spike on top of it once it's landed.

### Change Type

- [x] `internal` — Does not affect user-facing stuff
- [x] `chore` — Updating dependencies, other boring stuff

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
alex 2024-07-01 12:35:23 +01:00 committed by GitHub
parent cafa0f5636
commit 57fb7a0650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1278 additions and 719 deletions

54
.github/workflows/deploy-bemo.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Deploy bemo
# TODO: add some sort of production trigger
on:
pull_request:
push:
branches:
- main
env:
CI: 1
PRINT_GITHUB_ANNOTATIONS: 1
TLDRAW_ENV: ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
defaults:
run:
shell: bash
jobs:
deploy:
name: Deploy bemo to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-open
environment: ${{ github.ref == 'refs/heads/production' && 'bemo-production' || 'bemo-canary' }}
concurrency: bemo-${{ github.ref == 'refs/heads/production' && 'production' || github.ref }}
steps:
- name: Notify initial start
uses: MineBartekSA/discord-webhook@v2
if: github.ref == 'refs/heads/production'
with:
webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
content: 'Preparing ${{ env.TLDRAW_ENV }} bemo deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}'
- name: Check out code
uses: actions/checkout@v3
with:
submodules: true
- uses: ./.github/actions/setup
- name: Build types
run: yarn build-types
- name: Deploy
run: yarn tsx scripts/deploy-bemo.ts
env:
RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_BEMO_WORKER_DSN: ${{ secrets.SENTRY_BEMO_WORKER_DSN }}

View file

@ -1,4 +1,4 @@
name: Deploy name: Deploy .com
on: on:
pull_request: pull_request:
@ -17,11 +17,11 @@ defaults:
jobs: jobs:
deploy: deploy:
name: Deploy to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} name: Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }}
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-open runs-on: ubuntu-latest-16-cores-open
environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }} environment: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }}
concurrency: ${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }} concurrency: dotcom-${{ github.ref == 'refs/heads/production' && 'deploy-production' || github.ref }}
steps: steps:
- name: Notify initial start - name: Notify initial start
@ -29,14 +29,7 @@ jobs:
if: github.ref == 'refs/heads/production' if: github.ref == 'refs/heads/production'
with: with:
webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
content: 'Preparing ${{ env.TLDRAW_ENV }} deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}' content: 'Preparing ${{ env.TLDRAW_ENV }} dotcom deploy: ${{ github.event.head_commit.message }} by ${{ github.event.head_commit.author.name }}'
component: |
{
"type": 2,
"style": 5,
"label": "Open in GitHub",
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
- name: Check out code - name: Check out code
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -49,7 +42,7 @@ jobs:
run: yarn build-types run: yarn build-types
- name: Deploy - name: Deploy
run: yarn tsx scripts/deploy.ts run: yarn tsx scripts/deploy-dotcom.ts
env: env:
RELEASE_COMMIT_HASH: ${{ github.sha }} RELEASE_COMMIT_HASH: ${{ github.sha }}
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}

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

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

View file

@ -0,0 +1,38 @@
# bemo-worker
```
⠀⠀⠀⠀⠀⠀⢤⡴⠶⠷⠶⠶⠾⠷⠻⠶⠷⠿⠾⠶⠷⠿⠳⠷⠾⠾⠷⠷⠾⠾⠷⠿⠶⠿⠞⠷⠶⠷⢦⣤⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢠⡼⠟⠋⠤⣤⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠛⠻⣤⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠀⠁⡀⢀⠉⠙⠠⣄⣀⠀⠀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣘⣛⣧⣤⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠐⠠⠀⣠⣤⡘⠀⠌⡈⣷⡶⠛⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠛⠙⠋⠙⠉⠋⠛⠋⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⢐⣶⣶⠙⠋⠃⣴⣮⠁⣿⠀⠀⠀⠀⢠⣄⣤⣠⣄⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣠⣤⣤⣤⣄⣤⣠⣄⣠⡄⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡗⢸⠏⢁⡶⣶⡶⠏⠉⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠐⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠰⣿⡾⠁⠉⠀⣾⣿⠄⣿⠀⠀⠂⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡧⠐⠉⡁⣰⣶⡆⠉⠉⠀⣿⠀⠀⡀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢈⠐⠀⠀⠉⡀⢀⠁⠂⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠛⣤⡼⠋⠀⠀⠀⠀⠀⠀⠀⠀⠛⢣⣤⠛⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⣏⢠⣷⣾⣾⣤⣠⡀⠂⡁⣿⠀⠀⠈⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠶⣆⣀⣀⣰⠶⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⢸⣿⣿⣿⢿⣿⡃⠄⡀⣿⠀⠀⠐⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠸⣟⣿⣻⣿⣿⡄⠀⠄⣿⠀⠀⠠⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣤⣿⣿⣤
⠀⠀⠀⠀⢸⡗⠰⣿⣿⣿⣿⣾⠂⠄⠂⣿⠀⠀⢀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣯
⠀⠀⠀⠀⢸⡏⠘⠉⣿⣿⣿⡿⠆⠀⡁⣿⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⣧
⠀⠀⠀⠀⢸⣇⠨⣿⣿⣿⣿⣿⡁⠂⠄⣿⠀⠀⠂⠸⢧⣠⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⡼⠇⠀⠈⣿⠀⠀⠀⠀⠀⠀⣿⠀⢸⡿
⠀⠀⠀⠀⢸⡧⠘⠉⠙⠿⠻⠛⠂⢀⠂⣿⠀⠀⢀⠀⠀⢁⢈⡈⣁⢈⡁⣈⢁⡈⢁⠈⠁⠁⠈⠈⠀⠁⠈⠁⠉⠈⠁⠈⠀⠁⠀⠀⠠⣿⣤⠀⠀⠀⠀⠀⣿⡀⣸⣿
⠀⠀⠀⠀⢸⣇⣠⣢⣷⣶⣶⣦⡑⠀⠄⣿⠀⠀⠀⢰⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⠀⠀⠠⠀⠀⢀⣶⣶⠀⠀⠄⠐⠀⠐⣿⣼⠟⢣⣤⣤⠟⠁⣰⠿⠀
⠀⣀⣰⣶⠾⠛⠛⠙⠛⠉⣿⣿⡀⠐⡀⣿⠀⠀⠄⠘⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠉⠀⠀⢀⠠⠀⠘⠛⠛⠁⠀⠀⠀⠄⠈⣿⠙⠶⢧⣀⣀⣰⡾⠋⠀⠀
⣶⠋⠉⣘⣟⢿⣻⣟⡻⠻⠿⣿⠀⠄⡀⣿⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠈⠀⠀⠀⡀⠀⠀⠀⡀⠐⠀⢀⠀⣿⠀⠀⠈⠉⠉⠉⠁⠀⠀⠀
⠛⣦⣤⣤⣤⣤⣤⣤⣤⣤⠀⢹⣟⠀⡀⣿⠀⠀⠈⢀⣀⣻⡏⠉⣇⣀⡐⠀⠀⠄⠀⠀⠂⠀⢀⠀⣠⡼⠧⣄⠀⣠⣦⠄⠀⢀⠠⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠐⠠⠀⠛⠛⡀⠄⡀⣿⠀⠀⠠⢼⣇⣀⡀⢀⣀⣸⠇⠀⠀⠀⠀⠂⠀⠠⠀⠘⠛⠒⠒⠚⠻⣿⣂⠼⡇⠀⠀⢀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢸⡇⠠⠁⠂⠡⠈⡀⠄⠂⠄⣿⠀⠀⠀⠀⠀⠹⢇⣠⡏⠀⠀⠀⠀⠁⠀⡀⠐⠀⠀⠀⣠⣶⣿⣿⣷⣬⡉⠁⠀⠀⢀⠀⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠸⢷⣄⠂⣁⠂⠡⢀⠐⡈⠀⣿⠀⠀⠈⠀⠀⢂⣀⡠⠀⠀⠐⠀⠁⡀⠄⠀⠀⠀⠐⠀⣿⣿⣿⣿⣷⣿⡇⠀⠀⠠⠀⠀⠠⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠘⢳⣤⡄⢁⠂⠐⡀⠁⣿⠀⠀⢀⠘⠛⠛⠛⠃⠀⠘⠛⠛⠛⠛⠀⠀⠀⠁⠀⠀⠈⠻⠿⠿⠿⠋⠀⠀⢀⠀⠀⠐⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⡶⢦⣀⣄⣿⠀⠀⡀⠀⠠⠀⠀⠀⡀⠀⢀⠀⢀⠀⠀⡀⠂⠀⠈⠀⡀⠀⠀⠀⠀⠀⠀⠄⠀⠀⠈⠀⢀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠘⠛⢻⣿⣤⡀⣁⣀⡀⣀⢂⣀⣁⣀⣈⡀⣈⣀⣀⣁⣀⡐⣀⣀⢁⡀⣂⢀⣀⢂⣀⣀⢀⢂⣀⣠⣼⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠛⠛⠛⠛⢻⣿⣻⣿⣛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⣻⣟⣿⣿⣿⡛⠛⠛⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⡿⣿⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣻⣿⣿⣿⣯⣷⡗⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⣿⢻⣧⠀⠀⠀⠀⠀⠀⠀⠀⢰⡞⣿⣿⡶⣯⣽⡿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⢸⣿⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣏⣿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣶⡟⢩⣟⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠿⣧⣾⣿⣶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
```

View file

@ -0,0 +1,49 @@
{
"name": "@tldraw/bemo-worker",
"description": "A tiny little drawing app (merge server).",
"version": "2.0.0-alpha.11",
"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 ../../scripts/workers/dev.ts",
"test-ci": "lazy inherit",
"test": "yarn run -T jest",
"test-coverage": "lazy inherit",
"check-bundle-size": "yarn run -T tsx ../../scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"dependencies": {
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/tlsync": "workspace:*",
"@tldraw/utils": "workspace:*",
"itty-router": "^4.0.13",
"nanoid": "4.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"toucan-js": "^3.4.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"esbuild": "^0.21.5",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^5.3.3",
"wrangler": "3.62.0"
},
"jest": {
"preset": "config/jest/node",
"moduleNameMapper": {
"^~(.*)": "<rootDir>/src/$1"
},
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|escape-string-regexp)/)"
]
}
}

View file

@ -0,0 +1,38 @@
import { DurableObject } from 'cloudflare:workers'
import { Router } from 'itty-router'
import { createSentry } from './sentry'
import { Environment } from './types'
export class BemoDO extends DurableObject<Environment> {
private reportError(e: unknown, request?: Request) {
const sentry = createSentry(this.ctx, this.env, request)
console.error(e)
// eslint-disable-next-line deprecation/deprecation
sentry.captureException(e)
}
private readonly router = Router()
.get('/do', async () => {
return Response.json({ message: 'Hello from a durable object!' })
})
.get('/do/error', async () => {
this.doAnError()
})
.all('*', async () => new Response('Not found', { status: 404 }))
private doAnError() {
throw new Error('this is an error from a DO')
}
override async fetch(request: Request<unknown, CfProperties<unknown>>): Promise<Response> {
try {
return await this.router.handle(request)
} catch (error) {
this.reportError(error, request)
return new Response('Something went wrong', {
status: 500,
statusText: 'Internal Server Error',
})
}
}
}

View file

@ -0,0 +1,21 @@
import { Toucan } from 'toucan-js'
import { Environment } from './types'
interface Context {
waitUntil: ExecutionContext['waitUntil']
request?: Request
}
export function createSentry(ctx: Context, env: Environment, request?: Request) {
return new Toucan({
dsn: env.SENTRY_DSN,
release: `${env.WORKER_NAME}.${env.CF_VERSION_METADATA.id}`,
environment: env.WORKER_NAME,
context: ctx,
request,
requestDataOptions: {
allowedHeaders: ['user-agent'],
allowedSearchParams: /(.*)/,
},
})
}

View file

@ -0,0 +1,12 @@
import { BemoDO } from './BemoDO'
export interface Environment {
// bindings
BEMO_DO: DurableObjectNamespace<BemoDO>
TLDRAW_ENV: string | undefined
SENTRY_DSN: string | undefined
IS_LOCAL: string | undefined
WORKER_NAME: string | undefined
CF_VERSION_METADATA: WorkerVersionMetadata
}

View file

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

View file

@ -0,0 +1,37 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
import { WorkerEntrypoint } from 'cloudflare:workers'
import { Router, createCors } from 'itty-router'
import { createSentry } from './sentry'
import { Environment } from './types'
export { BemoDO } from './BemoDO'
const cors = createCors({ origins: ['*'] })
export default class Worker extends WorkerEntrypoint<Environment> {
private readonly router = Router()
.all('*', cors.preflight)
.get('/do', async (request) => {
const bemo = this.env.BEMO_DO.get(this.env.BEMO_DO.idFromName('bemo-do'))
const message = await (await bemo.fetch(request)).json()
return Response.json(message)
})
.all('*', async () => new Response('Not found', { status: 404 }))
override async fetch(request: Request): Promise<Response> {
try {
return await this.router.handle(request).then(cors.corsify)
} catch (error) {
const sentry = createSentry(this.ctx, this.env, request)
console.error(error)
// eslint-disable-next-line deprecation/deprecation
sentry.captureException(error)
return new Response('Something went wrong', {
status: 500,
statusText: 'Internal Server Error',
})
}
}
}

View file

@ -0,0 +1,26 @@
{
"extends": "../../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/tlschema"
},
{
"path": "../../packages/tlsync"
},
{
"path": "../../packages/utils"
}
]
}

View file

@ -0,0 +1,79 @@
main = "src/worker.ts"
compatibility_date = "2024-06-25"
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" },
]
#################### 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"

View file

@ -23,7 +23,7 @@
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"@types/ws": "^8.5.9", "@types/ws": "^8.5.9",
"lazyrepo": "0.0.0-alpha.27", "lazyrepo": "0.0.0-alpha.27",
"wrangler": "3.61.0" "wrangler": "3.62.0"
}, },
"jest": { "jest": {
"preset": "config/jest/node", "preset": "config/jest/node",

View file

@ -26,6 +26,9 @@ preview_bucket_name = 'uploads-preview'
type = "analytics_engine" type = "analytics_engine"
name = "MEASURE" name = "MEASURE"
[[env.staging.analytics_engine_datasets]]
binding = "MEASURE"
# production settings # production settings
[env.production] [env.production]
@ -45,7 +48,13 @@ preview_bucket_name = 'uploads-preview'
type = "analytics_engine" type = "analytics_engine"
name = "MEASURE" name = "MEASURE"
[[env.production.analytics_engine_datasets]]
binding = "MEASURE"
[[env.preview.r2_buckets]] [[env.preview.r2_buckets]]
binding = 'UPLOADS' binding = 'UPLOADS'
bucket_name = 'uploads' bucket_name = 'uploads'
preview_bucket_name = 'uploads-preview' preview_bucket_name = 'uploads-preview'
[[env.preview.analytics_engine_datasets]]
binding = "MEASURE"

View file

@ -7,18 +7,15 @@
"name": "tldraw GB Ltd.", "name": "tldraw GB Ltd.",
"email": "hello@tldraw.com" "email": "hello@tldraw.com"
}, },
"main": "./src/lib/worker.ts", "main": "./src/worker.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here", "/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [], "files": [],
"scripts": { "scripts": {
"dev": "concurrently --kill-others yarn:dev-cron yarn:dev-wrangler yarn:report-size", "dev": "yarn run -T tsx ../../scripts/workers/dev.ts",
"dev-cron": "yarn run -T tsx ./scripts/cron.ts",
"dev-wrangler": "yarn run -T tsx ./scripts/dev-wrap.ts",
"report-size": "node scripts/report-size.js",
"test-ci": "lazy inherit", "test-ci": "lazy inherit",
"test": "yarn run -T jest", "test": "yarn run -T jest",
"test-coverage": "lazy inherit", "test-coverage": "lazy inherit",
"check-bundle-size": "yarn run -T tsx ../../scripts/check-worker-bundle.ts --entry src/lib/worker.ts --size-limit-bytes 350000", "check-bundle-size": "yarn run -T tsx ../../scripts/check-worker-bundle.ts --entry src/worker.ts --size-limit-bytes 350000",
"lint": "yarn run -T tsx ../../scripts/lint.ts" "lint": "yarn run -T tsx ../../scripts/lint.ts"
}, },
"dependencies": { "dependencies": {
@ -29,21 +26,18 @@
"@tldraw/tlschema": "workspace:*", "@tldraw/tlschema": "workspace:*",
"@tldraw/tlsync": "workspace:*", "@tldraw/tlsync": "workspace:*",
"@tldraw/utils": "workspace:*", "@tldraw/utils": "workspace:*",
"esbuild": "^0.18.4",
"itty-router": "^4.0.13", "itty-router": "^4.0.13",
"nanoid": "4.0.2", "nanoid": "4.0.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"strip-ansi": "^7.1.0", "toucan-js": "^3.4.0"
"toucan-js": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"concurrently": "^8.2.2", "esbuild": "^0.21.5",
"lazyrepo": "0.0.0-alpha.27", "lazyrepo": "0.0.0-alpha.27",
"picocolors": "^1.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"wrangler": "3.61.0" "wrangler": "3.62.0"
}, },
"jest": { "jest": {
"preset": "config/jest/node", "preset": "config/jest/node",

View file

@ -1,10 +0,0 @@
const CRON_INTERVAL_MS = 10_000
setInterval(async () => {
try {
await fetch('http://127.0.0.1:8787/__scheduled')
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error triggering cron:', err)
}
}, CRON_INTERVAL_MS)

View file

@ -1,45 +0,0 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const { spawn } = require('child_process')
const colors = require('picocolors')
class Monitor {
lastLineTime = Date.now()
nextTick = 0
size = 0
start() {
console.log('Spawning')
const proc = spawn('npx', ['esbuild', 'src/lib/worker.ts', '--bundle', '--minify', '--watch'])
// listen for lines on stdin
proc.stdout.on('data', (data) => {
this.size += data.length
this.lastLineTime = Date.now()
clearTimeout(this.nextTick)
this.nextTick = setTimeout(() => {
console.log(
colors.bold(colors.yellow('dotcom-worker')),
'is roughly',
colors.bold(colors.cyan(Math.floor(this.size / 1024) + 'kb')),
'(minified)\n'
)
this.size = 0
}, 10)
})
process.on('SIGINT', () => {
console.log('Int')
proc.kill()
})
process.on('SIGTERM', () => {
console.log('Term')
proc.kill()
})
process.on('exit', () => {
console.log('Exiting')
proc.kill()
})
}
}
new Monitor().start()

View file

@ -18,7 +18,7 @@ import {
} from '@tldraw/tlsync' } from '@tldraw/tlsync'
import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils' import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils'
import { IRequest, Router } from 'itty-router' import { IRequest, Router } from 'itty-router'
import Toucan from 'toucan-js' import { Toucan } from 'toucan-js'
import { AlarmScheduler } from './AlarmScheduler' import { AlarmScheduler } from './AlarmScheduler'
import { PERSIST_INTERVAL_MS } from './config' import { PERSIST_INTERVAL_MS } from './config'
import { getR2KeyForRoom } from './r2' import { getR2KeyForRoom } from './r2'
@ -218,21 +218,17 @@ export class TLDrawDurableObject {
const sentry = new Toucan({ const sentry = new Toucan({
dsn: this.sentryDSN, dsn: this.sentryDSN,
request: req, request: req,
requestDataOptions: {
allowedHeaders: ['user-agent'], allowedHeaders: ['user-agent'],
allowedSearchParams: /(.*)/, allowedSearchParams: /(.*)/,
},
}) })
try { try {
return await this.router.handle(req).catch((err) => { return await this.router.handle(req)
console.error(err)
sentry.captureException(err)
return new Response('Something went wrong', {
status: 500,
statusText: 'Internal Server Error',
})
})
} catch (err) { } catch (err) {
console.error(err)
// eslint-disable-next-line deprecation/deprecation
sentry.captureException(err) sentry.captureException(err)
return new Response('Something went wrong', { return new Response('Something went wrong', {
status: 500, status: 500,

View file

@ -7,7 +7,7 @@ import {
ROOM_PREFIX, ROOM_PREFIX,
} from '@tldraw/dotcom-shared' } from '@tldraw/dotcom-shared'
import { Router, createCors } from 'itty-router' import { Router, createCors } from 'itty-router'
import Toucan from 'toucan-js' import { Toucan } from 'toucan-js'
import { createRoom } from './routes/createRoom' import { createRoom } from './routes/createRoom'
import { createRoomSnapshot } from './routes/createRoomSnapshot' import { createRoomSnapshot } from './routes/createRoomSnapshot'
import { forwardRoomRequest } from './routes/forwardRoomRequest' import { forwardRoomRequest } from './routes/forwardRoomRequest'
@ -51,14 +51,17 @@ const Worker = {
dsn: env.SENTRY_DSN, dsn: env.SENTRY_DSN,
context, // Includes 'waitUntil', which is essential for Sentry logs to be delivered. Modules workers do not include 'request' in context -- you'll need to set it separately. context, // Includes 'waitUntil', which is essential for Sentry logs to be delivered. Modules workers do not include 'request' in context -- you'll need to set it separately.
request, // request is not included in 'context', so we set it here. request, // request is not included in 'context', so we set it here.
requestDataOptions: {
allowedHeaders: ['user-agent'], allowedHeaders: ['user-agent'],
allowedSearchParams: /(.*)/, allowedSearchParams: /(.*)/,
},
}) })
return router return router
.handle(request, env, context) .handle(request, env, context)
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)
// eslint-disable-next-line deprecation/deprecation
sentry.captureException(err) sentry.captureException(err)
return new Response('Something went wrong', { return new Response('Something went wrong', {

View file

@ -1,4 +1,4 @@
main = "src/lib/worker.ts" main = "src/worker.ts"
compatibility_date = "2024-06-19" compatibility_date = "2024-06-19"
[dev] [dev]

View file

@ -22,6 +22,7 @@ Sentry.init({
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
release: sentryReleaseName, release: sentryReleaseName,
environment: env, environment: env,
// eslint-disable-next-line deprecation/deprecation
integrations: [new ExtraErrorData({ depth: 10 }) as any], integrations: [new ExtraErrorData({ depth: 10 }) as any],
// ... // ...
// Note: if you want to override the automatic release value, do not set a // Note: if you want to override the automatic release value, do not set a

View file

@ -16,8 +16,8 @@
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"@types/node": "~20.11", "@types/node": "~20.11",
"discord-api-types": "^0.37.67", "discord-api-types": "^0.37.67",
"esbuild": "^0.18.4", "esbuild": "^0.21.5",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"wrangler": "3.61.0" "wrangler": "3.62.0"
} }
} }

View file

@ -41,7 +41,7 @@
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"create-serve": "1.0.1", "create-serve": "1.0.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"esbuild": "^0.18.4", "esbuild": "^0.21.5",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"lazyrepo": "0.0.0-alpha.27", "lazyrepo": "0.0.0-alpha.27",
"react": "^18.2.0", "react": "^18.2.0",

View file

@ -137,7 +137,7 @@
"@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0", "@typescript-eslint/parser": "^5.57.0",
"assert": "^2.0.0", "assert": "^2.0.0",
"esbuild": "^0.18.4", "esbuild": "^0.21.5",
"eslint": "^8.37.0", "eslint": "^8.37.0",
"fs-extra": "^11.1.0", "fs-extra": "^11.1.0",
"lazyrepo": "0.0.0-alpha.27", "lazyrepo": "0.0.0-alpha.27",

View file

@ -37,7 +37,7 @@
"clean": "scripts/clean.sh", "clean": "scripts/clean.sh",
"postinstall": "husky install && yarn refresh-assets", "postinstall": "husky install && yarn refresh-assets",
"refresh-assets": "lazy refresh-assets", "refresh-assets": "lazy refresh-assets",
"dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw'", "dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw' --filter='apps/bemo-worker'",
"dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'", "dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'",
"dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker}' --filter='packages/tldraw'", "dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker}' --filter='packages/tldraw'",
"dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'", "dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'",
@ -120,6 +120,7 @@
"@sentry/cli": "^2.25.0", "@sentry/cli": "^2.25.0",
"@yarnpkg/types": "^4.0.0", "@yarnpkg/types": "^4.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"esbuild": "^0.21.5",
"mime": "^4.0.3", "mime": "^4.0.3",
"purgecss": "^5.0.0", "purgecss": "^5.0.0",
"svgo": "^3.0.2" "svgo": "^3.0.2"

View file

@ -41,7 +41,7 @@ export const assert: (value: unknown, message?: string) => asserts value = omitF
/** @internal */ /** @internal */
export const assertExists = omitFromStackTrace(<T>(value: T, message?: string): NonNullable<T> => { export const assertExists = omitFromStackTrace(<T>(value: T, message?: string): NonNullable<T> => {
// note that value == null is equivilent to value === null || value === undefined // note that value == null is equivalent to value === null || value === undefined
if (value == null) { if (value == null) {
throw new Error(message ?? 'value must be defined') throw new Error(message ?? 'value must be defined')
} }

120
scripts/deploy-bemo.ts Normal file
View file

@ -0,0 +1,120 @@
import assert from 'assert'
import { readFileSync } from 'fs'
import path, { join } from 'path'
import toml from 'toml'
import {
createGithubDeployment,
getDeployInfo,
setWranglerPreviewConfig,
wranglerDeploy,
} from './lib/deploy'
import { Discord } from './lib/discord'
import { exec } from './lib/exec'
import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog'
const workerDir = path.relative(process.cwd(), path.resolve(__dirname, '../apps/bemo-worker'))
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
// `env` instead. This makes sure that all required env vars are present.
const env = makeEnv([
'CLOUDFLARE_ACCOUNT_ID',
'CLOUDFLARE_API_TOKEN',
'DISCORD_DEPLOY_WEBHOOK_URL',
'RELEASE_COMMIT_HASH',
'TLDRAW_ENV',
'GH_TOKEN',
'SENTRY_AUTH_TOKEN',
'SENTRY_BEMO_WORKER_DSN',
])
const discord = new Discord({
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
shouldNotify: env.TLDRAW_ENV === 'production',
totalSteps: 3,
})
const { previewId, sha } = getDeployInfo()
async function main() {
assert(
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
'TLDRAW_ENV must be staging or production or preview'
)
await discord.message(`--- **${env.TLDRAW_ENV} bemo deploy pre-flight** ---`)
await discord.step('setting up deploy', async () => {
await exec('yarn', ['lazy', 'prebuild'])
})
await discord.step('cloudflare deploy dry run', async () => {
await deployBemoWorker({ dryRun: true })
})
// --- point of no return! do the deploy for real --- //
await discord.message(`--- **pre-flight complete, starting real bemo deploy** ---`)
// 2. deploy the cloudflare workers:
await discord.step('deploying bemo-worker to cloudflare', async () => {
await deployBemoWorker({ dryRun: false })
})
// we set the domain in the wrangler.toml file since it's managed by cloudflare
const domain = toml.parse(readFileSync(join(workerDir, 'wrangler.toml')).toString())?.env[
env.TLDRAW_ENV
]?.routes?.[0]?.pattern
if (!domain) {
throw new Error('Could not find the domain in wrangler.toml')
}
const deploymentUrl = `https://${domain}`
nicelog('Creating deployment for', deploymentUrl)
await createGithubDeployment(env, {
app: 'bemo',
deploymentUrl,
sha,
})
await discord.message(`**Deploy complete!**`)
}
let didUpdateBemoWorker = false
async function deployBemoWorker({ dryRun }: { dryRun: boolean }) {
const workerId = `${previewId ?? env.TLDRAW_ENV}-bemo`
if (previewId && !didUpdateBemoWorker) {
await setWranglerPreviewConfig(workerDir, {
name: workerId,
customDomain: `${previewId}-demo.tldraw.xyz`,
})
didUpdateBemoWorker = true
}
await wranglerDeploy({
location: workerDir,
dryRun,
env: env.TLDRAW_ENV,
vars: {
WORKER_NAME: workerId,
TLDRAW_ENV: env.TLDRAW_ENV,
SENTRY_DSN: env.SENTRY_BEMO_WORKER_DSN,
},
sentry: {
authToken: env.SENTRY_AUTH_TOKEN,
project: 'bemo-worker',
},
})
}
main().catch(async (err) => {
// don't notify discord on preview builds
if (env.TLDRAW_ENV !== 'preview') {
await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
always: true,
})
}
console.error(err)
process.exit(1)
})

View file

@ -1,12 +1,18 @@
import * as github from '@actions/github'
import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage' import { Upload } from '@aws-sdk/lib-storage'
import assert from 'assert' import assert from 'assert'
import { execSync } from 'child_process' import { execSync } from 'child_process'
import { appendFileSync, existsSync, readdirSync, writeFileSync } from 'fs' import { existsSync, readdirSync, writeFileSync } from 'fs'
import path, { join } from 'path' import path from 'path'
import { PassThrough } from 'stream' import { PassThrough } from 'stream'
import * as tar from 'tar' import * as tar from 'tar'
import {
createGithubDeployment,
getDeployInfo,
setWranglerPreviewConfig,
wranglerDeploy,
} from './lib/deploy'
import { Discord } from './lib/discord'
import { exec } from './lib/exec' import { exec } from './lib/exec'
import { makeEnv } from './lib/makeEnv' import { makeEnv } from './lib/makeEnv'
import { nicelog } from './lib/nicelog' import { nicelog } from './lib/nicelog'
@ -48,26 +54,13 @@ const env = makeEnv([
'R2_ACCESS_KEY_SECRET', 'R2_ACCESS_KEY_SECRET',
]) ])
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1] const discord = new Discord({
function getPreviewId() { webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
if (env.TLDRAW_ENV !== 'preview') return undefined shouldNotify: env.TLDRAW_ENV === 'production',
if (githubPrNumber) return `pr-${githubPrNumber}` totalSteps: 8,
return process.env.TLDRAW_PREVIEW_ID ?? undefined })
}
const previewId = getPreviewId()
if (env.TLDRAW_ENV === 'preview' && !previewId) {
throw new Error(
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
)
}
const sha =
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
// while the actual commit we want to create the deployment for is the 'head' of the PR.
github.context.eventName === 'pull_request'
? github.context.payload.pull_request?.head.sha
: github.context.sha
const { previewId, sha } = getDeployInfo()
const sentryReleaseName = `${env.TLDRAW_ENV}-${previewId ? previewId + '-' : ''}-${sha}` const sentryReleaseName = `${env.TLDRAW_ENV}-${previewId ? previewId + '-' : ''}-${sha}`
async function main() { async function main() {
@ -76,9 +69,9 @@ async function main() {
'TLDRAW_ENV must be staging or production or preview' 'TLDRAW_ENV must be staging or production or preview'
) )
await discordMessage(`--- **${env.TLDRAW_ENV} deploy pre-flight** ---`) await discord.message(`--- **${env.TLDRAW_ENV} dotcom deploy pre-flight** ---`)
await discordStep('[1/7] setting up deploy', async () => { await discord.step('setting up deploy', async () => {
// make sure the tldraw .css files are built: // make sure the tldraw .css files are built:
await exec('yarn', ['lazy', 'prebuild']) await exec('yarn', ['lazy', 'prebuild'])
@ -88,14 +81,14 @@ async function main() {
// deploy pre-flight steps: // deploy pre-flight steps:
// 1. get the dotcom app ready to go (env vars and pre-build) // 1. get the dotcom app ready to go (env vars and pre-build)
await discordStep('[2/7] building dotcom app', async () => { await discord.step('building dotcom app', async () => {
await createSentryRelease() await createSentryRelease()
await prepareDotcomApp() await prepareDotcomApp()
await uploadSourceMaps() await uploadSourceMaps()
await coalesceWithPreviousAssets(`${dotcom}/.vercel/output/static/assets`) await coalesceWithPreviousAssets(`${dotcom}/.vercel/output/static/assets`)
}) })
await discordStep('[3/7] cloudflare deploy dry run', async () => { await discord.step('cloudflare deploy dry run', async () => {
await deployAssetUploadWorker({ dryRun: true }) await deployAssetUploadWorker({ dryRun: true })
await deployHealthWorker({ dryRun: true }) await deployHealthWorker({ dryRun: true })
await deployTlsyncWorker({ dryRun: true }) await deployTlsyncWorker({ dryRun: true })
@ -103,22 +96,22 @@ async function main() {
// --- point of no return! do the deploy for real --- // // --- point of no return! do the deploy for real --- //
await discordMessage(`--- **pre-flight complete, starting real deploy** ---`) await discord.message(`--- **pre-flight complete, starting real dotcom deploy** ---`)
// 2. deploy the cloudflare workers: // 2. deploy the cloudflare workers:
await discordStep('[4/7] deploying asset uploader to cloudflare', async () => { await discord.step('deploying asset uploader to cloudflare', async () => {
await deployAssetUploadWorker({ dryRun: false }) await deployAssetUploadWorker({ dryRun: false })
}) })
await discordStep('[5/7] deploying multiplayer worker to cloudflare', async () => { await discord.step('deploying multiplayer worker to cloudflare', async () => {
await deployTlsyncWorker({ dryRun: false }) await deployTlsyncWorker({ dryRun: false })
}) })
await discordStep('[6/7] deploying health worker to cloudflare', async () => { await discord.step('deploying health worker to cloudflare', async () => {
await deployHealthWorker({ dryRun: false }) await deployHealthWorker({ dryRun: false })
}) })
// 3. deploy the pre-build dotcom app: // 3. deploy the pre-build dotcom app:
const { deploymentUrl, inspectUrl } = await discordStep( const { deploymentUrl, inspectUrl } = await discord.step(
'[7/7] deploying dotcom app to vercel', 'deploying dotcom app to vercel',
async () => { async () => {
return await deploySpa() return await deploySpa()
} }
@ -128,7 +121,7 @@ async function main() {
if (previewId) { if (previewId) {
const aliasDomain = `${previewId}-preview-deploy.tldraw.com` const aliasDomain = `${previewId}-preview-deploy.tldraw.com`
await discordStep('[8/7] aliasing preview deployment', async () => { await discord.step('aliasing preview deployment', async () => {
await vercelCli('alias', ['set', deploymentUrl, aliasDomain]) await vercelCli('alias', ['set', deploymentUrl, aliasDomain])
}) })
@ -136,9 +129,14 @@ async function main() {
} }
nicelog('Creating deployment for', deploymentUrl) nicelog('Creating deployment for', deploymentUrl)
await createGithubDeployment(deploymentAlias ?? deploymentUrl, inspectUrl) await createGithubDeployment(env, {
app: 'dotcom',
deploymentUrl: deploymentAlias ?? deploymentUrl,
inspectUrl,
sha,
})
await discordMessage(`**Deploy complete!**`) await discord.message(`**Deploy complete!**`)
} }
async function prepareDotcomApp() { async function prepareDotcomApp() {
@ -167,21 +165,15 @@ async function prepareDotcomApp() {
let didUpdateAssetUploadWorker = false let didUpdateAssetUploadWorker = false
async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) { async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) {
if (previewId && !didUpdateAssetUploadWorker) { if (previewId && !didUpdateAssetUploadWorker) {
appendFileSync( await setWranglerPreviewConfig(assetUpload, { name: `${previewId}-tldraw-assets` })
join(assetUpload, 'wrangler.toml'),
`
[env.preview]
name = "${previewId}-tldraw-assets"`
)
didUpdateAssetUploadWorker = true didUpdateAssetUploadWorker = true
} }
await exec('yarn', ['wrangler', 'deploy', dryRun ? '--dry-run' : null, '--env', env.TLDRAW_ENV], {
pwd: assetUpload, await wranglerDeploy({
env: { location: assetUpload,
NODE_ENV: 'production', dryRun,
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts env: env.TLDRAW_ENV,
CI: '1', vars: {},
},
}) })
} }
@ -189,79 +181,39 @@ let didUpdateTlsyncWorker = false
async function deployTlsyncWorker({ dryRun }: { dryRun: boolean }) { async function deployTlsyncWorker({ dryRun }: { dryRun: boolean }) {
const workerId = `${previewId ?? env.TLDRAW_ENV}-tldraw-multiplayer` const workerId = `${previewId ?? env.TLDRAW_ENV}-tldraw-multiplayer`
if (previewId && !didUpdateTlsyncWorker) { if (previewId && !didUpdateTlsyncWorker) {
appendFileSync( await setWranglerPreviewConfig(worker, { name: workerId })
join(worker, 'wrangler.toml'),
`
[env.preview]
name = "${previewId}-tldraw-multiplayer"`
)
didUpdateTlsyncWorker = true didUpdateTlsyncWorker = true
} }
await exec( await wranglerDeploy({
'yarn', location: worker,
[ dryRun,
'wrangler', env: env.TLDRAW_ENV,
'deploy', vars: {
dryRun ? '--dry-run' : null, SUPABASE_URL: env.SUPABASE_LITE_URL,
'--env', SUPABASE_KEY: env.SUPABASE_LITE_ANON_KEY,
env.TLDRAW_ENV, SENTRY_DSN: env.WORKER_SENTRY_DSN,
'--var', TLDRAW_ENV: env.TLDRAW_ENV,
`SUPABASE_URL:${env.SUPABASE_LITE_URL}`, APP_ORIGIN: env.APP_ORIGIN,
'--var', WORKER_NAME: workerId,
`SUPABASE_KEY:${env.SUPABASE_LITE_ANON_KEY}`,
'--var',
`SENTRY_DSN:${env.WORKER_SENTRY_DSN}`,
'--var',
`TLDRAW_ENV:${env.TLDRAW_ENV}`,
'--var',
`APP_ORIGIN:${env.APP_ORIGIN}`,
'--var',
`WORKER_NAME:${workerId}`,
],
{
pwd: worker,
env: {
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
}, },
} })
)
} }
let didUpdateHealthWorker = false let didUpdateHealthWorker = false
async function deployHealthWorker({ dryRun }: { dryRun: boolean }) { async function deployHealthWorker({ dryRun }: { dryRun: boolean }) {
if (previewId && !didUpdateHealthWorker) { if (previewId && !didUpdateHealthWorker) {
appendFileSync( await setWranglerPreviewConfig(healthWorker, { name: `${previewId}-tldraw-health` })
join(healthWorker, 'wrangler.toml'),
`
[env.preview]
name = "${previewId}-tldraw-health"`
)
didUpdateHealthWorker = true didUpdateHealthWorker = true
} }
await exec( await wranglerDeploy({
'yarn', location: healthWorker,
[ dryRun,
'wrangler', env: env.TLDRAW_ENV,
'deploy', vars: {
dryRun ? '--dry-run' : null, DISCORD_HEALTH_WEBHOOK_URL: env.DISCORD_HEALTH_WEBHOOK_URL,
'--env', HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: env.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH,
env.TLDRAW_ENV,
'--var',
`DISCORD_HEALTH_WEBHOOK_URL:${env.DISCORD_HEALTH_WEBHOOK_URL}`,
'--var',
`HEALTH_WORKER_UPDOWN_WEBHOOK_PATH:${env.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH}`,
],
{
pwd: healthWorker,
env: {
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
}, },
} })
)
} }
type ExecOpts = NonNullable<Parameters<typeof exec>[2]> type ExecOpts = NonNullable<Parameters<typeof exec>[2]>
@ -293,61 +245,6 @@ async function vercelCli(command: string, args: string[], opts?: ExecOpts) {
) )
} }
function sanitizeVariables(errorOutput: string): string {
const regex = /(--var\s+(\w+):[^ \n]+)/g
const sanitizedOutput = errorOutput.replace(regex, (_, match) => {
const [variable] = match.split(':')
return `${variable}:*`
})
return sanitizedOutput
}
async function discord(method: string, url: string, body: unknown): Promise<any> {
const response = await fetch(`${env.DISCORD_DEPLOY_WEBHOOK_URL}${url}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`Discord webhook request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
const AT_TEAM_MENTION = '<@&959380625100513310>'
async function discordMessage(content: string, { always = false }: { always?: boolean } = {}) {
const shouldNotify = env.TLDRAW_ENV === 'production' || always
if (!shouldNotify) {
return {
edit: () => {
// noop
},
}
}
const message = await discord('POST', '?wait=true', { content: sanitizeVariables(content) })
return {
edit: async (newContent: string) => {
await discord('PATCH', `/messages/${message.id}`, { content: sanitizeVariables(newContent) })
},
}
}
async function discordStep<T>(content: string, cb: () => Promise<T>): Promise<T> {
const message = await discordMessage(`${content}...`)
try {
const result = await cb()
await message.edit(`${content}`)
return result
} catch (err) {
await message.edit(`${content}`)
throw err
}
}
async function deploySpa(): Promise<{ deploymentUrl: string; inspectUrl: string }> { async function deploySpa(): Promise<{ deploymentUrl: string; inspectUrl: string }> {
// both 'staging' and 'production' are deployed to vercel as 'production' deploys // both 'staging' and 'production' are deployed to vercel as 'production' deploys
// in separate 'projects' // in separate 'projects'
@ -371,32 +268,6 @@ async function deploySpa(): Promise<{ deploymentUrl: string; inspectUrl: string
return { deploymentUrl, inspectUrl } return { deploymentUrl, inspectUrl }
} }
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
async function createGithubDeployment(deploymentUrl: string, inspectUrl: string) {
const client = github.getOctokit(env.GH_TOKEN)
const deployment = await client.rest.repos.createDeployment({
owner: 'tldraw',
repo: 'tldraw',
ref: sha,
payload: { web_url: deploymentUrl },
environment: env.TLDRAW_ENV,
transient_environment: true,
required_contexts: [],
auto_merge: false,
task: 'deploy',
})
await client.rest.repos.createDeploymentStatus({
owner: 'tldraw',
repo: 'tldraw',
deployment_id: (deployment.data as any).id,
state: 'success',
environment_url: deploymentUrl,
log_url: inspectUrl,
})
}
const sentryEnv = { const sentryEnv = {
SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN, SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN,
SENTRY_ORG: 'tldraw', SENTRY_ORG: 'tldraw',
@ -526,7 +397,9 @@ async function coalesceWithPreviousAssets(assetsDir: string) {
main().catch(async (err) => { main().catch(async (err) => {
// don't notify discord on preview builds // don't notify discord on preview builds
if (env.TLDRAW_ENV !== 'preview') { if (env.TLDRAW_ENV !== 'preview') {
await discordMessage(`${AT_TEAM_MENTION} Deploy failed: ${err.stack}`, { always: true }) await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
always: true,
})
} }
console.error(err) console.error(err)
process.exit(1) process.exit(1)

184
scripts/lib/deploy.ts Normal file
View file

@ -0,0 +1,184 @@
import * as github from '@actions/github'
import { readFileSync } from 'fs'
import { appendFile } from 'fs/promises'
import { join } from 'path'
import { env } from 'process'
import toml from 'toml'
import { exec } from './exec'
export function getDeployInfo() {
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
let previewId = process.env.TLDRAW_PREVIEW_ID
if (!previewId && env.TLDRAW_ENV === 'preview') {
if (githubPrNumber) {
previewId = `pr-${githubPrNumber}`
} else {
throw new Error(
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
)
}
}
const sha: string | undefined =
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
// while the actual commit we want to create the deployment for is the 'head' of the PR.
github.context.eventName === 'pull_request'
? github.context.payload.pull_request?.head.sha
: github.context.sha
if (!sha) {
throw new Error('Could not determine the SHA of the commit to deploy')
}
return {
githubPrNumber,
previewId,
sha,
}
}
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
export async function createGithubDeployment(
env: { GH_TOKEN: string; TLDRAW_ENV: string },
{
app,
deploymentUrl,
inspectUrl,
sha,
}: { app: string; deploymentUrl: string; inspectUrl?: string; sha: string }
) {
const client = github.getOctokit(env.GH_TOKEN)
const deployment = await client.rest.repos.createDeployment({
owner: 'tldraw',
repo: 'tldraw',
ref: sha,
payload: { web_url: deploymentUrl },
environment: `${app}-${env.TLDRAW_ENV}`,
transient_environment: env.TLDRAW_ENV === 'preview',
production_environment: env.TLDRAW_ENV === 'production',
required_contexts: [],
auto_merge: false,
task: 'deploy',
})
await client.rest.repos.createDeploymentStatus({
owner: 'tldraw',
repo: 'tldraw',
deployment_id: (deployment.data as any).id,
state: 'success',
environment_url: deploymentUrl,
log_url: inspectUrl,
})
}
/** Deploy a worker to wrangler, returning the deploy ID */
export async function wranglerDeploy({
location,
dryRun,
env,
vars,
sentry,
}: {
location: string
dryRun: boolean
env: string
vars: Record<string, string>
sentry?: {
authToken: string
project: string
release?: string
}
}) {
const varsArray = []
for (const [key, value] of Object.entries(vars)) {
varsArray.push('--var', `${key}:${value}`)
}
const out = await exec(
'yarn',
[
'wrangler',
'deploy',
dryRun ? '--dry-run' : null,
'--env',
env,
'--outdir',
'.wrangler/dist',
...varsArray,
],
{
pwd: location,
env: {
NODE_ENV: 'production',
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
CI: '1',
},
}
)
if (dryRun) return
const versionMatch = out.match(/Current Version ID: (.+)/)
if (!versionMatch) {
throw new Error('Could not find the deploy ID in wrangler output')
}
const workerName = toml.parse(readFileSync(join(location, 'wrangler.toml')).toString())?.env?.[
env
]?.name
if (!workerName) {
throw new Error('Could not find the worker name in wrangler output')
}
if (sentry) {
const release = sentry.release ?? `${workerName}.${versionMatch[1]}`
const sentryEnv = {
SENTRY_AUTH_TOKEN: sentry.authToken,
SENTRY_ORG: 'tldraw',
SENTRY_PROJECT: sentry.project,
}
// create a sentry release:
exec('yarn', ['run', '-T', 'sentry-cli', 'releases', 'new', release], {
pwd: location,
env: sentryEnv,
})
// upload sourcemaps to the release:
exec(
'yarn',
[
'run',
'-T',
'sentry-cli',
'releases',
'files',
release,
'upload-sourcemaps',
'.wrangler/dist',
],
{
pwd: location,
env: sentryEnv,
}
)
}
}
export async function setWranglerPreviewConfig(
location: string,
{ name, customDomain }: { name: string; customDomain?: string }
) {
await appendFile(
join(location, 'wrangler.toml'),
`
[env.preview]
name = "${name}"
${customDomain ? `routes = [ { pattern = "${customDomain}", custom_domain = true} ]` : ''}
`
)
}

70
scripts/lib/discord.ts Normal file
View file

@ -0,0 +1,70 @@
function sanitizeVariables(errorOutput: string): string {
const regex = /(--var\s+(\w+):[^ \n]+)/g
const sanitizedOutput = errorOutput.replace(regex, (_, match) => {
const [variable] = match.split(':')
return `${variable}:*`
})
return sanitizedOutput
}
export class Discord {
static AT_TEAM_MENTION = '<@&959380625100513310>'
constructor(opts: { webhookUrl: string; shouldNotify: boolean; totalSteps?: number }) {
this.webhookUrl = opts.webhookUrl
this.shouldNotify = opts.shouldNotify
this.totalSteps = opts.totalSteps ?? 0
}
webhookUrl: string
shouldNotify: boolean
totalSteps: number
currentStep = 0
private async send(method: string, url: string, body: unknown): Promise<any> {
const response = await fetch(`${this.webhookUrl}${url}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`Discord webhook request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
async message(content: string, { always = false }: { always?: boolean } = {}) {
if (!always && !this.shouldNotify) {
return {
edit: () => {
// noop
},
}
}
const message = await this.send('POST', '?wait=true', { content: sanitizeVariables(content) })
return {
edit: async (newContent: string) => {
await this.send('PATCH', `/messages/${message.id}`, {
content: sanitizeVariables(newContent),
})
},
}
}
async step<T>(content: string, cb: () => Promise<T>): Promise<T> {
this.currentStep++
const message = await this.message(`[${this.currentStep}/${this.totalSteps}] ${content}...`)
try {
const result = await cb()
await message.edit(`${content}`)
return result
} catch (err) {
await message.edit(`${content}`)
throw err
}
}
}

View file

@ -35,7 +35,7 @@
"@typescript-eslint/utils": "^5.59.0", "@typescript-eslint/utils": "^5.59.0",
"ast-types": "^0.14.2", "ast-types": "^0.14.2",
"cross-fetch": "^3.1.5", "cross-fetch": "^3.1.5",
"esbuild": "^0.18.4", "esbuild": "^0.21.5",
"eslint": "^8.37.0", "eslint": "^8.37.0",
"glob": "^8.0.3", "glob": "^8.0.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@ -59,6 +59,7 @@
"ignore": "^5.2.4", "ignore": "^5.2.4",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"tar": "^7.0.1", "tar": "^7.0.1",
"tmp": "^0.2.3" "tmp": "^0.2.3",
"toml": "^3.0.0"
} }
} }

View file

@ -1,14 +1,11 @@
import { ChildProcessWithoutNullStreams, spawn } from 'child_process'
import kleur from 'kleur'
import stripAnsi from 'strip-ansi'
// at the time of writing, workerd will regularly crash with a segfault // at the time of writing, workerd will regularly crash with a segfault
// but the error is not caught by the process, so it will just hang // but the error is not caught by the process, so it will just hang
// this script wraps the process, tailing the logs and restarting the process // this script wraps the process, tailing the logs and restarting the process
// if we encounter the string 'Segmentation fault' // if we encounter the string 'Segmentation fault'
import { ChildProcessWithoutNullStreams, spawn } from 'child_process'
import stripAnsi from 'strip-ansi'
// eslint-disable-next-line no-console
const log = console.log
class MiniflareMonitor { class MiniflareMonitor {
private process: ChildProcessWithoutNullStreams | null = null private process: ChildProcessWithoutNullStreams | null = null
@ -19,7 +16,7 @@ class MiniflareMonitor {
public start(): void { public start(): void {
this.stop() // Ensure any existing process is stopped this.stop() // Ensure any existing process is stopped
log(`Starting wrangler...`) console.log(`Starting wrangler...`)
this.process = spawn(this.command, this.args, { this.process = spawn(this.command, this.args, {
env: { env: {
NODE_ENV: 'development', NODE_ENV: 'development',
@ -42,12 +39,12 @@ class MiniflareMonitor {
console.error('Segmentation fault detected. Restarting Miniflare...') console.error('Segmentation fault detected. Restarting Miniflare...')
this.restart() this.restart()
} else if (!err) { } else if (!err) {
log(output.replace('[mf:inf]', '')) // or handle the output differently console.log(output.replace('[mf:inf]', '')) // or handle the output differently
} }
} }
private restart(): void { private restart(): void {
log('Restarting wrangler...') console.log('Restarting wrangler...')
this.stop() this.stop()
setTimeout(() => this.start(), 3000) // Restart after a short delay setTimeout(() => this.start(), 3000) // Restart after a short delay
} }
@ -60,7 +57,60 @@ class MiniflareMonitor {
} }
} }
const monitor = new MiniflareMonitor('wrangler', [ class SizeReporter {
lastLineTime = Date.now()
nextTick?: NodeJS.Timeout
size = 0
start() {
console.log('Spawning size reporter...')
const proc = spawn('yarn', [
'run',
'-T',
'esbuild',
'src/worker.ts',
'--bundle',
'--minify',
'--watch',
'--external:cloudflare:*',
'--target=esnext',
'--format=esm',
])
// listen for lines on stdin
proc.stdout.on('data', (data) => {
this.size += data.length
this.lastLineTime = Date.now()
clearTimeout(this.nextTick)
this.nextTick = setTimeout(() => {
console.log(
kleur.bold(kleur.yellow('worker')),
'is roughly',
kleur.bold(kleur.cyan(Math.floor(this.size / 1024) + 'kb')),
'(minified)\n'
)
this.size = 0
}, 10)
})
proc.stderr.on('data', (data) => {
console.log(data.toString())
})
process.on('SIGINT', () => {
console.log('Int')
proc.kill()
})
process.on('SIGTERM', () => {
console.log('Term')
proc.kill()
})
process.on('exit', () => {
console.log('Exiting')
proc.kill()
})
}
}
new MiniflareMonitor('wrangler', [
'dev', 'dev',
'--env', '--env',
'dev', 'dev',
@ -69,5 +119,6 @@ const monitor = new MiniflareMonitor('wrangler', [
'info', 'info',
'--var', '--var',
'IS_LOCAL:true', 'IS_LOCAL:true',
]) ]).start()
monitor.start()
new SizeReporter().start()

771
yarn.lock

File diff suppressed because it is too large Load diff