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>
This commit is contained in:
Steve Ruiz 2024-01-16 14:38:05 +00:00 committed by GitHub
parent b7fb31f8f9
commit d7002057d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
248 changed files with 20084 additions and 245 deletions

75
.dockerignore Normal file
View 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

View file

@ -23,3 +23,6 @@ apps/docs/api-content.json
apps/docs/content.json apps/docs/content.json
apps/vscode/extension/editor/index.js 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/**/*

View file

@ -90,11 +90,24 @@ module.exports = {
'import/no-internal-modules': 'off', 'import/no-internal-modules': 'off',
}, },
}, },
// { {
// files: ['packages/tldraw/src/test/**/*'], files: ['apps/huppy/**/*', 'scripts/**/*'],
// rules: { rules: {
// 'import/no-internal-modules': 'off', '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
View 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

View file

@ -15,8 +15,8 @@ defaults:
shell: bash shell: bash
jobs: jobs:
build: test:
name: 'Build and run checks' name: 'Tests & checks'
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-latest-16-cores-open # TODO: this should probably run on multiple OSes runs-on: ubuntu-latest-16-cores-open # TODO: this should probably run on multiple OSes
@ -24,18 +24,7 @@ jobs:
- name: Check out code - name: Check out code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js environment - uses: ./.github/actions/setup
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
- name: Typecheck - name: Typecheck
run: yarn build-types run: yarn build-types
@ -49,13 +38,24 @@ jobs:
- name: Check API declarations and docs work as intended - name: Check API declarations and docs work as intended
run: yarn api-check 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 - name: Build all projects
# the sed pipe makes sure that github annotations come through without # the sed pipe makes sure that github annotations come through without
# turbo's prefix # turbo's prefix
run: "yarn build | sed -E 's/^.*? ::/::/'" run: "yarn build | sed -E 's/^.*? ::/::/'"
- name: Test
run: yarn test
- name: Pack public packages - name: Pack public packages
run: "yarn lazy pack-tarball | sed -E 's/^.*? ::/::/'" run: "yarn lazy pack-tarball | sed -E 's/^.*? ::/::/'"

76
.github/workflows/deploy.yml vendored Normal file
View 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 }}

View file

@ -56,7 +56,6 @@ jobs:
with: with:
node-version: 18.18.2 node-version: 18.18.2
cache: 'yarn' cache: 'yarn'
cache-dependency-path: 'public-yarn.lock'
- name: Enable corepack - name: Enable corepack
run: corepack enable run: corepack enable

View file

@ -29,7 +29,6 @@ jobs:
with: with:
node-version: 18.18.2 node-version: 18.18.2
cache: 'yarn' cache: 'yarn'
cache-dependency-path: 'public-yarn.lock'
- name: Enable corepack - name: Enable corepack
run: corepack enable run: corepack enable

View 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 }}

View file

@ -22,7 +22,6 @@ jobs:
with: with:
node-version: 18.18.2 node-version: 18.18.2
cache: 'yarn' cache: 'yarn'
cache-dependency-path: 'public-yarn.lock'
- name: Enable corepack - name: Enable corepack
run: corepack enable run: corepack enable

View file

@ -21,7 +21,6 @@ jobs:
with: with:
node-version: 18.18.2 node-version: 18.18.2
cache: 'yarn' cache: 'yarn'
cache-dependency-path: 'public-yarn.lock'
- name: Enable corepack - name: Enable corepack
run: corepack enable run: corepack enable

View file

@ -33,7 +33,6 @@ jobs:
with: with:
node-version: 18.18.2 node-version: 18.18.2
cache: 'yarn' cache: 'yarn'
cache-dependency-path: 'public-yarn.lock'
- name: Enable corepack - name: Enable corepack
run: corepack enable run: corepack enable

View 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

8
.gitignore vendored
View file

@ -84,3 +84,11 @@ apps/examples/e2e/test-results
apps/examples/playwright-report apps/examples/playwright-report
docs/gen docs/gen
.dev.vars
.env.local
.env.development.local
.env*
.wrangler
/vercel.json

View file

@ -1,13 +1,6 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.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 npx lazy run build-api
git add packages/*/api-report.md git add packages/*/api-report.md
git add packages/*/api/api.json git add packages/*/api/api.json

View file

@ -16,3 +16,10 @@ apps/vscode/extension/editor/*
apps/examples/www apps/examples/www
content.json 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/**/*.*

View file

@ -1,4 +1,3 @@
enableInlineBuilds: true enableInlineBuilds: true
lockfileFilename: public-yarn.lock
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.5.0.cjs yarnPath: .yarn/releases/yarn-3.5.0.cjs

1
apps/dotcom-asset-upload/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp-assets

View 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

View 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)/)"
]
}
}

View 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

View file

@ -0,0 +1,6 @@
export interface Env {
UPLOADS: R2Bucket
KV: KVNamespace
ASSET_UPLOADER_AUTH_TOKEN: string | undefined
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": {
"noEmit": true,
"emitDeclarationOnly": false
}
}

View 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'

View file

@ -0,0 +1 @@
.vercel

View file

@ -0,0 +1,3 @@
# @tldraw/bookmark-extractor
Deploy this manually with `vercel deploy --prod`.

View 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)
})
})
}

View 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)
}
}

View 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"
}
}

View 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
View file

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

View 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

View 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>
```

View 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)/)"
]
}
}

View 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)

View 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()

View 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()

View 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,
})
})
})

View 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}`])
}
}

View 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()
}
}

View file

@ -0,0 +1,5 @@
/**
* How often we the document to R2?
* 10 seconds.
*/
export const PERSIST_INTERVAL_MS = 10_000

View file

@ -0,0 +1,3 @@
export function getR2KeyForRoom(persistenceKey: string) {
return `public_rooms/${persistenceKey}`
}

View 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 }))
}

View 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 }))
}

View 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)
}

View 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' },
})
}

View 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' },
})
}

View 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,
})
)
}

View 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()
}

View 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
}

View 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' }))
}

View file

@ -0,0 +1,5 @@
export async function fourOhFour() {
return new Response('Not found', {
status: 404,
})
}

View 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'
}

View 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 })
}

View 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
}
}
}

View 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)
}

View 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

View 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" }
]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]
}
}

View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

View file

@ -0,0 +1,11 @@
User-agent: *
Disallow: /r
User-agent: *
Disallow: /v
User-agent: *
Disallow: /s
User-agent: *
Allow: /

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 B

View 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

View 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

View 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

View 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()

View 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()

View 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[]
}

View 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'

View 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))

View 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

View 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'

View file

@ -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)
}

View file

@ -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>
</>
)
}

View 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}
/>
)
})

Some files were not shown because too many files have changed in this diff Show more