unbrivate, dot com in (#2475)
This PR moves the tldraw.com app into the public repo. ### Change Type - [x] `internal` — Any other changes that don't affect the published package[^2] --------- Co-authored-by: Dan Groshev <git@dgroshev.com> Co-authored-by: alex <alex@dytry.ch>
75
.dockerignore
Normal file
|
@ -0,0 +1,75 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
.git
|
||||
**/.git
|
||||
|
||||
dist
|
||||
dist-cjs
|
||||
dist-esm
|
||||
.tsbuild*
|
||||
.lazy
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# turborepo
|
||||
.turbo
|
||||
|
||||
coverage
|
||||
|
||||
**/*.env
|
||||
**/*.tsbuildinfo
|
||||
|
||||
**/*.css.map
|
||||
**/*.js.map
|
||||
apps/webdriver/www/index.js
|
||||
apps/webdriver/www/index.css
|
||||
apps/dotcom-worker/.dev.vars
|
||||
nohup.out
|
||||
|
||||
packages/*/package
|
||||
packages/*/*.tgz
|
||||
|
||||
tsconfig.build.json
|
||||
.vercel
|
||||
|
||||
api-json
|
||||
api-md
|
||||
|
||||
apps/webdriver/www
|
||||
!apps/webdriver/www/index.html
|
||||
|
||||
# yarn v2
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
packages/*/api
|
||||
apps/examples/www/index.css
|
||||
apps/examples/www/index.js
|
||||
.tsbuild
|
||||
packages/dotcom-worker/.dev.vars
|
|
@ -22,4 +22,7 @@ apps/examples/www
|
|||
apps/docs/api-content.json
|
||||
apps/docs/content.json
|
||||
apps/vscode/extension/editor/index.js
|
||||
apps/vscode/extension/editor/tldraw-assets.json
|
||||
apps/vscode/extension/editor/tldraw-assets.json
|
||||
**/sentry.server.config.js
|
||||
**/scripts/upload-sourcemaps.js
|
||||
**/coverage/**/*
|
||||
|
|
25
.eslintrc.js
|
@ -90,11 +90,24 @@ module.exports = {
|
|||
'import/no-internal-modules': 'off',
|
||||
},
|
||||
},
|
||||
// {
|
||||
// files: ['packages/tldraw/src/test/**/*'],
|
||||
// rules: {
|
||||
// 'import/no-internal-modules': 'off',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
files: ['apps/huppy/**/*', 'scripts/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['apps/dotcom/**/*'],
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
2,
|
||||
{
|
||||
object: 'crypto',
|
||||
property: 'randomUUID',
|
||||
message: 'Please use the makeUUID util instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
19
.github/actions/setup/action.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
name: Setup tldraw/tldraw
|
||||
description: Set up node & yarn
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
shell: bash
|
||||
|
||||
- name: Setup Node.js Environment
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
shell: bash
|
34
.github/workflows/checks.yml
vendored
|
@ -15,8 +15,8 @@ defaults:
|
|||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 'Build and run checks'
|
||||
test:
|
||||
name: 'Tests & checks'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest-16-cores-open # TODO: this should probably run on multiple OSes
|
||||
|
||||
|
@ -24,18 +24,7 @@ jobs:
|
|||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn build-types
|
||||
|
@ -49,13 +38,24 @@ jobs:
|
|||
- name: Check API declarations and docs work as intended
|
||||
run: yarn api-check
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
build:
|
||||
name: 'Build all projects'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest-16-cores-open
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Build all projects
|
||||
# the sed pipe makes sure that github annotations come through without
|
||||
# turbo's prefix
|
||||
run: "yarn build | sed -E 's/^.*? ::/::/'"
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
- name: Pack public packages
|
||||
run: "yarn lazy pack-tarball | sed -E 's/^.*? ::/::/'"
|
||||
|
|
76
.github/workflows/deploy.yml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- production
|
||||
|
||||
env:
|
||||
CI: 1
|
||||
PRINT_GITHUB_ANNOTATIONS: 1
|
||||
TLDRAW_ENV: ${{ (github.event.ref == 'refs/heads/production' && 'production') || (github.event.ref == 'refs/heads/main' && 'staging') || 'preview' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to ${{ (github.event.ref == 'refs/heads/production' && 'production') || (github.event.ref == 'refs/heads/main' && 'staging') || 'preview' }}
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest-16-cores-open
|
||||
environment: ${{ github.event.ref == 'refs/heads/production' && 'deploy-production' || 'deploy-staging' }}
|
||||
concurrency: ${{ github.event.ref == 'refs/heads/production' && 'deploy-production' || github.event.ref }}
|
||||
|
||||
steps:
|
||||
- name: Notify initial start
|
||||
uses: MineBartekSA/discord-webhook@v2
|
||||
if: github.event.ref == 'refs/heads/production'
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
|
||||
content: 'Preparing ${{ env.TLDRAW_ENV }} 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
|
||||
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.ts
|
||||
env:
|
||||
RELEASE_COMMIT_HASH: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
ASSET_UPLOAD: ${{ vars.ASSET_UPLOAD }}
|
||||
MULTIPLAYER_SERVER: ${{ vars.MULTIPLAYER_SERVER }}
|
||||
SUPABASE_LITE_URL: ${{ vars.SUPABASE_LITE_URL }}
|
||||
VERCEL_PROJECT_ID: ${{ vars.VERCEL_DOTCOM_PROJECT_ID }}
|
||||
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
|
||||
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }}
|
||||
GC_MAPS_API_KEY: ${{ secrets.GC_MAPS_API_KEY }}
|
||||
WORKER_SENTRY_DSN: ${{ secrets.WORKER_SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SUPABASE_LITE_ANON_KEY: ${{ secrets.SUPABASE_LITE_ANON_KEY }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_ACCESS_KEY_SECRET: ${{ secrets.R2_ACCESS_KEY_SECRET }}
|
||||
|
||||
APP_ORIGIN: ${{ vars.APP_ORIGIN }}
|
|
@ -56,7 +56,6 @@ jobs:
|
|||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
|
1
.github/workflows/playwright.yml
vendored
|
@ -29,7 +29,6 @@ jobs:
|
|||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
|
36
.github/workflows/prune-preview-deploys.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
name: Prune preview deploys
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# run once per day at midnight or whatever
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
env:
|
||||
CI: 1
|
||||
PRINT_GITHUB_ANNOTATIONS: 1
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Prune preview deploys
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
environment: deploy-staging
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Prune preview deploys
|
||||
run: yarn tsx scripts/prune-preview-deploys.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
1
.github/workflows/publish-canary.yml
vendored
|
@ -22,7 +22,6 @@ jobs:
|
|||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
|
1
.github/workflows/publish-manual.yml
vendored
|
@ -21,7 +21,6 @@ jobs:
|
|||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
|
1
.github/workflows/publish-new.yml
vendored
|
@ -33,7 +33,6 @@ jobs:
|
|||
with:
|
||||
node-version: 18.18.2
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'public-yarn.lock'
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
|
111
.github/workflows/trigger-production-build.yml
vendored
Normal file
|
@ -0,0 +1,111 @@
|
|||
name: Trigger production build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- hotfixes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: 'Target ref to deploy'
|
||||
required: true
|
||||
default: 'main'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
TARGET: ${{ github.event.inputs.target }}
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: ${{ github.event_name == 'workflow_dispatch' && 'Manual trigger' || 'Hotfix' }}
|
||||
runs-on: ubuntu-latest-16-cores-open
|
||||
concurrency: trigger-production
|
||||
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ vars.HUPPY_APP_ID }}
|
||||
private-key: ${{ secrets.HUPPY_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
ref: refs/heads/production
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get target commit hash (manual dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
set -x
|
||||
|
||||
# if the target exists on its own use that
|
||||
if git rev-parse "$TARGET" --quiet; then
|
||||
target_hash=$(git rev-parse "$TARGET")
|
||||
fi
|
||||
# if not try prefixed with origin:
|
||||
if [ -z "$target_hash" ]; then
|
||||
target_hash=$(git rev-parse "origin/$TARGET")
|
||||
fi
|
||||
|
||||
echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV
|
||||
echo "TARGET_HASH=$target_hash" >> $GITHUB_ENV
|
||||
|
||||
- name: Get target commit hash (hotfix)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
set -x
|
||||
echo "TARGET_HASH=$GITHUB_SHA" >> $GITHUB_ENV
|
||||
echo "TARGET=hotfix" >> $GITHUB_ENV
|
||||
# is the hotfix sha already on production?
|
||||
if git merge-base --is-ancestor "$GITHUB_SHA" production; then
|
||||
echo "SHOULD_DEPLOY=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "SHOULD_DEPLOY=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Author setup (manual dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
set -x
|
||||
git config --global user.name "${{ github.actor }}"
|
||||
git config --global user.email 'huppy+${{ github.actor }}@tldraw.com'
|
||||
|
||||
- name: Author setup (hotfix)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
set -x
|
||||
commit_author_name=$(git log -1 --pretty=format:%cn "$TARGET_HASH")
|
||||
commit_author_email=$(git log -1 --pretty=format:%ce "$TARGET_HASH")
|
||||
git config --global user.name "$commit_author_name"
|
||||
git config --global user.email "$commit_author_email"
|
||||
|
||||
- name: Get target tree hash
|
||||
run: |
|
||||
set -x
|
||||
tree_hash=$(git show --quiet --pretty=format:%T "$TARGET_HASH")
|
||||
echo "TREE_HASH=$tree_hash" >> $GITHUB_ENV
|
||||
|
||||
- name: Create commit & update production branch
|
||||
run: |
|
||||
set -eux
|
||||
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
message="Deploy from $TARGET ($TARGET_HASH) at $now"
|
||||
current_prod_hash=$(git rev-parse HEAD)
|
||||
|
||||
commit=$(git commit-tree -m "$message" -p "$current_prod_hash" -p "$TARGET_HASH" "$TREE_HASH")
|
||||
|
||||
git update-ref refs/heads/production "$commit"
|
||||
git checkout production
|
||||
|
||||
- name: Push commit to trigger deployment
|
||||
if: env.SHOULD_DEPLOY == 'true'
|
||||
run: |
|
||||
set -x
|
||||
git push origin production
|
||||
# reset hotfixes to the latest production
|
||||
git push origin production:hotfixes --force
|
10
.gitignore
vendored
|
@ -83,4 +83,12 @@ apps/examples/build.esbuild.json
|
|||
apps/examples/e2e/test-results
|
||||
apps/examples/playwright-report
|
||||
|
||||
docs/gen
|
||||
docs/gen
|
||||
|
||||
.dev.vars
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env*
|
||||
|
||||
.wrangler
|
||||
/vercel.json
|
|
@ -1,13 +1,6 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# if the folder we're in is called bublic, it means we're a submodule in the brivate repo.
|
||||
# We need to grab .envrc to set up yarn correctly.
|
||||
current_file="$(readlink -f "$0")"
|
||||
if [[ $current_file == */bublic/.husky/pre-commit ]]; then
|
||||
source "$(dirname -- "$0")/../../.envrc"
|
||||
fi
|
||||
|
||||
npx lazy run build-api
|
||||
git add packages/*/api-report.md
|
||||
git add packages/*/api/api.json
|
||||
|
|
|
@ -15,4 +15,11 @@ apps/docs/content.json
|
|||
apps/vscode/extension/editor/*
|
||||
apps/examples/www
|
||||
content.json
|
||||
apps/docs/utils/vector-db/index.json
|
||||
apps/docs/utils/vector-db/index.json
|
||||
**/gen/**/*.md
|
||||
|
||||
**/.vercel/*
|
||||
**/.wrangler/*
|
||||
**/.out/*
|
||||
**/.temp/*
|
||||
apps/dotcom/public/**/*.*
|
|
@ -1,4 +1,3 @@
|
|||
enableInlineBuilds: true
|
||||
lockfileFilename: public-yarn.lock
|
||||
nodeLinker: node-modules
|
||||
yarnPath: .yarn/releases/yarn-3.5.0.cjs
|
||||
|
|
1
apps/dotcom-asset-upload/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
tmp-assets
|
85
apps/dotcom-asset-upload/CHANGELOG.md
Normal file
|
@ -0,0 +1,85 @@
|
|||
# asset-upload
|
||||
|
||||
## 2.0.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Release day!
|
||||
|
||||
## 2.0.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## 2.0.0-alpha.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add licenses.
|
||||
|
||||
## 2.0.0-alpha.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add CSS files to tldraw/tldraw.
|
||||
|
||||
## 2.0.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add children to tldraw/tldraw
|
||||
|
||||
## 2.0.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Change permissions.
|
||||
|
||||
## 2.0.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tldraw, editor
|
||||
|
||||
## 0.1.0-alpha.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix stale reactors.
|
||||
|
||||
## 0.1.0-alpha.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix type export bug.
|
||||
|
||||
## 0.1.0-alpha.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix import bugs.
|
||||
|
||||
## 0.1.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Changes validation requirements, exports validation helpers.
|
||||
|
||||
## 0.1.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - Pre-pre-release update
|
||||
|
||||
## 0.0.2-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix error with HMR
|
||||
|
||||
## 0.0.2-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Initial release
|
37
apps/dotcom-asset-upload/package.json
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "dotcom-asset-upload",
|
||||
"description": "A Cloudflare Worker to upload and serve images",
|
||||
"version": "2.0.0-alpha.8",
|
||||
"private": true,
|
||||
"packageManager": "yarn@3.5.0",
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development wrangler dev --log-level info --persist-to tmp-assets",
|
||||
"test": "lazy inherit --passWithNoTests",
|
||||
"test-coverage": "lazy inherit --passWithNoTests",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"itty-cors": "^0.3.4",
|
||||
"itty-router": "^2.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230821.0",
|
||||
"@types/ws": "^8.5.3",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"wrangler": "3.16.0"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "config/jest/node",
|
||||
"moduleNameMapper": {
|
||||
"^~(.*)": "<rootDir>/src/$1"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(nanoid|escape-string-regexp)/)"
|
||||
]
|
||||
}
|
||||
}
|
180
apps/dotcom-asset-upload/src/index.ts
Normal file
|
@ -0,0 +1,180 @@
|
|||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { createCors } from 'itty-cors'
|
||||
import { Router } from 'itty-router'
|
||||
|
||||
const { preflight, corsify } = createCors({ origins: ['*'] })
|
||||
|
||||
interface Env {
|
||||
UPLOADS: R2Bucket
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
encoded: string | null
|
||||
): undefined | { offset: number; end: number; length: number } {
|
||||
if (encoded === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const parts = (encoded.split('bytes=')[1]?.split('-') ?? []).filter(Boolean)
|
||||
if (parts.length !== 2) {
|
||||
console.error('Not supported to skip specifying the beginning/ending byte at this time')
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
offset: Number(parts[0]),
|
||||
end: Number(parts[1]),
|
||||
length: Number(parts[1]) + 1 - Number(parts[0]),
|
||||
}
|
||||
}
|
||||
|
||||
function objectNotFound(objectName: string): Response {
|
||||
return new Response(`<html><body>R2 object "<b>${objectName}</b>" not found</body></html>`, {
|
||||
status: 404,
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=UTF-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
router
|
||||
.all('*', preflight)
|
||||
.get('/uploads/list', async (request, env: Env) => {
|
||||
// we need to protect this behind auth
|
||||
const url = new URL(request.url)
|
||||
const options: R2ListOptions = {
|
||||
prefix: url.searchParams.get('prefix') ?? undefined,
|
||||
delimiter: url.searchParams.get('delimiter') ?? undefined,
|
||||
cursor: url.searchParams.get('cursor') ?? undefined,
|
||||
}
|
||||
|
||||
const listing = await env.UPLOADS.list(options)
|
||||
return Response.json(listing)
|
||||
})
|
||||
.get('/uploads/:objectName', async (request: Request, env: Env, ctx: ExecutionContext) => {
|
||||
const url = new URL(request.url)
|
||||
|
||||
const range = parseRange(request.headers.get('range'))
|
||||
|
||||
// NOTE: caching will only work when this is deployed to
|
||||
// a custom domain, not a workers.dev domain. It's a no-op
|
||||
// otherwise.
|
||||
|
||||
// Construct the cache key from the cache URL
|
||||
const cacheKey = new Request(url.toString(), request)
|
||||
const cache = caches.default as Cache
|
||||
|
||||
// Check whether the value is already available in the cache
|
||||
// if not, you will need to fetch it from R2, and store it in the cache
|
||||
// for future access
|
||||
let cachedResponse
|
||||
if (!range) {
|
||||
cachedResponse = await cache.match(cacheKey)
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
}
|
||||
|
||||
const ifNoneMatch = request.headers.get('if-none-match')
|
||||
let hs = request.headers
|
||||
if (ifNoneMatch?.startsWith('W/')) {
|
||||
hs = new Headers(request.headers)
|
||||
hs.set('if-none-match', ifNoneMatch.slice(2))
|
||||
}
|
||||
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
const object = await env.UPLOADS.get(request.params.objectName, {
|
||||
range,
|
||||
onlyIf: hs,
|
||||
})
|
||||
|
||||
if (object === null) {
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
return objectNotFound(request.params.objectName)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('etag', object.httpEtag)
|
||||
if (range) {
|
||||
headers.set('content-range', `bytes ${range.offset}-${range.end}/${object.size}`)
|
||||
}
|
||||
|
||||
// Cache API respects Cache-Control headers. Setting s-max-age to 7 days
|
||||
// Any changes made to the response here will be reflected in the cached value
|
||||
headers.append('Cache-Control', 's-maxage=604800')
|
||||
|
||||
const hasBody = 'body' in object && object.body
|
||||
const status = hasBody ? (range ? 206 : 200) : 304
|
||||
const response = new Response(hasBody ? object.body : undefined, {
|
||||
headers,
|
||||
status,
|
||||
})
|
||||
|
||||
// Store the response in the cache for future access
|
||||
if (!range) {
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()))
|
||||
}
|
||||
|
||||
return response
|
||||
})
|
||||
.head('/uploads/:objectName', async (request: Request, env: Env) => {
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
const object = await env.UPLOADS.head(request.params.objectName)
|
||||
|
||||
if (object === null) {
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
return objectNotFound(request.params.objectName)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('etag', object.httpEtag)
|
||||
return new Response(null, {
|
||||
headers,
|
||||
})
|
||||
})
|
||||
.post('/uploads/:objectName', async (request: Request, env: Env) => {
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
const object = await env.UPLOADS.put(request.params.objectName, request.body, {
|
||||
httpMetadata: request.headers,
|
||||
})
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
etag: object.httpEtag,
|
||||
},
|
||||
})
|
||||
})
|
||||
.delete('/uploads/:objectName', async (request: Request, env: Env) => {
|
||||
// Not sure if this is necessary, might be dangerous to expose
|
||||
// TODO: infer types from path
|
||||
// @ts-expect-error
|
||||
await env.UPLOADS.delete(request.params.objectName)
|
||||
return new Response()
|
||||
})
|
||||
.get('*', () => new Response('Not found', { status: 404 }))
|
||||
|
||||
const Worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
return router
|
||||
.handle(request, env, ctx)
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err, err.stack)
|
||||
return new Response((err as Error).message, { status: 500 })
|
||||
})
|
||||
.then(corsify)
|
||||
},
|
||||
}
|
||||
|
||||
export default Worker
|
6
apps/dotcom-asset-upload/src/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface Env {
|
||||
UPLOADS: R2Bucket
|
||||
|
||||
KV: KVNamespace
|
||||
ASSET_UPLOADER_AUTH_TOKEN: string | undefined
|
||||
}
|
9
apps/dotcom-asset-upload/tsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false
|
||||
}
|
||||
}
|
51
apps/dotcom-asset-upload/wrangler.toml
Normal file
|
@ -0,0 +1,51 @@
|
|||
name = "tldraw-assets"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2022-09-22"
|
||||
|
||||
[dev]
|
||||
port = 8788
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads'
|
||||
preview_bucket_name = 'uploads-preview'
|
||||
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
# staging settings
|
||||
[env.staging]
|
||||
name = "main-tldraw-assets"
|
||||
|
||||
[[env.staging.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads'
|
||||
preview_bucket_name = 'uploads-preview'
|
||||
|
||||
[[env.staging.unsafe.bindings]]
|
||||
type = "analytics_engine"
|
||||
name = "MEASURE"
|
||||
|
||||
|
||||
# production settings
|
||||
[env.production]
|
||||
name = "tldraw-assets"
|
||||
|
||||
[[env.production.routes]]
|
||||
pattern = 'assets.tldraw.xyz'
|
||||
custom_domain = true
|
||||
zone_name = 'tldraw.xyz'
|
||||
|
||||
[[env.production.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads'
|
||||
preview_bucket_name = 'uploads-preview'
|
||||
|
||||
[[env.production.unsafe.bindings]]
|
||||
type = "analytics_engine"
|
||||
name = "MEASURE"
|
||||
|
||||
[[env.preview.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads'
|
||||
preview_bucket_name = 'uploads-preview'
|
1
apps/dotcom-bookmark-extractor/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.vercel
|
3
apps/dotcom-bookmark-extractor/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @tldraw/bookmark-extractor
|
||||
|
||||
Deploy this manually with `vercel deploy --prod`.
|
35
apps/dotcom-bookmark-extractor/api/_cors.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import Cors from 'cors'
|
||||
|
||||
const whitelist = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:4000',
|
||||
'http://localhost:5420',
|
||||
'https://www.tldraw.com',
|
||||
'https://staging.tldraw.com',
|
||||
process.env.NEXT_PUBLIC_VERCEL_URL,
|
||||
'vercel.app',
|
||||
]
|
||||
|
||||
export const cors = Cors({
|
||||
methods: ['POST'],
|
||||
origin: function (origin, callback) {
|
||||
if (origin?.endsWith('.tldraw.com')) {
|
||||
callback(null, true)
|
||||
} else if (origin?.endsWith('-tldraw.vercel.app')) {
|
||||
callback(null, true)
|
||||
} else if (origin && whitelist.includes(origin)) {
|
||||
callback(null, true)
|
||||
} else {
|
||||
callback(new Error(`Not allowed by CORS (${origin})`))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function runCorsMiddleware(req: any, res: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
cors(req, res, (result) => {
|
||||
if (result instanceof Error) return reject(result)
|
||||
return resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
25
apps/dotcom-bookmark-extractor/api/bookmark.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// @ts-expect-error
|
||||
import grabity from 'grabity'
|
||||
import { runCorsMiddleware } from './_cors'
|
||||
|
||||
interface RequestBody {
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ResponseBody {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export default async function handler(req: any, res: any) {
|
||||
try {
|
||||
await runCorsMiddleware(req, res)
|
||||
const { url } = typeof req.body === 'string' ? JSON.parse(req.body) : (req.body as RequestBody)
|
||||
const it = await grabity.grabIt(url)
|
||||
res.send(it)
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
res.status(500).send(error.message)
|
||||
}
|
||||
}
|
24
apps/dotcom-bookmark-extractor/package.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@tldraw/bookmark-extractor",
|
||||
"description": "A tiny little drawing app (merge server).",
|
||||
"version": "2.0.0-alpha.11",
|
||||
"private": true,
|
||||
"packageManager": "yarn@3.5.0",
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.15",
|
||||
"cors": "^2.8.5",
|
||||
"grabity": "^1.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"typescript": "^5.0.2"
|
||||
}
|
||||
}
|
34
apps/dotcom-bookmark-extractor/tsconfig.json
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"include": ["api"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*", ".vercel"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importHelpers": true,
|
||||
"resolveJsonModule": true,
|
||||
"incremental": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["dom", "DOM.Iterable", "esnext"],
|
||||
"experimentalDecorators": true,
|
||||
"module": "CommonJS",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"useDefineForClassFields": true,
|
||||
"noImplicitOverride": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"references": []
|
||||
}
|
2
apps/dotcom-worker/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
build
|
||||
.wrangler
|
146
apps/dotcom-worker/CHANGELOG.md
Normal file
|
@ -0,0 +1,146 @@
|
|||
# @tldraw/tlsync-worker
|
||||
|
||||
## 2.0.0-alpha.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @tldraw/tlsync@2.0.0-alpha.11
|
||||
|
||||
## 2.0.0-alpha.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @tldraw/tlsync@2.0.0-alpha.10
|
||||
|
||||
## 2.0.0-alpha.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Release day!
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.9
|
||||
|
||||
## 2.0.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [23dd81cfe]
|
||||
- @tldraw/tlsync@2.0.0-alpha.8
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.8
|
||||
|
||||
## 2.0.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bug fixes.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.7
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.7
|
||||
|
||||
## 2.0.0-alpha.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add licenses.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.6
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.6
|
||||
|
||||
## 2.0.0-alpha.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add CSS files to tldraw/tldraw.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.5
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.5
|
||||
|
||||
## 2.0.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add children to tldraw/tldraw
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.4
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.4
|
||||
|
||||
## 2.0.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Change permissions.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.3
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.3
|
||||
|
||||
## 2.0.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tldraw, editor
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@2.0.0-alpha.2
|
||||
- @tldraw/tlsync-server@2.0.0-alpha.2
|
||||
|
||||
## 0.1.0-alpha.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix stale reactors.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.1.0-alpha.11
|
||||
- @tldraw/tlsync-server@0.1.0-alpha.11
|
||||
|
||||
## 0.1.0-alpha.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix type export bug.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.1.0-alpha.10
|
||||
- @tldraw/tlsync-server@0.1.0-alpha.10
|
||||
|
||||
## 0.1.0-alpha.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix import bugs.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.1.0-alpha.9
|
||||
- @tldraw/tlsync-server@0.1.0-alpha.9
|
||||
|
||||
## 0.1.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Changes validation requirements, exports validation helpers.
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.1.0-alpha.8
|
||||
- @tldraw/tlsync-server@0.1.0-alpha.8
|
||||
|
||||
## 0.1.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - Pre-pre-release update
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.1.0-alpha.7
|
||||
- @tldraw/tlsync-server@0.1.0-alpha.7
|
||||
|
||||
## 0.0.2-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix error with HMR
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.0.2-alpha.1
|
||||
- @tldraw/tlsync-server@0.0.2-alpha.1
|
||||
|
||||
## 0.0.2-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Initial release
|
||||
- Updated dependencies
|
||||
- @tldraw/tlsync@0.0.2-alpha.0
|
||||
- @tldraw/tlsync-server@0.0.2-alpha.0
|
12
apps/dotcom-worker/README.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# @tldraw/tlsync-worker
|
||||
|
||||
## Enable database persistence for local dev
|
||||
|
||||
The values for `env.SUPABASE_KEY` and `env.SUPABASE_URL` are stored in the Cloudflare Workers dashboard for this worker. However we use `--local` mode for local development, which doesn't read these values from the dashboard.
|
||||
|
||||
To workaround this, create a file called `.dev.vars` under `merge-server` with the required values (which you can currently find at https://app.supabase.com/project/bfcjbbjqflgfzxhskwct/settings/api). This will be read by `wrangler dev --local` and used to populate the environment variables.
|
||||
|
||||
```
|
||||
SUPABASE_URL=<url>
|
||||
SUPABASE_KEY=<key>
|
||||
```
|
53
apps/dotcom-worker/package.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "@tldraw/dotcom-worker",
|
||||
"description": "A tiny little drawing app (merge server).",
|
||||
"version": "2.0.0-alpha.11",
|
||||
"private": true,
|
||||
"packageManager": "yarn@3.5.0",
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"main": "./src/lib/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": "concurrently --kill-others yarn:dev-cron yarn:dev-wrangler yarn:report-size",
|
||||
"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": "lazy inherit",
|
||||
"test-coverage": "lazy inherit",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/auth-helpers-remix": "^0.2.2",
|
||||
"@supabase/supabase-js": "^2.33.2",
|
||||
"@tldraw/store": "workspace:*",
|
||||
"@tldraw/tlschema": "workspace:*",
|
||||
"@tldraw/tlsync": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"esbuild": "^0.18.4",
|
||||
"itty-router": "^4.0.13",
|
||||
"nanoid": "4.0.2",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"toucan-js": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230821.0",
|
||||
"concurrently": "^8.2.1",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"picocolors": "^1.0.0",
|
||||
"typescript": "^5.0.2",
|
||||
"wrangler": "3.16.0"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "config/jest/node",
|
||||
"moduleNameMapper": {
|
||||
"^~(.*)": "<rootDir>/src/$1"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(nanoid|escape-string-regexp)/)"
|
||||
]
|
||||
}
|
||||
}
|
10
apps/dotcom-worker/scripts/cron.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
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)
|
73
apps/dotcom-worker/scripts/dev-wrap.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
// 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
|
||||
// this script wraps the process, tailing the logs and restarting the process
|
||||
// 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 {
|
||||
private process: ChildProcessWithoutNullStreams | null = null
|
||||
|
||||
constructor(
|
||||
private command: string,
|
||||
private args: string[] = []
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
this.stop() // Ensure any existing process is stopped
|
||||
log(`Starting wrangler...`)
|
||||
this.process = spawn(this.command, this.args, {
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
...process.env,
|
||||
},
|
||||
})
|
||||
|
||||
this.process.stdout.on('data', (data: Buffer) => {
|
||||
this.handleOutput(stripAnsi(data.toString().replace('\r', '').trim()))
|
||||
})
|
||||
|
||||
this.process.stderr.on('data', (data: Buffer) => {
|
||||
this.handleOutput(stripAnsi(data.toString().replace('\r', '').trim()), true)
|
||||
})
|
||||
}
|
||||
|
||||
private handleOutput(output: string, err = false): void {
|
||||
if (!output) return
|
||||
if (output.includes('Segmentation fault')) {
|
||||
console.error('Segmentation fault detected. Restarting Miniflare...')
|
||||
this.restart()
|
||||
} else if (!err) {
|
||||
log(output.replace('[mf:inf]', '')) // or handle the output differently
|
||||
}
|
||||
}
|
||||
|
||||
private restart(): void {
|
||||
log('Restarting wrangler...')
|
||||
this.stop()
|
||||
setTimeout(() => this.start(), 3000) // Restart after a short delay
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
this.process = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const monitor = new MiniflareMonitor('wrangler', [
|
||||
'dev',
|
||||
'--env',
|
||||
'dev',
|
||||
'--test-scheduled',
|
||||
'--log-level',
|
||||
'info',
|
||||
'--var',
|
||||
'IS_LOCAL:true',
|
||||
])
|
||||
monitor.start()
|
45
apps/dotcom-worker/scripts/report-size.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
/* 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()
|
259
apps/dotcom-worker/src/lib/AlarmScheduler.test.ts
Normal file
|
@ -0,0 +1,259 @@
|
|||
import { noop } from '@tldraw/utils'
|
||||
import { AlarmScheduler } from './AlarmScheduler'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
function makeMockAlarmScheduler<Key extends string>(alarms: {
|
||||
[K in Key]: jest.Mock<Promise<void>, []>
|
||||
}) {
|
||||
const data = new Map<string, number>()
|
||||
let scheduledAlarm: number | null = null
|
||||
|
||||
const storage = {
|
||||
getAlarm: async () => scheduledAlarm,
|
||||
setAlarm: jest.fn((time: number | Date) => {
|
||||
scheduledAlarm = typeof time === 'number' ? time : time.getTime()
|
||||
}),
|
||||
get: async (key: string) => data.get(key),
|
||||
list: async () => new Map(data),
|
||||
delete: async (keys: string[]) => {
|
||||
let count = 0
|
||||
for (const key of keys) {
|
||||
if (data.delete(key)) count++
|
||||
}
|
||||
return count
|
||||
},
|
||||
put: async (entries: Record<string, number>) => {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
data.set(key, value)
|
||||
}
|
||||
},
|
||||
asObject: () => Object.fromEntries(data),
|
||||
}
|
||||
|
||||
const scheduler = new AlarmScheduler({
|
||||
alarms,
|
||||
storage: () => storage,
|
||||
})
|
||||
|
||||
const advanceTime = async (time: number) => {
|
||||
jest.advanceTimersByTime(time)
|
||||
if (scheduledAlarm !== null && scheduledAlarm <= Date.now()) {
|
||||
scheduledAlarm = null
|
||||
await scheduler.onAlarm()
|
||||
// process the alarms that were scheduled during the onAlarm call:
|
||||
if (scheduledAlarm) await advanceTime(0)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduler,
|
||||
storage,
|
||||
alarms,
|
||||
advanceTime,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AlarmScheduler', () => {
|
||||
beforeEach(() => {
|
||||
jest.setSystemTime(1_000_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
test('scheduling alarms', async () => {
|
||||
const { scheduler, storage } = makeMockAlarmScheduler({
|
||||
one: jest.fn(),
|
||||
two: jest.fn(),
|
||||
three: jest.fn(),
|
||||
})
|
||||
|
||||
// when no alarms are scheduled, we always call storage.setAlarm
|
||||
await scheduler.scheduleAlarmAfter('one', 1000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(1)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_001_000)
|
||||
expect(storage.asObject()).toStrictEqual({ 'alarm-one': 1_001_000 })
|
||||
|
||||
// if a later alarm is scheduled, we don't call storage.setAlarm
|
||||
await scheduler.scheduleAlarmAfter('two', 2000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(1)
|
||||
expect(storage.asObject()).toStrictEqual({ 'alarm-one': 1_001_000, 'alarm-two': 1_002_000 })
|
||||
|
||||
// if a sooner alarm is scheduled, we call storage.setAlarm again
|
||||
await scheduler.scheduleAlarmAfter('three', 500, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(2)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_000_500)
|
||||
expect(storage.asObject()).toStrictEqual({
|
||||
'alarm-one': 1_001_000,
|
||||
'alarm-two': 1_002_000,
|
||||
'alarm-three': 1_000_500,
|
||||
})
|
||||
|
||||
// if the soonest alarm is scheduled later, we don't call storage.setAlarm with a later time - we
|
||||
// just let it no-op and reschedule when the alarm is actually triggered:
|
||||
await scheduler.scheduleAlarmAfter('three', 1000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(2)
|
||||
expect(storage.asObject()).toStrictEqual({
|
||||
'alarm-one': 1_001_000,
|
||||
'alarm-two': 1_002_000,
|
||||
'alarm-three': 1_001_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('onAlarm - basic function', async () => {
|
||||
const { scheduler, alarms, storage, advanceTime } = makeMockAlarmScheduler({
|
||||
one: jest.fn(),
|
||||
two: jest.fn(),
|
||||
three: jest.fn(),
|
||||
})
|
||||
|
||||
// schedule some alarms:
|
||||
await scheduler.scheduleAlarmAfter('one', 1000, { overwrite: 'always' })
|
||||
await scheduler.scheduleAlarmAfter('two', 1000, { overwrite: 'always' })
|
||||
await scheduler.scheduleAlarmAfter('three', 2000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(1)
|
||||
expect(storage.asObject()).toStrictEqual({
|
||||
'alarm-one': 1_001_000,
|
||||
'alarm-two': 1_001_000,
|
||||
'alarm-three': 1_002_000,
|
||||
})
|
||||
|
||||
// firing the alarm calls the appropriate alarm functions...
|
||||
await advanceTime(1000)
|
||||
expect(alarms.one).toHaveBeenCalledTimes(1)
|
||||
expect(alarms.two).toHaveBeenCalledTimes(1)
|
||||
expect(alarms.three).not.toHaveBeenCalled()
|
||||
// ...deletes the called alarms...
|
||||
expect(storage.asObject()).toStrictEqual({ 'alarm-three': 1_002_000 })
|
||||
// ...and reschedules the next alarm:
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(2)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_002_000)
|
||||
|
||||
// firing the alarm again calls the next alarm and doesn't reschedule:
|
||||
await advanceTime(1000)
|
||||
expect(alarms.one).toHaveBeenCalledTimes(1)
|
||||
expect(alarms.two).toHaveBeenCalledTimes(1)
|
||||
expect(alarms.three).toHaveBeenCalledTimes(1)
|
||||
expect(storage.asObject()).toStrictEqual({})
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('can schedule an alarm within an alarm', async () => {
|
||||
const { scheduler, storage, advanceTime, alarms } = makeMockAlarmScheduler({
|
||||
a: jest.fn(async () => {
|
||||
scheduler.scheduleAlarmAfter('b', 1000, { overwrite: 'always' })
|
||||
}),
|
||||
b: jest.fn(),
|
||||
c: jest.fn(),
|
||||
})
|
||||
|
||||
// sequence should be a -> c -> b:
|
||||
await scheduler.scheduleAlarmAfter('a', 1000, { overwrite: 'always' })
|
||||
await scheduler.scheduleAlarmAfter('c', 1500, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(1)
|
||||
|
||||
// a...
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toBeCalledTimes(1)
|
||||
expect(alarms.b).toBeCalledTimes(0)
|
||||
expect(alarms.c).toBeCalledTimes(0)
|
||||
// called for b, then a again to reschedule c:
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(3)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_001_500)
|
||||
|
||||
// ...b...
|
||||
await advanceTime(500)
|
||||
expect(alarms.a).toBeCalledTimes(1)
|
||||
expect(alarms.b).toBeCalledTimes(0)
|
||||
expect(alarms.c).toBeCalledTimes(1)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(4)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_002_000)
|
||||
|
||||
// ...c
|
||||
await advanceTime(500)
|
||||
expect(alarms.a).toBeCalledTimes(1)
|
||||
expect(alarms.b).toBeCalledTimes(1)
|
||||
expect(alarms.c).toBeCalledTimes(1)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(4)
|
||||
|
||||
// sequence should be a -> b -> c:
|
||||
await scheduler.scheduleAlarmAfter('a', 1000, { overwrite: 'always' })
|
||||
await scheduler.scheduleAlarmAfter('c', 3000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(5)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_003_000)
|
||||
|
||||
// a...
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toBeCalledTimes(2)
|
||||
expect(alarms.b).toBeCalledTimes(1)
|
||||
expect(alarms.c).toBeCalledTimes(1)
|
||||
// called for b, not needed to reschedule c:
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(6)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_004_000)
|
||||
|
||||
// ...b...
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toBeCalledTimes(2)
|
||||
expect(alarms.b).toBeCalledTimes(2)
|
||||
expect(alarms.c).toBeCalledTimes(1)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(7)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_005_000)
|
||||
|
||||
// ...c
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toBeCalledTimes(2)
|
||||
expect(alarms.b).toBeCalledTimes(2)
|
||||
expect(alarms.c).toBeCalledTimes(2)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(7)
|
||||
})
|
||||
|
||||
test('can schedule the same alarm within an alarm', async () => {
|
||||
const { scheduler, storage, advanceTime, alarms } = makeMockAlarmScheduler({
|
||||
a: jest.fn(async () => {
|
||||
scheduler.scheduleAlarmAfter('a', 1000, { overwrite: 'always' })
|
||||
}),
|
||||
})
|
||||
|
||||
await scheduler.scheduleAlarmAfter('a', 1000, { overwrite: 'always' })
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(1)
|
||||
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toHaveBeenCalledTimes(1)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(2)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_002_000)
|
||||
expect(storage.asObject()).toStrictEqual({ 'alarm-a': 1_002_000 })
|
||||
|
||||
await advanceTime(1000)
|
||||
expect(alarms.a).toHaveBeenCalledTimes(2)
|
||||
expect(storage.setAlarm).toHaveBeenCalledTimes(3)
|
||||
expect(storage.setAlarm).toHaveBeenLastCalledWith(1_003_000)
|
||||
expect(storage.asObject()).toStrictEqual({ 'alarm-a': 1_003_000 })
|
||||
})
|
||||
|
||||
test('handles retries', async () => {
|
||||
const { scheduler, advanceTime, storage, alarms } = await makeMockAlarmScheduler({
|
||||
error: jest.fn(async () => {
|
||||
throw new Error('something went wrong')
|
||||
}),
|
||||
ok: jest.fn(),
|
||||
})
|
||||
|
||||
await scheduler.scheduleAlarmAfter('error', 1000, { overwrite: 'always' })
|
||||
await scheduler.scheduleAlarmAfter('ok', 1000, { overwrite: 'always' })
|
||||
expect(storage.asObject()).toStrictEqual({
|
||||
'alarm-error': 1_001_000,
|
||||
'alarm-ok': 1_001_000,
|
||||
})
|
||||
|
||||
jest.spyOn(console, 'log').mockImplementation(noop)
|
||||
await expect(async () => advanceTime(1000)).rejects.toThrowError(
|
||||
'Some alarms failed to fire, scheduling retry'
|
||||
)
|
||||
expect(alarms.error).toHaveBeenCalledTimes(1)
|
||||
expect(alarms.ok).toHaveBeenCalledTimes(1)
|
||||
expect(storage.asObject()).toStrictEqual({
|
||||
'alarm-error': 1_001_000,
|
||||
})
|
||||
})
|
||||
})
|
115
apps/dotcom-worker/src/lib/AlarmScheduler.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { exhaustiveSwitchError, hasOwnProperty } from '@tldraw/utils'
|
||||
|
||||
type AlarmOpts = {
|
||||
overwrite: 'always' | 'if-sooner'
|
||||
}
|
||||
|
||||
export class AlarmScheduler<Key extends string> {
|
||||
storage: () => {
|
||||
getAlarm(): Promise<number | null>
|
||||
setAlarm(scheduledTime: number | Date): void
|
||||
get(key: string): Promise<number | undefined>
|
||||
list(options: { prefix: string }): Promise<Map<string, number>>
|
||||
delete(keys: string[]): Promise<number>
|
||||
put(entries: Record<string, number>): Promise<void>
|
||||
}
|
||||
alarms: { [K in Key]: () => Promise<void> }
|
||||
|
||||
constructor(opts: Pick<AlarmScheduler<Key>, 'storage' | 'alarms'>) {
|
||||
this.storage = opts.storage
|
||||
this.alarms = opts.alarms
|
||||
}
|
||||
|
||||
_alarmsScheduledDuringCurrentOnAlarmCall: Set<Key> | null = null
|
||||
async onAlarm() {
|
||||
if (this._alarmsScheduledDuringCurrentOnAlarmCall !== null) {
|
||||
// i _think_ DOs alarms are one-at-a-time, but throwing here will cause a retry
|
||||
throw new Error('onAlarm called before previous call finished')
|
||||
}
|
||||
this._alarmsScheduledDuringCurrentOnAlarmCall = new Set()
|
||||
try {
|
||||
const alarms = await this.storage().list({ prefix: 'alarm-' })
|
||||
const successfullyExecutedAlarms = new Set<Key>()
|
||||
let shouldRetry = false
|
||||
let nextAlarmTime = null
|
||||
|
||||
for (const [key, requestedTime] of alarms) {
|
||||
const cleanedKey = key.replace(/^alarm-/, '') as Key
|
||||
if (!hasOwnProperty(this.alarms, cleanedKey)) continue
|
||||
if (requestedTime > Date.now()) {
|
||||
if (nextAlarmTime === null || requestedTime < nextAlarmTime) {
|
||||
nextAlarmTime = requestedTime
|
||||
}
|
||||
continue
|
||||
}
|
||||
const alarm = this.alarms[cleanedKey]
|
||||
try {
|
||||
await alarm()
|
||||
successfullyExecutedAlarms.add(cleanedKey)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Error firing alarm ${cleanedKey}:`, err)
|
||||
shouldRetry = true
|
||||
}
|
||||
}
|
||||
|
||||
const keysToDelete = []
|
||||
for (const key of successfullyExecutedAlarms) {
|
||||
if (this._alarmsScheduledDuringCurrentOnAlarmCall.has(key)) continue
|
||||
keysToDelete.push(`alarm-${key}`)
|
||||
}
|
||||
if (keysToDelete.length > 0) {
|
||||
await this.storage().delete(keysToDelete)
|
||||
}
|
||||
|
||||
if (shouldRetry) {
|
||||
throw new Error('Some alarms failed to fire, scheduling retry')
|
||||
} else if (nextAlarmTime !== null) {
|
||||
await this.setCoreAlarmIfNeeded(nextAlarmTime)
|
||||
}
|
||||
} finally {
|
||||
this._alarmsScheduledDuringCurrentOnAlarmCall = null
|
||||
}
|
||||
}
|
||||
|
||||
private async setCoreAlarmIfNeeded(targetAlarmTime: number) {
|
||||
const currentAlarmTime = await this.storage().getAlarm()
|
||||
if (currentAlarmTime === null || targetAlarmTime < currentAlarmTime) {
|
||||
await this.storage().setAlarm(targetAlarmTime)
|
||||
}
|
||||
}
|
||||
|
||||
async scheduleAlarmAt(key: Key, time: number | Date, opts: AlarmOpts) {
|
||||
const targetTime = typeof time === 'number' ? time : time.getTime()
|
||||
if (this._alarmsScheduledDuringCurrentOnAlarmCall !== null) {
|
||||
this._alarmsScheduledDuringCurrentOnAlarmCall.add(key)
|
||||
}
|
||||
switch (opts.overwrite) {
|
||||
case 'always':
|
||||
await this.storage().put({ [`alarm-${key}`]: targetTime })
|
||||
break
|
||||
case 'if-sooner': {
|
||||
const currentScheduled = await this.storage().get(`alarm-${key}`)
|
||||
if (!currentScheduled || currentScheduled > targetTime) {
|
||||
await this.storage().put({ [`alarm-${key}`]: targetTime })
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
exhaustiveSwitchError(opts.overwrite)
|
||||
}
|
||||
await this.setCoreAlarmIfNeeded(targetTime)
|
||||
}
|
||||
|
||||
async scheduleAlarmAfter(key: Key, delayMs: number, opts: AlarmOpts) {
|
||||
await this.scheduleAlarmAt(key, Date.now() + delayMs, opts)
|
||||
}
|
||||
|
||||
async getAlarm(key: Key): Promise<number | null> {
|
||||
return (await this.storage().get(`alarm-${key}`)) ?? null
|
||||
}
|
||||
|
||||
async deleteAlarm(key: Key): Promise<void> {
|
||||
await this.storage().delete([`alarm-${key}`])
|
||||
}
|
||||
}
|
398
apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
Normal file
|
@ -0,0 +1,398 @@
|
|||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
RoomSnapshot,
|
||||
TLServer,
|
||||
TLSyncRoom,
|
||||
type DBLoadResult,
|
||||
type PersistedRoomSnapshotForSupabase,
|
||||
type RoomState,
|
||||
} from '@tldraw/tlsync'
|
||||
import { assert, assertExists } from '@tldraw/utils'
|
||||
import { IRequest, Router } from 'itty-router'
|
||||
import Toucan from 'toucan-js'
|
||||
import { AlarmScheduler } from './AlarmScheduler'
|
||||
import { PERSIST_INTERVAL_MS } from './config'
|
||||
import { getR2KeyForRoom } from './r2'
|
||||
import { Analytics, Environment } from './types'
|
||||
import { createSupabaseClient } from './utils/createSupabaseClient'
|
||||
import { throttle } from './utils/throttle'
|
||||
|
||||
const MAX_CONNECTIONS = 50
|
||||
|
||||
// increment this any time you make a change to this type
|
||||
const CURRENT_DOCUMENT_INFO_VERSION = 0
|
||||
type DocumentInfo = {
|
||||
version: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export class TLDrawDurableObject extends TLServer {
|
||||
// A unique identifier for this instance of the Durable Object
|
||||
id: DurableObjectId
|
||||
|
||||
// For TLSyncRoom
|
||||
_roomState: RoomState | undefined
|
||||
|
||||
// For storage
|
||||
storage: DurableObjectStorage
|
||||
|
||||
// For persistence
|
||||
supabaseClient: SupabaseClient | void
|
||||
|
||||
// For analytics
|
||||
measure: Analytics | undefined
|
||||
|
||||
// For error tracking
|
||||
sentryDSN: string | undefined
|
||||
|
||||
readonly supabaseTable: string
|
||||
readonly r2: {
|
||||
readonly rooms: R2Bucket
|
||||
readonly versionCache: R2Bucket
|
||||
}
|
||||
|
||||
_documentInfo: DocumentInfo | null = null
|
||||
|
||||
constructor(
|
||||
private controller: DurableObjectState,
|
||||
private env: Environment
|
||||
) {
|
||||
super()
|
||||
|
||||
this.id = controller.id
|
||||
this.storage = controller.storage
|
||||
this.sentryDSN = env.SENTRY_DSN
|
||||
this.measure = env.MEASURE
|
||||
this.supabaseClient = createSupabaseClient(env)
|
||||
|
||||
this.supabaseTable = env.TLDRAW_ENV === 'production' ? 'drawings' : 'drawings_staging'
|
||||
this.r2 = {
|
||||
rooms: env.ROOMS,
|
||||
versionCache: env.ROOMS_HISTORY_EPHEMERAL,
|
||||
}
|
||||
|
||||
controller.blockConcurrencyWhile(async () => {
|
||||
const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null
|
||||
if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) {
|
||||
this._documentInfo = null
|
||||
} else {
|
||||
this._documentInfo = existingDocumentInfo
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
readonly router = Router()
|
||||
.get(
|
||||
'/r/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.post(
|
||||
'/r/:roomId/restore',
|
||||
(req) => this.extractDocumentInfoFromRequest(req),
|
||||
(req) => this.onRestore(req)
|
||||
)
|
||||
.all('*', () => new Response('Not found', { status: 404 }))
|
||||
|
||||
readonly scheduler = new AlarmScheduler({
|
||||
storage: () => this.storage,
|
||||
alarms: {
|
||||
persist: async () => {
|
||||
const room = this.getRoomForPersistenceKey(this.documentInfo.slug)
|
||||
if (!room) return
|
||||
this.persistToDatabase(room.persistenceKey)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get documentInfo() {
|
||||
return assertExists(this._documentInfo, 'documentInfo must be present')
|
||||
}
|
||||
extractDocumentInfoFromRequest = async (req: IRequest) => {
|
||||
const slug = assertExists(req.params.roomId, 'roomId must be present')
|
||||
if (this._documentInfo) {
|
||||
assert(this._documentInfo.slug === slug, 'slug must match')
|
||||
} else {
|
||||
this._documentInfo = {
|
||||
version: CURRENT_DOCUMENT_INFO_VERSION,
|
||||
slug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle a request to the Durable Object.
|
||||
async fetch(req: IRequest) {
|
||||
const sentry = new Toucan({
|
||||
dsn: this.sentryDSN,
|
||||
request: req,
|
||||
allowedHeaders: ['user-agent'],
|
||||
allowedSearchParams: /(.*)/,
|
||||
})
|
||||
|
||||
try {
|
||||
return await this.router.handle(req).catch((err) => {
|
||||
console.error(err)
|
||||
sentry.captureException(err)
|
||||
|
||||
return new Response('Something went wrong', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
sentry.captureException(err)
|
||||
return new Response('Something went wrong', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async onRestore(req: IRequest) {
|
||||
const roomId = this.documentInfo.slug
|
||||
const roomKey = getR2KeyForRoom(roomId)
|
||||
const timestamp = ((await req.json()) as any).timestamp
|
||||
if (!timestamp) {
|
||||
return new Response('Missing timestamp', { status: 400 })
|
||||
}
|
||||
const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`)
|
||||
if (!data) {
|
||||
return new Response('Version not found', { status: 400 })
|
||||
}
|
||||
const dataText = await data.text()
|
||||
await this.r2.rooms.put(roomKey, dataText)
|
||||
const roomState = this.getRoomForPersistenceKey(roomId)
|
||||
if (!roomState) {
|
||||
// nothing else to do because the room is not currently in use
|
||||
return new Response()
|
||||
}
|
||||
const snapshot: RoomSnapshot = JSON.parse(dataText)
|
||||
const oldRoom = roomState.room
|
||||
const oldIds = oldRoom.getSnapshot().documents.map((d) => d.state.id)
|
||||
const newIds = new Set(snapshot.documents.map((d) => d.state.id))
|
||||
const removedIds = oldIds.filter((id) => !newIds.has(id))
|
||||
|
||||
const tombstones = { ...snapshot.tombstones }
|
||||
removedIds.forEach((id) => {
|
||||
tombstones[id] = oldRoom.clock + 1
|
||||
})
|
||||
newIds.forEach((id) => {
|
||||
delete tombstones[id]
|
||||
})
|
||||
|
||||
const newRoom = new TLSyncRoom(roomState.room.schema, {
|
||||
clock: oldRoom.clock + 1,
|
||||
documents: snapshot.documents.map((d) => ({
|
||||
lastChangedClock: oldRoom.clock + 1,
|
||||
state: d.state,
|
||||
})),
|
||||
schema: snapshot.schema,
|
||||
tombstones,
|
||||
})
|
||||
|
||||
// replace room with new one and kick out all the clients
|
||||
this.setRoomState(this.documentInfo.slug, { ...roomState, room: newRoom })
|
||||
oldRoom.close()
|
||||
|
||||
return new Response()
|
||||
}
|
||||
|
||||
async onRequest(req: IRequest) {
|
||||
// extract query params from request, should include instanceId
|
||||
const url = new URL(req.url)
|
||||
const params = Object.fromEntries(url.searchParams.entries())
|
||||
let { sessionKey, storeId } = params
|
||||
|
||||
// handle legacy param names
|
||||
sessionKey ??= params.instanceId
|
||||
storeId ??= params.localClientId
|
||||
|
||||
// Don't connect if we're already at max connections
|
||||
const roomState = this.getRoomForPersistenceKey(this.documentInfo.slug)
|
||||
if (roomState !== undefined) {
|
||||
if (roomState.room.sessions.size >= MAX_CONNECTIONS) {
|
||||
return new Response('Room is full', {
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create the websocket pair for the client
|
||||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||
|
||||
// Handle the connection (see TLServer)
|
||||
try {
|
||||
// block concurrency while initializing the room if that needs to happen
|
||||
await this.controller.blockConcurrencyWhile(() =>
|
||||
this.handleConnection({
|
||||
socket: serverWebSocket as any,
|
||||
persistenceKey: this.documentInfo.slug!,
|
||||
sessionKey,
|
||||
storeId,
|
||||
})
|
||||
)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
return new Response(e.message, { status: 500 })
|
||||
}
|
||||
|
||||
// Accept the websocket connection
|
||||
serverWebSocket.accept()
|
||||
serverWebSocket.addEventListener(
|
||||
'message',
|
||||
throttle(() => {
|
||||
this.schedulePersist()
|
||||
}, 2000)
|
||||
)
|
||||
serverWebSocket.addEventListener('close', () => {
|
||||
this.schedulePersist()
|
||||
})
|
||||
|
||||
return new Response(null, { status: 101, webSocket: clientWebSocket })
|
||||
}
|
||||
|
||||
logEvent(
|
||||
event:
|
||||
| {
|
||||
type: 'client'
|
||||
roomId: string
|
||||
name: string
|
||||
clientId: string
|
||||
instanceId: string
|
||||
localClientId: string
|
||||
}
|
||||
| {
|
||||
type: 'room'
|
||||
roomId: string
|
||||
name: string
|
||||
}
|
||||
) {
|
||||
switch (event.type) {
|
||||
case 'room': {
|
||||
this.measure?.writeDataPoint({
|
||||
blobs: [event.name, event.roomId], // we would add user/connection ids here if we could
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case 'client': {
|
||||
this.measure?.writeDataPoint({
|
||||
blobs: [event.name, event.roomId, event.clientId, event.instanceId], // we would add user/connection ids here if we could
|
||||
indexes: [event.localClientId],
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRoomForPersistenceKey(_persistenceKey: string): RoomState | undefined {
|
||||
return this._roomState // only one room per worker
|
||||
}
|
||||
|
||||
setRoomState(_persistenceKey: string, roomState: RoomState): void {
|
||||
this.deleteRoomState()
|
||||
this._roomState = roomState
|
||||
}
|
||||
|
||||
deleteRoomState(): void {
|
||||
this._roomState = undefined
|
||||
}
|
||||
|
||||
// Load the room's drawing data from supabase
|
||||
override async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
|
||||
try {
|
||||
const key = getR2KeyForRoom(persistenceKey)
|
||||
// when loading, prefer to fetch documents from the bucket
|
||||
const roomFromBucket = await this.r2.rooms.get(key)
|
||||
if (roomFromBucket) {
|
||||
return { type: 'room_found', snapshot: await roomFromBucket.json() }
|
||||
}
|
||||
|
||||
// if we don't have a room in the bucket, try to load from supabase
|
||||
if (!this.supabaseClient) return { type: 'room_not_found' }
|
||||
const { data, error } = await this.supabaseClient
|
||||
.from(this.supabaseTable)
|
||||
.select('*')
|
||||
.eq('slug', persistenceKey)
|
||||
|
||||
if (error) {
|
||||
this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_load_from_db' })
|
||||
|
||||
console.error('failed to retrieve document', persistenceKey, error)
|
||||
return { type: 'error', error: new Error(error.message) }
|
||||
}
|
||||
// if it didn't find a document, data will be an empty array
|
||||
if (data.length === 0) {
|
||||
return { type: 'room_not_found' }
|
||||
}
|
||||
|
||||
const roomFromSupabase = data[0] as PersistedRoomSnapshotForSupabase
|
||||
return { type: 'room_found', snapshot: roomFromSupabase.drawing }
|
||||
} catch (error) {
|
||||
this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_load_from_db' })
|
||||
|
||||
console.error('failed to fetch doc', persistenceKey, error)
|
||||
return { type: 'error', error: error as Error }
|
||||
}
|
||||
}
|
||||
|
||||
_isPersisting = false
|
||||
_lastPersistedClock: number | null = null
|
||||
|
||||
// Save the room to supabase
|
||||
async persistToDatabase(persistenceKey: string) {
|
||||
if (this._isPersisting) {
|
||||
setTimeout(() => {
|
||||
this.schedulePersist()
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this._isPersisting = true
|
||||
|
||||
const roomState = this.getRoomForPersistenceKey(persistenceKey)
|
||||
if (!roomState) {
|
||||
// room was closed
|
||||
return
|
||||
}
|
||||
|
||||
const { room } = roomState
|
||||
const { clock } = room
|
||||
if (this._lastPersistedClock === clock) return
|
||||
|
||||
try {
|
||||
const snapshot = JSON.stringify(room.getSnapshot())
|
||||
|
||||
const key = getR2KeyForRoom(persistenceKey)
|
||||
await Promise.all([
|
||||
this.r2.rooms.put(key, snapshot),
|
||||
this.r2.versionCache.put(key + `/` + new Date().toISOString(), snapshot),
|
||||
])
|
||||
this._lastPersistedClock = clock
|
||||
} catch (error) {
|
||||
this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_persist_to_db' })
|
||||
console.error('failed to persist document', persistenceKey, error)
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
this._isPersisting = false
|
||||
}
|
||||
}
|
||||
|
||||
async schedulePersist() {
|
||||
await this.scheduler.scheduleAlarmAfter('persist', PERSIST_INTERVAL_MS, {
|
||||
overwrite: 'if-sooner',
|
||||
})
|
||||
}
|
||||
|
||||
// Will be called automatically when the alarm ticks.
|
||||
async alarm() {
|
||||
await this.scheduler.onAlarm()
|
||||
}
|
||||
}
|
5
apps/dotcom-worker/src/lib/config.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* How often we the document to R2?
|
||||
* 10 seconds.
|
||||
*/
|
||||
export const PERSIST_INTERVAL_MS = 10_000
|
3
apps/dotcom-worker/src/lib/r2.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function getR2KeyForRoom(persistenceKey: string) {
|
||||
return `public_rooms/${persistenceKey}`
|
||||
}
|
45
apps/dotcom-worker/src/lib/routes/createRoom.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { RoomSnapshot, schema } from '@tldraw/tlsync'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type SnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
|
||||
// Sets up a new room based on a provided snapshot, e.g. when a user clicks the "Share" buttons or the "Fork project" buttons.
|
||||
export async function createRoom(request: IRequest, env: Environment): Promise<Response> {
|
||||
// The data sent from the client will include the data for the new room
|
||||
const data = (await request.json()) as SnapshotRequestBody
|
||||
|
||||
// There's a chance the data will be invalid, so we check it first
|
||||
const snapshotResult = validateSnapshot(data)
|
||||
if (!snapshotResult.ok) {
|
||||
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create a new slug for the room
|
||||
const slug = nanoid()
|
||||
|
||||
// Create the new snapshot
|
||||
const snapshot: RoomSnapshot = {
|
||||
schema: schema.serialize(),
|
||||
clock: 0,
|
||||
documents: Object.values(snapshotResult.value).map((r) => ({
|
||||
state: r,
|
||||
lastChangedClock: 0,
|
||||
})),
|
||||
tombstones: {},
|
||||
}
|
||||
|
||||
// Bang that snapshot into the database
|
||||
await env.ROOMS.put(getR2KeyForRoom(slug), JSON.stringify(snapshot))
|
||||
|
||||
// Send back the slug so that the client can redirect to the new room
|
||||
return new Response(JSON.stringify({ error: false, slug }))
|
||||
}
|
47
apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Environment } from '../types'
|
||||
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
|
||||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
||||
const data = (await request.json()) as CreateSnapshotRequestBody
|
||||
|
||||
const snapshotResult = validateSnapshot(data)
|
||||
if (!snapshotResult.ok) {
|
||||
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const roomId = `v2_c_${nanoid()}`
|
||||
|
||||
const persistedRoomSnapshot = {
|
||||
parent_slug: data.parent_slug,
|
||||
slug: roomId,
|
||||
drawing: {
|
||||
schema: data.schema,
|
||||
clock: 0,
|
||||
documents: Object.values(data.snapshot).map((r) => ({
|
||||
state: r,
|
||||
lastChangedClock: 0,
|
||||
})),
|
||||
tombstones: {},
|
||||
},
|
||||
}
|
||||
|
||||
const supabase = createSupabaseClient(env)
|
||||
if (!supabase) return noSupabaseSorry()
|
||||
|
||||
const supabaseTable = getSnapshotsTable(env)
|
||||
await supabase.from(supabaseTable).insert(persistedRoomSnapshot)
|
||||
|
||||
return new Response(JSON.stringify({ error: false, roomId }))
|
||||
}
|
16
apps/dotcom-worker/src/lib/routes/forwardRoomRequest.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
|
||||
// Forwards a room request to the durable object associated with that room
|
||||
export async function forwardRoomRequest(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return fourOhFour()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
// Set up the durable object for this room
|
||||
const id = env.TLDR_DOC.idFromName(`/r/${roomId}`)
|
||||
return env.TLDR_DOC.get(id).fetch(request)
|
||||
}
|
39
apps/dotcom-worker/src/lib/routes/getRoomHistory.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
|
||||
// Returns the history of a room as a list of objects with timestamps
|
||||
export async function getRoomHistory(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return fourOhFour()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
const versionCacheBucket = env.ROOMS_HISTORY_EPHEMERAL
|
||||
const bucketKey = getR2KeyForRoom(roomId)
|
||||
|
||||
let batch = await versionCacheBucket.list({
|
||||
prefix: bucketKey,
|
||||
})
|
||||
const result = [...batch.objects.map((o) => o.key)]
|
||||
|
||||
// ✅ - use the truncated property to check if there are more
|
||||
// objects to be returned
|
||||
while (batch.truncated) {
|
||||
const next = await versionCacheBucket.list({
|
||||
cursor: batch.cursor,
|
||||
})
|
||||
result.push(...next.objects.map((o) => o.key))
|
||||
|
||||
batch = next
|
||||
}
|
||||
|
||||
// these are ISO timestamps, so they sort lexicographically
|
||||
result.sort()
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
30
apps/dotcom-worker/src/lib/routes/getRoomHistorySnapshot.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { IRequest } from 'itty-router'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
|
||||
// Get a snapshot of the room at a given point in time
|
||||
export async function getRoomHistorySnapshot(
|
||||
request: IRequest,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
|
||||
if (!roomId) return fourOhFour()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
const timestamp = request.params.timestamp
|
||||
|
||||
const versionCacheBucket = env.ROOMS_HISTORY_EPHEMERAL
|
||||
|
||||
const result = await versionCacheBucket.get(getR2KeyForRoom(roomId) + '/' + timestamp)
|
||||
|
||||
if (!result) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
return new Response(result.body, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
36
apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { RoomSnapshot } from '@tldraw/tlsync'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||
|
||||
// Returns a snapshot of the room at a given point in time
|
||||
export async function getRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) return fourOhFour()
|
||||
|
||||
// Create a supabase client
|
||||
const supabase = createSupabaseClient(env)
|
||||
if (!supabase) return noSupabaseSorry()
|
||||
|
||||
// Get the snapshot from the table
|
||||
const supabaseTable = getSnapshotsTable(env)
|
||||
const result = await supabase
|
||||
.from(supabaseTable)
|
||||
.select('drawing')
|
||||
.eq('slug', roomId)
|
||||
.maybeSingle()
|
||||
const data = result.data?.drawing as RoomSnapshot
|
||||
|
||||
if (!data) return fourOhFour()
|
||||
|
||||
// Send back the snapshot!
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
records: data.documents.map((d) => d.state),
|
||||
schema: data.schema,
|
||||
error: false,
|
||||
})
|
||||
)
|
||||
}
|
20
apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
|
||||
// This is the entry point for joining an existing room
|
||||
export async function joinExistingRoom(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) return fourOhFour()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
// This needs to be a websocket request!
|
||||
if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||
// Set up the durable object for this room
|
||||
const id = env.TLDR_DOC.idFromName(`/r/${roomId}`)
|
||||
return env.TLDR_DOC.get(id).fetch(request)
|
||||
}
|
||||
|
||||
return fourOhFour()
|
||||
}
|
29
apps/dotcom-worker/src/lib/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// https://developers.cloudflare.com/analytics/analytics-engine/
|
||||
|
||||
// This type isn't available in @cloudflare/workers-types yet
|
||||
export type Analytics = {
|
||||
writeDataPoint(data: {
|
||||
blobs?: string[]
|
||||
doubles?: number[]
|
||||
indexes?: [string] // only one here
|
||||
}): void
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
// bindings
|
||||
TLDR_DOC: DurableObjectNamespace
|
||||
MEASURE: Analytics | undefined
|
||||
|
||||
ROOMS: R2Bucket
|
||||
ROOMS_HISTORY_EPHEMERAL: R2Bucket
|
||||
|
||||
// env vars
|
||||
SUPABASE_URL: string | undefined
|
||||
SUPABASE_KEY: string | undefined
|
||||
|
||||
APP_ORIGIN: string | undefined
|
||||
|
||||
TLDRAW_ENV: string | undefined
|
||||
SENTRY_DSN: string | undefined
|
||||
IS_LOCAL: string | undefined
|
||||
}
|
12
apps/dotcom-worker/src/lib/utils/createSupabaseClient.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { createClient } from '@supabase/supabase-js'
|
||||
import { Environment } from '../types'
|
||||
|
||||
export function createSupabaseClient(env: Environment) {
|
||||
return env.SUPABASE_URL && env.SUPABASE_KEY
|
||||
? createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
|
||||
: console.warn('No supabase credentials, loading from supabase disabled')
|
||||
}
|
||||
|
||||
export function noSupabaseSorry() {
|
||||
return new Response(JSON.stringify({ error: true, message: 'Could not create supabase client' }))
|
||||
}
|
5
apps/dotcom-worker/src/lib/utils/fourOhFour.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export async function fourOhFour() {
|
||||
return new Response('Not found', {
|
||||
status: 404,
|
||||
})
|
||||
}
|
10
apps/dotcom-worker/src/lib/utils/getSnapshotsTable.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Environment } from '../types'
|
||||
|
||||
export function getSnapshotsTable(env: Environment) {
|
||||
if (env.TLDRAW_ENV === 'production') {
|
||||
return 'snapshots'
|
||||
} else if (env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'preview') {
|
||||
return 'snapshots_staging'
|
||||
}
|
||||
return 'snapshots_dev'
|
||||
}
|
9
apps/dotcom-worker/src/lib/utils/roomIdIsTooLong.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
const MAX_ROOM_ID_LENGTH = 128
|
||||
|
||||
export function isRoomIdTooLong(roomId: string) {
|
||||
return roomId.length > MAX_ROOM_ID_LENGTH
|
||||
}
|
||||
|
||||
export function roomIdIsTooLong() {
|
||||
return new Response('Room ID too long', { status: 400 })
|
||||
}
|
19
apps/dotcom-worker/src/lib/utils/throttle.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export function throttle(fn: () => void, limit: number) {
|
||||
let waiting = false
|
||||
let invokeOnTail = false
|
||||
return () => {
|
||||
if (!waiting) {
|
||||
fn()
|
||||
waiting = true
|
||||
setTimeout(() => {
|
||||
waiting = false
|
||||
if (invokeOnTail) {
|
||||
invokeOnTail = false
|
||||
fn()
|
||||
}
|
||||
}, limit)
|
||||
} else {
|
||||
invokeOnTail = true
|
||||
}
|
||||
}
|
||||
}
|
50
apps/dotcom-worker/src/lib/utils/validateSnapshot.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { schema } from '@tldraw/tlsync'
|
||||
import { Result, objectMapEntries } from '@tldraw/utils'
|
||||
|
||||
type SnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
|
||||
export function validateSnapshot(
|
||||
body: SnapshotRequestBody
|
||||
): Result<SerializedStore<TLRecord>, string> {
|
||||
// Migrate the snapshot using the provided schema
|
||||
const migrationResult = schema.migrateStoreSnapshot({ store: body.snapshot, schema: body.schema })
|
||||
if (migrationResult.type === 'error') {
|
||||
return Result.err(migrationResult.reason)
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [id, record] of objectMapEntries(migrationResult.value)) {
|
||||
// Throw if any records have mis-matched ids
|
||||
if (id !== record.id) {
|
||||
throw new Error(`Record id ${id} does not match record id ${record.id}`)
|
||||
}
|
||||
|
||||
// Get the corresponding record type from the provided schema
|
||||
const recordType = schema.types[record.typeName]
|
||||
|
||||
// Throw if any records have missing record type definitions
|
||||
if (!recordType) {
|
||||
throw new Error(`Missing definition for record type ${record.typeName}`)
|
||||
}
|
||||
|
||||
// Remove all records whose record type scopes are not 'document'.
|
||||
// This is legacy cleanup code.
|
||||
if (recordType.scope !== 'document') {
|
||||
delete migrationResult.value[id]
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate the record
|
||||
recordType.validate(record)
|
||||
}
|
||||
} catch (e: any) {
|
||||
return Result.err(e.message)
|
||||
}
|
||||
|
||||
return Result.ok(migrationResult.value)
|
||||
}
|
103
apps/dotcom-worker/src/lib/worker.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
import { Router, createCors } from 'itty-router'
|
||||
import { env } from 'process'
|
||||
import Toucan from 'toucan-js'
|
||||
import { createRoom } from './routes/createRoom'
|
||||
import { createRoomSnapshot } from './routes/createRoomSnapshot'
|
||||
import { forwardRoomRequest } from './routes/forwardRoomRequest'
|
||||
import { getRoomHistory } from './routes/getRoomHistory'
|
||||
import { getRoomHistorySnapshot } from './routes/getRoomHistorySnapshot'
|
||||
import { getRoomSnapshot } from './routes/getRoomSnapshot'
|
||||
import { joinExistingRoom } from './routes/joinExistingRoom'
|
||||
import { Environment } from './types'
|
||||
import { fourOhFour } from './utils/fourOhFour'
|
||||
export { TLDrawDurableObject } from './TLDrawDurableObject'
|
||||
|
||||
const { preflight, corsify } = createCors({
|
||||
origins: Object.assign([], { includes: (origin: string) => isAllowedOrigin(origin) }),
|
||||
})
|
||||
|
||||
const router = Router()
|
||||
.all('*', preflight)
|
||||
.all('*', blockUnknownOrigins)
|
||||
.post('/new-room', createRoom)
|
||||
.post('/snapshots', createRoomSnapshot)
|
||||
.get('/snapshot/:roomId', getRoomSnapshot)
|
||||
.get('/r/:roomId', joinExistingRoom)
|
||||
.get('/r/:roomId/history', getRoomHistory)
|
||||
.get('/r/:roomId/history/:timestamp', getRoomHistorySnapshot)
|
||||
.post('/r/:roomId/restore', forwardRoomRequest)
|
||||
.all('*', fourOhFour)
|
||||
|
||||
const Worker = {
|
||||
fetch(request: Request, env: Environment, context: ExecutionContext) {
|
||||
const sentry = new Toucan({
|
||||
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.
|
||||
request, // request is not included in 'context', so we set it here.
|
||||
allowedHeaders: ['user-agent'],
|
||||
allowedSearchParams: /(.*)/,
|
||||
})
|
||||
|
||||
return router
|
||||
.handle(request, env, context)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
sentry.captureException(err)
|
||||
|
||||
return new Response('Something went wrong', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
})
|
||||
})
|
||||
.then((response) => {
|
||||
const setCookies = response.headers.getAll('set-cookie')
|
||||
// unfortunately corsify mishandles the set-cookie header, so
|
||||
// we need to manually add it back in
|
||||
const result = corsify(response)
|
||||
if ([...setCookies].length === 0) {
|
||||
return result
|
||||
}
|
||||
const newResponse = new Response(result.body, result)
|
||||
newResponse.headers.delete('set-cookie')
|
||||
// add cookies from original response
|
||||
for (const cookie of setCookies) {
|
||||
newResponse.headers.append('set-cookie', cookie)
|
||||
}
|
||||
return newResponse
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin: string) {
|
||||
if (origin === 'http://localhost:3000') return true
|
||||
if (origin === 'http://localhost:5420') return true
|
||||
if (origin.endsWith('.tldraw.com')) return true
|
||||
if (origin.endsWith('-tldraw.vercel.app')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function blockUnknownOrigins(request: Request) {
|
||||
// allow requests for the same origin (new rewrite routing for SPA)
|
||||
if (request.headers.get('sec-fetch-site') === 'same-origin') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (new URL(request.url).pathname === '/auth/callback') {
|
||||
// allow auth callback because we use the special cookie to verify
|
||||
// the request
|
||||
return undefined
|
||||
}
|
||||
|
||||
const origin = request.headers.get('origin')
|
||||
if (env.IS_LOCAL !== 'true' && (!origin || !isAllowedOrigin(origin))) {
|
||||
console.error('Attempting to connect from an invalid origin:', origin, env, request)
|
||||
return new Response('Not allowed', { status: 403 })
|
||||
}
|
||||
|
||||
// origin doesn't match, so we can continue
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default Worker
|
16
apps/dotcom-worker/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src", "scripts"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../packages/tlsync" },
|
||||
{ "path": "../../packages/tlschema" },
|
||||
{ "path": "../../packages/validate" },
|
||||
{ "path": "../../packages/store" },
|
||||
{ "path": "../../packages/utils" }
|
||||
]
|
||||
}
|
116
apps/dotcom-worker/wrangler.toml
Normal file
|
@ -0,0 +1,116 @@
|
|||
main = "src/lib/worker.ts"
|
||||
compatibility_date = "2023-10-16"
|
||||
|
||||
[dev]
|
||||
port = 8787
|
||||
|
||||
# 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 = ["TLDrawDurableObject"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
new_classes = ["TLProWorkspaceDurableObject"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v3"
|
||||
deleted_classes = ["TLProWorkspaceDurableObject"]
|
||||
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
#################### Environment names ####################
|
||||
# dev should never actually get deployed anywhere
|
||||
[env.dev]
|
||||
name = "dev-tldraw-multiplayer"
|
||||
|
||||
# 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 = "main-tldraw-multiplayer"
|
||||
|
||||
# production gets the proper name
|
||||
[env.production]
|
||||
name = "tldraw-multiplayer"
|
||||
|
||||
#################### Durable objects ####################
|
||||
# durable objects have the same configuration in all environments:
|
||||
[[env.dev.durable_objects.bindings]]
|
||||
name = "TLDR_DOC"
|
||||
class_name = "TLDrawDurableObject"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "TLDR_DOC", class_name = "TLDrawDurableObject" },
|
||||
]
|
||||
|
||||
[[env.preview.durable_objects.bindings]]
|
||||
name = "TLDR_DOC"
|
||||
class_name = "TLDrawDurableObject"
|
||||
|
||||
[[env.staging.durable_objects.bindings]]
|
||||
name = "TLDR_DOC"
|
||||
class_name = "TLDrawDurableObject"
|
||||
|
||||
[[env.production.durable_objects.bindings]]
|
||||
name = "TLDR_DOC"
|
||||
class_name = "TLDrawDurableObject"
|
||||
|
||||
#################### Analytics engine ####################
|
||||
# durable objects have the same configuration in all environments:
|
||||
[[env.dev.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.preview.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.staging.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.production.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
#################### Rooms R2 bucket ####################
|
||||
# in dev, we write to the preview bucket and need a `preview_bucket_name`
|
||||
[[env.dev.r2_buckets]]
|
||||
binding = "ROOMS"
|
||||
bucket_name = "rooms-preview"
|
||||
preview_bucket_name = "rooms-preview"
|
||||
|
||||
# in preview and staging we write to the preview bucket
|
||||
[[env.preview.r2_buckets]]
|
||||
binding = "ROOMS"
|
||||
bucket_name = "rooms-preview"
|
||||
|
||||
[[env.staging.r2_buckets]]
|
||||
binding = "ROOMS"
|
||||
bucket_name = "rooms-preview"
|
||||
|
||||
# in production, we write to the main bucket
|
||||
[[env.production.r2_buckets]]
|
||||
binding = "ROOMS"
|
||||
bucket_name = "rooms"
|
||||
|
||||
#################### Rooms History bucket ####################
|
||||
# in dev, we write to the preview bucket and need a `preview_bucket_name`
|
||||
[[env.dev.r2_buckets]]
|
||||
binding = "ROOMS_HISTORY_EPHEMERAL"
|
||||
bucket_name = "rooms-history-ephemeral-preview"
|
||||
preview_bucket_name = "rooms-history-ephemeral-preview"
|
||||
|
||||
# in preview and staging we write to the preview bucket
|
||||
[[env.preview.r2_buckets]]
|
||||
binding = "ROOMS_HISTORY_EPHEMERAL"
|
||||
bucket_name = "rooms-history-ephemeral-preview"
|
||||
|
||||
[[env.staging.r2_buckets]]
|
||||
binding = "ROOMS_HISTORY_EPHEMERAL"
|
||||
bucket_name = "rooms-history-ephemeral-preview"
|
||||
|
||||
# in production, we write to the main bucket
|
||||
[[env.production.r2_buckets]]
|
||||
binding = "ROOMS_HISTORY_EPHEMERAL"
|
||||
bucket_name = "rooms-history-ephemeral"
|
42
apps/dotcom/.gitignore
vendored
Normal file
|
@ -0,0 +1,42 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# PWA build artifacts
|
||||
/public/*.js
|
||||
/dev-dist
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
221
apps/dotcom/CHANGELOG.md
Normal file
|
@ -0,0 +1,221 @@
|
|||
# app
|
||||
|
||||
## 2.0.0-alpha.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @tldraw/editor@2.0.0-alpha.11
|
||||
- @tldraw/polyfills@2.0.0-alpha.10
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.11
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.10
|
||||
- @tldraw/ui@2.0.0-alpha.11
|
||||
- @tldraw/utils@2.0.0-alpha.10
|
||||
- @tldraw/app-shared@2.0.0-alpha.11
|
||||
|
||||
## 2.0.0-alpha.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [4b4399b6e]
|
||||
- @tldraw/polyfills@2.0.0-alpha.9
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.10
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.9
|
||||
- @tldraw/ui@2.0.0-alpha.10
|
||||
- @tldraw/utils@2.0.0-alpha.9
|
||||
- @tldraw/editor@2.0.0-alpha.10
|
||||
- @tldraw/app-shared@2.0.0-alpha.10
|
||||
|
||||
## 2.0.0-alpha.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Release day!
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.9
|
||||
- @tldraw/editor@2.0.0-alpha.9
|
||||
- @tldraw/polyfills@2.0.0-alpha.8
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.9
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.8
|
||||
- @tldraw/ui@2.0.0-alpha.9
|
||||
- @tldraw/utils@2.0.0-alpha.8
|
||||
|
||||
## 2.0.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [23dd81cfe]
|
||||
- @tldraw/editor@2.0.0-alpha.8
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.8
|
||||
- @tldraw/ui@2.0.0-alpha.8
|
||||
- @tldraw/app-shared@2.0.0-alpha.8
|
||||
|
||||
## 2.0.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bug fixes.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.7
|
||||
- @tldraw/editor@2.0.0-alpha.7
|
||||
- @tldraw/polyfills@2.0.0-alpha.7
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.7
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.7
|
||||
- @tldraw/ui@2.0.0-alpha.7
|
||||
- @tldraw/utils@2.0.0-alpha.7
|
||||
|
||||
## 2.0.0-alpha.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add licenses.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.6
|
||||
- @tldraw/editor@2.0.0-alpha.6
|
||||
- @tldraw/polyfills@2.0.0-alpha.6
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.6
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.6
|
||||
- @tldraw/ui@2.0.0-alpha.6
|
||||
- @tldraw/utils@2.0.0-alpha.6
|
||||
|
||||
## 2.0.0-alpha.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add CSS files to tldraw/tldraw.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.5
|
||||
- @tldraw/editor@2.0.0-alpha.5
|
||||
- @tldraw/polyfills@2.0.0-alpha.5
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.5
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.5
|
||||
- @tldraw/ui@2.0.0-alpha.5
|
||||
- @tldraw/utils@2.0.0-alpha.5
|
||||
|
||||
## 2.0.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add children to tldraw/tldraw
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.4
|
||||
- @tldraw/editor@2.0.0-alpha.4
|
||||
- @tldraw/polyfills@2.0.0-alpha.4
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.4
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.4
|
||||
- @tldraw/ui@2.0.0-alpha.4
|
||||
- @tldraw/utils@2.0.0-alpha.4
|
||||
|
||||
## 2.0.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Change permissions.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.3
|
||||
- @tldraw/editor@2.0.0-alpha.3
|
||||
- @tldraw/polyfills@2.0.0-alpha.3
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.3
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.3
|
||||
- @tldraw/ui@2.0.0-alpha.3
|
||||
- @tldraw/utils@2.0.0-alpha.3
|
||||
|
||||
## 2.0.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tldraw, editor
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@2.0.0-alpha.2
|
||||
- @tldraw/editor@2.0.0-alpha.2
|
||||
- @tldraw/polyfills@2.0.0-alpha.2
|
||||
- @tldraw/tlsync-client@2.0.0-alpha.2
|
||||
- @tldraw/tlvalidate@2.0.0-alpha.2
|
||||
- @tldraw/ui@2.0.0-alpha.2
|
||||
- @tldraw/utils@2.0.0-alpha.2
|
||||
|
||||
## 0.1.0-alpha.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix stale reactors.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@0.1.0-alpha.11
|
||||
- @tldraw/polyfills@0.1.0-alpha.11
|
||||
- @tldraw/tldraw-beta@0.1.0-alpha.11
|
||||
- @tldraw/tlsync-client@0.1.0-alpha.11
|
||||
- @tldraw/tlvalidate@0.1.0-alpha.11
|
||||
- @tldraw/ui@0.1.0-alpha.11
|
||||
- @tldraw/utils@0.1.0-alpha.11
|
||||
|
||||
## 0.1.0-alpha.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix type export bug.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@0.1.0-alpha.10
|
||||
- @tldraw/polyfills@0.1.0-alpha.10
|
||||
- @tldraw/tldraw-beta@0.1.0-alpha.10
|
||||
- @tldraw/tlsync-client@0.1.0-alpha.10
|
||||
- @tldraw/tlvalidate@0.1.0-alpha.10
|
||||
- @tldraw/ui@0.1.0-alpha.10
|
||||
- @tldraw/utils@0.1.0-alpha.10
|
||||
|
||||
## 0.1.0-alpha.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix import bugs.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@0.1.0-alpha.9
|
||||
- @tldraw/polyfills@0.1.0-alpha.9
|
||||
- @tldraw/tldraw-beta@0.1.0-alpha.9
|
||||
- @tldraw/tlsync-client@0.1.0-alpha.9
|
||||
- @tldraw/tlvalidate@0.1.0-alpha.9
|
||||
- @tldraw/ui@0.1.0-alpha.9
|
||||
- @tldraw/utils@0.1.0-alpha.9
|
||||
|
||||
## 0.1.0-alpha.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Changes validation requirements, exports validation helpers.
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@0.1.0-alpha.8
|
||||
- @tldraw/polyfills@0.1.0-alpha.8
|
||||
- @tldraw/tldraw-beta@0.1.0-alpha.8
|
||||
- @tldraw/tlsync-client@0.1.0-alpha.8
|
||||
- @tldraw/tlvalidate@0.1.0-alpha.8
|
||||
- @tldraw/ui@0.1.0-alpha.8
|
||||
- @tldraw/utils@0.1.0-alpha.8
|
||||
|
||||
## 0.1.0-alpha.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - Pre-pre-release update
|
||||
- Updated dependencies
|
||||
- @tldraw/app-shared@0.1.0-alpha.7
|
||||
- @tldraw/polyfills@0.1.0-alpha.7
|
||||
- @tldraw/tldraw-beta@0.1.0-alpha.7
|
||||
- @tldraw/tlsync-client@0.1.0-alpha.7
|
||||
- @tldraw/tlvalidate@0.1.0-alpha.7
|
||||
- @tldraw/ui@0.1.0-alpha.7
|
||||
- @tldraw/utils@0.1.0-alpha.7
|
||||
|
||||
## 0.0.2-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix error with HMR
|
||||
- Updated dependencies
|
||||
- @tldraw/polyfills@0.0.2-alpha.1
|
||||
|
||||
## 0.0.2-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Initial release
|
||||
- Updated dependencies
|
||||
- @tldraw/polyfills@0.0.2-alpha.0
|
80
apps/dotcom/README.md
Normal file
|
@ -0,0 +1,80 @@
|
|||
# Project overview
|
||||
|
||||
This project is a Next.js application which contains the **tldraw free** as well as the **tldraw pro** applications. We are currently using the Next.js 13 option of having both `pages` (tldraw free) and `app` (tldraw pro) directory inside the same app. We did this since the free offering is the continuation of a Next.js version 12 app and it allowed us to combine it with the new App router option from Next.js 13 for tldraw pro without having to do a full migration to App router.
|
||||
|
||||
We also split the supabase into two projects:
|
||||
|
||||
- `tldraw-v2` for tldraw free where we mainly store the snapshots data
|
||||
- `tldraw-pro` for tldraw pro which holds all the relational data that the pro version requires
|
||||
|
||||
On top of that we also use R2 for storing the documents data.
|
||||
|
||||
# How to run the project
|
||||
|
||||
## Tldraw pro
|
||||
|
||||
The development of tldraw pro happens against a local supabase instance. To set that up, you'll
|
||||
first need to [install & start docker](https://www.docker.com/products/docker-desktop/).
|
||||
|
||||
Once docker is started & you've run `yarn` to install tldraw's dependencies, the rest should be
|
||||
handled automatically. Running `yarn dev-app` will:
|
||||
|
||||
1. Start a local instance of supabase
|
||||
2. Run any database migrations
|
||||
3. Update your .env.local file with credentials for your local supabase instance
|
||||
4. Start tldraw
|
||||
|
||||
The [supabase local development docs](https://supabase.com/docs/guides/cli/local-development) are a
|
||||
good reference. When working on tldraw, the `supabase` command is available by running `yarn
|
||||
supabase` in the `apps/app` directory e.g. `yarn supabase status`.
|
||||
|
||||
When you're finished, we don't stop supabase because it takes a while each time we start and stop
|
||||
it. Run `yarn supabase stop` to stop it manually.
|
||||
|
||||
If you write any new database migrations, you can apply those with `yarn supabase migration up`.
|
||||
|
||||
## Some helpers
|
||||
|
||||
1. You can see your db schema at the `Studio URL` printed out in the step 2.
|
||||
2. If you ever need to reset your local supabase instance you can run `supabase db reset` in the root of `apps/app` project.
|
||||
3. The production version of Supabase sends out emails for certain events (email confirmation link, password reset link, etc). In local development you can find these emails at the `Inbucket URL` printed out in the step 2.
|
||||
|
||||
## Tldraw free
|
||||
|
||||
The development of tldraw free happens against the production supabase instance. We only store snapshots data to one of the three tables, depending on the environment. The tables are:
|
||||
|
||||
- `snapshots` - for production
|
||||
- `snapshots_staging` - for staging
|
||||
- `snapshots_dev` - for development
|
||||
|
||||
For local development you need to add the following env variables to `.env.local`:
|
||||
|
||||
- `SUPABASE_URL` - use the production supabase url
|
||||
- `SUPABASE_KEY` - use the production supabase anon key
|
||||
|
||||
Once you have the environment variables set up you can run `yarn dev-app` from the root folder of our repo to start developing.
|
||||
|
||||
## Running database tests
|
||||
|
||||
You need to have a psql client [installed](https://www.timescale.com/blog/how-to-install-psql-on-mac-ubuntu-debian-windows/). You can then run `yarn test-supabase` to run [db tests](https://supabase.com/docs/guides/database/extensions/pgtap).
|
||||
|
||||
## Sending emails
|
||||
|
||||
We are using [Resend](https://resend.com/) for sending emails. It allows us to write emails as React components. Emails live in a separate app `apps/tl-emails`.
|
||||
|
||||
Right now we are only using Resend via Supabase, but in the future we will probably also include Resend in our application and send emails directly.
|
||||
|
||||
The development workflow is as follows:
|
||||
|
||||
### 1. Creating / updating an email template
|
||||
To start the development server for email run `yarn dev-email` from the root folder of our repo. You can then open [http://localhost:3333](http://localhost:3333) to see the result. This allows for quick local development of email templates.
|
||||
|
||||
Any images you want to use in the email should be uploaded to supabase to the `email` bucket.
|
||||
|
||||
Supabase provides some custom params (like the magic link url) that we can insert into our email, [check their website](https://supabase.com/dashboard/project/faafybhoymfftncjttyq/auth/templates) for more info.
|
||||
|
||||
### 2. Generating the `html` version of the email
|
||||
Once you are happy with the email template you can run `yarn build-email` from the root folder of our repo. This will generate the `html` version of the email and place it in `apps/tl-emails/out` folder.
|
||||
|
||||
### 3. Updating the template in Supabase
|
||||
Once you have the `html` version of the email you can copy it into the Supabase template editor. You can find the templates [here](https://supabase.com/dashboard/project/faafybhoymfftncjttyq/auth/templates).
|
17
apps/dotcom/decs.d.ts
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
declare namespace React {
|
||||
interface HTMLAttributes {
|
||||
/**
|
||||
* Indicates the browser should ignore the element and its contents in terms of interaction.
|
||||
* This is a boolean attribute but isn't properly supported by react yet - pass "" to enable
|
||||
* it, or undefined to disable it.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert
|
||||
*/
|
||||
inert?: ''
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>
|
||||
export default content
|
||||
}
|
49
apps/dotcom/index.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<meta name="theme-color" content="#FFFFFF" data-rh="true" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="application-name" content="tldraw" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="tldraw" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#FFFFFF" />
|
||||
|
||||
<meta name="description" content="A free and instant collaborative diagramming tool." />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="msapplication-config" content="browserconfig.xml" />
|
||||
<meta name="msapplication-TileColor" content="#FFFFFF" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="apple-touch-icon" href="/touch-icon-iphone.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/apple-touch-icon-167x167.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:url" content="https://www.tldraw.com/" />
|
||||
<meta name="twitter:title" content="tldraw" />
|
||||
<meta name="twitter:description" content="A free and instant collaborative diagramming tool." />
|
||||
<meta name="twitter:image" content="https://www.tldraw.com/social-twitter.png" />
|
||||
<meta name="twitter:creator" content="@tldraw" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="tldraw" />
|
||||
<meta property="og:description" content="A free and instant collaborative diagramming tool." />
|
||||
<meta property="og:site_name" content="tldraw" />
|
||||
<meta property="og:url" content="https://www.tldraw.com/" />
|
||||
<meta property="og:image" content="https://www.tldraw.com/social-og.png" />
|
||||
<title>tldraw</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" class="site-wrapper"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
64
apps/dotcom/package.json
Normal file
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "dotcom",
|
||||
"description": "The production app for tldraw.",
|
||||
"version": "2.0.0-alpha.11",
|
||||
"private": true,
|
||||
"packageManager": "yarn@3.5.0",
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "yarn run -T tsx scripts/dev-app.ts",
|
||||
"build": "yarn run -T tsx scripts/build.ts",
|
||||
"start": "VITE_PREVIEW=1 yarn run -T tsx scripts/dev-app.ts",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts",
|
||||
"test": "lazy inherit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-popover": "1.0.6-rc.5",
|
||||
"@sentry/integrations": "^7.34.0",
|
||||
"@sentry/react": "^7.77.0",
|
||||
"@tldraw/assets": "workspace:*",
|
||||
"@tldraw/tldraw": "workspace:*",
|
||||
"@tldraw/tlsync": "workspace:*",
|
||||
"@vercel/analytics": "^1.0.1",
|
||||
"browser-fs-access": "^0.33.0",
|
||||
"idb": "^7.1.1",
|
||||
"nanoid": "4.0.2",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet-async": "^1.3.0",
|
||||
"react-router-dom": "^6.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/cli": "^2.25.0",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.2.33",
|
||||
"@typescript-eslint/utils": "^5.59.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"fast-glob": "^3.3.1",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-pwa": "^0.17.0",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "config/jest/node",
|
||||
"roots": [
|
||||
"<rootDir>"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(nanoid|nanoevents)/)"
|
||||
],
|
||||
"setupFiles": [
|
||||
"./setupTests.js"
|
||||
]
|
||||
}
|
||||
}
|
5
apps/dotcom/public/404-Sad-tldraw.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<path d="M0 3.95604C0 1.77118 1.69661 0 3.78947 0H32.2105C34.3034 0 36 1.77118 36 3.95604V32.044C36 34.2288 34.3034 36 32.2105 36H3.78947C1.69661 36 0 34.2288 0 32.044V3.95604Z" fill="black"/>
|
||||
<path d="M15.9715 10.296C15.9715 11.166 15.6741 11.9042 15.0794 12.5106C14.4847 13.117 13.7606 13.4202 12.9073 13.4202C12.0282 13.4202 11.2912 13.117 10.6965 12.5106C10.1018 11.9042 9.80441 11.166 9.80441 10.296C9.80441 9.42601 10.1018 8.68781 10.6965 8.08144C11.2912 7.47506 12.0282 7.17188 12.9073 7.17188C13.7606 7.17188 14.4847 7.47506 15.0794 8.08144C15.6741 8.68781 15.9715 9.42601 15.9715 10.296ZM9.76563 21.2448C9.76563 20.3748 10.063 19.6366 10.6577 19.0302C11.2783 18.3975 12.0282 18.0811 12.9073 18.0811C13.7348 18.0811 14.4588 18.3975 15.0794 19.0302C15.7 19.6366 16.062 20.3221 16.1654 21.0866C16.3723 22.5103 16.1137 23.9208 15.3897 25.3181C14.6915 26.7154 13.6831 27.7831 12.3643 28.5213C11.6403 28.9432 11.0456 28.93 10.5801 28.4818C10.1406 28.06 10.2699 27.559 10.968 26.979C11.3559 26.689 11.6791 26.3199 11.9377 25.8717C12.1963 25.4236 12.3643 24.9622 12.4419 24.4876C12.4678 24.2767 12.3773 24.1713 12.1704 24.1713C11.6532 24.1449 11.1232 23.8549 10.5801 23.3012C10.0371 22.7476 9.76563 22.0621 9.76563 21.2448Z" fill="white"/>
|
||||
<path d="M20.5 18C20.5 22.5943 23.1625 25.9175 25.528 27.6736C26.2842 28.2349 27.0059 27.4082 26.5946 26.561C25.5508 24.4105 24.5 21.3553 24.5 18C24.5 14.6447 25.5508 11.5895 26.5946 9.43903C27.0059 8.59181 26.2842 7.76508 25.528 8.32643C23.1625 10.0825 20.5 13.4057 20.5 18Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/dotcom/public/Shantell_Sans-Tldrawish.woff2
Normal file
BIN
apps/dotcom/public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/dotcom/public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/dotcom/public/android-chrome-maskable-192x192.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/dotcom/public/android-chrome-maskable-512x512.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
apps/dotcom/public/android-chrome-maskable-beta-512x512.png
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
apps/dotcom/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
apps/dotcom/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 281 B |
BIN
apps/dotcom/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 425 B |
BIN
apps/dotcom/public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
11
apps/dotcom/public/favicon.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1094_102908)">
|
||||
<path d="M0.501953 4.4032C0.501953 2.4611 2.01005 0.886719 3.87037 0.886719H29.1335C30.9939 0.886719 32.502 2.4611 32.502 4.4032V29.3702C32.502 31.3123 30.9939 32.8867 29.1335 32.8867H3.87037C2.01005 32.8867 0.501953 31.3123 0.501953 29.3702V4.4032Z" fill="white"/>
|
||||
<path d="M19.1433 10.0387C19.1433 10.8121 18.879 11.4683 18.3503 12.0073C17.8217 12.5463 17.1781 12.8158 16.4196 12.8158C15.6381 12.8158 14.983 12.5463 14.4544 12.0073C13.9258 11.4683 13.6614 10.8121 13.6614 10.0387C13.6614 9.2654 13.9258 8.60922 14.4544 8.07022C14.983 7.53122 15.6381 7.26172 16.4196 7.26172C17.1781 7.26172 17.8217 7.53122 18.3503 8.07022C18.879 8.60922 19.1433 9.2654 19.1433 10.0387ZM13.627 19.771C13.627 18.9977 13.8913 18.3415 14.4199 17.8025C14.9716 17.2401 15.6381 16.9588 16.4196 16.9588C17.1551 16.9588 17.7987 17.2401 18.3503 17.8025C18.9019 18.3415 19.2237 18.9508 19.3157 19.6304C19.4995 20.8959 19.2697 22.1496 18.6261 23.3917C18.0055 24.6337 17.1091 25.5828 15.9369 26.239C15.2933 26.614 14.7647 26.6023 14.351 26.2039C13.9602 25.8289 14.0752 25.3836 14.6957 24.8681C15.0405 24.6103 15.3278 24.2822 15.5577 23.8838C15.7875 23.4854 15.9369 23.0753 16.0059 22.6535C16.0289 22.466 15.9484 22.3723 15.7645 22.3723C15.3048 22.3488 14.8336 22.0911 14.351 21.5989C13.8683 21.1068 13.627 20.4975 13.627 19.771Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1094_102908">
|
||||
<rect width="32" height="32" fill="white" transform="translate(0.501953 0.886719)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/dotcom/public/flat.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/dotcom/public/github-hero-dark.png
Normal file
After Width: | Height: | Size: 219 KiB |
BIN
apps/dotcom/public/github-hero-light.png
Normal file
After Width: | Height: | Size: 219 KiB |
11
apps/dotcom/public/robots.txt
Normal file
|
@ -0,0 +1,11 @@
|
|||
User-agent: *
|
||||
Disallow: /r
|
||||
|
||||
User-agent: *
|
||||
Disallow: /v
|
||||
|
||||
User-agent: *
|
||||
Disallow: /s
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
11
apps/dotcom/public/site.webmanifest
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
BIN
apps/dotcom/public/social-image.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
apps/dotcom/public/social-og.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
apps/dotcom/public/social-twitter.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
apps/dotcom/public/staging-favicon-16.png
Normal file
After Width: | Height: | Size: 311 B |
BIN
apps/dotcom/public/staging-favicon-32.png
Normal file
After Width: | Height: | Size: 541 B |
12
apps/dotcom/public/staging-favicon.svg
Normal file
|
@ -0,0 +1,12 @@
|
|||
<svg width="513" height="513" viewBox="0 0 513 513" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1750_113791)">
|
||||
<path d="M0.501953 57.1505C0.501953 26.0768 24.6314 0.886719 54.3967 0.886719H458.607C488.372 0.886719 512.502 26.0769 512.502 57.1505V456.623C512.502 487.697 488.372 512.887 458.607 512.887H54.3967C24.6315 512.887 0.501953 487.697 0.501953 456.623V57.1505Z" fill="white"/>
|
||||
<path d="M220.261 147.432C220.261 159.806 216.032 170.305 207.574 178.929C199.115 187.553 188.818 191.865 176.682 191.865C164.179 191.865 153.698 187.553 145.239 178.929C136.781 170.305 132.552 159.806 132.552 147.432C132.552 135.059 136.781 124.56 145.239 115.936C153.698 107.312 164.179 103 176.682 103C188.818 103 199.115 107.312 207.574 115.936C216.032 124.56 220.261 135.059 220.261 147.432ZM132 303.149C132 290.775 136.229 280.276 144.688 271.652C153.514 262.653 164.179 258.154 176.682 258.154C188.45 258.154 198.748 262.653 207.574 271.652C216.4 280.276 221.548 290.025 223.019 300.899C225.961 321.147 222.284 341.207 211.987 361.08C202.057 380.952 187.715 396.138 168.959 406.637C158.662 412.636 150.204 412.449 143.584 406.074C137.332 400.075 139.171 392.951 149.101 384.702C154.617 380.577 159.214 375.328 162.891 368.954C166.569 362.579 168.959 356.018 170.063 349.268C170.43 346.269 169.143 344.769 166.201 344.769C158.846 344.394 151.307 340.269 143.584 332.395C135.861 324.521 132 314.772 132 303.149Z" fill="black"/>
|
||||
<path d="M382 238.56C382 265.185 378.424 289.669 371.273 312.011C365.022 331.736 355.817 350.181 343.658 367.347C340.592 371.675 335.553 374.113 330.249 374.113H325.287C313.024 374.113 305.344 359.303 310.555 348.202V348.202C315.346 337.994 319.53 326.859 323.106 314.795C326.682 302.66 329.471 290.097 331.473 277.106C333.476 264.114 334.477 251.266 334.477 238.56C334.477 221.642 332.725 204.582 329.221 187.379C325.788 170.105 321.068 154.115 315.06 139.411C314.255 137.405 313.435 135.449 312.602 133.542C306.886 120.464 315.977 103.113 330.249 103.113V103.113C335.553 103.113 340.592 105.552 343.658 109.88C355.817 127.045 365.022 145.49 371.273 165.215C378.424 187.558 382 212.006 382 238.56Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1750_113791">
|
||||
<rect width="512" height="512" fill="white" transform="translate(0.501953 0.886719)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
15
apps/dotcom/public/tldraw-white-on-black.svg
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg width="30" height="31" viewBox="0 0 30 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_155_47932)">
|
||||
<path
|
||||
d="M0 3.7967C0 1.97598 1.41384 0.5 3.15789 0.5H26.8421C28.5862 0.5 30 1.97598 30 3.7967V27.2033C30 29.024 28.5862 30.5 26.8421 30.5H3.1579C1.41384 30.5 0 29.024 0 27.2033V3.7967Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M17.4762 9.08002C17.4762 9.80504 17.2284 10.4202 16.7328 10.9255C16.2372 11.4308 15.6339 11.6835 14.9228 11.6835C14.1901 11.6835 13.576 11.4308 13.0804 10.9255C12.5848 10.4202 12.337 9.80504 12.337 9.08002C12.337 8.35501 12.5848 7.73985 13.0804 7.23453C13.576 6.72922 14.1901 6.47656 14.9228 6.47656C15.6339 6.47656 16.2372 6.72922 16.7328 7.23453C17.2284 7.73985 17.4762 8.35501 17.4762 9.08002ZM12.3047 18.204C12.3047 17.479 12.5525 16.8638 13.0481 16.3585C13.5653 15.8312 14.1901 15.5676 14.9228 15.5676C15.6123 15.5676 16.2157 15.8312 16.7328 16.3585C17.25 16.8638 17.5517 17.4351 17.6379 18.0722C17.8102 19.2586 17.5948 20.434 16.9914 21.5984C16.4096 22.7628 15.5692 23.6526 14.4703 24.2678C13.8669 24.6193 13.3713 24.6083 12.9835 24.2348C12.6171 23.8833 12.7249 23.4659 13.3067 22.9825C13.6299 22.7409 13.8992 22.4333 14.1147 22.0598C14.3302 21.6863 14.4703 21.3018 14.5349 20.9064C14.5565 20.7306 14.481 20.6427 14.3087 20.6427C13.8777 20.6207 13.436 20.3791 12.9835 19.9177C12.5309 19.4563 12.3047 18.8851 12.3047 18.204Z"
|
||||
fill="white" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_155_47932">
|
||||
<rect width="30" height="30" fill="white" transform="translate(0 0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
11
apps/dotcom/public/tldraw.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg width="513" height="512" viewBox="0 0 513 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1053_2035)">
|
||||
<path d="M0.501953 56.2637C0.501953 25.1901 24.6314 0 54.3967 0H458.607C488.372 0 512.502 25.1901 512.502 56.2637V455.736C512.502 486.81 488.372 512 458.607 512H54.3967C24.6315 512 0.501953 486.81 0.501953 455.736V56.2637Z" fill="white"/>
|
||||
<path d="M298.763 146.432C298.763 158.806 294.534 169.305 286.076 177.929C277.617 186.553 267.32 190.865 255.184 190.865C242.68 190.865 232.199 186.553 223.741 177.929C215.283 169.305 211.054 158.806 211.054 146.432C211.054 134.059 215.283 123.56 223.741 114.936C232.199 106.312 242.68 102 255.184 102C267.32 102 277.617 106.312 286.076 114.936C294.534 123.56 298.763 134.059 298.763 146.432ZM210.502 302.149C210.502 289.775 214.731 279.276 223.189 270.652C232.016 261.653 242.68 257.154 255.184 257.154C266.952 257.154 277.249 261.653 286.076 270.652C294.902 279.276 300.05 289.025 301.521 299.899C304.463 320.147 300.786 340.207 290.489 360.08C280.559 379.952 266.217 395.138 247.461 405.637C237.164 411.636 228.706 411.449 222.086 405.074C215.834 399.075 217.673 391.951 227.603 383.702C233.119 379.577 237.716 374.328 241.393 367.954C245.071 361.579 247.461 355.018 248.565 348.268C248.932 345.269 247.645 343.769 244.703 343.769C237.348 343.394 229.809 339.269 222.086 331.395C214.363 323.521 210.502 313.772 210.502 302.149Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1053_2035">
|
||||
<rect width="512" height="512" fill="white" transform="translate(0.501953)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
59
apps/dotcom/scripts/build.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import glob from 'fast-glob'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
import { exec } from '../../../scripts/lib/exec'
|
||||
import { Config } from './vercel-output-config'
|
||||
|
||||
import { config } from 'dotenv'
|
||||
import { nicelog } from '../../../scripts/lib/nicelog'
|
||||
config({
|
||||
path: './.env.local',
|
||||
})
|
||||
|
||||
nicelog('The multiplayer server is', process.env.MULTIPLAYER_SERVER)
|
||||
|
||||
async function build() {
|
||||
await exec('vite', ['build', '--emptyOutDir'])
|
||||
await exec('yarn', ['run', '-T', 'sentry-cli', 'sourcemaps', 'inject', 'dist/assets'])
|
||||
// Clear output static folder (in case we are running locally and have already built the app once before)
|
||||
await exec('rm', ['-rf', '.vercel/output'])
|
||||
mkdirSync('.vercel/output', { recursive: true })
|
||||
await exec('cp', ['-r', 'dist', '.vercel/output/static'])
|
||||
await exec('rm', ['-rf', ...glob.sync('.vercel/output/static/**/*.js.map')])
|
||||
writeFileSync(
|
||||
'.vercel/output/config.json',
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 3,
|
||||
routes: [
|
||||
// rewrite api calls to the multiplayer server
|
||||
{
|
||||
src: '^/api(/(.*))?$',
|
||||
dest: `${
|
||||
process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http') ?? 'http://127.0.0.1:8787'
|
||||
}$1`,
|
||||
check: true,
|
||||
},
|
||||
// cache static assets immutably
|
||||
{
|
||||
src: '^/assets/(.*)$',
|
||||
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
|
||||
},
|
||||
// serve static files
|
||||
{
|
||||
handle: 'filesystem',
|
||||
},
|
||||
// finally handle SPA routing
|
||||
{
|
||||
src: '.*',
|
||||
dest: '/index.html',
|
||||
},
|
||||
],
|
||||
overrides: {},
|
||||
} satisfies Config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
build()
|
41
apps/dotcom/scripts/dev-app.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { writeFileSync } from 'fs'
|
||||
import { exec } from '../../../scripts/lib/exec'
|
||||
import { readFileIfExists } from '../../../scripts/lib/file'
|
||||
import { nicelog } from '../../../scripts/lib/nicelog'
|
||||
|
||||
async function main() {
|
||||
await writeEnvFileVars('../dotcom-worker/.dev.vars', {
|
||||
APP_ORIGIN: 'http://localhost:3000',
|
||||
})
|
||||
if (process.env.VITE_PREVIEW === '1') {
|
||||
await exec('vite', ['preview', '--host', '--port', '3000'])
|
||||
} else {
|
||||
await exec('vite', ['dev', '--host', '--port', '3000'])
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEnvFileVars(filePath: string, vars: Record<string, string>) {
|
||||
nicelog(`Writing env vars to ${filePath}: ${Object.keys(vars).join(', ')}`)
|
||||
let envFileContents = (await readFileIfExists(filePath)) ?? ''
|
||||
|
||||
const KEYS_TO_SKIP: string[] = []
|
||||
|
||||
for (const key of Object.keys(vars)) {
|
||||
envFileContents = envFileContents.replace(new RegExp(`(\n|^)${key}=.*(?:\n|$)`), '$1')
|
||||
}
|
||||
|
||||
if (envFileContents && !envFileContents.endsWith('\n')) envFileContents += '\n'
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (KEYS_TO_SKIP.includes(key)) {
|
||||
continue
|
||||
}
|
||||
envFileContents += `${key}=${value}\n`
|
||||
}
|
||||
|
||||
writeFileSync(filePath, envFileContents)
|
||||
|
||||
nicelog(`Wrote env vars to ${filePath}`)
|
||||
}
|
||||
|
||||
main()
|
111
apps/dotcom/scripts/vercel-output-config.d.ts
vendored
Normal file
|
@ -0,0 +1,111 @@
|
|||
// copied from https://github.com/vercel/vercel/blob/f8c893bb156d12284866c801dcd3e5fe3ef08e20/packages/gatsby-plugin-vercel-builder/src/types.d.ts#L4
|
||||
// seems like vercel don't export a good version of this type anywhere at the time of writing
|
||||
|
||||
import type { Images } from '@vercel/build-utils'
|
||||
|
||||
export type Config = {
|
||||
version: 3
|
||||
routes?: Route[]
|
||||
images?: Images
|
||||
wildcard?: WildcardConfig
|
||||
overrides?: OverrideConfig
|
||||
cache?: string[]
|
||||
}
|
||||
|
||||
type Route = Source | Handler
|
||||
|
||||
type Source = {
|
||||
src: string
|
||||
dest?: string
|
||||
headers?: Record<string, string>
|
||||
methods?: string[]
|
||||
continue?: boolean
|
||||
caseSensitive?: boolean
|
||||
check?: boolean
|
||||
status?: number
|
||||
has?: Array<HostHasField | HeaderHasField | CookieHasField | QueryHasField>
|
||||
missing?: Array<HostHasField | HeaderHasField | CookieHasField | QueryHasField>
|
||||
locale?: Locale
|
||||
middlewarePath?: string
|
||||
}
|
||||
|
||||
type Locale = {
|
||||
redirect?: Record<string, string>
|
||||
cookie?: string
|
||||
}
|
||||
|
||||
type HostHasField = {
|
||||
type: 'host'
|
||||
value: string
|
||||
}
|
||||
|
||||
type HeaderHasField = {
|
||||
type: 'header'
|
||||
key: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
type CookieHasField = {
|
||||
type: 'cookie'
|
||||
key: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
type QueryHasField = {
|
||||
type: 'query'
|
||||
key: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
type HandleValue =
|
||||
| 'rewrite'
|
||||
| 'filesystem' // check matches after the filesystem misses
|
||||
| 'resource'
|
||||
| 'miss' // check matches after every filesystem miss
|
||||
| 'hit'
|
||||
| 'error' // check matches after error (500, 404, etc.)
|
||||
|
||||
type Handler = {
|
||||
handle: HandleValue
|
||||
src?: string
|
||||
dest?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
type WildCard = {
|
||||
domain: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type WildcardConfig = Array<WildCard>
|
||||
|
||||
type Override = {
|
||||
path?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
type OverrideConfig = Record<string, Override>
|
||||
|
||||
type ServerlessFunctionConfig = {
|
||||
handler: string
|
||||
runtime: string
|
||||
memory?: number
|
||||
maxDuration?: number
|
||||
environment?: Record<string, string>[]
|
||||
allowQuery?: string[]
|
||||
regions?: string[]
|
||||
}
|
||||
|
||||
export type NodejsServerlessFunctionConfig = ServerlessFunctionConfig & {
|
||||
launcherType: 'Nodejs'
|
||||
shouldAddHelpers?: boolean // default: false
|
||||
shouldAddSourceMapSupport?: boolean // default: false
|
||||
}
|
||||
|
||||
export type PrerenderFunctionConfig = {
|
||||
expiration: number | false
|
||||
group?: number
|
||||
bypassToken?: string
|
||||
fallback?: string
|
||||
allowQuery?: string[]
|
||||
}
|
3
apps/dotcom/sentry-release-name.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
// This file is replaced during deployments to point to a meaningful release name in Sentry.
|
||||
// DO NOT MESS WITH THIS LINE OR THE ONE BELOW IT. I WILL FIND YOU
|
||||
export const sentryReleaseName = 'local'
|
57
apps/dotcom/sentry.client.config.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
// This file configures the initialization of Sentry on the browser.
|
||||
// The config you add here will be used whenever a page is visited.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import { ExtraErrorData } from '@sentry/integrations'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { Editor, getErrorAnnotations } from '@tldraw/tldraw'
|
||||
import { sentryReleaseName } from './sentry-release-name'
|
||||
import { env } from './src/utils/env'
|
||||
import { setGlobalErrorReporter } from './src/utils/errorReporting'
|
||||
|
||||
function requireSentryDsn() {
|
||||
if (!process.env.SENTRY_DSN) {
|
||||
throw new Error('SENTRY_DSN is required')
|
||||
}
|
||||
return process.env.SENTRY_DSN as string
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: env === 'development' ? undefined : requireSentryDsn(),
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
release: sentryReleaseName,
|
||||
environment: env,
|
||||
integrations: [new ExtraErrorData({ depth: 10 }) as any],
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
|
||||
beforeSend: (event, hint) => {
|
||||
if (env === 'development') {
|
||||
console.error('[SentryDev]', hint.originalException ?? hint.syntheticException)
|
||||
return null
|
||||
}
|
||||
// todo: re-evaulate use of window here?
|
||||
const editor: Editor | undefined = (window as any).editor
|
||||
const appErrorAnnotations = editor?.createErrorAnnotations('unknown', 'unknown')
|
||||
const errorAnnotations = getErrorAnnotations(hint.originalException as any)
|
||||
|
||||
event.tags = {
|
||||
...appErrorAnnotations?.tags,
|
||||
...errorAnnotations.tags,
|
||||
...event.tags,
|
||||
}
|
||||
|
||||
event.extra = {
|
||||
...appErrorAnnotations?.extras,
|
||||
...errorAnnotations.extras,
|
||||
...event.extra,
|
||||
}
|
||||
|
||||
return event
|
||||
},
|
||||
})
|
||||
|
||||
setGlobalErrorReporter((error) => Sentry.captureException(error))
|
4
apps/dotcom/sentry.properties
Normal file
|
@ -0,0 +1,4 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=tldraw
|
||||
defaults.project=lite
|
||||
cli.executable=../../node_modules/@sentry/cli/bin/sentry-cli
|
4
apps/dotcom/setupTests.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
|
||||
|
||||
process.env.MULTIPLAYER_SERVER = 'https://localhost:8787'
|
||||
process.env.ASSET_UPLOAD = 'https://localhost:8788'
|
|
@ -0,0 +1,43 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import '../../../styles/core.css'
|
||||
|
||||
// todo: remove tailwind
|
||||
|
||||
export function BoardHistoryLog({ data }: { data: string[] }) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-header">{'No history found'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="board-history__list">
|
||||
{data.map((v, i) => {
|
||||
const timeStamp = v.split('/').pop()
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link to={`./${timeStamp}`} target="_blank">
|
||||
{formatDate(timeStamp!)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateISOString: string) {
|
||||
const date = new Date(dateISOString)
|
||||
return Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
}).format(date)
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import { Tldraw, createTLStore, defaultShapeUtils } from '@tldraw/tldraw'
|
||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
||||
import { useCallback, useState } from 'react'
|
||||
import '../../../styles/core.css'
|
||||
import { assetUrls } from '../../utils/assetUrls'
|
||||
import { useFileSystem } from '../../utils/useFileSystem'
|
||||
|
||||
export function BoardHistorySnapshot({
|
||||
data,
|
||||
roomId,
|
||||
timestamp,
|
||||
token,
|
||||
}: {
|
||||
data: RoomSnapshot
|
||||
roomId: string
|
||||
timestamp: string
|
||||
token?: string
|
||||
}) {
|
||||
const [store] = useState(() => {
|
||||
const store = createTLStore({ shapeUtils: defaultShapeUtils })
|
||||
store.loadSnapshot({
|
||||
schema: data.schema!,
|
||||
store: Object.fromEntries(data.documents.map((doc) => [doc.state.id, doc.state])) as any,
|
||||
})
|
||||
return store
|
||||
})
|
||||
|
||||
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
|
||||
|
||||
const restoreVersion = useCallback(async () => {
|
||||
const sure = window.confirm('Are you sure?')
|
||||
if (!sure) return
|
||||
|
||||
const res = await fetch(`/api/r/${roomId}/restore`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token
|
||||
? {
|
||||
Authorization: 'Bearer ' + token,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({ timestamp }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
window.alert('Something went wrong!')
|
||||
return
|
||||
}
|
||||
|
||||
window.alert('done')
|
||||
}, [roomId, timestamp, token])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
store={store}
|
||||
assetUrls={assetUrls}
|
||||
onMount={(editor) => {
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
setTimeout(() => {
|
||||
editor.setCurrentTool('hand')
|
||||
})
|
||||
}}
|
||||
overrides={[fileSystemUiOverrides]}
|
||||
inferDarkMode
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="board-history__restore">
|
||||
<button onClick={restoreVersion}>{'Restore this version'}</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
207
apps/dotcom/src/components/CursorChatBubble.tsx
Normal file
|
@ -0,0 +1,207 @@
|
|||
import { preventDefault, track, useContainer, useEditor, useTranslation } from '@tldraw/tldraw'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
KeyboardEvent,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
// todo:
|
||||
// - not cleaning up
|
||||
const CHAT_MESSAGE_TIMEOUT_CLOSING = 2000
|
||||
const CHAT_MESSAGE_TIMEOUT_CHATTING = 5000
|
||||
|
||||
export const CursorChatBubble = track(function CursorChatBubble() {
|
||||
const editor = useEditor()
|
||||
const container = useContainer()
|
||||
const { isChatting, chatMessage } = editor.getInstanceState()
|
||||
|
||||
const rTimeout = useRef<any>(-1)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const closingUp = !isChatting && chatMessage
|
||||
if (closingUp || isChatting) {
|
||||
const duration = isChatting ? CHAT_MESSAGE_TIMEOUT_CHATTING : CHAT_MESSAGE_TIMEOUT_CLOSING
|
||||
rTimeout.current = setTimeout(() => {
|
||||
editor.updateInstanceState({ chatMessage: '', isChatting: false })
|
||||
setValue('')
|
||||
container.focus()
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(rTimeout.current)
|
||||
}
|
||||
}, [container, editor, chatMessage, isChatting])
|
||||
|
||||
if (isChatting)
|
||||
return <CursorChatInput value={value} setValue={setValue} chatMessage={chatMessage} />
|
||||
|
||||
return chatMessage.trim() ? <NotEditingChatMessage chatMessage={chatMessage} /> : null
|
||||
})
|
||||
|
||||
function usePositionBubble(ref: RefObject<HTMLInputElement>) {
|
||||
const editor = useEditor()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
const { x, y } = editor.inputs.currentScreenPoint
|
||||
ref.current?.style.setProperty('transform', `translate(${x}px, ${y}px)`)
|
||||
|
||||
// Positioning the chat bubble
|
||||
function positionChatBubble(e: PointerEvent) {
|
||||
ref.current?.style.setProperty('transform', `translate(${e.clientX}px, ${e.clientY}px)`)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', positionChatBubble)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', positionChatBubble)
|
||||
}
|
||||
}, [ref, editor])
|
||||
}
|
||||
|
||||
const NotEditingChatMessage = ({ chatMessage }: { chatMessage: string }) => {
|
||||
const editor = useEditor()
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
usePositionBubble(ref)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="tl-cursor-chat tl-cursor-chat__bubble"
|
||||
style={{ backgroundColor: editor.user.getColor() }}
|
||||
>
|
||||
{chatMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CursorChatInput = track(function CursorChatInput({
|
||||
chatMessage,
|
||||
value,
|
||||
setValue,
|
||||
}: {
|
||||
chatMessage: string
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
}) {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const container = useContainer()
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const placeholder = chatMessage || msg('cursor-chat.type-to-chat')
|
||||
|
||||
usePositionBubble(ref)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
const textMeasurement = editor.textMeasure.measureText(value || placeholder, {
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontStyle: 'normal',
|
||||
maxWidth: null,
|
||||
lineHeight: 1,
|
||||
padding: '6px',
|
||||
})
|
||||
|
||||
elm.style.setProperty('width', textMeasurement.w + 'px')
|
||||
}, [editor, value, placeholder])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Focus the editor
|
||||
let raf = requestAnimationFrame(() => {
|
||||
raf = requestAnimationFrame(() => {
|
||||
ref.current?.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const stopChatting = useCallback(() => {
|
||||
editor.updateInstanceState({ isChatting: false })
|
||||
container.focus()
|
||||
}, [editor, container])
|
||||
|
||||
// Update the chat message as the user types
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target
|
||||
setValue(value.slice(0, 64))
|
||||
editor.updateInstanceState({ chatMessage: value })
|
||||
},
|
||||
[editor, setValue]
|
||||
)
|
||||
|
||||
// Handle some keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
// get this from the element so that this hook doesn't depend on value
|
||||
const { value: currentValue } = elm
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter': {
|
||||
preventDefault(e)
|
||||
e.stopPropagation()
|
||||
|
||||
// If the user hasn't typed anything, stop chatting
|
||||
if (!currentValue) {
|
||||
stopChatting()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, 'send' the message
|
||||
setValue('')
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
preventDefault(e)
|
||||
e.stopPropagation()
|
||||
stopChatting()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[stopChatting, setValue]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
// todo: figure out what's an acceptable / sanitized paste
|
||||
preventDefault(e)
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`tl-cursor-chat`}
|
||||
style={{ backgroundColor: editor.user.getColor() }}
|
||||
onBlur={stopChatting}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
})
|