diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..979539967 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.eslintignore b/.eslintignore index 2790821f8..ba793d8a1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -22,4 +22,7 @@ apps/examples/www apps/docs/api-content.json apps/docs/content.json apps/vscode/extension/editor/index.js -apps/vscode/extension/editor/tldraw-assets.json \ No newline at end of file +apps/vscode/extension/editor/tldraw-assets.json +**/sentry.server.config.js +**/scripts/upload-sourcemaps.js +**/coverage/**/* diff --git a/.eslintrc.js b/.eslintrc.js index 72ecf8c17..97d940e71 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -90,11 +90,24 @@ module.exports = { 'import/no-internal-modules': 'off', }, }, - // { - // files: ['packages/tldraw/src/test/**/*'], - // rules: { - // 'import/no-internal-modules': 'off', - // }, - // }, + { + files: ['apps/huppy/**/*', 'scripts/**/*'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['apps/dotcom/**/*'], + rules: { + 'no-restricted-properties': [ + 2, + { + object: 'crypto', + property: 'randomUUID', + message: 'Please use the makeUUID util instead.', + }, + ], + }, + }, ], } diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..3c26214eb --- /dev/null +++ b/.github/actions/setup/action.yml @@ -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 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7e010baa2..b74943f74 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,8 +15,8 @@ defaults: shell: bash jobs: - build: - name: 'Build and run checks' + test: + name: 'Tests & checks' timeout-minutes: 15 runs-on: ubuntu-latest-16-cores-open # TODO: this should probably run on multiple OSes @@ -24,18 +24,7 @@ jobs: - name: Check out code uses: actions/checkout@v3 - - name: Setup Node.js environment - uses: actions/setup-node@v3 - with: - node-version: 18.18.2 - cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - - - name: Enable corepack - run: corepack enable - - - name: Install dependencies - run: yarn + - uses: ./.github/actions/setup - name: Typecheck run: yarn build-types @@ -49,13 +38,24 @@ jobs: - name: Check API declarations and docs work as intended run: yarn api-check + - name: Test + run: yarn test + + build: + name: 'Build all projects' + timeout-minutes: 15 + runs-on: ubuntu-latest-16-cores-open + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - uses: ./.github/actions/setup + - name: Build all projects # the sed pipe makes sure that github annotations come through without # turbo's prefix run: "yarn build | sed -E 's/^.*? ::/::/'" - - name: Test - run: yarn test - - name: Pack public packages run: "yarn lazy pack-tarball | sed -E 's/^.*? ::/::/'" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..1315b3aa3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.github/workflows/playwright-update-snapshots.yml b/.github/workflows/playwright-update-snapshots.yml index 33535d911..b1c6fdeec 100644 --- a/.github/workflows/playwright-update-snapshots.yml +++ b/.github/workflows/playwright-update-snapshots.yml @@ -56,7 +56,6 @@ jobs: with: node-version: 18.18.2 cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - name: Enable corepack run: corepack enable diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b589206d8..5f54906e4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -29,7 +29,6 @@ jobs: with: node-version: 18.18.2 cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - name: Enable corepack run: corepack enable diff --git a/.github/workflows/prune-preview-deploys.yml b/.github/workflows/prune-preview-deploys.yml new file mode 100644 index 000000000..9d3b34d43 --- /dev/null +++ b/.github/workflows/prune-preview-deploys.yml @@ -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 }} diff --git a/.github/workflows/publish-canary.yml b/.github/workflows/publish-canary.yml index ed11efec5..ae56dc0ae 100644 --- a/.github/workflows/publish-canary.yml +++ b/.github/workflows/publish-canary.yml @@ -22,7 +22,6 @@ jobs: with: node-version: 18.18.2 cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - name: Enable corepack run: corepack enable diff --git a/.github/workflows/publish-manual.yml b/.github/workflows/publish-manual.yml index bc13a8056..b3bd76530 100644 --- a/.github/workflows/publish-manual.yml +++ b/.github/workflows/publish-manual.yml @@ -21,7 +21,6 @@ jobs: with: node-version: 18.18.2 cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - name: Enable corepack run: corepack enable diff --git a/.github/workflows/publish-new.yml b/.github/workflows/publish-new.yml index 731c43bdd..d6f0ef81c 100644 --- a/.github/workflows/publish-new.yml +++ b/.github/workflows/publish-new.yml @@ -33,7 +33,6 @@ jobs: with: node-version: 18.18.2 cache: 'yarn' - cache-dependency-path: 'public-yarn.lock' - name: Enable corepack run: corepack enable diff --git a/.github/workflows/trigger-production-build.yml b/.github/workflows/trigger-production-build.yml new file mode 100644 index 000000000..29b203ad7 --- /dev/null +++ b/.github/workflows/trigger-production-build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 476753db4..2ad0eb54d 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,12 @@ apps/examples/build.esbuild.json apps/examples/e2e/test-results apps/examples/playwright-report -docs/gen \ No newline at end of file +docs/gen + +.dev.vars +.env.local +.env.development.local +.env* + +.wrangler +/vercel.json \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index d2fde70c3..cdcc9d17e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,13 +1,6 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -# if the folder we're in is called bublic, it means we're a submodule in the brivate repo. -# We need to grab .envrc to set up yarn correctly. -current_file="$(readlink -f "$0")" -if [[ $current_file == */bublic/.husky/pre-commit ]]; then - source "$(dirname -- "$0")/../../.envrc" -fi - npx lazy run build-api git add packages/*/api-report.md git add packages/*/api/api.json diff --git a/.prettierignore b/.prettierignore index f5e256403..2088ec33e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,4 +15,11 @@ apps/docs/content.json apps/vscode/extension/editor/* apps/examples/www content.json -apps/docs/utils/vector-db/index.json \ No newline at end of file +apps/docs/utils/vector-db/index.json +**/gen/**/*.md + +**/.vercel/* +**/.wrangler/* +**/.out/* +**/.temp/* +apps/dotcom/public/**/*.* \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index d89fb3ac9..db4ecfa4d 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,4 +1,3 @@ enableInlineBuilds: true -lockfileFilename: public-yarn.lock nodeLinker: node-modules yarnPath: .yarn/releases/yarn-3.5.0.cjs diff --git a/apps/dotcom-asset-upload/.gitignore b/apps/dotcom-asset-upload/.gitignore new file mode 100644 index 000000000..6512d9d80 --- /dev/null +++ b/apps/dotcom-asset-upload/.gitignore @@ -0,0 +1 @@ +tmp-assets \ No newline at end of file diff --git a/apps/dotcom-asset-upload/CHANGELOG.md b/apps/dotcom-asset-upload/CHANGELOG.md new file mode 100644 index 000000000..dd2389843 --- /dev/null +++ b/apps/dotcom-asset-upload/CHANGELOG.md @@ -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 diff --git a/apps/dotcom-asset-upload/package.json b/apps/dotcom-asset-upload/package.json new file mode 100644 index 000000000..b8d47d2a5 --- /dev/null +++ b/apps/dotcom-asset-upload/package.json @@ -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": { + "^~(.*)": "/src/$1" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(nanoid|escape-string-regexp)/)" + ] + } +} diff --git a/apps/dotcom-asset-upload/src/index.ts b/apps/dotcom-asset-upload/src/index.ts new file mode 100644 index 000000000..d0b5f9ef7 --- /dev/null +++ b/apps/dotcom-asset-upload/src/index.ts @@ -0,0 +1,180 @@ +/// +/// + +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(`R2 object "${objectName}" not found`, { + 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 diff --git a/apps/dotcom-asset-upload/src/types.ts b/apps/dotcom-asset-upload/src/types.ts new file mode 100644 index 000000000..5131eacb8 --- /dev/null +++ b/apps/dotcom-asset-upload/src/types.ts @@ -0,0 +1,6 @@ +export interface Env { + UPLOADS: R2Bucket + + KV: KVNamespace + ASSET_UPLOADER_AUTH_TOKEN: string | undefined +} diff --git a/apps/dotcom-asset-upload/tsconfig.json b/apps/dotcom-asset-upload/tsconfig.json new file mode 100644 index 000000000..051903fbc --- /dev/null +++ b/apps/dotcom-asset-upload/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "include": ["src"], + "exclude": ["node_modules", "dist", ".tsbuild*"], + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false + } +} diff --git a/apps/dotcom-asset-upload/wrangler.toml b/apps/dotcom-asset-upload/wrangler.toml new file mode 100644 index 000000000..8679d676d --- /dev/null +++ b/apps/dotcom-asset-upload/wrangler.toml @@ -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' \ No newline at end of file diff --git a/apps/dotcom-bookmark-extractor/.gitignore b/apps/dotcom-bookmark-extractor/.gitignore new file mode 100644 index 000000000..e985853ed --- /dev/null +++ b/apps/dotcom-bookmark-extractor/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/apps/dotcom-bookmark-extractor/README.md b/apps/dotcom-bookmark-extractor/README.md new file mode 100644 index 000000000..0ec89ed1a --- /dev/null +++ b/apps/dotcom-bookmark-extractor/README.md @@ -0,0 +1,3 @@ +# @tldraw/bookmark-extractor + +Deploy this manually with `vercel deploy --prod`. diff --git a/apps/dotcom-bookmark-extractor/api/_cors.ts b/apps/dotcom-bookmark-extractor/api/_cors.ts new file mode 100644 index 000000000..2b7cf6e46 --- /dev/null +++ b/apps/dotcom-bookmark-extractor/api/_cors.ts @@ -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) + }) + }) +} diff --git a/apps/dotcom-bookmark-extractor/api/bookmark.ts b/apps/dotcom-bookmark-extractor/api/bookmark.ts new file mode 100644 index 000000000..8c65cb3b5 --- /dev/null +++ b/apps/dotcom-bookmark-extractor/api/bookmark.ts @@ -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) + } +} diff --git a/apps/dotcom-bookmark-extractor/package.json b/apps/dotcom-bookmark-extractor/package.json new file mode 100644 index 000000000..49167bb94 --- /dev/null +++ b/apps/dotcom-bookmark-extractor/package.json @@ -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" + } +} diff --git a/apps/dotcom-bookmark-extractor/tsconfig.json b/apps/dotcom-bookmark-extractor/tsconfig.json new file mode 100644 index 000000000..97c028379 --- /dev/null +++ b/apps/dotcom-bookmark-extractor/tsconfig.json @@ -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": [] +} diff --git a/apps/dotcom-worker/.gitignore b/apps/dotcom-worker/.gitignore new file mode 100644 index 000000000..b3e55cbea --- /dev/null +++ b/apps/dotcom-worker/.gitignore @@ -0,0 +1,2 @@ +build +.wrangler \ No newline at end of file diff --git a/apps/dotcom-worker/CHANGELOG.md b/apps/dotcom-worker/CHANGELOG.md new file mode 100644 index 000000000..cacc52d1d --- /dev/null +++ b/apps/dotcom-worker/CHANGELOG.md @@ -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 diff --git a/apps/dotcom-worker/README.md b/apps/dotcom-worker/README.md new file mode 100644 index 000000000..8c5f2adce --- /dev/null +++ b/apps/dotcom-worker/README.md @@ -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= +SUPABASE_KEY= +``` diff --git a/apps/dotcom-worker/package.json b/apps/dotcom-worker/package.json new file mode 100644 index 000000000..f9b5f02d1 --- /dev/null +++ b/apps/dotcom-worker/package.json @@ -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": { + "^~(.*)": "/src/$1" + }, + "transformIgnorePatterns": [ + "node_modules/(?!(nanoid|escape-string-regexp)/)" + ] + } +} diff --git a/apps/dotcom-worker/scripts/cron.ts b/apps/dotcom-worker/scripts/cron.ts new file mode 100644 index 000000000..417c32eb5 --- /dev/null +++ b/apps/dotcom-worker/scripts/cron.ts @@ -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) diff --git a/apps/dotcom-worker/scripts/dev-wrap.ts b/apps/dotcom-worker/scripts/dev-wrap.ts new file mode 100644 index 000000000..0e79b198f --- /dev/null +++ b/apps/dotcom-worker/scripts/dev-wrap.ts @@ -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() diff --git a/apps/dotcom-worker/scripts/report-size.js b/apps/dotcom-worker/scripts/report-size.js new file mode 100644 index 000000000..1378231eb --- /dev/null +++ b/apps/dotcom-worker/scripts/report-size.js @@ -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() diff --git a/apps/dotcom-worker/src/lib/AlarmScheduler.test.ts b/apps/dotcom-worker/src/lib/AlarmScheduler.test.ts new file mode 100644 index 000000000..012fd8a20 --- /dev/null +++ b/apps/dotcom-worker/src/lib/AlarmScheduler.test.ts @@ -0,0 +1,259 @@ +import { noop } from '@tldraw/utils' +import { AlarmScheduler } from './AlarmScheduler' + +jest.useFakeTimers() + +function makeMockAlarmScheduler(alarms: { + [K in Key]: jest.Mock, []> +}) { + const data = new Map() + 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) => { + 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, + }) + }) +}) diff --git a/apps/dotcom-worker/src/lib/AlarmScheduler.ts b/apps/dotcom-worker/src/lib/AlarmScheduler.ts new file mode 100644 index 000000000..9a8ed4360 --- /dev/null +++ b/apps/dotcom-worker/src/lib/AlarmScheduler.ts @@ -0,0 +1,115 @@ +import { exhaustiveSwitchError, hasOwnProperty } from '@tldraw/utils' + +type AlarmOpts = { + overwrite: 'always' | 'if-sooner' +} + +export class AlarmScheduler { + storage: () => { + getAlarm(): Promise + setAlarm(scheduledTime: number | Date): void + get(key: string): Promise + list(options: { prefix: string }): Promise> + delete(keys: string[]): Promise + put(entries: Record): Promise + } + alarms: { [K in Key]: () => Promise } + + constructor(opts: Pick, 'storage' | 'alarms'>) { + this.storage = opts.storage + this.alarms = opts.alarms + } + + _alarmsScheduledDuringCurrentOnAlarmCall: Set | 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() + 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 { + return (await this.storage().get(`alarm-${key}`)) ?? null + } + + async deleteAlarm(key: Key): Promise { + await this.storage().delete([`alarm-${key}`]) + } +} diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts new file mode 100644 index 000000000..8cad7356e --- /dev/null +++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts @@ -0,0 +1,398 @@ +/// +/// + +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 { + 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() + } +} diff --git a/apps/dotcom-worker/src/lib/config.ts b/apps/dotcom-worker/src/lib/config.ts new file mode 100644 index 000000000..b0c41ee95 --- /dev/null +++ b/apps/dotcom-worker/src/lib/config.ts @@ -0,0 +1,5 @@ +/** + * How often we the document to R2? + * 10 seconds. + */ +export const PERSIST_INTERVAL_MS = 10_000 diff --git a/apps/dotcom-worker/src/lib/r2.ts b/apps/dotcom-worker/src/lib/r2.ts new file mode 100644 index 000000000..706680de6 --- /dev/null +++ b/apps/dotcom-worker/src/lib/r2.ts @@ -0,0 +1,3 @@ +export function getR2KeyForRoom(persistenceKey: string) { + return `public_rooms/${persistenceKey}` +} diff --git a/apps/dotcom-worker/src/lib/routes/createRoom.ts b/apps/dotcom-worker/src/lib/routes/createRoom.ts new file mode 100644 index 000000000..c6a450ce4 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/createRoom.ts @@ -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 +} + +// 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 { + // 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 })) +} diff --git a/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts b/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts new file mode 100644 index 000000000..0307d5303 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/createRoomSnapshot.ts @@ -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 + parent_slug?: string | string[] | undefined +} + +export async function createRoomSnapshot(request: IRequest, env: Environment): Promise { + 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 })) +} diff --git a/apps/dotcom-worker/src/lib/routes/forwardRoomRequest.ts b/apps/dotcom-worker/src/lib/routes/forwardRoomRequest.ts new file mode 100644 index 000000000..fc3befcc4 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/forwardRoomRequest.ts @@ -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 { + 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) +} diff --git a/apps/dotcom-worker/src/lib/routes/getRoomHistory.ts b/apps/dotcom-worker/src/lib/routes/getRoomHistory.ts new file mode 100644 index 000000000..33c99045d --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/getRoomHistory.ts @@ -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 { + 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' }, + }) +} diff --git a/apps/dotcom-worker/src/lib/routes/getRoomHistorySnapshot.ts b/apps/dotcom-worker/src/lib/routes/getRoomHistorySnapshot.ts new file mode 100644 index 000000000..1541d97ca --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/getRoomHistorySnapshot.ts @@ -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 { + 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' }, + }) +} diff --git a/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts b/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts new file mode 100644 index 000000000..1ec5b2c42 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/getRoomSnapshot.ts @@ -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 { + 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, + }) + ) +} diff --git a/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts new file mode 100644 index 000000000..99569fe99 --- /dev/null +++ b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts @@ -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 { + 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() +} diff --git a/apps/dotcom-worker/src/lib/types.ts b/apps/dotcom-worker/src/lib/types.ts new file mode 100644 index 000000000..b454677d6 --- /dev/null +++ b/apps/dotcom-worker/src/lib/types.ts @@ -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 +} diff --git a/apps/dotcom-worker/src/lib/utils/createSupabaseClient.ts b/apps/dotcom-worker/src/lib/utils/createSupabaseClient.ts new file mode 100644 index 000000000..f9b404839 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/createSupabaseClient.ts @@ -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' })) +} diff --git a/apps/dotcom-worker/src/lib/utils/fourOhFour.ts b/apps/dotcom-worker/src/lib/utils/fourOhFour.ts new file mode 100644 index 000000000..c443e0c52 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/fourOhFour.ts @@ -0,0 +1,5 @@ +export async function fourOhFour() { + return new Response('Not found', { + status: 404, + }) +} diff --git a/apps/dotcom-worker/src/lib/utils/getSnapshotsTable.ts b/apps/dotcom-worker/src/lib/utils/getSnapshotsTable.ts new file mode 100644 index 000000000..6a6d5ab13 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/getSnapshotsTable.ts @@ -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' +} diff --git a/apps/dotcom-worker/src/lib/utils/roomIdIsTooLong.ts b/apps/dotcom-worker/src/lib/utils/roomIdIsTooLong.ts new file mode 100644 index 000000000..5fb33b731 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/roomIdIsTooLong.ts @@ -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 }) +} diff --git a/apps/dotcom-worker/src/lib/utils/throttle.ts b/apps/dotcom-worker/src/lib/utils/throttle.ts new file mode 100644 index 000000000..4adca0ef4 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/throttle.ts @@ -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 + } + } +} diff --git a/apps/dotcom-worker/src/lib/utils/validateSnapshot.ts b/apps/dotcom-worker/src/lib/utils/validateSnapshot.ts new file mode 100644 index 000000000..ac37b9554 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/validateSnapshot.ts @@ -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 +} + +export function validateSnapshot( + body: SnapshotRequestBody +): Result, 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) +} diff --git a/apps/dotcom-worker/src/lib/worker.ts b/apps/dotcom-worker/src/lib/worker.ts new file mode 100644 index 000000000..9c415186c --- /dev/null +++ b/apps/dotcom-worker/src/lib/worker.ts @@ -0,0 +1,103 @@ +/// +/// +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 diff --git a/apps/dotcom-worker/tsconfig.json b/apps/dotcom-worker/tsconfig.json new file mode 100644 index 000000000..050479e54 --- /dev/null +++ b/apps/dotcom-worker/tsconfig.json @@ -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" } + ] +} diff --git a/apps/dotcom-worker/wrangler.toml b/apps/dotcom-worker/wrangler.toml new file mode 100644 index 000000000..98368c816 --- /dev/null +++ b/apps/dotcom-worker/wrangler.toml @@ -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" diff --git a/apps/dotcom/.gitignore b/apps/dotcom/.gitignore new file mode 100644 index 000000000..e125cc140 --- /dev/null +++ b/apps/dotcom/.gitignore @@ -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 diff --git a/apps/dotcom/CHANGELOG.md b/apps/dotcom/CHANGELOG.md new file mode 100644 index 000000000..d3f40ce68 --- /dev/null +++ b/apps/dotcom/CHANGELOG.md @@ -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 diff --git a/apps/dotcom/README.md b/apps/dotcom/README.md new file mode 100644 index 000000000..bc69357d2 --- /dev/null +++ b/apps/dotcom/README.md @@ -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). diff --git a/apps/dotcom/decs.d.ts b/apps/dotcom/decs.d.ts new file mode 100644 index 000000000..430c960b1 --- /dev/null +++ b/apps/dotcom/decs.d.ts @@ -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> + export default content +} diff --git a/apps/dotcom/index.html b/apps/dotcom/index.html new file mode 100644 index 000000000..537481bcb --- /dev/null +++ b/apps/dotcom/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tldraw + + + +
+ + + + diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json new file mode 100644 index 000000000..cfb6900a6 --- /dev/null +++ b/apps/dotcom/package.json @@ -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": [ + "" + ], + "testEnvironment": "jsdom", + "transformIgnorePatterns": [ + "node_modules/(?!(nanoid|nanoevents)/)" + ], + "setupFiles": [ + "./setupTests.js" + ] + } +} diff --git a/apps/dotcom/public/404-Sad-tldraw.svg b/apps/dotcom/public/404-Sad-tldraw.svg new file mode 100644 index 000000000..5e0ee36aa --- /dev/null +++ b/apps/dotcom/public/404-Sad-tldraw.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/dotcom/public/Shantell_Sans-Tldrawish.woff2 b/apps/dotcom/public/Shantell_Sans-Tldrawish.woff2 new file mode 100644 index 000000000..7a50fc98d Binary files /dev/null and b/apps/dotcom/public/Shantell_Sans-Tldrawish.woff2 differ diff --git a/apps/dotcom/public/android-chrome-192x192.png b/apps/dotcom/public/android-chrome-192x192.png new file mode 100644 index 000000000..4e671804a Binary files /dev/null and b/apps/dotcom/public/android-chrome-192x192.png differ diff --git a/apps/dotcom/public/android-chrome-512x512.png b/apps/dotcom/public/android-chrome-512x512.png new file mode 100644 index 000000000..b637f9153 Binary files /dev/null and b/apps/dotcom/public/android-chrome-512x512.png differ diff --git a/apps/dotcom/public/android-chrome-maskable-192x192.png b/apps/dotcom/public/android-chrome-maskable-192x192.png new file mode 100644 index 000000000..7928cb537 Binary files /dev/null and b/apps/dotcom/public/android-chrome-maskable-192x192.png differ diff --git a/apps/dotcom/public/android-chrome-maskable-512x512.png b/apps/dotcom/public/android-chrome-maskable-512x512.png new file mode 100644 index 000000000..db17ee009 Binary files /dev/null and b/apps/dotcom/public/android-chrome-maskable-512x512.png differ diff --git a/apps/dotcom/public/android-chrome-maskable-beta-512x512.png b/apps/dotcom/public/android-chrome-maskable-beta-512x512.png new file mode 100644 index 000000000..d746be636 Binary files /dev/null and b/apps/dotcom/public/android-chrome-maskable-beta-512x512.png differ diff --git a/apps/dotcom/public/apple-touch-icon.png b/apps/dotcom/public/apple-touch-icon.png new file mode 100644 index 000000000..1de727577 Binary files /dev/null and b/apps/dotcom/public/apple-touch-icon.png differ diff --git a/apps/dotcom/public/favicon-16x16.png b/apps/dotcom/public/favicon-16x16.png new file mode 100644 index 000000000..408ef44b5 Binary files /dev/null and b/apps/dotcom/public/favicon-16x16.png differ diff --git a/apps/dotcom/public/favicon-32x32.png b/apps/dotcom/public/favicon-32x32.png new file mode 100644 index 000000000..0d9cce1ec Binary files /dev/null and b/apps/dotcom/public/favicon-32x32.png differ diff --git a/apps/dotcom/public/favicon.ico b/apps/dotcom/public/favicon.ico new file mode 100644 index 000000000..05e2571c7 Binary files /dev/null and b/apps/dotcom/public/favicon.ico differ diff --git a/apps/dotcom/public/favicon.svg b/apps/dotcom/public/favicon.svg new file mode 100644 index 000000000..167939275 --- /dev/null +++ b/apps/dotcom/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/dotcom/public/flat.png b/apps/dotcom/public/flat.png new file mode 100644 index 000000000..bfffe50ea Binary files /dev/null and b/apps/dotcom/public/flat.png differ diff --git a/apps/dotcom/public/github-hero-dark.png b/apps/dotcom/public/github-hero-dark.png new file mode 100644 index 000000000..6a0acdfc4 Binary files /dev/null and b/apps/dotcom/public/github-hero-dark.png differ diff --git a/apps/dotcom/public/github-hero-light.png b/apps/dotcom/public/github-hero-light.png new file mode 100644 index 000000000..855c7d6e4 Binary files /dev/null and b/apps/dotcom/public/github-hero-light.png differ diff --git a/apps/dotcom/public/robots.txt b/apps/dotcom/public/robots.txt new file mode 100644 index 000000000..302ba5af8 --- /dev/null +++ b/apps/dotcom/public/robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Disallow: /r + +User-agent: * +Disallow: /v + +User-agent: * +Disallow: /s + +User-agent: * +Allow: / diff --git a/apps/dotcom/public/site.webmanifest b/apps/dotcom/public/site.webmanifest new file mode 100644 index 000000000..52a2fe3f6 --- /dev/null +++ b/apps/dotcom/public/site.webmanifest @@ -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" +} diff --git a/apps/dotcom/public/social-image.png b/apps/dotcom/public/social-image.png new file mode 100644 index 000000000..d2863ddaa Binary files /dev/null and b/apps/dotcom/public/social-image.png differ diff --git a/apps/dotcom/public/social-og.png b/apps/dotcom/public/social-og.png new file mode 100644 index 000000000..b58dbf96a Binary files /dev/null and b/apps/dotcom/public/social-og.png differ diff --git a/apps/dotcom/public/social-twitter.png b/apps/dotcom/public/social-twitter.png new file mode 100644 index 000000000..d2863ddaa Binary files /dev/null and b/apps/dotcom/public/social-twitter.png differ diff --git a/apps/dotcom/public/staging-favicon-16.png b/apps/dotcom/public/staging-favicon-16.png new file mode 100644 index 000000000..7f2c10e1c Binary files /dev/null and b/apps/dotcom/public/staging-favicon-16.png differ diff --git a/apps/dotcom/public/staging-favicon-32.png b/apps/dotcom/public/staging-favicon-32.png new file mode 100644 index 000000000..a59972275 Binary files /dev/null and b/apps/dotcom/public/staging-favicon-32.png differ diff --git a/apps/dotcom/public/staging-favicon.svg b/apps/dotcom/public/staging-favicon.svg new file mode 100644 index 000000000..1f0cf7e07 --- /dev/null +++ b/apps/dotcom/public/staging-favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/dotcom/public/tldraw-white-on-black.svg b/apps/dotcom/public/tldraw-white-on-black.svg new file mode 100644 index 000000000..57069fdfb --- /dev/null +++ b/apps/dotcom/public/tldraw-white-on-black.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/dotcom/public/tldraw.svg b/apps/dotcom/public/tldraw.svg new file mode 100644 index 000000000..f0c9e1f94 --- /dev/null +++ b/apps/dotcom/public/tldraw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/dotcom/scripts/build.ts b/apps/dotcom/scripts/build.ts new file mode 100644 index 000000000..fa2828c04 --- /dev/null +++ b/apps/dotcom/scripts/build.ts @@ -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() diff --git a/apps/dotcom/scripts/dev-app.ts b/apps/dotcom/scripts/dev-app.ts new file mode 100644 index 000000000..0594ef17a --- /dev/null +++ b/apps/dotcom/scripts/dev-app.ts @@ -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) { + 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() diff --git a/apps/dotcom/scripts/vercel-output-config.d.ts b/apps/dotcom/scripts/vercel-output-config.d.ts new file mode 100644 index 000000000..a7dff7bcd --- /dev/null +++ b/apps/dotcom/scripts/vercel-output-config.d.ts @@ -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 + methods?: string[] + continue?: boolean + caseSensitive?: boolean + check?: boolean + status?: number + has?: Array + missing?: Array + locale?: Locale + middlewarePath?: string +} + +type Locale = { + redirect?: Record + 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 + +type Override = { + path?: string + contentType?: string +} + +type OverrideConfig = Record + +type ServerlessFunctionConfig = { + handler: string + runtime: string + memory?: number + maxDuration?: number + environment?: Record[] + 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[] +} diff --git a/apps/dotcom/sentry-release-name.ts b/apps/dotcom/sentry-release-name.ts new file mode 100644 index 000000000..98fa27118 --- /dev/null +++ b/apps/dotcom/sentry-release-name.ts @@ -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' diff --git a/apps/dotcom/sentry.client.config.ts b/apps/dotcom/sentry.client.config.ts new file mode 100644 index 000000000..13417f2e9 --- /dev/null +++ b/apps/dotcom/sentry.client.config.ts @@ -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)) diff --git a/apps/dotcom/sentry.properties b/apps/dotcom/sentry.properties new file mode 100644 index 000000000..c5a9f609f --- /dev/null +++ b/apps/dotcom/sentry.properties @@ -0,0 +1,4 @@ +defaults.url=https://sentry.io/ +defaults.org=tldraw +defaults.project=lite +cli.executable=../../node_modules/@sentry/cli/bin/sentry-cli diff --git a/apps/dotcom/setupTests.js b/apps/dotcom/setupTests.js new file mode 100644 index 000000000..88d3a65a1 --- /dev/null +++ b/apps/dotcom/setupTests.js @@ -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' diff --git a/apps/dotcom/src/components/BoardHistoryLog/BoardHistoryLog.tsx b/apps/dotcom/src/components/BoardHistoryLog/BoardHistoryLog.tsx new file mode 100644 index 000000000..9d9be6851 --- /dev/null +++ b/apps/dotcom/src/components/BoardHistoryLog/BoardHistoryLog.tsx @@ -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 ( +
+

{'No history found'}

+
+ ) + } + + return ( +
+
    + {data.map((v, i) => { + const timeStamp = v.split('/').pop() + return ( +
  • + + {formatDate(timeStamp!)} + +
  • + ) + })} +
+
+ ) +} + +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) +} diff --git a/apps/dotcom/src/components/BoardHistorySnapshot/BoardHistorySnapshot.tsx b/apps/dotcom/src/components/BoardHistorySnapshot/BoardHistorySnapshot.tsx new file mode 100644 index 000000000..e4f06174f --- /dev/null +++ b/apps/dotcom/src/components/BoardHistorySnapshot/BoardHistorySnapshot.tsx @@ -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 ( + <> +
+ { + editor.updateInstanceState({ isReadonly: true }) + setTimeout(() => { + editor.setCurrentTool('hand') + }) + }} + overrides={[fileSystemUiOverrides]} + inferDarkMode + autoFocus + /> +
+
+ +
+ + ) +} diff --git a/apps/dotcom/src/components/CursorChatBubble.tsx b/apps/dotcom/src/components/CursorChatBubble.tsx new file mode 100644 index 000000000..a7fa62b62 --- /dev/null +++ b/apps/dotcom/src/components/CursorChatBubble.tsx @@ -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(-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 + + return chatMessage.trim() ? : null +}) + +function usePositionBubble(ref: RefObject) { + 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(null) + + usePositionBubble(ref) + + return ( +
+ {chatMessage} +
+ ) +} + +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(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) => { + 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 ( + + ) +}) diff --git a/apps/dotcom/src/components/DefaultErrorFallback/DefaultErrorFallback.tsx b/apps/dotcom/src/components/DefaultErrorFallback/DefaultErrorFallback.tsx new file mode 100644 index 000000000..8f2df915b --- /dev/null +++ b/apps/dotcom/src/components/DefaultErrorFallback/DefaultErrorFallback.tsx @@ -0,0 +1,13 @@ +import { captureException } from '@sentry/react' +import { DefaultErrorFallback as ErrorFallback } from '@tldraw/tldraw' +import { useEffect } from 'react' +import { useRouteError } from 'react-router-dom' +import '../../../styles/globals.css' + +export function DefaultErrorFallback() { + const error = useRouteError() + useEffect(() => { + captureException(error) + }, [error]) + return +} diff --git a/apps/dotcom/src/components/EmbeddedInIFrameWarning.tsx b/apps/dotcom/src/components/EmbeddedInIFrameWarning.tsx new file mode 100644 index 000000000..a194a3b06 --- /dev/null +++ b/apps/dotcom/src/components/EmbeddedInIFrameWarning.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { useUrl } from '../hooks/useUrl' + +export function EmbeddedInIFrameWarning() { + // check if this still works + const url = useUrl() + + const [copied, setCopied] = React.useState(false) + const rTimeout = React.useRef(0) + + const handleCopy = React.useCallback(() => { + setCopied(true) + clearTimeout(rTimeout.current) + rTimeout.current = setTimeout(() => { + setCopied(false) + }, 1200) + + const textarea = document.createElement('textarea') + textarea.setAttribute('position', 'fixed') + textarea.setAttribute('top', '0') + textarea.setAttribute('readonly', 'true') + textarea.setAttribute('contenteditable', 'true') + textarea.style.position = 'fixed' + textarea.value = url + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + try { + const range = document.createRange() + range.selectNodeContents(textarea) + const sel = window.getSelection() + if (sel) { + sel.removeAllRanges() + sel.addRange(range) + textarea.setSelectionRange(0, textarea.value.length) + } + // eslint-disable-next-line deprecation/deprecation + document.execCommand('copy') + } catch (err) { + null // Could not copy to clipboard + } finally { + document.body.removeChild(textarea) + } + }, [url]) + + return ( +
+
+ + {'Visit this page on tldraw.com '} + + + + + +
+
+ ) +} diff --git a/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx new file mode 100644 index 000000000..9a275d8dd --- /dev/null +++ b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router-dom' + +export function ErrorPage({ + icon, + messages, +}: { + icon?: boolean + messages: { header: string; para1: string; para2?: string } + redirectTo?: string +}) { + return ( +
+
+ {icon && ( + {'Not + )} +
+

{messages.header}

+

{messages.para1}

+ {messages.para2 &&

{messages.para2}

} +
+ + Take me home. + +
+
+ ) +} diff --git a/apps/dotcom/src/components/ExportMenu.tsx b/apps/dotcom/src/components/ExportMenu.tsx new file mode 100644 index 000000000..5a51fc88d --- /dev/null +++ b/apps/dotcom/src/components/ExportMenu.tsx @@ -0,0 +1,98 @@ +import * as Popover from '@radix-ui/react-popover' +import { + Button, + useActions, + useBreakpoint, + useContainer, + useEditor, + useTranslation, +} from '@tldraw/tldraw' +import React, { useState } from 'react' +import { useShareMenuIsOpen } from '../hooks/useShareMenuOpen' +import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing' +import { getSaveFileCopyAction } from '../utils/useFileSystem' +import { useHandleUiEvents } from '../utils/useHandleUiEvent' + +export const ExportMenu = React.memo(function ExportMenu() { + const { [SHARE_PROJECT_ACTION]: shareProject, [SHARE_SNAPSHOT_ACTION]: shareSnapshot } = + useActions() + const container = useContainer() + const msg = useTranslation() + const breakpoint = useBreakpoint() + const handleUiEvent = useHandleUiEvents() + const showIcon = breakpoint < 5 + const editor = useEditor() + const saveFileCopyAction = getSaveFileCopyAction(editor, handleUiEvent) + const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false) + const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false) + + const [isOpen, onOpenChange] = useShareMenuIsOpen() + + return ( + + + + + + +
+
+ +
+ {userIds.length > 0 && ( +
+ {userIds.map((userId) => { + return + })} +
+ )} + {!hideShareMenu && ( +
+
+ )} +
+
+
+
+ ) +}) diff --git a/apps/dotcom/src/components/PeopleMenu/PeopleMenuAvatar.tsx b/apps/dotcom/src/components/PeopleMenu/PeopleMenuAvatar.tsx new file mode 100644 index 000000000..b864cf636 --- /dev/null +++ b/apps/dotcom/src/components/PeopleMenu/PeopleMenuAvatar.tsx @@ -0,0 +1,18 @@ +import { usePresence } from '@tldraw/tldraw' + +export function PeopleMenuAvatar({ userId }: { userId: string }) { + const presence = usePresence(userId) + + if (!presence) return null + return ( +
+ {presence.userName === 'New User' ? '' : presence.userName[0] ?? ''} +
+ ) +} diff --git a/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx b/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx new file mode 100644 index 000000000..ddddd32e4 --- /dev/null +++ b/apps/dotcom/src/components/PeopleMenu/PeopleMenuItem.tsx @@ -0,0 +1,63 @@ +import { + Button, + Icon, + track, + useEditor, + usePresence, + useTranslation, + useUiEvents, +} from '@tldraw/tldraw' +import { useCallback } from 'react' +import { UI_OVERRIDE_TODO_EVENT } from '../../utils/useHandleUiEvent' + +export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId: string }) { + const editor = useEditor() + const msg = useTranslation() + const trackEvent = useUiEvents() + + const presence = usePresence(userId) + + const handleFollowClick = useCallback(() => { + if (editor.getInstanceState().followingUserId === userId) { + editor.stopFollowingUser() + trackEvent('stop-following', { source: 'people-menu' }) + } else { + editor.startFollowingUser(userId) + trackEvent('start-following' as UI_OVERRIDE_TODO_EVENT, { source: 'people-menu' }) + } + }, [editor, userId, trackEvent]) + + const theyAreFollowingYou = presence?.followingUserId === editor.user.getId() + const youAreFollowingThem = editor.getInstanceState().followingUserId === userId + + if (!presence) return null + + return ( +
+ +
+ ) +}) diff --git a/apps/dotcom/src/components/PeopleMenu/PeopleMenuMore.tsx b/apps/dotcom/src/components/PeopleMenu/PeopleMenuMore.tsx new file mode 100644 index 000000000..eaa861d37 --- /dev/null +++ b/apps/dotcom/src/components/PeopleMenu/PeopleMenuMore.tsx @@ -0,0 +1,3 @@ +export function PeopleMenuMore({ count }: { count: number }) { + return
{'+' + Math.abs(count)}
+} diff --git a/apps/dotcom/src/components/PeopleMenu/UserPresenceColorPicker.tsx b/apps/dotcom/src/components/PeopleMenu/UserPresenceColorPicker.tsx new file mode 100644 index 000000000..1da5ad676 --- /dev/null +++ b/apps/dotcom/src/components/PeopleMenu/UserPresenceColorPicker.tsx @@ -0,0 +1,131 @@ +import * as Popover from '@radix-ui/react-popover' +import { + Button, + USER_COLORS, + track, + useContainer, + useEditor, + useTranslation, + useUiEvents, +} from '@tldraw/tldraw' +import React, { useCallback, useRef, useState } from 'react' +import { UI_OVERRIDE_TODO_EVENT } from '../../utils/useHandleUiEvent' + +export const UserPresenceColorPicker = track(function UserPresenceColorPicker() { + const editor = useEditor() + const container = useContainer() + const msg = useTranslation() + const trackEvent = useUiEvents() + + const rPointing = useRef(false) + + const [isOpen, setIsOpen] = useState(false) + const handleOpenChange = useCallback((isOpen: boolean) => { + setIsOpen(isOpen) + }, []) + + const value = editor.user.getColor() + + const onValueChange = useCallback( + (item: string) => { + editor.user.updateUserPreferences({ color: item }) + trackEvent('set-color' as UI_OVERRIDE_TODO_EVENT, { source: 'people-menu' }) + }, + [editor, trackEvent] + ) + + const { + handleButtonClick, + handleButtonPointerDown, + handleButtonPointerEnter, + handleButtonPointerUp, + } = React.useMemo(() => { + const handlePointerUp = () => { + rPointing.current = false + window.removeEventListener('pointerup', handlePointerUp) + } + + const handleButtonClick = (e: React.PointerEvent) => { + const { id } = e.currentTarget.dataset + if (!id) return + if (value === id) return + + onValueChange(id) + } + + const handleButtonPointerDown = (e: React.PointerEvent) => { + const { id } = e.currentTarget.dataset + if (!id) return + + onValueChange(id) + + rPointing.current = true + window.addEventListener('pointerup', handlePointerUp) // see TLD-658 + } + + const handleButtonPointerEnter = (e: React.PointerEvent) => { + if (!rPointing.current) return + + const { id } = e.currentTarget.dataset + if (!id) return + onValueChange(id) + } + + const handleButtonPointerUp = (e: React.PointerEvent) => { + const { id } = e.currentTarget.dataset + if (!id) return + onValueChange(id) + } + + return { + handleButtonClick, + handleButtonPointerDown, + handleButtonPointerEnter, + handleButtonPointerUp, + } + }, [value, onValueChange]) + + return ( + + + + + + + + ) +} diff --git a/apps/dotcom/src/utils/migration/migration.tsx b/apps/dotcom/src/utils/migration/migration.tsx new file mode 100644 index 000000000..01ef22728 --- /dev/null +++ b/apps/dotcom/src/utils/migration/migration.tsx @@ -0,0 +1,118 @@ +import { Editor, LegacyTldrawDocument, buildFromV1Document } from '@tldraw/tldraw' +import { openDB } from 'idb' + +export function isEditorEmpty(editor: Editor) { + const hasAnyShapes = editor.store.allRecords().some((r) => r.typeName === 'shape') + return !hasAnyShapes +} + +export async function findV1ContentFromIdb(): Promise<{ + document: LegacyTldrawDocument + clear: () => Promise +} | null> { + try { + const db = await openDB('keyval-store', 1) + const tx = db.transaction('keyval', 'readonly') + const store = tx.objectStore('keyval') + + const home: unknown = await store.get('home') + await tx.done + + if ( + home && + typeof home === 'object' && + 'document' in home && + home.document && + typeof home.document === 'object' && + 'version' in home.document && + typeof home.document.version === 'number' + ) { + return { + document: home.document as LegacyTldrawDocument, + clear: async () => { + try { + const tx = db.transaction('keyval', 'readwrite') + const store = tx.objectStore('keyval') + store.delete('home') + await tx.done + return + } catch { + // eh + } + }, + } + } + + return null + } catch { + return null + } +} + +export async function importFromV1LocalRoom( + editor: Editor, + didCancel: () => boolean +): Promise<{ didImport: false } | { didImport: true; document: LegacyTldrawDocument }> { + const v1Doc = await findV1ContentFromIdb() + if (didCancel() || !v1Doc) return { didImport: false } + + const hasAnyShapes = Object.values(v1Doc.document.pages).some( + (page) => Object.values(page.shapes).length > 0 + ) + if (!hasAnyShapes) return { didImport: false } + + buildFromV1Document(editor, v1Doc.document) + v1Doc.clear() + + if (isEditorEmpty(editor)) { + return { didImport: false } + } + + return { didImport: true, document: v1Doc.document } +} + +export async function importFromV1MultiplayerRoom( + editor: Editor, + roomSlug: string, + didCancel: () => boolean +): Promise<{ didImport: false } | { didImport: true; document: LegacyTldrawDocument }> { + const response = await fetch(`/api/static-legacy-multiplayer?roomSlug=${roomSlug}`) + if (!response.ok || didCancel()) { + return { didImport: false } + } + + const data = await response.json() + if (!data.room || didCancel()) { + return { didImport: false } + } + + // TODO: handle weird data formats (TLD-1605) & v1 migrations (TLD-1638) + const { assets, bindings, shapes, version } = data.room.storage.data + const PAGE_ID = 'page' + const document: LegacyTldrawDocument = { + id: 'doc', + name: roomSlug, + version, + pages: { + [PAGE_ID]: { + id: PAGE_ID, + name: 'Page 1', + childIndex: 1, + shapes: shapes?.data, + bindings: bindings?.data, + }, + }, + pageStates: { + [PAGE_ID]: { + id: PAGE_ID, + selectedIds: [], + camera: { point: [0, 0], zoom: 1 }, + }, + }, + assets: assets?.data ?? {}, + } + buildFromV1Document(editor, document) + + if (isEditorEmpty(editor)) return { didImport: false } + return { didImport: true, document } +} diff --git a/apps/dotcom/src/utils/migration/writeV1ContentsToIdb.tsx b/apps/dotcom/src/utils/migration/writeV1ContentsToIdb.tsx new file mode 100644 index 000000000..34777b749 --- /dev/null +++ b/apps/dotcom/src/utils/migration/writeV1ContentsToIdb.tsx @@ -0,0 +1,18 @@ +import { openDB } from 'idb' + +const v1Contents = { + home: JSON.parse( + '{"settings":{"isCadSelectMode":false,"isPenMode":false,"isDarkMode":true,"isZoomSnap":false,"isFocusMode":false,"isSnapping":false,"isDebugMode":false,"isReadonlyMode":false,"keepStyleMenuOpen":false,"nudgeDistanceLarge":16,"nudgeDistanceSmall":1,"showRotateHandles":true,"showBindingHandles":true,"showCloneHandles":false,"showGrid":false,"language":"en","dockPosition":"bottom","exportBackground":"transparent"},"appState":{"status":"idle","activeTool":"draw","currentPageId":"page","currentStyle":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"isToolLocked":false,"isMenuOpen":false,"isEmptyCanvas":false,"eraseLine":[],"snapLines":[],"isLoading":false,"disableAssets":false,"selectByContain":false},"document":{"id":"doc","name":"New Document","version":15.5,"pages":{"page":{"id":"page","name":"Page 1","childIndex":1,"shapes":{"9172e03a-bd79-4c2b-3abe-d16b4e3cf33f":{"id":"9172e03a-bd79-4c2b-3abe-d16b4e3cf33f","type":"draw","name":"Draw","parentId":"page","childIndex":1,"point":[431.69,168.94],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0,0.5],[0,0,0.5],[0,0.83,0.5],[0,3.31,0.5],[0,7.94,0.5],[0,16.67,0.5],[0,29.04,0.5],[0,43.27,0.5],[0,59.95,0.5],[0,78.62,0.5],[0,97.95,0.5],[0,116.69,0.5],[0,131.06,0.5],[0,145.44,0.5],[0,161.37,0.5],[0,172.49,0.5],[0,182.38,0.5],[0,191.3,0.5],[0,196.31,0.5],[0,200.31,0.5],[0,204.09,0.5],[0,206.54,0.5],[0,208.03,0.5],[0,208.77,0.5],[0,209.04,0.5],[0,209.15,0.5],[0,208.94,0.5],[0,207.75,0.5],[0,205.52,0.5],[0,202.47,0.5],[0,195.49,0.5],[0,192.03,0.5],[0,183.13,0.5],[0.25,172.63,0.5],[2.66,162.29,0.5],[7.19,152.27,0.5],[11.76,144.66,0.5],[17.02,137.91,0.5],[23.26,130.71,0.5],[28.88,125.43,0.5],[32.85,122.74,0.5],[36.32,121.08,0.5],[40.33,119.78,0.5],[43.67,119.28,0.5],[45.89,119.15,0.5],[47.84,119.58,0.5],[49.47,121.23,0.5],[51.03,124.94,0.5],[52.9,130.48,0.5],[54.69,136.93,0.5],[56.46,144.14,0.5],[58.32,152.38,0.5],[59.89,159.79,0.5],[61.09,166.49,0.5],[61.97,172.42,0.5],[62.54,177.6,0.5],[63.08,183.22,0.5],[63.56,187.41,0.5],[63.98,190.15,0.5],[64.33,191.82,0.5],[64.47,192.74,0.5],[64.47,193.29,0.5]],"isComplete":true},"9cfd08a9-0e26-4cbc-1a6f-8507ac58840e":{"id":"9cfd08a9-0e26-4cbc-1a6f-8507ac58840e","type":"draw","name":"Draw","parentId":"page","childIndex":2,"point":[496.61,282.12],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[23.03,46.08,0.5],[23.03,46.08,0.5],[23.25,46.08,0.5],[24.04,46.08,0.5],[26.3,46.08,0.5],[29.46,46.08,0.5],[32.5,46.08,0.5],[37.44,46.08,0.5],[44.54,46.08,0.5],[50.28,46.08,0.5],[54.64,46.08,0.5],[59.52,45.59,0.5],[64.01,44.39,0.5],[67.72,42.79,0.5],[71.12,40.77,0.5],[73.91,38.75,0.5],[75.69,36.93,0.5],[76.96,34.58,0.5],[78,31.96,0.5],[78.84,29.48,0.5],[79.5,26.55,0.5],[79.75,23.36,0.5],[79.77,20.04,0.5],[79.77,16.68,0.5],[79.74,13.24,0.5],[79.47,9.78,0.5],[78.85,7.65,0.5],[77.8,5.95,0.5],[76.32,3.88,0.5],[74.46,2.29,0.5],[71.89,0.92,0.5],[69.07,0.12,0.5],[66.18,0,0.5],[62.91,0,0.5],[59.16,0,0.5],[54.92,0.38,0.5],[49.27,2.6,0.5],[42.42,6.97,0.5],[35.97,12.44,0.5],[29.71,18.82,0.5],[23.27,25.81,0.5],[18.09,31.78,0.5],[13.78,37.7,0.5],[9.19,44.64,0.5],[5.5,51.15,0.5],[3.46,55.72,0.5],[2.21,59.28,0.5],[0.97,63.44,0.5],[0.21,67.06,0.5],[0,69.8,0.5],[0,72.32,0.5],[0.02,75.09,0.5],[0.41,77.8,0.5],[1.89,80.11,0.5],[4.15,82.33,0.5],[6.73,84.55,0.5],[9.87,86.85,0.5],[13.57,89.18,0.5],[17.74,91.21,0.5],[23.4,93.11,0.5],[30.11,94.9,0.5],[36.06,96.09,0.5],[42.04,96.93,0.5],[48.39,97.63,0.5],[54.27,98.17,0.5],[58.77,98.53,0.5],[63.26,98.61,0.5],[68.19,98.61,0.5],[72.31,98.27,0.5],[76.22,97.05,0.5],[79.93,95.29,0.5],[83.83,92.89,0.5],[88.14,89.74,0.5],[91.92,86.01,0.5]],"isComplete":true},"a4d8e089-da35-4691-33b1-cca4291f3ddb":{"id":"a4d8e089-da35-4691-33b1-cca4291f3ddb","type":"draw","name":"Draw","parentId":"page","childIndex":3,"point":[625.08,260.15],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,20.72,0.5],[0,20.72,0.5],[0,21.19,0.5],[0,22.55,0.5],[0,25.3,0.5],[0,29.71,0.5],[0,34.96,0.5],[0,41.62,0.5],[0,50.79,0.5],[0,60.42,0.5],[0,68.86,0.5],[0,75.54,0.5],[0,81.74,0.5],[0,88.59,0.5],[0,94.09,0.5],[0,97.64,0.5],[0,100.67,0.5],[0,103.31,0.5],[0,104.98,0.5],[0,106.08,0.5],[0,106.58,0.5],[0,106.59,0.5],[0,105.88,0.5],[0,103.95,0.5],[0,101.12,0.5],[0,96.28,0.5],[1.43,88.45,0.5],[4.55,79.33,0.5],[8.81,68.87,0.5],[14.98,56.77,0.5],[22.46,44.42,0.5],[30.55,32.89,0.5],[38.67,23.33,0.5],[45.37,16.98,0.5],[51.08,12.18,0.5],[55.92,8.42,0.5],[60.24,5.55,0.5],[64.79,2.94,0.5],[68.77,1.23,0.5],[72.22,0.25,0.5],[75.03,0,0.5],[77.22,0,0.5],[78.88,0,0.5],[80.29,0,0.5],[81.56,0,0.5],[82.62,0,0.5],[83.44,0.1,0.5]],"isComplete":true},"f54ec6aa-8e04-429a-288b-b22c5066c083":{"id":"f54ec6aa-8e04-429a-288b-b22c5066c083","type":"draw","name":"Draw","parentId":"page","childIndex":4,"point":[688.7,288.06],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[14.44,38.57,0.5],[14.44,38.57,0.5],[14.93,38.57,0.5],[17.05,38.57,0.5],[20.85,38.57,0.5],[25.65,38.57,0.5],[30.17,38.57,0.5],[34.68,38.57,0.5],[39.75,38.57,0.5],[44.45,38.57,0.5],[48.79,38.57,0.5],[52.6,38.35,0.5],[55.7,37.71,0.5],[58.21,36.63,0.5],[60.4,34.86,0.5],[62.46,32.22,0.5],[64.19,29.15,0.5],[65.32,25.53,0.5],[65.94,21.45,0.5],[66.23,17.88,0.5],[66.28,14.19,0.5],[66.28,10.73,0.5],[66.28,8.77,0.5],[66.28,7.29,0.5],[65.57,5.27,0.5],[64.44,3.62,0.5],[63.07,2.75,0.5],[61.32,1.82,0.5],[59.15,1.02,0.5],[56.03,0.39,0.5],[53.03,0,0.5],[50.22,0,0.5],[46.57,0,0.5],[40.79,0.33,0.5],[34.33,1.74,0.5],[29.01,3.85,0.5],[23.29,6.16,0.5],[17.14,8.72,0.5],[12.24,10.84,0.5],[8.89,12.32,0.5],[6.07,13.74,0.5],[3.55,15.23,0.5],[1.74,16.62,0.5],[0.74,17.88,0.5],[0.27,19.09,0.5],[0.05,20.64,0.5],[0,22.48,0.5],[0,25.13,0.5],[0,28.65,0.5],[0,32.96,0.5],[0,38.33,0.5],[0.8,44.54,0.5],[2.4,51.09,0.5],[4.34,57.48,0.5],[6.71,63.83,0.5],[9.53,69.63,0.5],[12.69,74.54,0.5],[15.86,78.53,0.5],[19.43,81.67,0.5],[23.48,84.29,0.5],[27.5,86.04,0.5],[31.44,86.85,0.5],[35.82,87.24,0.5],[41,87.39,0.5],[47.53,87.09,0.5],[54.47,86.01,0.5],[61.31,83.49,0.5],[68.22,79.99,0.5]],"isComplete":true},"640adf82-c8fd-4e93-18c4-2d8ae7817181":{"id":"640adf82-c8fd-4e93-18c4-2d8ae7817181","type":"draw","name":"Draw","parentId":"page","childIndex":5,"point":[880.73,291.19],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0,0.5],[0,0,0.5],[0,0.78,0.5],[0,3.52,0.5],[0,8.19,0.5],[0,14.22,0.5],[0,21.85,0.5],[0,30.52,0.5],[0,39.61,0.5],[0,48.76,0.5],[0,56.85,0.5],[0,62.9,0.5],[0,68.78,0.5],[0,75.83,0.5],[0,82.39,0.5],[0,88.28,0.5],[0,93.72,0.5],[0,97.54,0.5],[0,100.06,0.5],[0,102.63,0.5]],"isComplete":true},"018da8ea-206c-45f7-035c-575e3458cc70":{"id":"018da8ea-206c-45f7-035c-575e3458cc70","type":"draw","name":"Draw","parentId":"page","childIndex":6,"point":[879.72,270.57],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0.13,0.5],[0,0.13,0.5],[0,0,0.5]],"isComplete":true},"89f2a71a-bb44-4bf5-0501-a313d2aaf166":{"id":"89f2a71a-bb44-4bf5-0501-a313d2aaf166","type":"draw","name":"Draw","parentId":"page","childIndex":7,"point":[900.76,273.82],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[39.03,0,0.5],[39.03,0,0.5],[38.27,0,0.5],[36.36,0.05,0.5],[34.06,0.43,0.5],[31.29,1.43,0.5],[27.58,3.04,0.5],[24.47,4.56,0.5],[21.41,6.37,0.5],[17.77,8.52,0.5],[15.42,9.94,0.5],[13.58,11.49,0.5],[11.6,13.11,0.5],[10.36,14.37,0.5],[9.7,15.52,0.5],[9.28,16.25,0.5],[9.15,16.64,0.5],[9.15,16.95,0.5],[9.15,17.37,0.5],[9.15,17.87,0.5],[9.37,18.73,0.5],[10.38,20.22,0.5],[12.26,22.21,0.5],[14.58,24.54,0.5],[17.08,27.14,0.5],[19.44,29.72,0.5],[21.45,31.83,0.5],[23.79,34.34,0.5],[26.57,37.37,0.5],[28.66,39.68,0.5],[30.38,41.58,0.5],[32.24,43.65,0.5],[33.91,45.68,0.5],[35.55,47.73,0.5],[37.01,49.6,0.5],[38.14,51.26,0.5],[39.19,52.98,0.5],[39.98,54.7,0.5],[40.59,56.23,0.5],[41.06,57.31,0.5],[41.21,58.27,0.5],[41.23,59.11,0.5],[41.23,59.7,0.5],[41.23,60.3,0.5],[41.15,60.9,0.5],[40.65,61.45,0.5],[39.45,62.01,0.5],[37.61,62.83,0.5],[35.31,63.84,0.5],[32.71,64.96,0.5],[29.47,66.3,0.5],[25.34,67.75,0.5],[20.62,69.27,0.5],[16.31,70.5,0.5],[12.85,71.32,0.5],[9.62,72.12,0.5],[6.69,72.84,0.5],[4.28,73.32,0.5],[2.44,73.7,0.5],[1.29,73.91,0.5],[0.62,73.95,0.5],[0.17,73.95,0.5],[0,73.95,0.5]],"isComplete":true},"7d248229-1136-41d2-1cfd-717896f81fc6":{"id":"7d248229-1136-41d2-1cfd-717896f81fc6","type":"draw","name":"Draw","parentId":"page","childIndex":8,"point":[484.11,450.09],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[13.92,8.39,0.5],[13.92,8.39,0.5],[13.79,8.27,0.5],[13.32,8.14,0.5],[12.38,8.14,0.5],[10.83,8.14,0.5],[8.93,8.14,0.5],[7.02,8.14,0.5],[5.5,8.14,0.5],[3.85,8.29,0.5],[2.12,8.99,0.5],[1.15,10.1,0.5],[0.52,11.47,0.5],[0.11,13.24,0.5],[0,15.16,0.5],[0,17.06,0.5],[0,18.96,0.5],[0,21.04,0.5],[0,23.39,0.5],[0,25.69,0.5],[0,27.9,0.5],[0,29.75,0.5],[0,32,0.5],[0,34.16,0.5],[0,35.41,0.5],[0,36.69,0.5],[0,37.91,0.5],[0,39.09,0.5],[0,40.09,0.5],[0.13,40.47,0.5],[0.42,40.77,0.5],[0.87,41.18,0.5],[1.46,41.32,0.5],[2.06,41.32,0.5],[2.65,41.32,0.5],[3.28,41.32,0.5],[4.19,41.16,0.5],[5.55,40.26,0.5],[7.23,38.58,0.5],[9.15,36.72,0.5],[11.46,34.43,0.5],[14.25,31.28,0.5],[17.27,27.77,0.5],[20.12,24.14,0.5],[22.86,20.41,0.5],[25.07,17.3,0.5],[26.78,14.63,0.5],[28.46,11.86,0.5],[29.89,9.24,0.5],[31.09,6.97,0.5],[31.94,5,0.5],[32.53,3.29,0.5],[33.05,2.02,0.5],[33.38,1.18,0.5],[33.67,0.58,0.5],[34.09,0,0.5],[34.21,0,0.5],[34.46,0,0.5],[34.73,0.02,0.5],[34.98,0.28,0.5],[35.12,1.18,0.5],[35.33,2.4,0.5],[35.67,3.64,0.5],[36.02,5.14,0.5],[36.41,7.12,0.5],[36.9,9.71,0.5],[37.61,12.34,0.5],[38.53,14.82,0.5],[39.82,17.49,0.5],[41.21,20.04,0.5],[42.37,22.41,0.5],[43.69,24.86,0.5],[45.12,27.05,0.5],[46.34,28.98,0.5],[47.28,30.52,0.5],[47.97,31.57,0.5],[48.48,32.39,0.5],[48.81,32.91,0.5],[49.08,33.2,0.5],[49.23,33.35,0.5]],"isComplete":true},"40a6d973-88ac-4c50-168c-3c2b30abdf85":{"id":"40a6d973-88ac-4c50-168c-3c2b30abdf85","type":"draw","name":"Draw","parentId":"page","childIndex":9,"point":[607.9,419.71],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0.78,27.73,0.5],[0.78,27.73,0.5],[0.78,28.31,0.5],[0.78,29.73,0.5],[0.78,31.43,0.5],[0.78,34.65,0.5],[0.78,39.75,0.5],[0.78,43.98,0.5],[0.78,48.71,0.5],[0.78,54.72,0.5],[0.78,59.82,0.5],[0.78,65.32,0.5],[0.78,71.11,0.5],[0.78,75.25,0.5],[0.78,78,0.5],[0.78,80.75,0.5],[0.78,83.33,0.5],[0.78,85.05,0.5],[0.78,86.02,0.5],[0.78,86.45,0.5],[0.77,86.27,0.5],[0.6,85.28,0.5],[0.41,83.55,0.5],[0.19,81.04,0.5],[0,77.7,0.5],[0,72.77,0.5],[0.14,66.51,0.5],[0.94,59.77,0.5],[2.48,52.7,0.5],[4.4,46.31,0.5],[6.98,39.53,0.5],[10.19,31.95,0.5],[13.69,24.59,0.5],[17.27,17.7,0.5],[20.83,11.84,0.5],[24.29,7.33,0.5],[27.07,4.53,0.5],[29.41,2.85,0.5],[31.66,1.41,0.5],[33.89,0.42,0.5],[35.9,0.06,0.5],[37.64,0,0.5],[39.27,0,0.5],[40.5,0,0.5],[41.78,0.52,0.5],[43,1.47,0.5],[44.42,3.3,0.5],[45.93,5.44,0.5],[46.91,7.42,0.5],[47.89,9.65,0.5],[48.9,12.19,0.5],[49.71,14.86,0.5],[50.26,16.86,0.5],[50.66,19.22,0.5],[51.02,21.94,0.5],[51.17,24.4,0.5],[51.17,26.76,0.5],[51.17,28.85,0.5],[51.17,30.66,0.5],[50.92,32.13,0.5],[49.89,33.43,0.5],[48.12,34.79,0.5],[46.17,36.06,0.5],[43.87,37.13,0.5],[40.78,38.24,0.5],[37.14,39.4,0.5],[33.47,40.38,0.5],[29.51,41.14,0.5],[25.05,41.9,0.5],[21.58,42.6,0.5],[18.68,43.07,0.5],[15.54,43.32,0.5],[12.83,43.51,0.5],[10.44,43.7,0.5],[8.62,43.74,0.5],[7.44,43.74,0.5],[6.68,43.74,0.5],[6.26,43.72,0.5],[6.02,43.58,0.5]],"isComplete":true},"b7b78106-962a-4623-21a3-23afa9f04365":{"id":"b7b78106-962a-4623-21a3-23afa9f04365","type":"draw","name":"Draw","parentId":"page","childIndex":10,"point":[671.95,431.84],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,24.97,0.5],[0,24.97,0.5],[0,24.86,0.5],[0,24.9,0.5],[0,25.71,0.5],[0,27.33,0.5],[0,29.36,0.5],[0,31.85,0.5],[0,34.62,0.5],[0,36.71,0.5],[0,38.11,0.5],[0,39.6,0.5],[0,40.96,0.5],[0,41.87,0.5],[0,42.3,0.5],[0,42.23,0.5],[0,41.37,0.5],[0,39.96,0.5],[0,38.48,0.5],[0.03,36.54,0.5],[0.41,33.44,0.5],[1.59,29.23,0.5],[3.53,25.43,0.5],[6.5,21.89,0.5],[10.23,18.07,0.5],[14.22,14.44,0.5],[18.58,10.96,0.5],[22.99,8.06,0.5],[27.27,5.68,0.5],[30.93,3.89,0.5],[33.78,2.68,0.5],[36.63,1.6,0.5],[39.51,0.79,0.5],[41.87,0.3,0.5],[43.68,0.05,0.5],[45,0,0.5],[45.87,0,0.5],[46.37,0,0.5],[46.67,0,0.5],[46.92,0,0.5],[47.06,0,0.5]],"isComplete":true},"90453293-c9a0-45fd-11af-0f8d6778a15d":{"id":"90453293-c9a0-45fd-11af-0f8d6778a15d","type":"draw","name":"Draw","parentId":"page","childIndex":11,"point":[719.75,449.17],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[12.37,0,0.5],[12.37,0,0.5],[12.37,0.23,0.5],[12.05,0.89,0.5],[10.49,3.3,0.5],[8.2,7.48,0.5],[6.5,11.12,0.5],[5.37,14.15,0.5],[3.96,18.24,0.5],[2.4,23.34,0.5],[1.2,28.98,0.5],[0.36,33.94,0.5],[0.05,37.95,0.5],[0,42.19,0.5],[0,45.85,0.5],[0,48.12,0.5],[0,50.13,0.5],[0,52.55,0.5],[0.02,54.45,0.5],[0.29,56.11,0.5],[1.1,57.75,0.5],[2.25,59.02,0.5],[3.59,60.03,0.5],[5.14,60.74,0.5],[6.98,61.3,0.5],[9.28,61.75,0.5],[11.64,61.88,0.5],[13.83,61.88,0.5],[16.16,61.77,0.5],[18.52,61.14,0.5],[20.82,59.63,0.5],[23.19,57.55,0.5],[25.52,55.02,0.5],[28.06,51.85,0.5],[30.59,48.05,0.5],[32.93,43.68,0.5],[35.48,38.76,0.5],[37.47,34.53,0.5],[38.98,30.91,0.5],[40.44,26.82,0.5],[41.59,23.41,0.5],[42.45,20.63,0.5],[42.94,18.24,0.5],[43.32,16.33,0.5],[43.5,14.93,0.5],[43.5,14.16,0.5],[43.5,13.59,0.5],[43.47,13.19,0.5],[43.27,12.91,0.5],[42.8,12.62,0.5],[42.2,12.37,0.5],[41.6,12.21,0.5],[40.93,12,0.5],[40.07,11.68,0.5],[38.89,11.26,0.5],[37.35,10.62,0.5],[35.67,9.88,0.5],[33.86,9.01,0.5],[32.09,8.08,0.5],[30.41,7.26,0.5],[28.6,6.38,0.5],[26.7,5.44,0.5],[25.13,4.65,0.5],[24.02,4.11,0.5],[23.04,3.74,0.5],[22.25,3.44,0.5],[21.77,3.27,0.5],[21.47,3.24,0.5],[21.2,3.24,0.5],[20.92,3.24,0.5],[20.65,3.24,0.5],[20.48,3.24,0.5],[20.27,3.24,0.5],[19.84,3.24,0.5],[19.57,3.26,0.5],[19.3,3.42,0.5],[19.03,3.65,0.5],[18.76,3.75,0.5],[18.49,3.75,0.5],[18.27,3.8,0.5],[18.13,3.93,0.5],[17.94,4,0.5],[17.67,4,0.5],[17.4,4,0.5],[17.21,4,0.5],[17.06,4,0.5],[16.94,4,0.5],[16.8,4,0.5],[16.68,4,0.5],[16.66,4.11,0.5]],"isComplete":true},"7a17b436-ac36-4c40-30e7-83c7b1bb482c":{"id":"7a17b436-ac36-4c40-30e7-83c7b1bb482c","type":"draw","name":"Draw","parentId":"page","childIndex":12,"point":[771.25,444.22],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[16.77,0,0.5],[16.77,0,0.5],[16.77,0.59,0.5],[16.77,2.22,0.5],[16.77,4.96,0.5],[16.77,9.02,0.5],[16.77,12.88,0.5],[16.77,16.62,0.5],[16.77,21.1,0.5],[16.77,25.72,0.5],[16.77,30.46,0.5],[16.77,34.85,0.5],[16.77,39.14,0.5],[16.77,43.43,0.5],[16.77,47.18,0.5],[16.77,49.92,0.5],[16.77,52.18,0.5],[16.77,54.74,0.5],[16.77,57.14,0.5],[16.77,58.95,0.5],[16.77,60.27,0.5],[16.77,61.13,0.5],[16.5,61.92,0.5],[16.07,62.44,0.5],[15.66,62.62,0.5],[15.12,62.88,0.5],[14.29,63.05,0.5],[13.29,63.07,0.5],[12.32,63.07,0.5],[11.1,63.04,0.5],[9.7,62.82,0.5],[8.51,62.3,0.5],[7.29,61.62,0.5],[5.92,60.91,0.5],[4.76,60.23,0.5],[3.79,59.64,0.5],[2.89,59.16,0.5],[2.09,58.69,0.5],[1.3,58.22,0.5],[0.59,57.77,0.5],[0,57.39,0.5]],"isComplete":true},"b62f199c-4c4c-4c19-178f-5222c676d797":{"id":"b62f199c-4c4c-4c19-178f-5222c676d797","type":"draw","name":"Draw","parentId":"page","childIndex":13,"point":[789.25,428.83],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0,0.5],[0,0,0.5]],"isComplete":true},"5dd77884-e1ef-413e-0538-09ba647dddff":{"id":"5dd77884-e1ef-413e-0538-09ba647dddff","type":"draw","name":"Draw","parentId":"page","childIndex":14,"point":[797.87,439.15],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,24.51,0.5],[0,24.51,0.5],[0.12,24.51,0.5],[0.61,24.51,0.5],[1.66,24.51,0.5],[3.1,24.51,0.5],[5.11,24.51,0.5],[7.57,24.51,0.5],[10.02,24.51,0.5],[12.43,24.51,0.5],[14.47,24.51,0.5],[15.93,24.51,0.5],[17.43,24.51,0.5],[19.01,24.51,0.5],[20.52,24.51,0.5],[22.08,24.44,0.5],[23.22,24.14,0.5],[24.16,23.47,0.5],[25.04,22.61,0.5],[25.7,21.75,0.5],[26.49,20.13,0.5],[27.24,17.81,0.5],[27.7,15.52,0.5],[27.96,13.68,0.5],[28.15,12.16,0.5],[28.34,10.26,0.5],[28.38,8.36,0.5],[28.38,6.6,0.5],[28.38,4.94,0.5],[28.38,3.63,0.5],[28.38,2.56,0.5],[28.36,1.77,0.5],[28.04,1.18,0.5],[27.44,0.75,0.5],[26.62,0.46,0.5],[25.63,0.29,0.5],[24.67,0.13,0.5],[23.5,0,0.5],[21.72,0,0.5],[19.74,0,0.5],[17.82,0,0.5],[15.87,0,0.5],[13.83,0.09,0.5],[11.75,0.46,0.5],[9.93,1.23,0.5],[8.33,2.16,0.5],[7,3.07,0.5],[5.89,3.93,0.5],[5.01,4.76,0.5],[4.31,5.68,0.5],[3.77,6.66,0.5],[3.4,7.64,0.5],[3.09,8.45,0.5],[2.91,9.2,0.5],[2.88,10.15,0.5],[2.88,11.1,0.5],[2.88,12.26,0.5],[2.88,13.72,0.5],[2.88,15.15,0.5],[2.88,16.58,0.5],[2.88,18.35,0.5],[2.88,20.79,0.5],[2.88,22.92,0.5],[2.88,24.7,0.5],[2.88,26.92,0.5],[2.88,29.03,0.5],[2.95,30.93,0.5],[3.34,32.83,0.5],[4.25,34.67,0.5],[5.49,36.32,0.5],[6.92,37.85,0.5],[8.74,39.44,0.5],[11.09,41.01,0.5],[13.59,42.23,0.5],[16.04,43.26,0.5],[18.73,44.15,0.5],[21.71,44.88,0.5],[25.04,45.54,0.5],[28.74,46.06,0.5],[32.38,46.34,0.5],[36.11,46.38,0.5],[40.3,46.38,0.5],[44.76,46.38,0.5],[48.86,45.71,0.5],[54.63,42.61,0.5],[55.97,41.65,0.5],[59.95,37.24,0.5]],"isComplete":true},"c09756a6-bac3-4b27-36fb-156810148e2f":{"id":"c09756a6-bac3-4b27-36fb-156810148e2f","type":"draw","name":"Draw","parentId":"page","childIndex":15,"point":[876.3,434.91],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[14.44,0,0.5],[14.44,0,0.5],[13.97,0,0.5],[12.47,0,0.5],[10.91,0,0.5],[8.94,0.38,0.5],[6.2,1.37,0.5],[4.72,2,0.5],[3.77,2.61,0.5],[1.66,4.56,0.5],[0.16,6.61,0.5],[0,7.74,0.5],[0,8.4,0.5],[0,9.5,0.5],[0,11.74,0.5],[0,13.88,0.5],[0,15.63,0.5],[0,17.73,0.5],[0,20.42,0.5],[0.21,23.92,0.5],[1.22,27.95,0.5],[3.13,31.36,0.5],[5.75,34.45,0.5],[9.25,38.14,0.5],[13.29,41.59,0.5],[16.49,43.73,0.5],[18.97,45.3,0.5],[21.89,46.86,0.5],[24.58,48.05,0.5],[26.63,48.87,0.5],[28.33,49.31,0.5],[29.75,49.52,0.5],[31.09,49.54,0.5],[32.79,49.54,0.5],[34.53,49.53,0.5],[36.81,48.4,0.5]],"isComplete":true},"b7cfd422-2dc9-4a7e-03ff-af2b7b7f900a":{"id":"b7cfd422-2dc9-4a7e-03ff-af2b7b7f900a","type":"draw","name":"Draw","parentId":"page","childIndex":16,"point":[931.81,414.66],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0,0.5],[0,0,0.5],[0,0.59,0.5],[0,2.43,0.5],[0,5.25,0.5],[0,9.29,0.5],[0,14.57,0.5],[0,21.09,0.5],[0,25.83,0.5],[0,29.73,0.5],[0,33.41,0.5],[0,36.05,0.5],[0,39.06,0.5],[0,41.14,0.5],[0,42.64,0.5],[0,43.36,0.5],[0,43.59,0.5],[0.33,43.68,0.5],[0.97,43.68,0.5],[1.88,43.68,0.5],[3.07,43.68,0.5],[4.27,43.68,0.5],[5.78,43.68,0.5],[7.44,43.68,0.5],[8.89,43.68,0.5],[10.3,43.68,0.5],[11.68,43.54,0.5],[13.53,43.19,0.5],[15.61,42.8,0.5],[17.87,42.2,0.5],[19.92,41.59,0.5],[21.58,41.22,0.5],[23.78,40.61,0.5],[25.92,40,0.5],[27.53,39.47,0.5],[28.89,38.95,0.5],[29.99,38.46,0.5],[30.75,38.01,0.5],[31.3,37.71,0.5],[31.63,37.43,0.5]],"isComplete":true},"866e0da3-b1cd-44aa-3365-fd74d9ca2088":{"id":"866e0da3-b1cd-44aa-3365-fd74d9ca2088","type":"draw","name":"Draw","parentId":"page","childIndex":17,"point":[920.87,441.08],"rotation":0,"style":{"color":"black","size":"small","isFilled":false,"dash":"draw","scale":1},"points":[[0,0,0.5],[0,0,0.5],[0.47,0,0.5],[1.42,0,0.5],[3.15,0,0.5],[5.63,0,0.5],[8.38,0,0.5],[11.44,0,0.5],[14.83,0,0.5],[18.27,0,0.5],[20.33,0,0.5],[22.12,0,0.5],[24.03,0,0.5],[24.97,0,0.5],[25.68,0,0.5],[26.11,0,0.5]],"isComplete":true}},"bindings":{}}},"pageStates":{"page":{"id":"page","selectedIds":[],"camera":{"point":[0,0],"zoom":1},"editingId":null}},"assets":{}}}' + ), + home_version: 15.5, +} + +export async function writeV1ContentsToIdb() { + const db = await openDB('keyval-store', 1) + const tx = db.transaction('keyval', 'readwrite') + const store = tx.objectStore('keyval') + for (const [key, value] of Object.entries(v1Contents)) { + store.put(value, key) + } + await tx.done +} diff --git a/apps/dotcom/src/utils/qrcode.ts b/apps/dotcom/src/utils/qrcode.ts new file mode 100644 index 000000000..b2a5c4d24 --- /dev/null +++ b/apps/dotcom/src/utils/qrcode.ts @@ -0,0 +1,9 @@ +export async function createQRCodeImageDataString(url: string) { + const QRCode = await import('qrcode') + return await new Promise((res, rej) => { + QRCode.toDataURL(url, (err, str) => { + if (err) rej(err) + else res(str) + }) + }) +} diff --git a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts new file mode 100644 index 000000000..1739509d5 --- /dev/null +++ b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts @@ -0,0 +1,184 @@ +import { TLSYNC_PROTOCOL_VERSION } from '@tldraw/tlsync' +import * as ws from 'ws' +import { ClientWebSocketAdapter } from './ClientWebSocketAdapter' + +async function waitFor(predicate: () => boolean) { + let safety = 0 + while (!predicate()) { + if (safety++ > 1000) { + throw new Error('waitFor predicate timed out') + } + try { + jest.runAllTimers() + jest.useRealTimers() + await new Promise((resolve) => setTimeout(resolve, 10)) + } finally { + jest.useFakeTimers() + } + } +} + +jest.useFakeTimers() + +// TODO: unskip this test. It accidentally got disabled a long time ago when we moved this file into +// the dotcom folder which didn't have testing set up at the time. We need to spend some time fixing +// it before it can be re-enabled. +describe.skip(ClientWebSocketAdapter, () => { + let adapter: ClientWebSocketAdapter + let wsServer: ws.Server + let connectedWs: ws.WebSocket + const connectMock = jest.fn((socket) => { + connectedWs = socket + }) + beforeEach(() => { + adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233') + wsServer = new ws.Server({ port: 2233 }) + wsServer.on('connection', connectMock) + }) + afterEach(() => { + adapter.close() + wsServer.close() + connectMock.mockClear() + }) + + it('should be able to be constructed', () => { + expect(adapter).toBeTruthy() + }) + it('should start with connectionStatus=offline', () => { + expect(adapter.connectionStatus).toBe('offline') + }) + it('should start with connectionStatus=offline', () => { + expect(adapter.connectionStatus).toBe('offline') + }) + it('should respond to onopen events by setting connectionStatus=online', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(adapter.connectionStatus).toBe('online') + }) + it('should respond to onerror events by setting connectionStatus=error', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + adapter._ws?.onerror?.({} as any) + expect(adapter.connectionStatus).toBe('error') + }) + it('should try to reopen the connection if there was an error', () => { + const prevWes = adapter._ws + adapter._ws?.onerror?.({} as any) + jest.advanceTimersByTime(1000) + expect(adapter._ws).not.toBe(prevWes) + expect(adapter._ws?.readyState).toBe(WebSocket.CONNECTING) + }) + it('should transition to online if a retry succeeds', async () => { + adapter._ws?.onerror?.({} as any) + await waitFor(() => adapter.connectionStatus === 'online') + expect(adapter.connectionStatus).toBe('online') + }) + it('should call .close on the underlying socket if .close is called before the socket opens', async () => { + const closeSpy = jest.spyOn(adapter._ws!, 'close') + adapter.close() + await waitFor(() => closeSpy.mock.calls.length > 0) + expect(closeSpy).toHaveBeenCalled() + }) + it('should transition to offline if the server disconnects', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + connectedWs.terminate() + await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) + expect(adapter.connectionStatus).toBe('offline') + }) + it('retries to connect if the server disconnects', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + connectedWs.terminate() + await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) + expect(adapter.connectionStatus).toBe('offline') + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(adapter.connectionStatus).toBe('online') + connectedWs.terminate() + await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) + expect(adapter.connectionStatus).toBe('offline') + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(adapter.connectionStatus).toBe('online') + }) + + it('closes the socket if the window goes offline and attempts to reconnect', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + const closeSpy = jest.spyOn(adapter._ws!, 'close') + window.dispatchEvent(new Event('offline')) + expect(closeSpy).toHaveBeenCalled() + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + }) + + it('attempts to reconnect early if the window comes back online', async () => { + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + wsServer.close() + window.dispatchEvent(new Event('offline')) + adapter._reconnectTimeout.intervalLength = 50000 + window.dispatchEvent(new Event('online')) + expect(adapter._reconnectTimeout.intervalLength).toBeLessThan(1000) + }) + + it('supports receiving messages', async () => { + const onMessage = jest.fn() + adapter.onReceiveMessage(onMessage) + connectMock.mockImplementationOnce((ws) => { + ws.send('{ "type": "message", "data": "hello" }') + }) + + await waitFor(() => onMessage.mock.calls.length === 1) + expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' }) + }) + + // TODO: this is failing on github actions, investigate + it.skip('supports sending messages', async () => { + const onMessage = jest.fn() + connectMock.mockImplementationOnce((ws) => { + ws.on('message', onMessage) + }) + + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + + adapter.sendMessage({ + type: 'connect', + connectRequestId: 'test', + schema: { schemaVersion: 0, storeVersion: 0, recordVersions: {} }, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + lastServerClock: 0, + }) + + await waitFor(() => onMessage.mock.calls.length === 1) + + expect(onMessage.mock.calls[0][0].toString()).toBe( + '{"type":"connect","instanceId":"test","lastServerClock":0}' + ) + }) + + it('signals status changes', async () => { + const onStatusChange = jest.fn() + adapter.onStatusChange(onStatusChange) + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(onStatusChange).toHaveBeenCalledWith('online') + connectedWs.terminate() + await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) + expect(onStatusChange).toHaveBeenCalledWith('offline') + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(onStatusChange).toHaveBeenCalledWith('online') + connectedWs.terminate() + await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) + expect(onStatusChange).toHaveBeenCalledWith('offline') + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + expect(onStatusChange).toHaveBeenCalledWith('online') + adapter._ws?.onerror?.({} as any) + expect(onStatusChange).toHaveBeenCalledWith('error') + }) + + it('signals status changes while restarting', async () => { + const onStatusChange = jest.fn() + await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) + + adapter.onStatusChange(onStatusChange) + + adapter.restart() + + await waitFor(() => onStatusChange.mock.calls.length === 2) + + expect(onStatusChange).toHaveBeenCalledWith('offline') + expect(onStatusChange).toHaveBeenCalledWith('online') + }) +}) diff --git a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts new file mode 100644 index 000000000..2b12560ba --- /dev/null +++ b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts @@ -0,0 +1,235 @@ +import { atom, Atom, TLRecord } from '@tldraw/tldraw' +import { + chunk, + serializeMessage, + TLPersistentClientSocket, + TLPersistentClientSocketStatus, + TLSocketClientSentEvent, + TLSocketServerSentEvent, +} from '@tldraw/tlsync' + +function windowListen(...args: Parameters) { + window.addEventListener(...args) + return () => { + window.removeEventListener(...args) + } +} + +function debug(...args: any[]) { + // @ts-ignore + if (typeof window !== 'undefined' && window.__tldraw_socket_debug) { + // eslint-disable-next-line no-console + console.log(...args, new Error().stack) + } +} + +export class ClientWebSocketAdapter implements TLPersistentClientSocket { + _ws: WebSocket | null = null + + wasManuallyClosed = false + + disposables: (() => void)[] = [] + + close() { + this.wasManuallyClosed = true + this.disposables.forEach((d) => d()) + this._reconnectTimeout.clear() + if (this._ws?.readyState === WebSocket.OPEN) { + debug('close d') + this._ws.close() + } + } + + constructor(private getUri: () => Promise | string) { + this.disposables.push( + windowListen('online', () => { + debug('window online') + if (this.connectionStatus !== 'online') { + this._reconnectTimeout.clear() + this._attemptReconnect() + } + }), + windowListen('offline', () => { + debug('window offline') + if (this.connectionStatus === 'online') { + this._ws?.close() + this._ws?.onclose?.(null as any) + } + }), + windowListen('pointermove', () => { + // if the pointer moves while we are offline, we should try to reconnect more + // often than every 5 mins! + if (this.connectionStatus !== 'online') { + this._reconnectTimeout.userInteractionOccurred() + } + }), + windowListen('keydown', () => { + // if the user pressed a key while we are offline, we should try to reconnect more + // often than every 5 mins! + if (this.connectionStatus !== 'online') { + this._reconnectTimeout.userInteractionOccurred() + } + }) + ) + this._reconnectTimeout.run() + } + + private handleDisconnect(status: Exclude) { + debug('handleDisconnect', status, this.connectionStatus) + if ( + // if the status is the same as before, don't do anything + this.connectionStatus === status || + // if we receive an error we only care about it while we're in the initial state + (status === 'error' && this.connectionStatus === 'offline') + ) { + this._attemptReconnect() + return + } + this._connectionStatus.set(status) + this.statusListeners.forEach((cb) => cb(status)) + this._reconnectTimeout.clear() + this._attemptReconnect() + } + + private configureSocket() { + const ws = this._ws + if (!ws) return + ws.onopen = () => { + debug('ws.onopen') + // ws might be opened multiple times so need to check that it wasn't already supplanted + if (this._ws !== ws || this.wasManuallyClosed) { + if (ws.readyState === WebSocket.OPEN) { + debug('close a') + ws.close() + } + return + } + this._connectionStatus.set('online') + this.statusListeners.forEach((cb) => cb(this.connectionStatus)) + this._reconnectTimeout.clear() + } + ws.onclose = () => { + debug('ws.onclose') + this.handleDisconnect('offline') + } + ws.onerror = () => { + debug('ws.onerror') + this.handleDisconnect('error') + } + ws.onmessage = (ev) => { + const parsed = JSON.parse(ev.data.toString()) + this.messageListeners.forEach((cb) => cb(parsed)) + } + } + + readonly _reconnectTimeout = new ExponentialBackoffTimeout(async () => { + debug('close b') + this._ws?.close() + this._ws = new WebSocket(await this.getUri()) + this.configureSocket() + }) + + _attemptReconnect() { + debug('_attemptReconnect', this.wasManuallyClosed) + if (this.wasManuallyClosed) { + return + } + this._reconnectTimeout.run() + } + + _connectionStatus: Atom = atom( + 'websocket connection status', + 'initial' + ) + + // eslint-disable-next-line no-restricted-syntax + get connectionStatus(): TLPersistentClientSocketStatus { + const status = this._connectionStatus.get() + return status === 'initial' ? 'offline' : status + } + + sendMessage(msg: TLSocketClientSentEvent) { + if (!this._ws) return + if (this.connectionStatus === 'online') { + const chunks = chunk(serializeMessage(msg)) + for (const part of chunks) { + this._ws.send(part) + } + } else { + console.warn('Tried to send message while ' + this.connectionStatus) + } + } + + private messageListeners = new Set<(msg: TLSocketServerSentEvent) => void>() + onReceiveMessage(cb: (val: TLSocketServerSentEvent) => void) { + this.messageListeners.add(cb) + return () => { + this.messageListeners.delete(cb) + } + } + + private statusListeners = new Set<(status: TLPersistentClientSocketStatus) => void>() + onStatusChange(cb: (val: TLPersistentClientSocketStatus) => void) { + this.statusListeners.add(cb) + return () => { + this.statusListeners.delete(cb) + } + } + + restart() { + debug('close c') + this.close() + this.wasManuallyClosed = false + this._reconnectTimeout.clear() + this._reconnectTimeout.runNow() + } +} + +class ExponentialBackoffTimeout { + private timeout: NodeJS.Timeout | null = null + private nextScheduledRunTimestamp = 0 + intervalLength: number + + constructor( + private cb: () => Promise, + // five mins + private readonly maxIdleIntervalLength: number = 1000 * 60 * 5, + // five seconds + private readonly maxInteractiveIntervalLength: number = 1000, + private startIntervalLength: number = 500 + ) { + this.intervalLength = startIntervalLength + } + + runNow() { + this.cb() + } + + run() { + if (this.timeout) return + this.timeout = setTimeout(() => { + this.cb() + this.intervalLength = Math.min(this.intervalLength * 2, this.maxIdleIntervalLength) + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + }, this.intervalLength) + this.nextScheduledRunTimestamp = Date.now() + this.intervalLength + } + + clear() { + this.intervalLength = this.startIntervalLength + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = null + } + } + + userInteractionOccurred() { + if (Date.now() + this.maxInteractiveIntervalLength < this.nextScheduledRunTimestamp) { + this.clear() + this.run() + } + } +} diff --git a/apps/dotcom/src/utils/remote-sync/remote-sync.ts b/apps/dotcom/src/utils/remote-sync/remote-sync.ts new file mode 100644 index 000000000..bd111518d --- /dev/null +++ b/apps/dotcom/src/utils/remote-sync/remote-sync.ts @@ -0,0 +1,19 @@ +import { Signal, TLStoreSnapshot, TLUserPreferences } from '@tldraw/tldraw' +import { TLIncompatibilityReason } from '@tldraw/tlsync' + +/** @public */ +export class RemoteSyncError extends Error { + override name = 'RemoteSyncError' + constructor(public readonly reason: TLIncompatibilityReason) { + super(`remote sync error: ${reason}`) + } +} + +/** @public */ +export type UseSyncClientConfig = { + uri: string + roomId?: string + userPreferences?: Signal + snapshotForNewRoomRef?: { current: null | TLStoreSnapshot } + getAccessToken?: () => Promise | string | undefined | null +} diff --git a/apps/dotcom/src/utils/scratch-persistence-key.ts b/apps/dotcom/src/utils/scratch-persistence-key.ts new file mode 100644 index 000000000..9f2ec1b83 --- /dev/null +++ b/apps/dotcom/src/utils/scratch-persistence-key.ts @@ -0,0 +1,17 @@ +/** + * What is going on in this file? + * + * We had some bad early assumptions about how we would store documents. + * Which ended up with us generating random persistenceKey strings for the + * 'scratch' document for each user (i.e. each browser context), and storing it in localStorage. + * + * Many users still have that random string in their localStorage so we need to load it. But for new + * users it does not need to be unique and we can just use a constant. + */ +// DO NOT CHANGE THESE WITHOUT ADDING MIGRATION LOGIC. DOING SO WOULD WIPE ALL EXISTING LOCAL DATA. +const defaultDocumentKey = 'TLDRAW_DEFAULT_DOCUMENT_NAME_v2' +const w = typeof window === 'undefined' ? undefined : window + +export const SCRATCH_PERSISTENCE_KEY = + (w?.localStorage.getItem(defaultDocumentKey) as any) ?? 'tldraw_document_v3' +w?.localStorage.setItem(defaultDocumentKey, SCRATCH_PERSISTENCE_KEY) diff --git a/apps/dotcom/src/utils/sharing.ts b/apps/dotcom/src/utils/sharing.ts new file mode 100644 index 000000000..6bf21ad8c --- /dev/null +++ b/apps/dotcom/src/utils/sharing.ts @@ -0,0 +1,273 @@ +import { + AssetRecordType, + Editor, + SerializedSchema, + SerializedStore, + TLAsset, + TLAssetId, + TLRecord, + TLShape, + TLShapeId, + TLUiEventHandler, + TLUiOverrides, + TLUiToastsContextType, + TLUiTranslationKey, + assert, + findMenuItem, + isShape, + menuGroup, + menuItem, +} from '@tldraw/tldraw' +import { useMemo } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets' +import { getViewportUrlQuery } from '../hooks/useUrlState' +import { cloneAssetForShare } from './cloneAssetForShare' +import { ASSET_UPLOADER_URL } from './config' +import { shouldLeaveSharedProject } from './shouldLeaveSharedProject' +import { trackAnalyticsEvent } from './trackAnalyticsEvent' +import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent' + +export const SHARE_PROJECT_ACTION = 'share-project' as const +export const SHARE_SNAPSHOT_ACTION = 'share-snapshot' as const +const LEAVE_SHARED_PROJECT_ACTION = 'leave-shared-project' as const +export const FORK_PROJECT_ACTION = 'fork-project' as const +const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots` +const SNAPSHOT_UPLOAD_URL = `/api/new-room` + +type SnapshotRequestBody = { + schema: SerializedSchema + snapshot: SerializedStore +} + +type CreateSnapshotRequestBody = { + schema: SerializedSchema + snapshot: SerializedStore + parent_slug?: string | string[] | undefined +} + +type CreateSnapshotResponseBody = + | { + error: false + roomId: string + } + | { + error: true + message: string + } + +async function getSnapshotLink( + source: string, + editor: Editor, + handleUiEvent: TLUiEventHandler, + addToast: TLUiToastsContextType['addToast'], + msg: (id: TLUiTranslationKey) => string, + uploadFileToAsset: (file: File) => Promise, + parentSlug: string | undefined +) { + handleUiEvent('share-snapshot' as UI_OVERRIDE_TODO_EVENT, { source } as UI_OVERRIDE_TODO_EVENT) + const data = await getRoomData(editor, addToast, msg, uploadFileToAsset) + if (!data) return '' + + const res = await fetch(CREATE_SNAPSHOT_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + snapshot: data, + schema: editor.store.schema.serialize(), + parent_slug: parentSlug, + } satisfies CreateSnapshotRequestBody), + }) + const response = (await res.json()) as CreateSnapshotResponseBody + + if (!res.ok || response.error) { + console.error(await res.text()) + return '' + } + const paramsToUse = getViewportUrlQuery(editor) + const params = paramsToUse ? `?${new URLSearchParams(paramsToUse).toString()}` : '' + return new Blob([`${window.location.origin}/s/${response.roomId}${params}`], { + type: 'text/plain', + }) +} + +export function useSharing({ isMultiplayer }: { isMultiplayer: boolean }): TLUiOverrides { + const navigate = useNavigate() + const id = useSearchParams()[0].get('id') ?? undefined + const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL) + const handleUiEvent = useHandleUiEvents() + + return useMemo( + (): TLUiOverrides => ({ + actions(editor, actions, { addToast, msg, addDialog }) { + actions[LEAVE_SHARED_PROJECT_ACTION] = { + id: LEAVE_SHARED_PROJECT_ACTION, + label: 'action.leave-shared-project', + readonlyOk: true, + onSelect: async () => { + const shouldLeave = await shouldLeaveSharedProject(addDialog) + if (!shouldLeave) return + + handleUiEvent('leave-shared-project', {}) + + navigate('/') + }, + } + actions[SHARE_PROJECT_ACTION] = { + id: SHARE_PROJECT_ACTION, + label: 'action.share-project', + readonlyOk: true, + onSelect: async (source) => { + try { + handleUiEvent('share-project', { source }) + const data = await getRoomData(editor, addToast, msg, uploadFileToAsset) + if (!data) return + + const res = await fetch(SNAPSHOT_UPLOAD_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + schema: editor.store.schema.serialize(), + snapshot: data, + } satisfies SnapshotRequestBody), + }) + + const response = (await res.json()) as { error: boolean; slug?: string } + if (!res.ok || response.error) { + console.error(await res.text()) + throw new Error('Failed to upload snapshot') + } + + const query = getViewportUrlQuery(editor) + + navigate(`/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`) + } catch (error) { + console.error(error) + addToast({ + title: 'Error', + description: msg('share-menu.upload-failed'), + }) + } + }, + } + actions[SHARE_SNAPSHOT_ACTION] = { + id: SHARE_SNAPSHOT_ACTION, + label: 'share-menu.create-snapshot-link', + readonlyOk: true, + onSelect: async (source) => { + const result = getSnapshotLink( + source, + editor, + handleUiEvent, + addToast, + msg, + uploadFileToAsset, + id + ) + if (navigator?.clipboard?.write) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': result, + }), + ]) + } else if (navigator?.clipboard?.writeText) { + const link = await result + if (link === '') return + navigator.clipboard.writeText(await link.text()) + } + }, + } + actions[FORK_PROJECT_ACTION] = { + ...actions[SHARE_PROJECT_ACTION], + id: FORK_PROJECT_ACTION, + label: 'action.fork-project', + } + return actions + }, + menu(editor, menu, { actions }) { + const fileMenu = findMenuItem(menu, ['menu', 'file']) + assert(fileMenu.type === 'submenu') + if (isMultiplayer) { + fileMenu.children.unshift( + menuGroup( + 'share', + menuItem(actions[FORK_PROJECT_ACTION]), + menuItem(actions[LEAVE_SHARED_PROJECT_ACTION]) + )! + ) + } else { + fileMenu.children.unshift(menuGroup('share', menuItem(actions[SHARE_PROJECT_ACTION]))!) + } + return menu + }, + }), + [handleUiEvent, navigate, uploadFileToAsset, id, isMultiplayer] + ) +} + +async function getRoomData( + editor: Editor, + addToast: TLUiToastsContextType['addToast'], + msg: (id: TLUiTranslationKey) => string, + uploadFileToAsset: (file: File) => Promise +) { + const rawData = editor.store.serialize() + + // rawData contains a cache of previously added assets, + // which we don't want included in the shared document. + // So let's strip it out. + + // our final object that holds the data that we'll persist to a stash + const data: Record = {} + + // let's get all the assets/shapes in data + const shapes = new Map() + const assets = new Map() + + for (const record of Object.values(rawData)) { + if (AssetRecordType.isInstance(record)) { + // collect assets separately, don't add them to the proper doc yet + assets.set(record.id, record) + continue + } + data[record.id] = record + if (isShape(record)) { + shapes.set(record.id, record) + } + } + + // now add only those assets that are referenced in shapes + for (const shape of shapes.values()) { + if ('assetId' in shape.props) { + const asset = assets.get(shape.props.assetId as TLAssetId) + // if we can't find the asset it either means + // somethings gone wrong or we've already + // processed it + if (!asset) continue + + data[asset.id] = await cloneAssetForShare(asset, uploadFileToAsset) + // remove the asset after processing so we don't clone it multiple times + assets.delete(asset.id) + } + } + + const size = new Blob([JSON.stringify(data)]).size + + if (size > 3999999) { + addToast({ + title: 'Too big!', + description: msg('share-menu.project-too-large'), + }) + + trackAnalyticsEvent('shared-fail-too-big', { + size: size.toString(), + }) + + return null + } + return data +} diff --git a/apps/dotcom/src/utils/shouldClearDocument.tsx b/apps/dotcom/src/utils/shouldClearDocument.tsx new file mode 100644 index 000000000..def4f44fd --- /dev/null +++ b/apps/dotcom/src/utils/shouldClearDocument.tsx @@ -0,0 +1,76 @@ +import { Button, Dialog, TLUiDialogsContextType, useTranslation } from '@tldraw/tldraw' +import { useState } from 'react' +import { userPreferences } from './userPreferences' + +export async function shouldClearDocument(addDialog: TLUiDialogsContextType['addDialog']) { + if (userPreferences.showFileClearWarning.get()) { + const shouldContinue = await new Promise((resolve) => { + addDialog({ + component: ({ onClose }) => ( + { + resolve(false) + onClose() + }} + onContinue={() => { + resolve(true) + onClose() + }} + /> + ), + onClose: () => { + resolve(false) + }, + }) + }) + + return shouldContinue + } + return true +} + +function ConfirmClearDialog({ + onCancel, + onContinue, +}: { + onCancel: () => void + onContinue: () => void +}) { + const msg = useTranslation() + const [dontShowAgain, setDontShowAgain] = useState(false) + return ( + <> + + {msg('file-system.confirm-clear.title')} + + + + {msg('file-system.confirm-clear.description')} + + + + + + + + ) +} diff --git a/apps/dotcom/src/utils/shouldLeaveSharedProject.tsx b/apps/dotcom/src/utils/shouldLeaveSharedProject.tsx new file mode 100644 index 000000000..cd9fd8df5 --- /dev/null +++ b/apps/dotcom/src/utils/shouldLeaveSharedProject.tsx @@ -0,0 +1,82 @@ +import { + Button, + Dialog, + TLUiDialogsContextType, + useLocalStorageState, + useTranslation, +} from '@tldraw/tldraw' +import { userPreferences } from './userPreferences' + +export async function shouldLeaveSharedProject(addDialog: TLUiDialogsContextType['addDialog']) { + if (userPreferences.showFileOpenWarning.get()) { + const shouldContinue = await new Promise((resolve) => { + addDialog({ + component: ({ onClose }) => ( + { + resolve(false) + onClose() + }} + onContinue={() => { + resolve(true) + onClose() + }} + /> + ), + onClose: () => { + resolve(false) + }, + }) + }) + + return shouldContinue + } + return true +} + +function ConfirmLeaveDialog({ + onCancel, + onContinue, +}: { + onCancel: () => void + onContinue: () => void +}) { + const msg = useTranslation() + const [dontShowAgain, setDontShowAgain] = useLocalStorageState('confirm-leave', false) + + return ( + <> + + {msg('sharing.confirm-leave.title')} + + + + {msg('sharing.confirm-leave.description')} + + + + + + + + ) +} diff --git a/apps/dotcom/src/utils/shouldOverrideDocument.tsx b/apps/dotcom/src/utils/shouldOverrideDocument.tsx new file mode 100644 index 000000000..bbfdd5d59 --- /dev/null +++ b/apps/dotcom/src/utils/shouldOverrideDocument.tsx @@ -0,0 +1,76 @@ +import { Button, Dialog, TLUiDialogsContextType, useTranslation } from '@tldraw/tldraw' +import { useState } from 'react' +import { userPreferences } from './userPreferences' + +export async function shouldOverrideDocument(addDialog: TLUiDialogsContextType['addDialog']) { + if (userPreferences.showFileOpenWarning.get()) { + const shouldContinue = await new Promise((resolve) => { + addDialog({ + component: ({ onClose }) => ( + { + resolve(false) + onClose() + }} + onContinue={() => { + resolve(true) + onClose() + }} + /> + ), + onClose: () => { + resolve(false) + }, + }) + }) + + return shouldContinue + } + return true +} + +function ConfirmOpenDialog({ + onCancel, + onContinue, +}: { + onCancel: () => void + onContinue: () => void +}) { + const msg = useTranslation() + const [dontShowAgain, setDontShowAgain] = useState(false) + return ( + <> + + {msg('file-system.confirm-open.title')} + + + + {msg('file-system.confirm-open.description')} + + + + + + + + ) +} diff --git a/apps/dotcom/src/utils/trackAnalyticsEvent.ts b/apps/dotcom/src/utils/trackAnalyticsEvent.ts new file mode 100644 index 000000000..ce1710628 --- /dev/null +++ b/apps/dotcom/src/utils/trackAnalyticsEvent.ts @@ -0,0 +1,5 @@ +import va from '@vercel/analytics' + +export function trackAnalyticsEvent(name: string, data: { [key: string]: any }) { + va.track(name, data) +} diff --git a/apps/dotcom/src/utils/useCursorChat.ts b/apps/dotcom/src/utils/useCursorChat.ts new file mode 100644 index 000000000..75bd743bd --- /dev/null +++ b/apps/dotcom/src/utils/useCursorChat.ts @@ -0,0 +1,63 @@ +import { TLUiOverrides, menuGroup, menuItem } from '@tldraw/tldraw' +import { useMemo } from 'react' +import { useHandleUiEvents } from './useHandleUiEvent' + +export const CURSOR_CHAT_ACTION = 'open-cursor-chat' as const + +export function useCursorChat(): TLUiOverrides { + const handleUiEvent = useHandleUiEvents() + return useMemo( + (): TLUiOverrides => ({ + actions(editor, actions) { + actions[CURSOR_CHAT_ACTION] = { + id: 'open-cursor-chat', + label: 'action.open-cursor-chat', + readonlyOk: true, + kbd: '/', + onSelect(source: any) { + handleUiEvent('open-cursor-chat', { source }) + + // Don't open cursor chat if we're on a touch device + if (editor.getInstanceState().isCoarsePointer) { + return + } + + editor.updateInstanceState({ isChatting: true }) + }, + } + return actions + }, + contextMenu(editor, contextMenu, { actions }) { + if (editor.getSelectedShapes().length > 0 || editor.getInstanceState().isCoarsePointer) { + return contextMenu + } + + const cursorChatGroup = menuGroup('cursor-chat', menuItem(actions[CURSOR_CHAT_ACTION])) + if (!cursorChatGroup) { + return contextMenu + } + + const clipboardGroupIndex = contextMenu.findIndex((group) => group.id === 'clipboard-group') + if (clipboardGroupIndex === -1) { + contextMenu.push(cursorChatGroup) + return contextMenu + } + + contextMenu.splice(clipboardGroupIndex + 1, 0, cursorChatGroup) + return contextMenu + }, + keyboardShortcutsMenu(editor, keyboardShortcutsMenu, { actions }) { + const group = menuGroup( + 'shortcuts-dialog.collaboration', + menuItem(actions[CURSOR_CHAT_ACTION]) + ) + if (!group) { + return keyboardShortcutsMenu + } + keyboardShortcutsMenu.push(group) + return keyboardShortcutsMenu + }, + }), + [handleUiEvent] + ) +} diff --git a/apps/dotcom/src/utils/useFileSystem.tsx b/apps/dotcom/src/utils/useFileSystem.tsx new file mode 100644 index 000000000..9639a4552 --- /dev/null +++ b/apps/dotcom/src/utils/useFileSystem.tsx @@ -0,0 +1,155 @@ +import { + Editor, + TLDRAW_FILE_EXTENSION, + TLStore, + TLUiActionItem, + TLUiEventHandler, + TLUiOverrides, + assert, + findMenuItem, + menuGroup, + menuItem, + parseAndLoadDocument, + serializeTldrawJsonBlob, + transact, +} from '@tldraw/tldraw' +import { fileOpen, fileSave } from 'browser-fs-access' +import { useMemo } from 'react' +import { shouldClearDocument } from './shouldClearDocument' +import { shouldOverrideDocument } from './shouldOverrideDocument' +import { useHandleUiEvents } from './useHandleUiEvent' + +const SAVE_FILE_COPY_ACTION = 'save-file-copy' +const OPEN_FILE_ACTION = 'open-file' +const NEW_PROJECT_ACTION = 'new-file' + +const saveFileNames = new WeakMap() + +export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TLUiOverrides { + const handleUiEvent = useHandleUiEvents() + + return useMemo((): TLUiOverrides => { + return { + actions(editor, actions, { addToast, msg, addDialog }) { + actions[SAVE_FILE_COPY_ACTION] = getSaveFileCopyAction(editor, handleUiEvent) + actions[OPEN_FILE_ACTION] = { + id: OPEN_FILE_ACTION, + label: 'action.open-file', + readonlyOk: true, + kbd: '$o', + async onSelect(source) { + handleUiEvent('open-file', { source }) + // open in multiplayer is not currently supported + if (isMultiplayer) { + addToast({ + title: msg('file-system.shared-document-file-open-error.title'), + description: msg('file-system.shared-document-file-open-error.description'), + }) + return + } + + const shouldOverride = await shouldOverrideDocument(addDialog) + if (!shouldOverride) return + + let file + try { + file = await fileOpen({ + extensions: [TLDRAW_FILE_EXTENSION], + multiple: false, + description: 'tldraw project', + }) + } catch (e) { + // user cancelled + return + } + + await parseAndLoadDocument(editor, await file.text(), msg, addToast) + }, + } + actions[NEW_PROJECT_ACTION] = { + id: NEW_PROJECT_ACTION, + label: 'action.new-project', + readonlyOk: true, + async onSelect(source) { + handleUiEvent('create-new-project', { source }) + const shouldOverride = await shouldClearDocument(addDialog) + if (!shouldOverride) return + + transact(() => { + const isFocused = editor.getInstanceState().isFocused + editor.store.clear() + editor.store.ensureStoreIsUsable() + editor.history.clear() + editor.updateViewportScreenBounds() + editor.updateRenderingBounds() + editor.updateInstanceState({ isFocused }) + }) + }, + } + return actions + }, + menu(editor, menu, { actions }) { + const fileMenu = findMenuItem(menu, ['menu', 'file']) + assert(fileMenu.type === 'submenu') + + const saveItem = menuItem(actions[SAVE_FILE_COPY_ACTION]) + const openItem = menuItem(actions[OPEN_FILE_ACTION]) + const newItem = menuItem(actions[NEW_PROJECT_ACTION]) + const group = isMultiplayer + ? // open is not currently supported in multiplayer + menuGroup('filesystem', saveItem) + : menuGroup('filesystem', newItem, openItem, saveItem) + fileMenu.children.unshift(group!) + + return menu + }, + keyboardShortcutsMenu(editor, menu, { actions }) { + const fileItems = findMenuItem(menu, ['shortcuts-dialog.file']) + assert(fileItems.type === 'group') + fileItems.children.unshift(menuItem(actions[SAVE_FILE_COPY_ACTION])) + if (!isMultiplayer) { + fileItems.children.unshift(menuItem(actions[OPEN_FILE_ACTION])) + } + + return menu + }, + } + }, [isMultiplayer, handleUiEvent]) +} + +export function getSaveFileCopyAction( + editor: Editor, + handleUiEvent: TLUiEventHandler +): TLUiActionItem { + return { + id: SAVE_FILE_COPY_ACTION, + label: 'action.save-copy', + readonlyOk: true, + kbd: '$s', + async onSelect(source) { + handleUiEvent('save-project-to-file', { source }) + const defaultName = saveFileNames.get(editor.store) || `Untitled${TLDRAW_FILE_EXTENSION}` + + const blobToSave = serializeTldrawJsonBlob(editor.store) + let handle + try { + handle = await fileSave(blobToSave, { + fileName: defaultName, + extensions: [TLDRAW_FILE_EXTENSION], + description: 'tldraw project', + }) + } catch (e) { + // user cancelled + return + } + + if (handle) { + // we deliberately don't store the handle for re-use + // next time. we always want to save a copy, but to + // help the user out we'll remember the last name + // they used + saveFileNames.set(editor.store, handle.name) + } + }, + } +} diff --git a/apps/dotcom/src/utils/useHandleUiEvent.tsx b/apps/dotcom/src/utils/useHandleUiEvent.tsx new file mode 100644 index 000000000..b78f2ea48 --- /dev/null +++ b/apps/dotcom/src/utils/useHandleUiEvent.tsx @@ -0,0 +1,7 @@ +import { trackAnalyticsEvent } from './trackAnalyticsEvent' + +export type UI_OVERRIDE_TODO_EVENT = any + +export function useHandleUiEvents() { + return trackAnalyticsEvent +} diff --git a/apps/dotcom/src/utils/userPreferences.ts b/apps/dotcom/src/utils/userPreferences.ts new file mode 100644 index 000000000..a4900719c --- /dev/null +++ b/apps/dotcom/src/utils/userPreferences.ts @@ -0,0 +1,59 @@ +import { T, atom } from '@tldraw/tldraw' + +const channel = + typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('tldrawUserPreferences') : null + +export const userPreferences = { + showFileOpenWarning: createPreference('showFileOpenWarning', T.boolean, true), + showFileClearWarning: createPreference('showFileClearWarning', T.boolean, true), +} + +if (typeof window !== 'undefined') { + ;(window as any).userPreferences = userPreferences +} + +function createPreference(key: string, validator: T.Validator, defaultValue: Type) { + const preferenceAtom = atom( + `userPreferences.${key}`, + loadItemFromStorage(key, validator) ?? defaultValue + ) + + channel?.addEventListener('message', (event) => { + if (event.data.key === key) { + preferenceAtom.set(event.data.value) + } + }) + + return { + get() { + return preferenceAtom.get() + }, + set(newValue: Type) { + preferenceAtom.set(newValue) + saveItemToStorage(key, newValue) + channel?.postMessage({ key, value: newValue }) + }, + } +} + +function loadItemFromStorage(key: string, validator: T.Validator): Type | null { + if (typeof localStorage === 'undefined' || !localStorage) return null + + const item = localStorage.getItem(`tldrawUserPreferences.${key}`) + if (item == null) return null + try { + return validator.validate(JSON.parse(item)) + } catch (e) { + return null + } +} + +function saveItemToStorage(key: string, value: unknown): void { + if (typeof localStorage === 'undefined' || !localStorage) return + + try { + localStorage.setItem(`tldrawUserPreferences.${key}`, JSON.stringify(value)) + } catch (e) { + // not a big deal + } +} diff --git a/apps/dotcom/styles/core.css b/apps/dotcom/styles/core.css new file mode 100644 index 000000000..d1614ccbb --- /dev/null +++ b/apps/dotcom/styles/core.css @@ -0,0 +1,200 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&display=swap'); + +:root { + font-family: Inter, -apple-system, 'system-ui', 'Segoe UI', 'Noto Sans', Helvetica, Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; + + font-size: 12px; + font-weight: 500; + color: var(--text-color-0); + line-height: 1.6; + overscroll-behavior: none; + touch-action: none; +} + +/* + 1. Use a more-intuitive box-sizing model. +*/ +*, +*::before, +*::after { + box-sizing: border-box; +} +/* + 2. Remove default margin +*/ +* { + margin: 0; +} +/* + 5. Improve media defaults +*/ +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} +/* + 6. Remove built-in form typography styles +*/ +input, +button, +textarea, +select { + font: inherit; +} +/* + 7. Avoid text overflows +*/ +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +html { + height: 100%; +} + +html, +body { + overscroll-behavior-x: none; +} + +body { + display: flex; + height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-smooth: antialiased; + text-rendering: optimizeLegibility; +} + +div { + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; +} + +.site-wrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; +} + +.icon { + flex-shrink: 0; + width: 20px; + height: 20px; + background-color: currentColor; +} + +.scroll-light { + scrollbar-width: thin; +} +.scroll-light::-webkit-scrollbar { + display: block; + width: 8px; + height: 8px; + position: absolute; + top: 0; + left: 0; + background-color: inherit; +} +.scroll-light::-webkit-scrollbar-button { + display: none; + width: 0; + height: 10px; +} +.scroll-light::-webkit-scrollbar-thumb { + background-clip: padding-box; + width: 0; + min-height: 36px; + border: 2px solid transparent; + border-radius: 6px; + background-color: rgba(0, 0, 0, 0.25); +} +.scroll-light::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +/* ------------------- Error Page ------------------- */ + +.error-page { + display: flex; + inset: 0px; + position: absolute; + align-items: center; + justify-content: center; +} + +.error-page__container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; +} + +.error-page__content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +/* text-header mb-sm */ +.error-page__content h1 { + font-size: 20px; + font-weight: 800; + margin-bottom: 0.5rem; +} + +/* text-primary-bold text-grey */ +.error-page__content p { + font-size: 14px; + color: var(--text-color-2); +} + +/* text-primary-bold text-grey */ +.error-page__container a { + font-size: 14px; + font-weight: 500; + color: var(--text-color-2); + padding: 12px 4px; +} + +/* ------------------ Board history ----------------- */ + +.board-history__list { + padding: 8px 8px 8px 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.board-history__list a { + padding: 8px 8px 8px 0px; +} + +.board-history__list a:hover { + text-decoration: underline; +} + +.board-history__restore { + position: fixed; + top: 8px; + right: 8px; +} diff --git a/apps/dotcom/styles/globals.css b/apps/dotcom/styles/globals.css new file mode 100644 index 000000000..b7eab9337 --- /dev/null +++ b/apps/dotcom/styles/globals.css @@ -0,0 +1,14 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Plus+Jakarta+Sans:wght@600;800&display=swap'); +@import url('@tldraw/tldraw/tldraw.css'); +@import url('./z-board.css'); + +.tldraw__editor { + position: fixed; + top: 0px; + left: 0px; + bottom: 0px; + right: 0px; + width: 100%; + height: 100%; + overflow: hidden; +} diff --git a/apps/dotcom/styles/z-board.css b/apps/dotcom/styles/z-board.css new file mode 100644 index 000000000..936057cfd --- /dev/null +++ b/apps/dotcom/styles/z-board.css @@ -0,0 +1,304 @@ +/* ------------------ Da Share Zone ----------------- */ + +.tlui-share-zone { + padding: 0px 0px 0px 0px; + display: flex; + height: 40px; + flex-direction: row; + justify-content: flex-end; + z-index: var(--layer-panels); + align-items: center; +} + +.tlui-share-zone__connection-status { + width: 8px; + height: 100%; + position: relative; + display: flex; + align-items: center; +} + +.tlui-share-zone__connection-status::after { + content: ''; + width: 8px; + height: 8px; + background-color: currentColor; + border-radius: 100%; +} + +.tlui-share-zone__button { + font-family: inherit; + font-size: inherit; + border: 4px solid var(--color-background); + border-radius: 8px; + background-color: var(--color-selected); + color: var(--color-selected-contrast); + text-shadow: none; + pointer-events: all; + position: relative; +} + +.tlui-share-zone__button::before { + position: absolute; + display: block; + content: ''; + inset: -4px; + background-color: var(--color-background); + border-top-left-radius: var(--radius-3); + border-bottom-right-radius: var(--radius-3); + border-bottom-left-radius: var(--radius-3); + z-index: -1; +} + +.tlui-share-zone__button:active { + color: var(--color-selected-contrast); +} + +@media (hover: hover) { + .tlui-share-zone__button:hover { + color: var(--color-selected-contrast); + } + + .tlui-share-zone__button:not(:disabled, :focus-visible):hover { + color: var(--color-selected-contrast); + } +} + +.tlui-share-zone__popover { + font-size: 12px; + font-weight: inherit; + width: 200px; + max-width: 100%; + max-height: 100%; + position: relative; +} + +.tlui-share-zone__qr-code { + width: 200px; + height: 200px; + cursor: pointer; + background: none; + background-color: var(--color-muted-2); + background-size: cover; + border: none; +} + +.tlui-share-zone__spinner { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.tlui-share-zone__details { + font-size: 11px; + font-weight: 400; + padding: var(--space-4); + color: var(--color-text-1); + line-height: 1.5; + margin: 0px; +} + +.tlui-share-zone__status { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + position: relative; + left: -4px; +} + +.tlui-share-zone__status > div { + width: 8px; + height: 8px; + border-radius: 100%; +} + +/* ------------------- People Menu ------------------- */ + +.tlui-people-menu__avatars-button { + display: flex; + align-items: center; + justify-content: flex-end; + background: none; + border: none; + cursor: pointer; + pointer-events: all; + border-radius: var(--radius-1); + padding-right: 1px; + height: 36px; +} + +.tlui-people-menu__avatars { + display: flex; + flex-direction: row; +} + +.tlui-people-menu__avatar { + height: 22px; + width: 22px; + border: 3px solid var(--color-background); + background-color: var(--color-low); + border-radius: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + font-size: 10px; + font-weight: bold; + color: var(--color-selected-contrast); + z-index: 2; +} + +.tlui-people-menu__avatar:nth-of-type(n + 2) { + margin-left: -10px; +} + +.tlui-people-menu__avatars-button[data-state='open'] { + opacity: 1; +} + +@media (hover: hover) { + .tlui-people-menu__avatars-button:hover .tlui-people-menu__avatar { + border-color: var(--color-low); + } +} + +.tlui-people-menu__more { + min-width: 0px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-1); + font-family: inherit; + padding: 0px 4px; + letter-spacing: 1.5; +} +.tlui-people-menu__more::after { + border-radius: var(--radius-2); + inset: 0px; +} + +.tlui-people-menu__wrapper { + position: relative; + display: flex; + flex-direction: column; + width: 220px; + height: fit-content; + max-height: 50vh; +} + +.tlui-people-menu__section { + position: relative; + touch-action: auto; + flex-direction: column; + max-height: 100%; + overflow-x: hidden; + overflow-y: auto; + touch-action: auto; +} + +.tlui-people-menu__section:not(:last-child) { + border-bottom: 1px solid var(--color-divider); +} + +.tlui-people-menu__user { + display: flex; + justify-content: flex-start; + align-items: center; +} + +.tlui-people-menu__user__color-picker { + z-index: var(--layer-overlays); +} + +.tlui-people-menu__user__color { + flex-shrink: 0; +} + +.tlui-people-menu__user__name { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--color-text-1); + max-width: 100%; + flex-grow: 1; + flex-shrink: 100; +} + +.tlui-people-menu__user__label { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--color-text-3); + flex-grow: 100; + flex-shrink: 0; + margin-left: 4px; +} + +.tlui-people-menu__user__input { + flex-grow: 2; + height: 100%; + padding: 0px; + margin: 0px; +} + +.tlui-people-menu__user > .tlui-input__wrapper { + width: auto; + display: flex; + align-items: auto; + flex-grow: 2; + gap: 8px; + height: 100%; + padding: 0px; +} + +.tlui-people-menu__item { + display: flex; + justify-content: flex-start; + width: 100%; +} + +.tlui-people-menu__item__button { + padding: 0 11px; +} + +.tlui-people-menu__item > .tlui-button__menu { + width: auto; + display: flex; + align-items: auto; + justify-content: flex-start; + flex-grow: 2; + gap: 11px; +} + +.tlui-people-menu__item__follow { + min-width: 44px; +} + +.tlui-people-menu__item__follow[data-active='true'] .tlui-icon { + opacity: 1; +} + +.tlui-people-menu__item__follow:focus-visible .tlui-icon { + opacity: 1; +} + +@media (hover: hover) { + .tlui-people-menu__item__follow .tlui-icon { + opacity: 0; + } + + .tlui-people-menu__item__follow:hover .tlui-icon { + opacity: 1; + } +} + +.tlui-layout[data-breakpoint='0'] .tlui-offline-indicator { + margin-top: 4px; +} diff --git a/apps/dotcom/tsconfig.json b/apps/dotcom/tsconfig.json new file mode 100644 index 000000000..d8a56ee6a --- /dev/null +++ b/apps/dotcom/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "experimentalDecorators": true, + "downlevelIteration": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "_archive"], + "references": [ + { "path": "../../packages/tlsync" }, + { "path": "../../packages/tldraw" }, + { "path": "../../packages/assets" } + ] +} diff --git a/apps/dotcom/vite.config.ts b/apps/dotcom/vite.config.ts new file mode 100644 index 000000000..2d2f01239 --- /dev/null +++ b/apps/dotcom/vite.config.ts @@ -0,0 +1,116 @@ +import react from '@vitejs/plugin-react-swc' +import { config } from 'dotenv' +import { defineConfig } from 'vite' +import { VitePWA, VitePWAOptions } from 'vite-plugin-pwa' + +config({ + path: './.env.local', +}) + +export const getMultiplayerServerURL = () => { + return process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http') ?? 'http://127.0.0.1:8787' +} + +const pwaConfig: Partial = { + registerType: 'autoUpdate', + // Make sure the service worker doesn't try to handle API requests + workbox: { + navigateFallbackDenylist: [/^\/api/], + runtimeCaching: [{ handler: 'NetworkFirst', urlPattern: /\/.*/ }], + }, + // Uncomment this to test the PWA install flow locally + // devOptions: { enabled: true }, + manifest: { + name: 'tldraw', + short_name: 'tldraw', + description: 'a very good free whiteboard', + + icons: [ + { + src: '/android-chrome-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + { + src: '/android-chrome-maskable-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable', + }, + { + src: '/android-chrome-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/android-chrome-maskable-192x192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any maskable', + }, + ], + theme_color: '#ffffff', + background_color: '#ffffff', + start_url: '/', + display: 'standalone', + orientation: 'any', + }, +} + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react({ tsDecorators: true }), VitePWA(pwaConfig)], + publicDir: './public', + build: { + // output source maps to .map files and include //sourceMappingURL comments in JavaScript files + // these get uploaded to Sentry and can be used for debugging + sourcemap: true, + + // our svg icons break if we use data urls, so disable inline assets for now + assetsInlineLimit: 0, + }, + // add backwards-compatible support for NEXT_PUBLIC_ env vars + define: { + ...Object.fromEntries( + Object.entries(process.env) + .filter(([key]) => key.startsWith('NEXT_PUBLIC_')) + .map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]) + ), + 'process.env.MULTIPLAYER_SERVER': JSON.stringify(getMultiplayerServerURL()), + 'process.env.ASSET_UPLOAD': JSON.stringify(process.env.ASSET_UPLOAD ?? 'http://127.0.0.1:8788'), + 'process.env.TLDRAW_ENV': JSON.stringify(process.env.TLDRAW_ENV ?? 'development'), + // Fall back to staging DSN for local develeopment, although you still need to + // modify the env check in 'sentry.client.config.ts' to get it reporting errors + 'process.env.SENTRY_DSN': JSON.stringify( + process.env.SENTRY_DSN ?? + 'https://4adc43773d07854d8a60e119505182cc@o578706.ingest.sentry.io/4506178821881856' + ), + }, + server: { + proxy: { + '/api': { + target: getMultiplayerServerURL(), + rewrite: (path) => path.replace(/^\/api/, ''), + ws: false, // we talk to the websocket directly via workers.dev + // Useful for debugging proxy issues + // configure: (proxy, _options) => { + // proxy.on('error', (err, _req, _res) => { + // console.log('[proxy] proxy error', err) + // }) + // proxy.on('proxyReq', (proxyReq, req, _res) => { + // console.log('[proxy] Sending Request to the Target:', req.method, req.url) + // }) + // proxy.on('proxyRes', (proxyRes, req, _res) => { + // console.log( + // '[proxy] Received Response from the Target:', + // proxyRes.statusCode, + // req.url + // ) + // }) + // }, + }, + }, + }, +}) diff --git a/apps/huppy/.gitignore b/apps/huppy/.gitignore new file mode 100644 index 000000000..e81fc5106 --- /dev/null +++ b/apps/huppy/.gitignore @@ -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 + +# 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 +next-env.d.ts + +# Sentry +.sentryclirc diff --git a/apps/huppy/Dockerfile b/apps/huppy/Dockerfile new file mode 100644 index 000000000..8a35cf16f --- /dev/null +++ b/apps/huppy/Dockerfile @@ -0,0 +1,40 @@ +# Install dependencies only when needed +FROM node:18.12.1-alpine AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat + +RUN corepack enable +WORKDIR /app +COPY . . + +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn \ + yarn install --immutable + +ENV NEXT_TELEMETRY_DISABLED 1 + +WORKDIR /app/apps/huppy +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn build + +# Production image, copy all the files and run next +FROM node:18.12.1-alpine AS runner + +RUN apk update && apk upgrade && \ + apk add --no-cache bash git openssh + +WORKDIR /app + +RUN corepack enable + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app ./ + +USER nextjs + +WORKDIR /app/apps/huppy +CMD ["yarn", "start"] + diff --git a/apps/huppy/README.md b/apps/huppy/README.md new file mode 100644 index 000000000..91a71a655 --- /dev/null +++ b/apps/huppy/README.md @@ -0,0 +1,56 @@ +# Repo-tools + +Repo-tools is responsible for the huppy-bot app. + +## Development + +To develop huppy-bot, you'll need to create a .env file that looks like this: + +``` +REPO_SYNC_PRIVATE_KEY_B64= +REPO_SYNC_HOOK_SECRET= +``` + +DM alex to get hold of these credentials. + +To start the server, run `yarn dev-repo-sync`. Once running, you can go to +https://localhost:3000/deliveries to get to a list of github webhook event +deliveries. To test your code, pick an event that does roughly what you want and +hit 'simulate'. You can also ask GitHub to re-deliver events to the production +version of repo-sync through this UI. + +Huppy-bot isn't currently deployed automatically. To deploy, use: + +```sh +fly deploy --config apps/repo-sync/fly.toml --dockerfile apps/repo-sync/Dockerfile +``` + +from the repo root. + +## How it works + +Huppy runs on a server with persistent disk storage attached. It maintains local +mirrors of both our github repos on that disk. When events come in that mean we +need to do some work in a repo, it updates the local mirror, then clones them to +a temporary directory for work. This sort of pull + local clone is _much_ faster +(~1s) than normal from-scratch clones (~1m). + +Huppy's reponsibilities are organized into "flows". These are defined in +`src/flows`. A flow is an object with webhook handlers that implement some +complete set of functionality. Right now there aren't many, but we could add more! + +There's an alternative universe where huppy would exist as a set of github +actions instead. We didn't pursue this route for three reasons: + +1. Huppy needs to operate over multiple github repos at once, which isn't well + supported by actions. +2. Giving actions in our public repo access to our private repo could be a + security risk. We'd have to grant permission to OSS contributors to run + certain actions, which could mean accidentally giving them access to more + than we intend. +3. Having access to the full range of webhook & API options provided by GitHub + means we can create a better DX than would be possible with plain actions + (e.g. the "Fix" button when huppy detects that bublic is out of date). + +It also lets us make use of that local-clone trick, which means huppy responds +to requests in seconds rather than minutes. diff --git a/apps/huppy/fly.toml b/apps/huppy/fly.toml new file mode 100644 index 000000000..38936b4be --- /dev/null +++ b/apps/huppy/fly.toml @@ -0,0 +1,35 @@ +# fly.toml file generated for tldraw-repo-sync on 2023-04-25T14:25:01+01:00 +app = "tldraw-repo-sync" +kill_signal = "SIGINT" +kill_timeout = 5 +mounts = [] +primary_region = "lhr" +processes = [] + +[build] + +[env] +PORT = "8080" + +[mounts] +source = "git_store" +destination = "/tldraw_repo_sync_data" + +[[services]] +internal_port = 8080 +processes = ["app"] +protocol = "tcp" + +[services.concurrency] +hard_limit = 25 +soft_limit = 20 +type = "connections" + +[[services.ports]] +force_https = true +handlers = ["http"] +port = 80 + +[[services.ports]] +handlers = ["tls", "http"] +port = 443 diff --git a/apps/huppy/hacky.Dockerfile b/apps/huppy/hacky.Dockerfile new file mode 100644 index 000000000..62b55026b --- /dev/null +++ b/apps/huppy/hacky.Dockerfile @@ -0,0 +1,12 @@ +# this is extremely hacky and will only work for this one time :) + +# it seems that fly.io CLI somehow builds the image differently from +# pure docker and just hangs, consuming one full core; so instead of +# building and deploying, build separately through docker and then +# just reuse the image +# current workflow: +# docker build --progress plain -f apps/huppy/Dockerfile -t dgroshev/huppy --platform linux/amd64 . +# docker push dgroshev/huppy +# [adjust the image hash below] +# fly deploy --config apps/huppy/fly.toml --dockerfile apps/huppy/hacky.Dockerfile --local-only +FROM dgroshev/huppy@sha256:3dd947e860cb919ba8b5147f51b286ee413057c12bc973d021fbe8313f28401c \ No newline at end of file diff --git a/apps/huppy/next.config.js b/apps/huppy/next.config.js new file mode 100644 index 000000000..d1105fd60 --- /dev/null +++ b/apps/huppy/next.config.js @@ -0,0 +1,25 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + transpilePackages: [], + productionBrowserSourceMaps: true, + webpack: (config, context) => { + config.module.rules.push({ + test: /\.(svg|json|woff2)$/, + type: 'asset/resource', + }) + return config + }, + redirects: async () => { + return [{ source: '/', destination: 'https://www.tldraw.com/', permanent: false }] + }, + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, +} + +module.exports = nextConfig diff --git a/apps/huppy/package.json b/apps/huppy/package.json new file mode 100644 index 000000000..73b403d70 --- /dev/null +++ b/apps/huppy/package.json @@ -0,0 +1,39 @@ +{ + "name": "huppy", + "description": "Tools for managing our public and private repos", + "version": "2.0.0-alpha.10", + "private": true, + "packageManager": "yarn@3.5.0", + "author": { + "name": "tldraw GB Ltd.", + "email": "hello@tldraw.com" + }, + "homepage": "https://tldraw.dev", + "browserslist": [ + "defaults" + ], + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "yarn run -T tsx ../../scripts/lint.ts" + }, + "dependencies": { + "@octokit/core": "^5.0.1", + "@octokit/plugin-retry": "^6.0.1", + "@octokit/webhooks-types": "^6.11.0", + "@tldraw/utils": "workspace:*", + "@tldraw/validate": "workspace:*", + "@types/jsonwebtoken": "^9.0.1", + "json5": "^2.2.3", + "jsonwebtoken": "^9.0.0", + "next": "^13.2.3", + "octokit": "^3.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "eslint-config-next": "12.2.5", + "lazyrepo": "0.0.0-alpha.27" + } +} diff --git a/apps/huppy/pages/_app.tsx b/apps/huppy/pages/_app.tsx new file mode 100644 index 000000000..227178e9e --- /dev/null +++ b/apps/huppy/pages/_app.tsx @@ -0,0 +1,13 @@ +import type { AppProps } from 'next/app' +import Head from 'next/head' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + <> + + + + + + ) +} diff --git a/apps/huppy/pages/api/dev/getDelivery.ts b/apps/huppy/pages/api/dev/getDelivery.ts new file mode 100644 index 000000000..530583a71 --- /dev/null +++ b/apps/huppy/pages/api/dev/getDelivery.ts @@ -0,0 +1,17 @@ +import { assert } from '@tldraw/utils' +import { NextApiRequest, NextApiResponse } from 'next' +import { getAppOctokit } from '../../../src/octokit' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + assert(process.env.NODE_ENV !== 'production') + assert(req.method === 'GET') + const id = req.query.id + assert(typeof id === 'string') + + const gh = getAppOctokit() + const { data: delivery } = await gh.octokit.rest.apps.getWebhookDelivery({ + delivery_id: Number(id), + }) + + return res.json(delivery) +} diff --git a/apps/huppy/pages/api/dev/redeliver.ts b/apps/huppy/pages/api/dev/redeliver.ts new file mode 100644 index 000000000..9e2a9725e --- /dev/null +++ b/apps/huppy/pages/api/dev/redeliver.ts @@ -0,0 +1,17 @@ +import { assert } from '@tldraw/utils' +import { NextApiRequest, NextApiResponse } from 'next' +import { getAppOctokit } from '../../../src/octokit' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + assert(process.env.NODE_ENV !== 'production') + assert(req.method === 'POST') + const deliveryId = req.body.id as number + assert(typeof deliveryId === 'number') + + const gh = getAppOctokit() + await gh.octokit.rest.apps.redeliverWebhookDelivery({ + delivery_id: deliveryId, + }) + + return res.json({ ok: true }) +} diff --git a/apps/huppy/pages/api/dev/simulate.ts b/apps/huppy/pages/api/dev/simulate.ts new file mode 100644 index 000000000..575b7f66f --- /dev/null +++ b/apps/huppy/pages/api/dev/simulate.ts @@ -0,0 +1,37 @@ +import { assert, assertExists } from '@tldraw/utils' +import { NextApiRequest, NextApiResponse } from 'next' +import { Ctx } from '../../../src/ctx' +import { NamedEvent, onGithubEvent } from '../../../src/flow' +import { getAppOctokit, getInstallationToken } from '../../../src/octokit' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + assert(process.env.NODE_ENV !== 'production') + assert(req.method === 'POST') + const deliveryId = req.body.id as number + assert(typeof deliveryId === 'number') + + const app = getAppOctokit() + + const { data: delivery } = await app.octokit.rest.apps.getWebhookDelivery({ + delivery_id: deliveryId, + }) + + const installationId = assertExists(delivery.installation_id) + const ctx: Ctx = { + app, + installationId, + octokit: await app.getInstallationOctokit(installationId), + installationToken: await getInstallationToken(app, installationId), + } + + try { + const messages = await onGithubEvent(ctx, { + name: delivery.event, + payload: delivery.request.payload, + } as NamedEvent) + return res.json({ message: JSON.stringify(messages, null, 2) }) + } catch (err: any) { + console.log(err.stack) + return res.json({ message: err.message }) + } +} diff --git a/apps/huppy/pages/api/github-event.ts b/apps/huppy/pages/api/github-event.ts new file mode 100644 index 000000000..57c81c5ce --- /dev/null +++ b/apps/huppy/pages/api/github-event.ts @@ -0,0 +1,44 @@ +import type { WebhookEventName } from '@octokit/webhooks-types' +import { assert } from '@tldraw/utils' +import { NextApiRequest, NextApiResponse } from 'next' +import { Ctx } from '../../src/ctx' +import { NamedEvent, onGithubEvent } from '../../src/flow' +import { getAppOctokit, getInstallationToken } from '../../src/octokit' +import { wrapRequest } from '../../src/requestWrapper' +import { header } from '../../src/utils' + +const handler = wrapRequest( + '/api/github-event', + async function handler(req: NextApiRequest, res: NextApiResponse) { + const app = getAppOctokit() + const eventName = header(req, 'x-github-event') as WebhookEventName + await app.webhooks.verifyAndReceive({ + id: header(req, 'x-github-delivery'), + name: eventName, + signature: header(req, 'x-hub-signature-256'), + payload: JSON.stringify(req.body), + }) + + const event = { name: eventName, payload: req.body } as NamedEvent + assert( + 'installation' in event.payload && event.payload.installation, + 'event must have installation' + ) + + const installationId = event.payload.installation.id + const ctx: Ctx = { + app, + octokit: await app.getInstallationOctokit(Number(installationId)), + installationId: installationId, + installationToken: await getInstallationToken(app, installationId), + } + + // we deliberately don't await this so that the response is sent + // immediately. we'll process the event in the background. + onGithubEvent(ctx, event) + + return res.json({ ok: true }) + } +) + +export default handler diff --git a/apps/huppy/pages/api/on-release.ts b/apps/huppy/pages/api/on-release.ts new file mode 100644 index 000000000..ee06bda94 --- /dev/null +++ b/apps/huppy/pages/api/on-release.ts @@ -0,0 +1,35 @@ +import { assert } from '@tldraw/utils' +import { T } from '@tldraw/validate' +import { NextApiRequest, NextApiResponse } from 'next' +import { TLDRAW_ORG } from '../../src/config' +import { standaloneExamplesBranch } from '../../src/flows/standaloneExamplesBranch' +import { getCtxForOrg } from '../../src/getCtxForOrg' +import { wrapRequest } from '../../src/requestWrapper' + +const bodySchema = T.object({ + tagToRelease: T.string, + apiKey: T.string, + canary: T.boolean.optional(), +}) + +const handler = wrapRequest( + '/api/on-release', + async function handler(req: NextApiRequest, res: NextApiResponse) { + assert(req.method === 'POST') + const body = bodySchema.validate(req.body) + assert(typeof process.env.DEVELOPER_ACCESS_KEY === 'string') + if (body.apiKey !== process.env.DEVELOPER_ACCESS_KEY) { + res.status(401).send('Bad api key') + return + } + + const { tagToRelease } = body + await standaloneExamplesBranch.onCustomHook(await getCtxForOrg(TLDRAW_ORG), { + tagToRelease, + canary: !!body.canary, + }) + res.send('Created standalone examples branch') + } +) + +export default handler diff --git a/apps/huppy/pages/deliveries.tsx b/apps/huppy/pages/deliveries.tsx new file mode 100644 index 000000000..c23d314fa --- /dev/null +++ b/apps/huppy/pages/deliveries.tsx @@ -0,0 +1,164 @@ +import { assert } from '@tldraw/utils' +import { GetServerSideProps } from 'next' +import { useEffect, useState } from 'react' +import { getAppOctokit } from '../src/octokit' + +type Props = { + deliveries: { + id: number + guid: string + delivered_at: string + redelivery: boolean + duration: number + status: string + status_code: number + event: string + action: string | null + installation_id: number | null + repository_id: number | null + }[] + cursor: string | null +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + assert(process.env.NODE_ENV !== 'production') + + const gh = getAppOctokit() + const deliveries = await gh.octokit.rest.apps.listWebhookDeliveries({ + per_page: 100, + cursor: (context.query.cursor as string) ?? undefined, + }) + + const nextLinkMatch = deliveries.headers.link?.match(/(?<=<)([\S]*)(?=>; rel="Next")/i) + let cursor: string | null = null + if (nextLinkMatch) { + const url = new URL(nextLinkMatch[0]) + cursor = url.searchParams.get('cursor') + } + + return { props: { deliveries: deliveries.data, cursor } } +} + +type SelectedDelivery = { + id: number + data?: unknown +} + +export default function Deliveries({ deliveries, cursor }: Props) { + const [selectedDelivery, setSelectedDelivery] = useState(null) + const [isSimulating, setIsSimulating] = useState(false) + const [isRedelivering, setIsRedelivering] = useState(false) + + useEffect(() => { + if (!selectedDelivery || (selectedDelivery && selectedDelivery.data)) return + + let cancelled = false + ;(async () => { + const response = await fetch(`/api/dev/getDelivery?id=${selectedDelivery.id}`) + const data = await response.json() + if (cancelled) return + setSelectedDelivery({ id: selectedDelivery.id, data }) + })() + + return () => { + cancelled = true + } + }) + + return ( +
+

Deliveries

+
+
    + {deliveries.map((delivery) => ( +
  1. setSelectedDelivery({ id: delivery.id })} + > +
    + {delivery.event} + {delivery.action && `.${delivery.action}`} +
    +
    {formatDate(new Date(delivery.delivered_at))}
    +
  2. + ))} +
  3. + + Load more... + +
  4. +
+ {selectedDelivery && selectedDelivery.data ? ( +
+
+							{JSON.stringify(selectedDelivery.data, null, 2)}
+						
+
+ + +
+
+ ) : selectedDelivery ? ( +
+ loading... +
+ ) : null} +
+
+ ) +} + +function formatDate(date: Date) { + const intl = new Intl.DateTimeFormat('en-GB', { + dateStyle: 'short', + timeStyle: 'short', + }) + + return intl.format(date) +} diff --git a/apps/huppy/src/Queue.ts b/apps/huppy/src/Queue.ts new file mode 100644 index 000000000..749e21f3e --- /dev/null +++ b/apps/huppy/src/Queue.ts @@ -0,0 +1,15 @@ +export class Queue { + currentTask = Promise.resolve() + + enqueue(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.currentTask = this.currentTask.then(async () => { + try { + resolve(await task()) + } catch (err) { + reject(err) + } + }) + }) + } +} diff --git a/apps/huppy/src/comment.tsx b/apps/huppy/src/comment.tsx new file mode 100644 index 000000000..5ce40a402 --- /dev/null +++ b/apps/huppy/src/comment.tsx @@ -0,0 +1,48 @@ +import { APP_USER_NAME, TLDRAW_ORG, TLDRAW_PUBLIC_REPO } from './config' +import { Ctx } from './ctx' + +export async function findHuppyCommentIfExists(ctx: Ctx, prNumber: number) { + const { data: comments } = await ctx.octokit.rest.issues.listComments({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + issue_number: prNumber, + per_page: 100, + sort: 'created', + direction: 'asc', + }) + + const foundComment = comments.find((comment) => comment.user?.login === APP_USER_NAME) + + return foundComment ?? null +} + +export async function updateHuppyCommentIfExists(ctx: Ctx, prNumber: number, body: string) { + const foundComment = await findHuppyCommentIfExists(ctx, prNumber) + if (foundComment) { + await ctx.octokit.rest.issues.updateComment({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + comment_id: foundComment.id, + body, + }) + } +} + +export async function createOrUpdateHuppyComment(ctx: Ctx, prNumber: number, body: string) { + const foundComment = await findHuppyCommentIfExists(ctx, prNumber) + if (foundComment) { + await ctx.octokit.rest.issues.updateComment({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + comment_id: foundComment.id, + body, + }) + } else { + await ctx.octokit.rest.issues.createComment({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + issue_number: prNumber, + body, + }) + } +} diff --git a/apps/huppy/src/config.tsx b/apps/huppy/src/config.tsx new file mode 100644 index 000000000..6a5a75b97 --- /dev/null +++ b/apps/huppy/src/config.tsx @@ -0,0 +1,7 @@ +export const APP_USER_EMAIL = '128400622+huppy-bot[bot]@users.noreply.github.com' +export const APP_USER_NAME = 'huppy-bot[bot]' +export const APP_ID = '307634' + +export const TLDRAW_ORG = 'tldraw' +export const TLDRAW_PUBLIC_REPO = 'tldraw' +export const TLDRAW_PUBLIC_REPO_MAIN_BRANCH = 'main' diff --git a/apps/huppy/src/ctx.tsx b/apps/huppy/src/ctx.tsx new file mode 100644 index 000000000..6ef387b7a --- /dev/null +++ b/apps/huppy/src/ctx.tsx @@ -0,0 +1,8 @@ +import { App, Octokit } from 'octokit' + +export type Ctx = { + app: App + octokit: Octokit + installationToken: string + installationId: number +} diff --git a/apps/huppy/src/flow.tsx b/apps/huppy/src/flow.tsx new file mode 100644 index 000000000..8dbd499f0 --- /dev/null +++ b/apps/huppy/src/flow.tsx @@ -0,0 +1,54 @@ +import { WebhookEventMap } from '@octokit/webhooks-types' +import { Ctx } from './ctx' +import { allFlows } from './flows' +import { reportError } from './reportError' +import { camelCase, capitalize, elapsed } from './utils' + +type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : Lowercase + +export type NamedEvent = { + [Name in keyof WebhookEventMap]: { name: Name; payload: WebhookEventMap[Name] } +}[keyof WebhookEventMap] + +export type Flow = { + name: string + onCustomHook?: (ctx: Ctx, payload: CustomHookPayload) => Promise +} & { + [Name in keyof WebhookEventMap as `on${Capitalize>}`]?: ( + ctx: Ctx, + payload: WebhookEventMap[Name] + ) => Promise +} + +export async function onGithubEvent(ctx: Ctx, event: NamedEvent) { + let nameString = event.name + if ('action' in event.payload) { + nameString += `.${event.payload.action}` + } + console.log('Starting event:', nameString) + const handlerName = `on${capitalize(camelCase(event.name))}` as `on${Capitalize< + CamelCase + >}` + + const results: Record = {} + + for (const flow of allFlows) { + if (handlerName in flow) { + const actionName = `${flow.name}.${handlerName}` + const start = Date.now() + try { + console.log(`===== Starting ${actionName} =====`) + await (flow as any)[handlerName](ctx, event.payload) + console.log(`===== Finished ${actionName} in ${elapsed(start)} =====`) + results[actionName] = `ok in ${elapsed(start)}` + } catch (err: any) { + results[actionName] = err.message + await reportError(`Error in ${flow.name}.${handlerName}`, err) + } + } + } + + return results +} diff --git a/apps/huppy/src/flows/collectClaSignatures.tsx b/apps/huppy/src/flows/collectClaSignatures.tsx new file mode 100644 index 000000000..01a95daa1 --- /dev/null +++ b/apps/huppy/src/flows/collectClaSignatures.tsx @@ -0,0 +1,234 @@ +import { IssueCommentEvent } from '@octokit/webhooks-types' +import { assert } from '@tldraw/utils' +import { createOrUpdateHuppyComment, updateHuppyCommentIfExists } from '../comment' +import { TLDRAW_ORG, TLDRAW_PUBLIC_REPO } from '../config' +import { Ctx } from '../ctx' +import { Flow } from '../flow' + +const TARGET_REPO = TLDRAW_PUBLIC_REPO +const CLA_URL = + 'https://tldraw.notion.site/Contributor-License-Agreement-4d529dd5e4b3438b90cdf2a2f9d7e7e6?pvs=4' +const SIGNING_MESSAGE = 'I have read and agree to the Contributor License Agreement.' +const RE_CHECK_MESSAGE = '/huppy check cla' +const CLA_SIGNATURES_BRANCH = 'cla-signees' + +const pullRequestActionsToCheck = ['opened', 'synchronize', 'reopened', 'edited'] + +type Signing = { + githubId: number + signedAt: string + signedVersion: 1 + signingComment: string +} +type SigneeInfo = { + unsigned: Set + signees: Map + total: number +} + +export const collectClaSignatures: Flow = { + name: 'collectClaSignatures', + + onPullRequest: async (ctx, event) => { + if (event.repository.full_name !== `${TLDRAW_ORG}/${TARGET_REPO}`) return + if (!pullRequestActionsToCheck.includes(event.action)) return + + await checkAllContributorsHaveSignedCla(ctx, event.pull_request) + }, + + onIssueComment: async (ctx, event) => { + if (event.repository.full_name !== `${TLDRAW_ORG}/${TARGET_REPO}`) return + if (event.issue.pull_request === undefined) return + + switch (event.comment.body.trim().toLowerCase()) { + case SIGNING_MESSAGE.toLowerCase(): + await addSignatureFromComment(ctx, event) + break + case RE_CHECK_MESSAGE.toLowerCase(): { + const pr = await ctx.octokit.rest.pulls.get({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + pull_number: event.issue.number, + }) + await checkAllContributorsHaveSignedCla(ctx, pr.data) + await ctx.octokit.rest.reactions.createForIssueComment({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + comment_id: event.comment.id, + content: '+1', + }) + break + } + } + }, +} + +async function addSignatureFromComment(ctx: Ctx, event: IssueCommentEvent) { + const existingSignature = await getClaSigneeInfo(ctx, event.comment.user.login.toLowerCase()) + if (existingSignature) { + await ctx.octokit.rest.reactions.createForIssueComment({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + comment_id: event.comment.id, + content: 'heart', + }) + return + } + + const newSigning: Signing = { + githubId: event.comment.user.id, + signedAt: event.comment.created_at, + signedVersion: 1, + signingComment: event.comment.html_url, + } + + await ctx.octokit.rest.repos.createOrUpdateFileContents({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + path: `${event.comment.user.login.toLowerCase()}.json`, + branch: CLA_SIGNATURES_BRANCH, + content: Buffer.from(JSON.stringify(newSigning, null, '\t')).toString('base64'), + message: `Add CLA signature for ${event.comment.user.login}`, + }) + + const pr = await ctx.octokit.rest.pulls.get({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + pull_number: event.issue.number, + }) + await checkAllContributorsHaveSignedCla(ctx, pr.data) + + await ctx.octokit.rest.reactions.createForIssueComment({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + comment_id: event.comment.id, + content: 'heart', + }) +} + +async function checkAllContributorsHaveSignedCla( + ctx: Ctx, + pr: { head: { sha: string }; number: number } +) { + const info = await getClaSigneesFromPr(ctx, pr) + + if (info.unsigned.size === 0) { + await ctx.octokit.rest.repos.createCommitStatus({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + sha: pr.head.sha, + state: 'success', + context: 'CLA Signatures', + description: getStatusDescription(info), + }) + await updateHuppyCommentIfExists(ctx, pr.number, getHuppyCommentContents(info)) + return + } + + await ctx.octokit.rest.repos.createCommitStatus({ + owner: TLDRAW_ORG, + repo: TARGET_REPO, + sha: pr.head.sha, + state: 'failure', + context: 'CLA Signatures', + description: getStatusDescription(info), + }) + + await createOrUpdateHuppyComment(ctx, pr.number, getHuppyCommentContents(info)) +} + +async function getClaSigneesFromPr(ctx: Ctx, pr: { number: number }): Promise { + const allAuthors = new Set() + + const commits = await ctx.octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls/{pull_number}/commits', + { + owner: TLDRAW_ORG, + repo: TARGET_REPO, + pull_number: pr.number, + } + ) + + for (const commit of commits) { + if (commit.author && !commit.author.login.endsWith('[bot]')) { + allAuthors.add(commit.author.login.toLowerCase()) + } + } + + const signees = new Map() + const unsigned = new Set() + for (const author of [...allAuthors].sort()) { + const signeeInfo = await getClaSigneeInfo(ctx, author) + if (signeeInfo) { + signees.set(author, signeeInfo) + } else { + unsigned.add(author) + } + } + + return { signees, unsigned, total: allAuthors.size } +} + +async function getClaSigneeInfo(ctx: Ctx, authorName: string) { + try { + const response = await ctx.octokit.rest.repos.getContent({ + owner: TLDRAW_ORG, + repo: TLDRAW_PUBLIC_REPO, + path: `${authorName}.json`, + ref: 'cla-signees', + }) + assert(!Array.isArray(response.data), 'Expected a file, not a directory') + assert(response.data.type === 'file', 'Expected a file, not a directory') + return { + signing: JSON.parse( + Buffer.from(response.data.content, 'base64').toString('utf-8') + ) as Signing, + fileSha: response.data.sha, + } + } catch (err: any) { + if (err.status === 404) { + return null + } + throw err + } +} + +function getHuppyCommentContents(info: SigneeInfo) { + if (info.signees.size > 1) { + let listing = `**${info.signees.size}** out of **${info.total}** ${ + info.total === 1 ? 'authors has' : 'authors have' + } signed the [CLA](${CLA_URL}).\n\n` + + for (const author of info.unsigned) { + listing += `- [ ] @${author}\n` + } + for (const author of info.signees.keys()) { + listing += `- [x] @${author}\n` + } + + if (info.unsigned.size === 0) { + return `${listing}\n\nThanks!` + } + + return `Hey, thanks for your pull request! Before we can merge your PR, each author will need to sign our [Contributor License Agreement](${CLA_URL}) by posting a comment that reads: + +> ${SIGNING_MESSAGE} +--- + +${listing}` + } else { + const author = [...info.signees.keys()][0] + + if (info.unsigned.size === 0) { + return `**${author}** has signed the [Contributor License Agreement](${CLA_URL}). Thanks!` + } + + return `Hey, thanks for your pull request! Before we can merge your PR, you will need to sign our [Contributor License Agreement](${CLA_URL}) by posting a comment that reads: + +> ${SIGNING_MESSAGE}` + } +} + +function getStatusDescription(info: SigneeInfo) { + return `${info.signees.size}/${info.total} signed. Comment '${RE_CHECK_MESSAGE}' to re-check.` +} diff --git a/apps/huppy/src/flows/enforcePrLabels.tsx b/apps/huppy/src/flows/enforcePrLabels.tsx new file mode 100644 index 000000000..dd760902f --- /dev/null +++ b/apps/huppy/src/flows/enforcePrLabels.tsx @@ -0,0 +1,119 @@ +import { TLDRAW_ORG, TLDRAW_PUBLIC_REPO, TLDRAW_PUBLIC_REPO_MAIN_BRANCH } from '../config' +import { Flow } from '../flow' + +export const enforcePrLabels: Flow = { + name: 'enforcePrLabels', + async onPullRequest(ctx, event) { + if (event.repository.full_name !== `${TLDRAW_ORG}/${TLDRAW_PUBLIC_REPO}`) return + if (event.pull_request.base.ref !== TLDRAW_PUBLIC_REPO_MAIN_BRANCH) return + + const fail = async (message: string) => { + await ctx.octokit.rest.repos.createCommitStatus({ + owner: event.repository.owner.login, + repo: event.repository.name, + sha: event.pull_request.head.sha, + state: 'failure', + description: message, + context: 'Release Label', + }) + } + + const succeed = async (message: string) => { + await ctx.octokit.rest.repos.createCommitStatus({ + owner: event.repository.owner.login, + repo: event.repository.name, + sha: event.pull_request.head.sha, + state: 'success', + description: message, + context: 'Release Label', + }) + } + + const pull = event.pull_request + + if (pull.draft) { + return await succeed('Draft PR, skipping label check') + } + + if (pull.closed_at || pull.merged_at) { + return await succeed('Closed PR, skipping label check') + } + + const currentReleaseLabels = pull.labels + .map((label) => label.name) + .filter((label) => VALID_LABELS.includes(label)) + + if (currentReleaseLabels.length > 1 && !allHaveSameBumpType(currentReleaseLabels)) { + return fail(`PR has multiple release labels: ${currentReleaseLabels.join(', ')}`) + } + + const prBody = pull.body + + const selectedReleaseLabels = VALID_LABELS.filter((label) => + prBody?.match(new RegExp(`^\\s*?-\\s*\\[\\s*x\\s*\\]\\s+\`${label}\``, 'm')) + ) as (keyof typeof LABEL_TYPES)[] + + if (selectedReleaseLabels.length > 1 && !allHaveSameBumpType(selectedReleaseLabels)) { + return await fail( + `PR has multiple checked labels: ${selectedReleaseLabels.join( + ', ' + )}. Please select only one` + ) + } + + const [current] = currentReleaseLabels + const [selected] = selectedReleaseLabels + + if (!current && !selected) { + return await fail( + `Please assign one of the following release labels to this PR: ${VALID_LABELS.join(', ')}` + ) + } + + if (current === selected || (current && !selected)) { + return succeed(`PR has label: ${current}`) + } + + // otherwise the label has changed or is being set for the first time + // from the pr body + if (current) { + await ctx.octokit.rest.issues.removeLabel({ + issue_number: event.number, + name: current, + owner: 'ds300', + repo: 'lazyrepo', + }) + } + + console.log('adding labels') + await ctx.octokit.rest.issues.addLabels({ + issue_number: pull.number, + owner: event.repository.organization ?? event.repository.owner.login, + repo: event.repository.name, + labels: [selected], + } as any) + + return await succeed(`PR label set to: ${selected}`) + }, +} + +const LABEL_TYPES = { + tests: 'none', + internal: 'none', + documentation: 'none', + dependencies: 'patch', + major: 'major', + minor: 'minor', + patch: 'patch', +} + +const VALID_LABELS = Object.keys(LABEL_TYPES) + +function allHaveSameBumpType(labels: string[]) { + const [first] = labels + return labels.every( + (label) => + LABEL_TYPES[label as keyof typeof LABEL_TYPES] === + LABEL_TYPES[first as keyof typeof LABEL_TYPES] + ) +} diff --git a/apps/huppy/src/flows/index.tsx b/apps/huppy/src/flows/index.tsx new file mode 100644 index 000000000..15ec9e47f --- /dev/null +++ b/apps/huppy/src/flows/index.tsx @@ -0,0 +1,5 @@ +import { collectClaSignatures } from './collectClaSignatures' +import { enforcePrLabels } from './enforcePrLabels' +import { standaloneExamplesBranch } from './standaloneExamplesBranch' + +export const allFlows = [enforcePrLabels, standaloneExamplesBranch, collectClaSignatures] diff --git a/apps/huppy/src/flows/standaloneExamplesBranch.tsx b/apps/huppy/src/flows/standaloneExamplesBranch.tsx new file mode 100644 index 000000000..f9b5b6a43 --- /dev/null +++ b/apps/huppy/src/flows/standaloneExamplesBranch.tsx @@ -0,0 +1,107 @@ +import { assert } from '@tldraw/utils' +import * as fs from 'fs/promises' +import * as path from 'path' +import { Flow } from '../flow' +import { withWorkingRepo } from '../repo' +import { readJsonIfExists } from '../utils' + +const filesToCopyFromRoot = ['.gitignore', '.prettierrc', 'LICENSE'] +const packageDepsToSyncFromRoot = ['typescript', '@types/react', '@types/react-dom'] + +export const standaloneExamplesBranch = { + name: 'standaloneExamplesBranch', + + onCustomHook: async (ctx, event) => { + await withWorkingRepo( + 'public', + ctx.installationToken, + event.tagToRelease, + async ({ git, repoPath }) => { + const standaloneExamplesWorkDir = path.join(repoPath, '.git', 'standalone-examples') + + const currentCommitHash = await git.trimmed('rev-parse', 'HEAD') + const branchName = `standalone-examples-${currentCommitHash}` + await git('checkout', '-b', branchName) + + // copy examples into new folder + console.log('Copying examples into new folder...') + for (const file of await git.lines('ls-files', 'apps/examples')) { + const relativePath = path.relative('apps/examples', file) + await fs.mkdir(path.join(standaloneExamplesWorkDir, path.dirname(relativePath)), { + recursive: true, + }) + await fs.copyFile( + path.join(repoPath, file), + path.join(standaloneExamplesWorkDir, relativePath) + ) + } + + for (const file of filesToCopyFromRoot) { + await fs.copyFile(path.join(repoPath, file), path.join(standaloneExamplesWorkDir, file)) + } + + console.log('Creation tsconfig.json...') + const tsconfig = await readJsonIfExists(path.join(repoPath, 'config/tsconfig.base.json')) + tsconfig.includes = ['src'] + await fs.writeFile( + path.join(standaloneExamplesWorkDir, 'tsconfig.json'), + JSON.stringify(tsconfig, null, '\t') + ) + + console.log('Creation package.json...') + const rootPackageJson = await readJsonIfExists(path.join(repoPath, 'package.json')) + const examplesPackageJson = await readJsonIfExists( + path.join(standaloneExamplesWorkDir, 'package.json') + ) + + for (const dep of packageDepsToSyncFromRoot) { + examplesPackageJson.dependencies[dep] = rootPackageJson.dependencies[dep] + } + + for (const name of Object.keys( + examplesPackageJson.dependencies as Record + )) { + if (!name.startsWith('@tldraw/')) continue + const packageJsonFile = await readJsonIfExists( + path.join(repoPath, 'packages', name.replace('@tldraw/', ''), 'package.json') + ) + assert(packageJsonFile, `package.json for ${name} must exist`) + if (event.canary) { + const baseVersion = packageJsonFile.version.replace(/-.*$/, '') + const canaryTag = `canary.${currentCommitHash.slice(0, 12)}` + examplesPackageJson.dependencies[name] = `${baseVersion}-${canaryTag}` + } else { + examplesPackageJson.dependencies[name] = packageJsonFile.version + } + } + + await fs.writeFile( + path.join(standaloneExamplesWorkDir, 'package.json'), + JSON.stringify(examplesPackageJson, null, '\t') + ) + + console.log('Deleting existing repo contents...') + for (const file of await fs.readdir(repoPath)) { + if (file === '.git') continue + await fs.rm(path.join(repoPath, file), { recursive: true, force: true }) + } + + console.log('Moving new repo contents into place...') + for (const file of await fs.readdir(standaloneExamplesWorkDir)) { + await fs.rename(path.join(standaloneExamplesWorkDir, file), path.join(repoPath, file)) + } + + await fs.rm(standaloneExamplesWorkDir, { recursive: true, force: true }) + + console.log('Committing & pushing changes...') + await git('add', '-A') + await git( + 'commit', + '-m', + `[automated] Update standalone examples from ${event.tagToRelease}` + ) + await git('push', '--force', 'origin', `${branchName}:examples`) + } + ) + }, +} satisfies Flow<{ tagToRelease: string; canary: boolean }> diff --git a/apps/huppy/src/getCtxForOrg.tsx b/apps/huppy/src/getCtxForOrg.tsx new file mode 100644 index 000000000..24ab4c2e7 --- /dev/null +++ b/apps/huppy/src/getCtxForOrg.tsx @@ -0,0 +1,21 @@ +import { Ctx } from './ctx' +import { getAppOctokit, getInstallationToken } from './octokit' + +export async function getCtxForOrg(orgName: string): Promise { + const app = getAppOctokit() + + for await (const { installation } of app.eachInstallation.iterator()) { + if (!installation.account) continue + if (!('login' in installation.account)) continue + if (installation.account.login !== orgName) continue + + return { + app, + installationId: installation.id, + octokit: await app.getInstallationOctokit(installation.id), + installationToken: await getInstallationToken(app, installation.id), + } + } + + throw new Error(`No installation found for org ${orgName}`) +} diff --git a/apps/huppy/src/octokit.ts b/apps/huppy/src/octokit.ts new file mode 100644 index 000000000..619ecaaed --- /dev/null +++ b/apps/huppy/src/octokit.ts @@ -0,0 +1,67 @@ +import { Octokit as OctokitCore } from '@octokit/core' +import { retry } from '@octokit/plugin-retry' +import { assert } from '@tldraw/utils' +import console from 'console' +import { App, Octokit } from 'octokit' +import { APP_ID } from './config' + +export function getGitHubAuth() { + const REPO_SYNC_PRIVATE_KEY_B64 = process.env.REPO_SYNC_PRIVATE_KEY_B64 + assert( + typeof REPO_SYNC_PRIVATE_KEY_B64 === 'string', + 'REPO_SYNC_PRIVATE_KEY_B64 must be a string' + ) + const REPO_SYNC_PRIVATE_KEY = Buffer.from(REPO_SYNC_PRIVATE_KEY_B64, 'base64').toString('utf-8') + + const REPO_SYNC_HOOK_SECRET = process.env.REPO_SYNC_HOOK_SECRET + assert(typeof REPO_SYNC_HOOK_SECRET === 'string', 'REPO_SYNC_HOOK_SECRET must be a string') + + return { + privateKey: REPO_SYNC_PRIVATE_KEY, + webhookSecret: REPO_SYNC_HOOK_SECRET, + } +} + +export async function getInstallationToken(gh: App, installationId: number) { + const { data } = await gh.octokit.rest.apps.createInstallationAccessToken({ + installation_id: installationId, + } as any) + return data.token +} + +function requestLogPlugin(octokit: OctokitCore) { + octokit.hook.wrap('request', async (request, options) => { + const url = options.url.replace(/{([^}]+)}/g, (_, key) => (options as any)[key]) + let info = `${options.method} ${url}` + if (options.request.retryCount) { + info += ` (retry ${options.request.retryCount})` + } + + try { + const result = await request(options) + console.log(`[gh] ${result.status} ${info}`) + return result + } catch (err: any) { + console.log(`[gh] ${err.status} ${info}`) + throw err + } + }) +} + +const OctokitWithRetry = Octokit.plugin(requestLogPlugin, retry).defaults({ + retry: { + // retry on 404s, which can occur if we make a request for a resource before it's ready + doNotRetry: [400, 401, 403, 422, 451], + }, +}) + +export function getAppOctokit() { + const { privateKey, webhookSecret } = getGitHubAuth() + return new App({ + Octokit: OctokitWithRetry, + appId: APP_ID, + privateKey, + webhooks: { secret: webhookSecret }, + log: console, + }) +} diff --git a/apps/huppy/src/repo.ts b/apps/huppy/src/repo.ts new file mode 100644 index 000000000..e45ee6055 --- /dev/null +++ b/apps/huppy/src/repo.ts @@ -0,0 +1,143 @@ +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { exec } from '../../../scripts/lib/exec' +import { Queue } from './Queue' +import { APP_USER_EMAIL, APP_USER_NAME, TLDRAW_ORG, TLDRAW_PUBLIC_REPO } from './config' + +const globalGitQueue = new Queue() + +const repos = { + public: { + org: TLDRAW_ORG, + name: TLDRAW_PUBLIC_REPO, + path: 'tldraw-public', + queue: new Queue(), + }, +} as const + +export function prefixOutput(prefix: string) { + return { + processStdoutLine: (line: string) => process.stdout.write(`${prefix}${line}\n`), + processStderrLine: (line: string) => process.stderr.write(`${prefix}${line}\n`), + } +} + +export function createGit(pwd: string) { + const git = async (command: string, ...args: (string | null)[]) => + exec('git', [command, ...args], { pwd, ...prefixOutput(`[git ${command}] `) }) + + git.trimmed = async (command: string, ...args: (string | null)[]) => + (await git(command, ...args)).trim() + + git.lines = async (command: string, ...args: (string | null)[]) => + (await git(command, ...args)).trim().split('\n') + + git.cd = (dir: string) => createGit(path.join(pwd, dir)) + + return git +} + +export type Git = ReturnType + +export async function getPersistentDataPath() { + try { + await fs.writeFile('/tldraw_repo_sync_data/check', 'ok') + return '/tldraw_repo_sync_data' + } catch { + const tempPersistent = path.join(os.tmpdir(), 'tldraw_repo_sync_data') + await fs.mkdir(tempPersistent, { recursive: true }) + return tempPersistent + } +} + +async function initBaseRepo(repoKey: keyof typeof repos, installationToken: string) { + const repo = repos[repoKey] + + const persistentDataPath = await getPersistentDataPath() + const repoPath = path.join(persistentDataPath, repo.path) + + try { + await fs.rm(repoPath, { recursive: true, force: true }) + } catch { + // dw + } + + const repoUrl = `https://x-access-token:${installationToken}@github.com/${repo.org}/${repo.name}.git` + await globalGitQueue.enqueue(() => exec('git', ['clone', '--mirror', repoUrl, repoPath])) +} + +export async function getBaseRepo(repoKey: keyof typeof repos, installationToken: string) { + const repo = repos[repoKey] + + return await repo.queue.enqueue(async () => { + const persistentDataPath = await getPersistentDataPath() + const repoPath = path.join(persistentDataPath, repo.path) + const git = createGit(repoPath) + + try { + await fs.readFile(path.join(repoPath, 'HEAD')) + } catch { + await initBaseRepo(repoKey, installationToken) + return { repo, path: repoPath } + } + + const remote = await git.trimmed('remote', 'get-url', 'origin') + if (!remote.endsWith(`@github.com/${repo.org}/${repo.name}.git`)) { + await initBaseRepo(repoKey, installationToken) + return { repo, path: repoPath } + } + + // update remote with a fresh JWT: + await git( + 'remote', + 'set-url', + 'origin', + `https://x-access-token:${installationToken}@github.com/${repo.org}/${repo.name}.git` + ) + + // make sure we're up to date with origin: + await git('remote', 'update') + + return { repo, path: repoPath } + }) +} + +export async function withWorkingRepo( + repoKey: keyof typeof repos, + installationToken: string, + ref: string, + fn: (opts: { repoPath: string; git: Git }) => Promise +) { + const { repo, path: repoPath } = await getBaseRepo(repoKey, installationToken) + const workingDir = path.join( + os.tmpdir(), + `tldraw_repo_sync_${Math.random().toString(36).slice(2)}` + ) + + await globalGitQueue.enqueue(() => exec('git', ['clone', '--no-checkout', repoPath, workingDir])) + const git = createGit(workingDir) + + try { + // update remote with a fresh JWT: + await git( + 'remote', + 'set-url', + 'origin', + `https://x-access-token:${installationToken}@github.com/${repo.org}/${repo.name}.git` + ) + + await git('checkout', ref) + await setLocalAuthorInfo(workingDir) + + return await fn({ repoPath: workingDir, git }) + } finally { + await fs.rm(workingDir, { recursive: true }) + } +} + +export async function setLocalAuthorInfo(pwd: string) { + const git = createGit(pwd) + await git('config', '--local', 'user.name', APP_USER_NAME) + await git('config', '--local', 'user.email', APP_USER_EMAIL) +} diff --git a/apps/huppy/src/reportError.tsx b/apps/huppy/src/reportError.tsx new file mode 100644 index 000000000..2b5eeed03 --- /dev/null +++ b/apps/huppy/src/reportError.tsx @@ -0,0 +1,22 @@ +import os from 'os' + +const discordWebhookUrl = process.env.HUPPY_WEBHOOK_URL + +export async function reportError(context: string, error: Error) { + if (typeof discordWebhookUrl === 'undefined') { + throw new Error('HUPPY_WEBHOOK_URL not set') + } + + const body = JSON.stringify({ + content: `[${os.hostname}] ${context}:\n\`\`\`\n${error.stack}\n\`\`\``, + }) + console.log(context, error.stack) + if (process.env.NODE_ENV !== 'production') return + await fetch(discordWebhookUrl, { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/json', + }, + }) +} diff --git a/apps/huppy/src/requestWrapper.tsx b/apps/huppy/src/requestWrapper.tsx new file mode 100644 index 000000000..6b22b66b2 --- /dev/null +++ b/apps/huppy/src/requestWrapper.tsx @@ -0,0 +1,16 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { reportError } from './reportError' + +export function wrapRequest( + name: string, + handler: (req: NextApiRequest, res: NextApiResponse) => Promise +) { + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + await handler(req, res as NextApiResponse) + } catch (err: any) { + reportError(`Error in ${name}`, err) + res.status(500).json({ error: err.message }) + } + } +} diff --git a/apps/huppy/src/utils.ts b/apps/huppy/src/utils.ts new file mode 100644 index 000000000..3d086559a --- /dev/null +++ b/apps/huppy/src/utils.ts @@ -0,0 +1,50 @@ +import * as fs from 'fs/promises' +import json5 from 'json5' +import { NextApiRequest } from 'next' + +export function header(req: NextApiRequest, name: keyof NextApiRequest['headers']): string { + const value = req.headers[name] + if (!value) { + throw new Error(`Missing header: ${name}`) + } + if (Array.isArray(value)) { + throw new Error(`Header ${name} has multiple values`) + } + return value +} + +export function firstLine(str: string) { + return str.split('\n')[0] +} + +export function sleepMs(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function camelCase(name: string) { + return name.replace(/[_-]([a-z0-9])/gi, (g) => g[1].toUpperCase()) +} + +export function capitalize(name: string) { + return name[0].toUpperCase() + name.slice(1) +} + +export function elapsed(start: number) { + return `${((Date.now() - start) / 1000).toFixed(2)}s` +} + +export async function readFileIfExists(file: string) { + try { + return await fs.readFile(file, 'utf8') + } catch { + return null + } +} + +export async function readJsonIfExists(file: string) { + const fileContents = await readFileIfExists(file) + if (fileContents === null) { + return null + } + return json5.parse(fileContents) +} diff --git a/apps/huppy/tsconfig.json b/apps/huppy/tsconfig.json new file mode 100644 index 000000000..24c60cfee --- /dev/null +++ b/apps/huppy/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "experimentalDecorators": true, + "downlevelIteration": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "_archive"], + "references": [{ "path": "../../packages/utils" }, { "path": "../../packages/validate" }] +} diff --git a/config/eslint-preset-react.js b/config/eslint-preset-react.js index b6a76291d..fd5257e8e 100644 --- a/config/eslint-preset-react.js +++ b/config/eslint-preset-react.js @@ -4,7 +4,7 @@ module.exports = { extends: ['prettier'], settings: { next: { - rootDir: ['apps/*/', 'packages/*/', 'bublic/apps/*/', 'bublic/packages/*/'], + rootDir: ['apps/*/', 'packages/*/'], }, }, rules: { diff --git a/config/eslint-preset.js b/config/eslint-preset.js index bd0cc958e..1fad2832a 100644 --- a/config/eslint-preset.js +++ b/config/eslint-preset.js @@ -4,7 +4,7 @@ module.exports = { extends: ['prettier'], settings: { next: { - rootDir: ['apps/*/', 'packages/*/', 'bublic/apps/*/', 'bublic/packages/*/'], + rootDir: ['apps/*/', 'packages/*/'], }, }, ignorePatterns: ['**/*.js'], diff --git a/lazy.config.ts b/lazy.config.ts index 82f638009..b39923632 100644 --- a/lazy.config.ts +++ b/lazy.config.ts @@ -1,13 +1,28 @@ import { LazyConfig } from 'lazyrepo' -export function generateSharedScripts(bublic: '' | '/bublic') { - return { +const config = { + baseCacheConfig: { + include: [ + '/package.json', + '/yarn.lock', + '/lazy.config.ts', + '/config/**/*', + '/scripts/**/*', + ], + exclude: [ + '/coverage/**/*', + '/dist*/**/*', + '**/*.tsbuildinfo', + '/docs/gen/**/*', + ], + }, + scripts: { build: { baseCommand: 'exit 0', runsAfter: { prebuild: {}, 'refresh-assets': {} }, workspaceOverrides: { - '{bublic/,}apps/vscode/*': { runsAfter: { 'refresh-assets': {} } }, - '{bublic/,}packages/*': { + 'apps/vscode/*': { runsAfter: { 'refresh-assets': {} } }, + 'packages/*': { runsAfter: { 'build-api': { in: 'self-only' }, prebuild: { in: 'self-only' } }, cache: { inputs: ['api/**/*', 'src/**/*'], @@ -20,7 +35,7 @@ export function generateSharedScripts(bublic: '' | '/bublic') runsAfter: { 'refresh-assets': {} }, cache: 'none', workspaceOverrides: { - '{bublic/,}apps/vscode/*': { runsAfter: { build: { in: 'self-only' } } }, + 'apps/vscode/*': { runsAfter: { build: { in: 'self-only' } } }, }, }, test: { @@ -50,14 +65,14 @@ export function generateSharedScripts(bublic: '' | '/bublic') }, 'refresh-assets': { execution: 'top-level', - baseCommand: `tsx ${bublic}/scripts/refresh-assets.ts`, + baseCommand: `tsx /scripts/refresh-assets.ts`, cache: { - inputs: ['package.json', `${bublic}/scripts/refresh-assets.ts`, `${bublic}/assets/**/*`], + inputs: ['package.json', `/scripts/refresh-assets.ts`, `/assets/**/*`], }, }, 'build-types': { execution: 'top-level', - baseCommand: `tsx ${bublic}/scripts/typecheck.ts`, + baseCommand: `tsx /scripts/typecheck.ts`, cache: { inputs: { include: ['/**/*.{ts,tsx}', '/tsconfig.json'], @@ -88,33 +103,12 @@ export function generateSharedScripts(bublic: '' | '/bublic') }, 'api-check': { execution: 'top-level', - baseCommand: `tsx ${bublic}/scripts/api-check.ts`, + baseCommand: `tsx /scripts/api-check.ts`, runsAfter: { 'build-api': {} }, cache: { - inputs: [`${bublic}/packages/*/api/public.d.ts`], + inputs: [`/packages/*/api/public.d.ts`], }, }, - } satisfies LazyConfig['scripts'] -} - -const config = { - baseCacheConfig: { - include: [ - '/{,bublic/}package.json', - '/{,bublic/}public-yarn.lock', - '/{,bublic/}lazy.config.ts', - '/{,bublic/}config/**/*', - '/{,bublic/}scripts/**/*', - ], - exclude: [ - '/coverage/**/*', - '/dist*/**/*', - '**/*.tsbuildinfo', - '/{,bublic/}docs/gen/**/*', - ], - }, - scripts: { - ...generateSharedScripts(''), }, } satisfies LazyConfig diff --git a/package.json b/package.json index 72b9bcdc2..210577b4a 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,12 @@ "clean": "scripts/clean.sh", "postinstall": "husky install && yarn refresh-assets", "refresh-assets": "lazy refresh-assets", + "dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw'", + "dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'", + "dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker}' --filter='packages/tldraw'", + "dev-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'", "build": "lazy build", - "dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='{,bublic/}apps/examples' --filter='{,bublic/}packages/tldraw'", - "dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='{,bublic/}apps/vscode/{extension,editor}'", + "build-app": "lazy run build --filter 'apps/dotcom'", "build-types": "lazy inherit", "build-api": "lazy build-api", "build-package": "lazy build-package", @@ -49,7 +52,8 @@ "check-scripts": "tsx scripts/check-scripts.ts", "api-check": "lazy api-check", "test": "lazy test", - "e2e": "lazy e2e --filter='{,bublic/}apps/examples'" + "test-coverage": "lazy test-coverage && node scripts/offer-coverage.mjs", + "e2e": "lazy e2e --filter='apps/examples'" }, "engines": { "npm": ">=7.0.0" @@ -100,6 +104,8 @@ "domino@^2.1.6": "patch:domino@npm%3A2.1.6#./.yarn/patches/domino-npm-2.1.6-b0dc3de857.patch" }, "dependencies": { + "@sentry/cli": "^2.25.0", + "cross-env": "^7.0.3", "purgecss": "^5.0.0", "svgo": "^3.0.2" } diff --git a/packages/state/LICENSE.md b/packages/state/LICENSE.md new file mode 100644 index 000000000..0ad7260cb --- /dev/null +++ b/packages/state/LICENSE.md @@ -0,0 +1 @@ +This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md) diff --git a/packages/store/LICENSE.md b/packages/store/LICENSE.md new file mode 100644 index 000000000..0ad7260cb --- /dev/null +++ b/packages/store/LICENSE.md @@ -0,0 +1 @@ +This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md) diff --git a/packages/tlsync/CHANGELOG.md b/packages/tlsync/CHANGELOG.md new file mode 100644 index 000000000..89b868bea --- /dev/null +++ b/packages/tlsync/CHANGELOG.md @@ -0,0 +1,168 @@ +# @tldraw/tlsync + +## 2.0.0-alpha.11 + +### Patch Changes + +- Updated dependencies + - @tldraw/tlschema@2.0.0-alpha.11 + - @tldraw/tlstore@2.0.0-alpha.11 + - @tldraw/utils@2.0.0-alpha.10 + +## 2.0.0-alpha.10 + +### Patch Changes + +- Updated dependencies [4b4399b6e] + - @tldraw/tlschema@2.0.0-alpha.10 + - @tldraw/tlstore@2.0.0-alpha.10 + - @tldraw/utils@2.0.0-alpha.9 + +## 2.0.0-alpha.9 + +### Patch Changes + +- Release day! +- Updated dependencies + - @tldraw/tlschema@2.0.0-alpha.9 + - @tldraw/tlstore@2.0.0-alpha.9 + - @tldraw/utils@2.0.0-alpha.8 + +## 2.0.0-alpha.8 + +### Patch Changes + +- 23dd81cfe: Make signia a peer dependency +- Updated dependencies [23dd81cfe] + - @tldraw/tlstore@2.0.0-alpha.8 + - @tldraw/tlschema@2.0.0-alpha.8 + +## 2.0.0-alpha.7 + +### Patch Changes + +- Bug fixes. +- Updated dependencies + - @tldraw/tlschema@2.0.0-alpha.7 + - @tldraw/tlstore@2.0.0-alpha.7 + - @tldraw/utils@2.0.0-alpha.7 + +## 2.0.0-alpha.6 + +### Patch Changes + +- Add licenses. +- Updated dependencies + - @tldraw/tlschema@2.0.0-alpha.6 + - @tldraw/tlstore@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/tlschema@2.0.0-alpha.5 + - @tldraw/tlstore@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/tlschema@2.0.0-alpha.4 + - @tldraw/tlstore@2.0.0-alpha.4 + - @tldraw/utils@2.0.0-alpha.4 + +## 2.0.0-alpha.3 + +### Patch Changes + +- Change permissions. +- Updated dependencies + - @tldraw/tlschema@2.0.0-alpha.3 + - @tldraw/tlstore@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/tlschema@2.0.0-alpha.2 + - @tldraw/tlstore@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/tlschema@0.1.0-alpha.11 + - @tldraw/tlstore@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/tlschema@0.1.0-alpha.10 + - @tldraw/tlstore@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/tlschema@0.1.0-alpha.9 + - @tldraw/tlstore@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/tlschema@0.1.0-alpha.8 + - @tldraw/tlstore@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/tlschema@0.1.0-alpha.7 + - @tldraw/tlstore@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/tlschema@0.0.2-alpha.1 + - @tldraw/tlstore@0.0.2-alpha.1 + - @tldraw/utils@0.0.2-alpha.1 + +## 0.0.2-alpha.0 + +### Patch Changes + +- Initial release +- Updated dependencies + - @tldraw/tlschema@0.0.2-alpha.0 + - @tldraw/tlstore@0.0.2-alpha.0 + - @tldraw/utils@0.0.2-alpha.0 diff --git a/packages/tlsync/LICENSE.md b/packages/tlsync/LICENSE.md new file mode 100644 index 000000000..0ad7260cb --- /dev/null +++ b/packages/tlsync/LICENSE.md @@ -0,0 +1 @@ +This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md) diff --git a/packages/tlsync/README.md b/packages/tlsync/README.md new file mode 100644 index 000000000..07d091e48 --- /dev/null +++ b/packages/tlsync/README.md @@ -0,0 +1 @@ +# @tldraw/tlsync diff --git a/packages/tlsync/api-extractor.json b/packages/tlsync/api-extractor.json new file mode 100644 index 000000000..f1ed80e93 --- /dev/null +++ b/packages/tlsync/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../config/api-extractor.json" +} diff --git a/packages/tlsync/api-report.md b/packages/tlsync/api-report.md new file mode 100644 index 000000000..5e6f14679 --- /dev/null +++ b/packages/tlsync/api-report.md @@ -0,0 +1,296 @@ +## API Report File for "@tldraw/tlsync" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Atom } from 'signia'; +import { BaseRecord } from '@tldraw/tlstore'; +import { MigrationFailureReason } from '@tldraw/tlstore'; +import { RecordsDiff } from '@tldraw/tlstore'; +import { RecordType } from '@tldraw/tlstore'; +import { Result } from '@tldraw/utils'; +import { SerializedSchema } from '@tldraw/tlstore'; +import { Signal } from 'signia'; +import { Store } from '@tldraw/tlstore'; +import { StoreSchema } from '@tldraw/tlstore'; +import * as WebSocket_2 from 'ws'; + +// @public (undocumented) +export type AppendOp = [type: ValueOpType.Append, values: unknown[], offset: number]; + +// @public (undocumented) +export function applyObjectDiff(object: T, objectDiff: ObjectDiff): T; + +// @public (undocumented) +export type DeleteOp = [type: ValueOpType.Delete]; + +// @public (undocumented) +export function diffRecord(prev: object, next: object): null | ObjectDiff; + +// @public +export const getNetworkDiff: >(diff: RecordsDiff) => NetworkDiff | null; + +// @public +export type NetworkDiff = { + [id: string]: RecordOp; +}; + +// @public (undocumented) +export type ObjectDiff = { + [k: string]: ValueOp; +}; + +// @public (undocumented) +export type PatchOp = [type: ValueOpType.Patch, diff: ObjectDiff]; + +// @public (undocumented) +export type PersistedRoomSnapshot = { + id: string; + slug: string; + drawing: RoomSnapshot; +}; + +// @public (undocumented) +export type PutOp = [type: ValueOpType.Put, value: unknown]; + +// @public (undocumented) +export type RecordOp = [RecordOpType.Patch, ObjectDiff] | [RecordOpType.Put, R] | [RecordOpType.Remove]; + +// @public (undocumented) +export enum RecordOpType { + // (undocumented) + Patch = "patch", + // (undocumented) + Put = "put", + // (undocumented) + Remove = "remove" +} + +// @public (undocumented) +export type RoomClient = { + serializedSchema: SerializedSchema; + socket: TLRoomSocket; + id: string; +}; + +// @public (undocumented) +export type RoomForId = { + id: string; + persistenceId?: string; + timeout?: any; + room: TLSyncRoom; + clients: Map>; +}; + +// @public (undocumented) +export type RoomSnapshot = { + clock: number; + documents: Array<{ + state: BaseRecord; + lastChangedClock: number; + }>; + tombstones?: Record; + schema?: SerializedSchema; +}; + +// @public +export function serializeMessage(message: Message): string; + +// @public (undocumented) +export type TLConnectRequest = { + type: 'connect'; + lastServerClock: number; + protocolVersion: number; + schema: SerializedSchema; + instanceId: string; +}; + +// @public (undocumented) +export enum TLIncompatibilityReason { + // (undocumented) + ClientTooOld = "clientTooOld", + // (undocumented) + InvalidOperation = "invalidOperation", + // (undocumented) + InvalidRecord = "invalidRecord", + // (undocumented) + ServerTooOld = "serverTooOld" +} + +// @public +export type TLPersistentClientSocket = { + connectionStatus: 'error' | 'offline' | 'online'; + sendMessage: (msg: TLSocketClientSentEvent) => void; + onReceiveMessage: SubscribingFn>; + onStatusChange: SubscribingFn; + restart: () => void; +}; + +// @public (undocumented) +export type TLPersistentClientSocketStatus = 'error' | 'offline' | 'online'; + +// @public (undocumented) +export type TLPingRequest = { + type: 'ping'; +}; + +// @public (undocumented) +export type TLPushRequest = { + type: 'push'; + clientClock: number; + diff: NetworkDiff; +} | { + type: 'push'; + clientClock: number; + presence: [RecordOpType.Patch, ObjectDiff] | [RecordOpType.Put, R]; +}; + +// @public (undocumented) +export type TLRoomSocket = { + isOpen: boolean; + sendMessage: (msg: TLSocketServerSentEvent) => void; +}; + +// @public +export abstract class TLServer { + abstract deleteRoomForId(roomId: string): void; + abstract getRoomForId(roomId: string): RoomForId | undefined; + handleConnection: (ws: WebSocket_2.WebSocket, roomId: string) => Promise; + loadFromDatabase?(roomId: string): Promise; + logEvent?(_event: { + roomId: string; + name: string; + clientId?: string; + }): void; + persistToDatabase?(roomId: string): Promise; + abstract setRoomForId(roomId: string, roomForId: RoomForId): void; +} + +// @public (undocumented) +export type TLSocketClientSentEvent = TLConnectRequest | TLPingRequest | TLPushRequest; + +// @public (undocumented) +export type TLSocketServerSentEvent = { + type: 'connect'; + hydrationType: 'wipe_all' | 'wipe_presence'; + protocolVersion: number; + schema: SerializedSchema; + diff: NetworkDiff; + serverClock: number; +} | { + type: 'error'; + error?: any; +} | { + type: 'incompatibility_error'; + reason: TLIncompatibilityReason; +} | { + type: 'patch'; + diff: NetworkDiff; + serverClock: number; +} | { + type: 'pong'; +} | { + type: 'push_result'; + clientClock: number; + serverClock: number; + action: 'commit' | 'discard' | { + rebaseWithDiff: NetworkDiff; + }; +}; + +// @public (undocumented) +export const TLSYNC_PROTOCOL_VERSION = 3; + +// @public +export class TLSyncClient = Store> { + constructor(config: { + store: S; + socket: TLPersistentClientSocket; + instanceId: string; + onLoad: (self: TLSyncClient) => void; + onLoadError: (error: Error) => void; + onSyncError: (reason: TLIncompatibilityReason) => void; + onAfterConnect?: (self: TLSyncClient) => void; + }); + // (undocumented) + close(): void; + // (undocumented) + incomingDiffBuffer: Extract, { + type: 'patch' | 'push_result'; + }>[]; + readonly instanceId: string; + // (undocumented) + isConnectedToRoom: boolean; + // (undocumented) + lastPushedPresenceState: null | R; + readonly onAfterConnect?: (self: TLSyncClient) => void; + // (undocumented) + readonly onSyncError: (reason: TLIncompatibilityReason) => void; + // (undocumented) + readonly presenceState: Signal; + // (undocumented) + readonly socket: TLPersistentClientSocket; + // (undocumented) + readonly store: S; +} + +// @public +export class TLSyncRoom { + constructor(schema: StoreSchema, snapshot?: RoomSnapshot); + addClient(client: RoomClient): void; + broadcastPatch({ diff, sourceClient }: { + diff: NetworkDiff; + sourceClient: RoomClient; + }): this; + // (undocumented) + clientIdsToInstanceIds: Map; + // (undocumented) + clients: Map>; + // (undocumented) + clock: number; + // (undocumented) + readonly documentTypes: Set; + // (undocumented) + getSnapshot(): RoomSnapshot; + handleClose: (client: RoomClient) => void; + handleConnection: (client: RoomClient) => this; + handleMessage: (client: RoomClient, message: TLSocketClientSentEvent) => Promise; + migrateDiffForClient(client: RoomClient, diff: NetworkDiff): Result, MigrationFailureReason>; + // (undocumented) + readonly presenceType: RecordType; + // (undocumented) + pruneTombstones(): void; + removeClient(client: RoomClient): void; + // (undocumented) + readonly schema: StoreSchema; + sendMessageToClient(client: RoomClient, message: TLSocketServerSentEvent): this; + // (undocumented) + readonly serializedSchema: SerializedSchema; + // (undocumented) + state: Atom<{ + documents: Record>; + tombstones: Record; + }, unknown>; + // (undocumented) + tombstoneHistoryStartsAtClock: number; +} + +// @public (undocumented) +export type ValueOp = AppendOp | DeleteOp | PatchOp | PutOp; + +// @public (undocumented) +export enum ValueOpType { + // (undocumented) + Append = "append", + // (undocumented) + Delete = "delete", + // (undocumented) + Patch = "patch", + // (undocumented) + Put = "put" +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/tlsync/package.json b/packages/tlsync/package.json new file mode 100644 index 000000000..bcf5a7c9c --- /dev/null +++ b/packages/tlsync/package.json @@ -0,0 +1,68 @@ +{ + "name": "@tldraw/tlsync", + "description": "A tiny little drawing app (multiplayer sync).", + "version": "2.0.0-alpha.11", + "private": true, + "packageManager": "yarn@3.5.0", + "author": { + "name": "tldraw GB Ltd.", + "email": "hello@tldraw.com" + }, + "homepage": "https://tldraw.dev", + "license": "SEE LICENSE IN LICENSE.md", + "repository": { + "type": "git", + "url": "https://github.com/tldraw/tldraw" + }, + "bugs": { + "url": "https://github.com/tldraw/tldraw/issues" + }, + "keywords": [ + "tldraw", + "drawing", + "app", + "development", + "whiteboard", + "canvas", + "infinite" + ], + "/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish", + "main": "./src/index.ts", + "types": "./.tsbuild/index.d.ts", + "/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here", + "files": [], + "scripts": { + "test": "lazy inherit", + "test-coverage": "lazy inherit", + "lint": "yarn run -T tsx ../../scripts/lint.ts" + }, + "devDependencies": { + "@tldraw/tldraw": "workspace:*", + "typescript": "^5.0.2", + "uuid-by-string": "^4.0.0", + "uuid-readable": "^0.0.2" + }, + "jest": { + "preset": "config/jest/node", + "testEnvironment": "jsdom", + "moduleNameMapper": { + "^~(.*)": "/src/$1" + }, + "transformIgnorePatterns": [ + "ignore everything. swc is fast enough to transform everything" + ], + "setupFiles": [ + "./setupJest.js" + ] + }, + "dependencies": { + "@tldraw/state": "workspace:*", + "@tldraw/store": "workspace:*", + "@tldraw/tlschema": "workspace:*", + "@tldraw/utils": "workspace:*", + "lodash.isequal": "^4.5.0", + "nanoevents": "^7.0.1", + "nanoid": "4.0.2", + "ws": "^8.16.0" + } +} diff --git a/packages/tlsync/setupJest.js b/packages/tlsync/setupJest.js new file mode 100644 index 000000000..b61be23d3 --- /dev/null +++ b/packages/tlsync/setupJest.js @@ -0,0 +1,13 @@ +require('fake-indexeddb/auto') +global.ResizeObserver = require('resize-observer-polyfill') +global.crypto ??= new (require('@peculiar/webcrypto').Crypto)() +global.FontFace = class FontFace { + load() { + return Promise.resolve() + } +} +document.fonts = { + add: () => {}, + delete: () => {}, + forEach: () => {}, +} diff --git a/packages/tlsync/src/index.ts b/packages/tlsync/src/index.ts new file mode 100644 index 000000000..417949046 --- /dev/null +++ b/packages/tlsync/src/index.ts @@ -0,0 +1,35 @@ +export { TLServer, type DBLoadResult } from './lib/TLServer' +export { + TLSyncClient, + type TLPersistentClientSocket, + type TLPersistentClientSocketStatus, +} from './lib/TLSyncClient' +export { TLSyncRoom, type RoomSnapshot, type TLRoomSocket } from './lib/TLSyncRoom' +export { chunk } from './lib/chunk' +export { + RecordOpType, + ValueOpType, + applyObjectDiff, + diffRecord, + getNetworkDiff, + type AppendOp, + type DeleteOp, + type NetworkDiff, + type ObjectDiff, + type PatchOp, + type PutOp, + type RecordOp, + type ValueOp, +} from './lib/diff' +export { + TLIncompatibilityReason, + TLSYNC_PROTOCOL_VERSION, + type TLConnectRequest, + type TLPingRequest, + type TLPushRequest, + type TLSocketClientSentEvent, + type TLSocketServerSentEvent, +} from './lib/protocol' +export { schema } from './lib/schema' +export { serializeMessage } from './lib/serializeMessage' +export type { PersistedRoomSnapshotForSupabase, RoomState as RoomState } from './lib/server-types' diff --git a/packages/tlsync/src/lib/RoomSession.ts b/packages/tlsync/src/lib/RoomSession.ts new file mode 100644 index 000000000..a9152f1fa --- /dev/null +++ b/packages/tlsync/src/lib/RoomSession.ts @@ -0,0 +1,36 @@ +import { SerializedSchema, UnknownRecord } from '@tldraw/store' +import { TLRoomSocket } from './TLSyncRoom' + +export enum RoomSessionState { + AWAITING_CONNECT_MESSAGE = 'awaiting-connect-message', + AWAITING_REMOVAL = 'awaiting-removal', + CONNECTED = 'connected', +} + +export const SESSION_START_WAIT_TIME = 10000 +export const SESSION_REMOVAL_WAIT_TIME = 10000 +export const SESSION_IDLE_TIMEOUT = 20000 + +export type RoomSession = + | { + state: RoomSessionState.AWAITING_CONNECT_MESSAGE + sessionKey: string + presenceId: string + socket: TLRoomSocket + sessionStartTime: number + } + | { + state: RoomSessionState.AWAITING_REMOVAL + sessionKey: string + presenceId: string + socket: TLRoomSocket + cancellationTime: number + } + | { + state: RoomSessionState.CONNECTED + sessionKey: string + presenceId: string + socket: TLRoomSocket + serializedSchema: SerializedSchema + lastInteractionTime: number + } diff --git a/packages/tlsync/src/lib/ServerSocketAdapter.ts b/packages/tlsync/src/lib/ServerSocketAdapter.ts new file mode 100644 index 000000000..9f6ed134b --- /dev/null +++ b/packages/tlsync/src/lib/ServerSocketAdapter.ts @@ -0,0 +1,20 @@ +import { UnknownRecord } from '@tldraw/store' +import ws from 'ws' +import { TLRoomSocket } from './TLSyncRoom' +import { TLSocketServerSentEvent } from './protocol' +import { serializeMessage } from './serializeMessage' + +/** @public */ +export class ServerSocketAdapter implements TLRoomSocket { + constructor(public readonly ws: WebSocket | ws.WebSocket) {} + // eslint-disable-next-line no-restricted-syntax + get isOpen(): boolean { + return this.ws.readyState === 1 // ready state open + } + sendMessage(msg: TLSocketServerSentEvent) { + this.ws.send(serializeMessage(msg)) + } + close() { + this.ws.close() + } +} diff --git a/packages/tlsync/src/lib/TLServer.ts b/packages/tlsync/src/lib/TLServer.ts new file mode 100644 index 000000000..5389a3410 --- /dev/null +++ b/packages/tlsync/src/lib/TLServer.ts @@ -0,0 +1,267 @@ +import { nanoid } from 'nanoid' +import * as WebSocket from 'ws' +import { ServerSocketAdapter } from './ServerSocketAdapter' +import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom' +import { JsonChunkAssembler } from './chunk' +import { schema } from './schema' +import { RoomState } from './server-types' + +type LoadKind = 'new' | 'reopen' | 'open' +export type DBLoadResult = + | { + type: 'error' + error?: Error | undefined + } + | { + type: 'room_found' + snapshot: RoomSnapshot + } + | { + type: 'room_not_found' + } + +/** + * This class manages rooms for a websocket server. + * + * @public + */ +export abstract class TLServer { + schema = schema + + async getInitialRoomState(persistenceKey: string): Promise<[RoomState, LoadKind]> { + let roomState = this.getRoomForPersistenceKey(persistenceKey) + + let roomOpenKind = 'open' as 'open' | 'reopen' | 'new' + + // If no room exists for the id, create one + if (roomState === undefined) { + // Try to load a room from persistence + if (this.loadFromDatabase) { + const data = await this.loadFromDatabase(persistenceKey) + if (data.type === 'error') { + throw data.error + } + + if (data.type === 'room_found') { + roomOpenKind = 'reopen' + + roomState = { + persistenceKey, + room: new TLSyncRoom(this.schema, data.snapshot), + } + } + } + + // If we still don't have a room, create a new one + if (roomState === undefined) { + roomOpenKind = 'new' + + roomState = { + persistenceKey, + room: new TLSyncRoom(this.schema), + } + } + + const thisRoom = roomState.room + + roomState.room.events.on('room_became_empty', async () => { + // Record that the room is now empty + const roomState = this.getRoomForPersistenceKey(persistenceKey) + if (!roomState || roomState.room !== thisRoom) { + // room was already closed + return + } + this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_empty' }) + this.deleteRoomState(persistenceKey) + roomState.room.close() + + try { + await this.persistToDatabase?.(persistenceKey) + } catch (err) { + this.logEvent({ type: 'room', roomId: persistenceKey, name: 'fail_persist' }) + console.error('failed to save to storage', err) + } + }) + + // persist on an interval... + this.setRoomState(persistenceKey, roomState) + + // If we created a new room, then persist to the database again; + // we may have run migrations or cleanup, so let's make sure that + // the new data is put back into the database. + this.persistToDatabase?.(persistenceKey) + } + + return [roomState, roomOpenKind] + } + + /** + * When a connection comes in, set up the client and event listeners for the client's room. The + * roomId is the websocket's protocol. + * + * @param ws - The client's websocket connection. + * @public + */ + handleConnection = async ({ + socket, + persistenceKey, + sessionKey, + storeId, + }: { + socket: WebSocket.WebSocket + persistenceKey: string + sessionKey: string + storeId: string + }) => { + const clientId = nanoid() + const [roomState, roomOpenKind] = await this.getInitialRoomState(persistenceKey) + + roomState.room.handleNewSession(sessionKey, new ServerSocketAdapter(socket)) + + if (roomOpenKind === 'new' || roomOpenKind === 'reopen') { + // Record that the room is now active + this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_start' }) + + // Record what kind of room start event this is (why don't we extend the previous event? or even remove it?) + this.logEvent({ + type: 'client', + roomId: persistenceKey, + name: roomOpenKind === 'new' ? 'room_create' : 'room_reopen', + clientId, + instanceId: sessionKey, + localClientId: storeId, + }) + } + + // Record that the user entered the room + this.logEvent({ + type: 'client', + roomId: persistenceKey, + name: 'enter', + clientId, + instanceId: sessionKey, + localClientId: storeId, + }) + + // Handle a 'message' event from the server. + const assembler = new JsonChunkAssembler() + const handleMessageFromClient = (event: WebSocket.MessageEvent) => { + try { + if (typeof event.data === 'string') { + const res = assembler.handleMessage(event.data) + if (res?.data) { + roomState.room.handleMessage(sessionKey, res.data as any) + } + if (res?.error) { + console.warn('Error assembling message', res.error) + } + } else { + console.warn('Unknown message type', typeof event.data) + } + } catch (e) { + console.error(e) + socket.send(JSON.stringify({ type: 'error', error: e })) + socket.close(400) + } + } + + const handleCloseOrErrorFromClient = () => { + // Remove the client from the room and delete associated user data. + roomState?.room.handleClose(sessionKey) + } + + const unsub = roomState.room.events.on('session_removed', async (ev) => { + // Record who the last person to leave the room was + if (sessionKey !== ev.sessionKey) return + unsub() + this.logEvent({ + type: 'client', + roomId: persistenceKey, + name: 'leave', + clientId, + instanceId: sessionKey, + localClientId: storeId, + }) + this.logEvent({ + type: 'client', + roomId: persistenceKey, + name: 'last_out', + clientId, + instanceId: sessionKey, + localClientId: storeId, + }) + + socket.removeEventListener('message', handleMessageFromClient) + socket.removeEventListener('close', handleCloseOrErrorFromClient) + socket.removeEventListener('error', handleCloseOrErrorFromClient) + }) + + socket.addEventListener('message', handleMessageFromClient) + socket.addEventListener('close', handleCloseOrErrorFromClient) + socket.addEventListener('error', handleCloseOrErrorFromClient) + } + + /** + * Load data from a database. (Optional) + * + * @param roomId - The id of the room to load. + * @public + */ + abstract loadFromDatabase?(roomId: string): Promise + + /** + * Persist data to a database. (Optional) + * + * @param roomId - The id of the room to load. + * @public + */ + abstract persistToDatabase?(roomId: string): Promise + + /** + * Log an event. (Optional) + * + * @param event - The event to log. + * @public + */ + abstract logEvent( + event: + | { + type: 'client' + roomId: string + name: string + clientId: string + instanceId: string + localClientId: string + } + | { + type: 'room' + roomId: string + name: string + } + ): void + + /** + * Get a room by its id. + * + * @param persistenceKey - The id of the room to get. + * @public + */ + abstract getRoomForPersistenceKey(persistenceKey: string): RoomState | undefined + + /** + * Set a room to an id. + * + * @param persistenceKey - The id of the room to set. + * @param roomState - The room to set. + * @public + */ + abstract setRoomState(persistenceKey: string, roomState: RoomState): void + + /** + * Delete a room by its id. + * + * @param persistenceKey - The id of the room to delete. + * @public + */ + abstract deleteRoomState(persistenceKey: string): void +} diff --git a/packages/tlsync/src/lib/TLSyncClient.ts b/packages/tlsync/src/lib/TLSyncClient.ts new file mode 100644 index 000000000..133059d61 --- /dev/null +++ b/packages/tlsync/src/lib/TLSyncClient.ts @@ -0,0 +1,590 @@ +import { Signal, react, transact } from '@tldraw/state' +import { + RecordId, + RecordsDiff, + Store, + UnknownRecord, + reverseRecordsDiff, + squashRecordDiffs, +} from '@tldraw/store' +import { exhaustiveSwitchError, objectMapEntries, rafThrottle } from '@tldraw/utils' +import isEqual from 'lodash.isequal' +import { nanoid } from 'nanoid' +import { NetworkDiff, RecordOpType, applyObjectDiff, diffRecord, getNetworkDiff } from './diff' +import { interval } from './interval' +import { + TLIncompatibilityReason, + TLPushRequest, + TLSYNC_PROTOCOL_VERSION, + TLSocketClientSentEvent, + TLSocketServerSentEvent, +} from './protocol' +import './requestAnimationFrame.polyfill' + +type SubscribingFn = (cb: (val: T) => void) => () => void + +/** @public */ +export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error' +/** + * A socket that can be used to send and receive messages to the server. It should handle staying + * open and reconnecting when the connection is lost. In actual client code this will be a wrapper + * around a websocket or socket.io or something similar. + * + * @public + */ +export type TLPersistentClientSocket = { + /** Whether there is currently an open connection to the server. */ + connectionStatus: 'online' | 'offline' | 'error' + /** Send a message to the server */ + sendMessage: (msg: TLSocketClientSentEvent) => void + /** Attach a listener for messages sent by the server */ + onReceiveMessage: SubscribingFn> + /** Attach a listener for connection status changes */ + onStatusChange: SubscribingFn + /** Restart the connection */ + restart: () => void +} + +const PING_INTERVAL = 5000 +const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING_INTERVAL * 2 + +// Should connect support chunking the response to allow for large payloads? + +/** + * TLSyncClient manages syncing data in a local Store with a remote server. + * + * It uses a git-style push/pull/rebase model. + * + * @public + */ +export class TLSyncClient = Store> { + /** The last clock time from the most recent server update */ + private lastServerClock = 0 + private lastServerInteractionTimestamp = Date.now() + + /** The queue of in-flight push requests that have not yet been acknowledged by the server */ + private pendingPushRequests: { request: TLPushRequest; sent: boolean }[] = [] + + /** + * The diff of 'unconfirmed', 'optimistic' changes that have been made locally by the user if we + * take this diff, reverse it, and apply that to the store, our store will match exactly the most + * recent state of the server that we know about + */ + private speculativeChanges: RecordsDiff = { + added: {} as any, + updated: {} as any, + removed: {} as any, + } + + private disposables: Array<() => void> = [] + + readonly store: S + readonly socket: TLPersistentClientSocket + + readonly presenceState: Signal | undefined + + // isOnline is true when we have an open socket connection and we have + // established a connection with the server room (i.e. we have received a 'connect' message) + isConnectedToRoom = false + + /** + * The client clock is essentially a counter for push requests Each time a push request is created + * the clock is incremented. This clock is sent with the push request to the server, and the + * server returns it with the response so that we can match up the response with the request. + * + * The clock may also be used at one point in the future to allow the client to re-send push + * requests idempotently (i.e. the server will keep track of each client's clock and not execute + * requests it has already handled), but at the time of writing this is neither needed nor + * implemented. + * + * @public + */ + private clientClock = 0 + + /** + * Called immediately after a connect acceptance has been received and processed Use this to make + * any changes to the store that are required to keep it operational + */ + public readonly onAfterConnect?: (self: TLSyncClient, isNew: boolean) => void + public readonly onSyncError: (reason: TLIncompatibilityReason) => void + + private isDebugging = false + private debug(...args: any[]) { + if (this.isDebugging) { + // eslint-disable-next-line no-console + console.debug(...args) + } + } + + private readonly presenceType: R['typeName'] + + didCancel?: () => boolean + + constructor(config: { + store: S + socket: TLPersistentClientSocket + presence: Signal + onLoad: (self: TLSyncClient) => void + onLoadError: (error: Error) => void + onSyncError: (reason: TLIncompatibilityReason) => void + onAfterConnect?: (self: TLSyncClient, isNew: boolean) => void + didCancel?: () => boolean + }) { + this.didCancel = config.didCancel + + this.presenceType = config.store.scopedTypes.presence.values().next().value + if (!this.presenceType || config.store.scopedTypes.presence.size > 1) { + throw new Error('Store must have exactly one presence type') + } + + if (typeof window !== 'undefined') { + ;(window as any).tlsync = this + } + this.store = config.store + this.socket = config.socket + this.onAfterConnect = config.onAfterConnect + this.onSyncError = config.onSyncError + + let didLoad = false + + this.presenceState = config.presence + + this.disposables.push( + // when local 'user' changes are made, send them to the server + // or stash them locally in offline mode + this.store.listen( + ({ changes }) => { + if (this.didCancel?.()) return this.close() + this.debug('received store changes', { changes }) + this.push(changes) + }, + { source: 'user', scope: 'document' } + ), + // when the server sends us events, handle them + this.socket.onReceiveMessage((msg) => { + if (this.didCancel?.()) return this.close() + this.debug('received message from server', msg) + this.handleServerEvent(msg) + // the first time we receive a message from the server, we should trigger + + // one of the load callbacks + if (!didLoad) { + didLoad = true + if (msg.type === 'error') { + config.onLoadError(msg.error) + } else { + config.onLoad(this) + } + } + }), + // handle switching between online and offline + this.socket.onStatusChange((status) => { + if (this.didCancel?.()) return this.close() + this.debug('socket status changed', status) + if (status === 'online') { + this.sendConnectMessage() + } else { + this.resetConnection() + // if we reached here before connecting to the server + // it's a socket error, mostly likely the server is down or + // it's the wrong url. + if (status === 'error' && !didLoad) { + didLoad = true + config.onLoadError(new Error('socket error')) + } + } + }), + // Send a ping every PING_INTERVAL ms while online + interval(() => { + if (this.didCancel?.()) return this.close() + this.debug('ping loop', { isConnectedToRoom: this.isConnectedToRoom }) + if (!this.isConnectedToRoom) return + try { + this.socket.sendMessage({ type: 'ping' }) + } catch (error) { + console.warn('ping failed, resetting', error) + this.resetConnection() + } + }, PING_INTERVAL), + // Check the server connection health, reset the connection if needed + interval(() => { + if (this.didCancel?.()) return this.close() + this.debug('health check loop', { isConnectedToRoom: this.isConnectedToRoom }) + if (!this.isConnectedToRoom) return + const timeSinceLastServerInteraction = Date.now() - this.lastServerInteractionTimestamp + + if ( + timeSinceLastServerInteraction < + MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION + ) { + this.debug('health check passed', { timeSinceLastServerInteraction }) + // last ping was recent, so no need to take any action + return + } + + console.warn(`Haven't heard from the server in a while, resetting connection...`) + this.resetConnection() + }, PING_INTERVAL * 2) + ) + + if (this.presenceState) { + this.disposables.push( + react('pushPresence', () => { + if (this.didCancel?.()) return this.close() + this.pushPresence(this.presenceState!.get()) + }) + ) + } + // if the socket is already online before this client was instantiated + // then we should send a connect message right away + if (this.socket.connectionStatus === 'online') { + this.sendConnectMessage() + } + } + + latestConnectRequestId: string | null = null + + /** + * This is the first message that is sent over a newly established socket connection. And we need + * to wait for the response before this client can be used. + */ + private sendConnectMessage() { + if (this.isConnectedToRoom) { + console.error('sendConnectMessage called while already connected') + return + } + this.debug('sending connect message') + this.latestConnectRequestId = nanoid() + this.socket.sendMessage({ + type: 'connect', + connectRequestId: this.latestConnectRequestId, + schema: this.store.schema.serialize(), + protocolVersion: TLSYNC_PROTOCOL_VERSION, + lastServerClock: this.lastServerClock, + }) + } + + /** Switch to offline mode */ + private resetConnection(hard = false) { + this.debug('resetting connection') + if (hard) { + this.lastServerClock = 0 + } + // kill all presence state + this.store.remove(Object.keys(this.store.serialize('presence')) as any) + this.lastPushedPresenceState = null + this.isConnectedToRoom = false + this.pendingPushRequests = [] + this.incomingDiffBuffer = [] + if (this.socket.connectionStatus === 'online') { + this.socket.restart() + } + } + + /** + * Invoked when the socket connection comes online, either for the first time or as the result of + * a reconnect. The goal is to rebase on the server's state and fire off a new push request for + * any local changes that were made while offline. + */ + private didReconnect(event: Extract, { type: 'connect' }>) { + this.debug('did reconnect', event) + if (event.connectRequestId !== this.latestConnectRequestId) { + // ignore connect events for old connect requests + return + } + this.latestConnectRequestId = null + + if (this.isConnectedToRoom) { + console.error('didReconnect called while already connected') + this.resetConnection(true) + return + } + if (this.pendingPushRequests.length > 0) { + console.error('pendingPushRequests should already be empty when we reconnect') + this.resetConnection(true) + return + } + // at the end of this process we want to have at most one pending push request + // based on anything inside this.speculativeChanges + transact(() => { + // Now our goal is to rebase on the server's state. + // This means wiping away any peer presence data, which the server will replace in full on every connect. + // If the server does not have enough history to give us a partial document state hydration we will + // also need to wipe away all of our document state before hydrating with the server's state from scratch. + const stashedChanges = this.speculativeChanges + this.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any } + + this.store.mergeRemoteChanges(() => { + // gather records to delete in a NetworkDiff + const wipeDiff: NetworkDiff = {} + const wipeAll = event.hydrationType === 'wipe_all' + if (!wipeAll) { + // if we're only wiping presence data, undo the speculative changes first + this.store.applyDiff(reverseRecordsDiff(stashedChanges), false) + } + + // now wipe all presence data and, if needed, all document data + for (const [id, record] of objectMapEntries(this.store.serialize('all'))) { + if ( + (wipeAll && this.store.scopedTypes.document.has(record.typeName)) || + record.typeName === this.presenceType + ) { + wipeDiff[id] = [RecordOpType.Remove] + } + } + + // then apply the upstream changes + this.applyNetworkDiff({ ...wipeDiff, ...event.diff }, true) + }) + + // now re-apply the speculative changes as a 'user' to trigger + // creating a new push request with the appropriate diff + this.isConnectedToRoom = true + this.store.applyDiff(stashedChanges) + + this.store.ensureStoreIsUsable() + // TODO: reinstate isNew + this.onAfterConnect?.(this, false) + }) + + this.lastServerClock = event.serverClock + } + + incomingDiffBuffer: Extract, { type: 'patch' | 'push_result' }>[] = [] + + /** Handle events received from the server */ + private handleServerEvent = (event: TLSocketServerSentEvent) => { + this.debug('received server event', event) + this.lastServerInteractionTimestamp = Date.now() + // always update the lastServerClock when it is present + switch (event.type) { + case 'connect': + this.didReconnect(event) + break + case 'error': + console.error('Server error', event.error) + console.error('Restarting socket') + this.socket.restart() + break + case 'patch': + case 'push_result': + // wait for a connect to succeed before processing more events + if (!this.isConnectedToRoom) break + this.incomingDiffBuffer.push(event) + this.scheduleRebase() + break + case 'incompatibility_error': + this.onSyncError(event.reason) + break + case 'pong': + // noop, we only use ping/pong to set lastSeverInteractionTimestamp + break + default: + exhaustiveSwitchError(event) + } + } + + close() { + this.debug('closing') + this.disposables.forEach((dispose) => dispose()) + } + + lastPushedPresenceState: R | null = null + + private pushPresence(nextPresence: R | null) { + if (!this.isConnectedToRoom) { + // if we're offline, don't do anything + return + } + let req: TLPushRequest | null = null + if (!this.lastPushedPresenceState && nextPresence) { + // we don't have a last presence state, so we need to push the full state + req = { + type: 'push', + presence: [RecordOpType.Put, nextPresence], + clientClock: this.clientClock++, + } + } else if (this.lastPushedPresenceState && nextPresence) { + // we have a last presence state, so we need to push a diff if there is one + const diff = diffRecord(this.lastPushedPresenceState, nextPresence) + if (diff) { + req = { + type: 'push', + presence: [RecordOpType.Patch, diff], + clientClock: this.clientClock++, + } + } + } + this.lastPushedPresenceState = nextPresence + if (req) { + this.pendingPushRequests.push({ request: req, sent: false }) + this.flushPendingPushRequests() + } + } + + /** Push a change to the server, or stash it locally if we're offline */ + private push(change: RecordsDiff) { + this.debug('push', change) + // the Store doesn't do deep equality checks when making changes + // so it's possible that the diff passed in here is actually a no-op. + // either way, we also don't want to send whole objects over the wire if + // only small parts of them have changed, so we'll do a shallow-ish diff + // which also uses deep equality checks to see if the change is actually + // a no-op. + const diff = getNetworkDiff(change) + if (!diff) return + + // the change is not a no-op so we'll send it to the server + // but first let's merge the records diff into the speculative changes + this.speculativeChanges = squashRecordDiffs([this.speculativeChanges, change]) + + if (!this.isConnectedToRoom) { + // don't sent push requests or even store them up while offline + // when we come back online we'll generate another push request from + // scratch based on the speculativeChanges diff + return + } + + const pushRequest: TLPushRequest = { + type: 'push', + diff, + clientClock: this.clientClock++, + } + + this.pendingPushRequests.push({ request: pushRequest, sent: false }) + + // immediately calling .send on the websocket here was causing some interaction + // slugishness when e.g. drawing or translating shapes. Seems like it blocks + // until the send completes. So instead we'll schedule a send to happen on some + // tick in the near future. + this.flushPendingPushRequests() + } + + /** Send any unsent push requests to the server */ + private flushPendingPushRequests = rafThrottle(() => { + this.debug('flushing pending push requests', { + isConnectedToRoom: this.isConnectedToRoom, + pendingPushRequests: this.pendingPushRequests, + }) + if (!this.isConnectedToRoom || this.store.isPossiblyCorrupted()) { + return + } + for (const pendingPushRequest of this.pendingPushRequests) { + if (!pendingPushRequest.sent) { + if (this.socket.connectionStatus !== 'online') { + // we went offline, so don't send anything + return + } + this.socket.sendMessage(pendingPushRequest.request) + pendingPushRequest.sent = true + } + } + }) + + /** + * Applies a 'network' diff to the store this does value-based equality checking so that if the + * data is the same (as opposed to merely identical with ===), then no change is made and no + * changes will be propagated back to store listeners + */ + private applyNetworkDiff(diff: NetworkDiff, runCallbacks: boolean) { + this.debug('applyNetworkDiff', diff) + const changes: RecordsDiff = { added: {} as any, updated: {} as any, removed: {} as any } + type k = keyof typeof changes.updated + let hasChanges = false + for (const [id, op] of objectMapEntries(diff)) { + if (op[0] === RecordOpType.Put) { + const existing = this.store.get(id as RecordId) + if (existing && !isEqual(existing, op[1])) { + hasChanges = true + changes.updated[id as k] = [existing, op[1]] + } else { + hasChanges = true + changes.added[id as k] = op[1] + } + } else if (op[0] === RecordOpType.Patch) { + const record = this.store.get(id as RecordId) + if (!record) { + // the record was removed upstream + continue + } + const patched = applyObjectDiff(record, op[1]) + hasChanges = true + changes.updated[id as k] = [record, patched] + } else if (op[0] === RecordOpType.Remove) { + if (this.store.has(id as RecordId)) { + hasChanges = true + changes.removed[id as k] = this.store.get(id as RecordId) + } + } + } + if (hasChanges) { + this.store.applyDiff(changes, runCallbacks) + } + } + + private rebase = () => { + // need to make sure that our speculative changes are in sync with the actual store instance before + // proceeding, to avoid inconsistency bugs. + this.store._flushHistory() + if (this.incomingDiffBuffer.length === 0) return + + const diffs = this.incomingDiffBuffer + this.incomingDiffBuffer = [] + + try { + this.store.mergeRemoteChanges(() => { + // first undo speculative changes + this.store.applyDiff(reverseRecordsDiff(this.speculativeChanges), false) + + // then apply network diffs on top of known-to-be-synced data + for (const diff of diffs) { + if (diff.type === 'patch') { + this.applyNetworkDiff(diff.diff, true) + continue + } + // handling push_result + if (this.pendingPushRequests.length === 0) { + throw new Error('Received push_result but there are no pending push requests') + } + if (this.pendingPushRequests[0].request.clientClock !== diff.clientClock) { + throw new Error( + 'Received push_result for a push request that is not at the front of the queue' + ) + } + if (diff.action === 'discard') { + this.pendingPushRequests.shift() + } else if (diff.action === 'commit') { + const { request } = this.pendingPushRequests.shift()! + if ('diff' in request) { + this.applyNetworkDiff(request.diff, true) + } + } else { + this.applyNetworkDiff(diff.action.rebaseWithDiff, true) + this.pendingPushRequests.shift() + } + } + // update the speculative diff while re-applying pending changes + try { + this.speculativeChanges = this.store.extractingChanges(() => { + for (const { request } of this.pendingPushRequests) { + if (!('diff' in request)) continue + this.applyNetworkDiff(request.diff, true) + } + }) + } catch (e) { + console.error(e) + // throw away the speculative changes and start over + this.speculativeChanges = { added: {} as any, updated: {} as any, removed: {} as any } + this.resetConnection() + } + }) + this.store.ensureStoreIsUsable() + this.lastServerClock = diffs.at(-1)?.serverClock ?? this.lastServerClock + } catch (e) { + console.error(e) + this.resetConnection() + } + } + + private scheduleRebase = rafThrottle(this.rebase) +} diff --git a/packages/tlsync/src/lib/TLSyncRoom.ts b/packages/tlsync/src/lib/TLSyncRoom.ts new file mode 100644 index 000000000..fce32750e --- /dev/null +++ b/packages/tlsync/src/lib/TLSyncRoom.ts @@ -0,0 +1,955 @@ +import { Atom, atom, transaction } from '@tldraw/state' +import { + IdOf, + MigrationFailureReason, + RecordType, + SerializedSchema, + StoreSchema, + UnknownRecord, + compareRecordVersions, + getRecordVersion, +} from '@tldraw/store' +import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlschema' +import { + Result, + assertExists, + exhaustiveSwitchError, + getOwnProperty, + hasOwnProperty, + objectMapEntries, + objectMapKeys, +} from '@tldraw/utils' +import isEqual from 'lodash.isequal' +import { createNanoEvents } from 'nanoevents' +import { + RoomSession, + RoomSessionState, + SESSION_IDLE_TIMEOUT, + SESSION_REMOVAL_WAIT_TIME, + SESSION_START_WAIT_TIME, +} from './RoomSession' +import { + NetworkDiff, + ObjectDiff, + RecordOp, + RecordOpType, + ValueOpType, + applyObjectDiff, + diffRecord, +} from './diff' +import { interval } from './interval' +import { + TLIncompatibilityReason, + TLSYNC_PROTOCOL_VERSION, + TLSocketClientSentEvent, + TLSocketServerSentEvent, +} from './protocol' + +/** @public */ +export type TLRoomSocket = { + isOpen: boolean + sendMessage: (msg: TLSocketServerSentEvent) => void + close: () => void +} + +// the max number of tombstones to keep in the store +export const MAX_TOMBSTONES = 3000 +// the number of tombstones to delete when the max is reached +export const TOMBSTONE_PRUNE_BUFFER_SIZE = 300 + +const timeSince = (time: number) => Date.now() - time + +class DocumentState { + _atom: Atom<{ state: R; lastChangedClock: number }> + + static createWithoutValidating( + state: R, + lastChangedClock: number, + recordType: RecordType + ): DocumentState { + return new DocumentState(state, lastChangedClock, recordType) + } + + static createAndValidate( + state: R, + lastChangedClock: number, + recordType: RecordType + ): Result, Error> { + try { + recordType.validate(state) + } catch (error: any) { + return Result.err(error) + } + return Result.ok(new DocumentState(state, lastChangedClock, recordType)) + } + + private constructor( + state: R, + lastChangedClock: number, + private readonly recordType: RecordType + ) { + this._atom = atom('document:' + state.id, { state, lastChangedClock }) + } + // eslint-disable-next-line no-restricted-syntax + get state() { + return this._atom.get().state + } + // eslint-disable-next-line no-restricted-syntax + get lastChangedClock() { + return this._atom.get().lastChangedClock + } + replaceState(state: R, clock: number): Result { + const diff = diffRecord(this.state, state) + if (!diff) return Result.ok(null) + try { + this.recordType.validate(state) + } catch (error: any) { + return Result.err(error) + } + this._atom.set({ state, lastChangedClock: clock }) + return Result.ok(diff) + } + mergeDiff(diff: ObjectDiff, clock: number): Result { + const newState = applyObjectDiff(this.state, diff) + return this.replaceState(newState, clock) + } +} + +/** @public */ +export type RoomSnapshot = { + clock: number + documents: Array<{ state: UnknownRecord; lastChangedClock: number }> + tombstones?: Record + schema?: SerializedSchema +} + +/** + * A room is a workspace for a group of clients. It allows clients to collaborate on documents + * within that workspace. + * + * @public + */ +export class TLSyncRoom { + // A table of connected clients + readonly sessions = new Map>() + + pruneSessions = () => { + for (const client of this.sessions.values()) { + switch (client.state) { + case RoomSessionState.CONNECTED: { + const hasTimedOut = timeSince(client.lastInteractionTime) > SESSION_IDLE_TIMEOUT + if (hasTimedOut || !client.socket.isOpen) { + this.cancelSession(client.sessionKey) + } + break + } + case RoomSessionState.AWAITING_CONNECT_MESSAGE: { + const hasTimedOut = timeSince(client.sessionStartTime) > SESSION_START_WAIT_TIME + if (hasTimedOut || !client.socket.isOpen) { + // remove immediately + this.removeSession(client.sessionKey) + } + break + } + case RoomSessionState.AWAITING_REMOVAL: { + const hasTimedOut = timeSince(client.cancellationTime) > SESSION_REMOVAL_WAIT_TIME + if (hasTimedOut) { + this.removeSession(client.sessionKey) + } + break + } + default: { + exhaustiveSwitchError(client) + } + } + } + } + + private disposables: Array<() => void> = [interval(this.pruneSessions, 2000)] + + close() { + this.disposables.forEach((d) => d()) + this.sessions.forEach((session) => { + session.socket.close() + }) + } + + readonly events = createNanoEvents<{ + room_became_empty: () => void + session_removed: (args: { sessionKey: string }) => void + }>() + + // Values associated with each uid (must be serializable). + state = atom<{ + documents: Record> + tombstones: Record + }>('room state', { + documents: {}, + tombstones: {}, + }) + + // this clock should start higher than the client, to make sure that clients who sync with their + // initial lastServerClock value get the full state + // in this case clients will start with 0, and the server will start with 1 + clock = 1 + tombstoneHistoryStartsAtClock = this.clock + // map from record id to clock upon deletion + + readonly serializedSchema: SerializedSchema + + readonly documentTypes: Set + readonly presenceType: RecordType + + constructor( + public readonly schema: StoreSchema, + snapshot?: RoomSnapshot + ) { + // do a json serialization cycle to make sure the schema has no 'undefined' values + this.serializedSchema = JSON.parse(JSON.stringify(schema.serialize())) + + this.documentTypes = new Set( + Object.values>(schema.types) + .filter((t) => t.scope === 'document') + .map((t) => t.typeName) + ) + + const presenceTypes = new Set( + Object.values>(schema.types).filter((t) => t.scope === 'presence') + ) + + if (presenceTypes.size != 1) { + throw new Error( + `TLSyncRoom: exactly one presence type is expected, but found ${presenceTypes.size}` + ) + } + + this.presenceType = presenceTypes.values().next().value + + if (!snapshot) { + snapshot = { + clock: 0, + documents: [ + { + state: DocumentRecordType.create({ id: TLDOCUMENT_ID }), + lastChangedClock: 0, + }, + { + state: PageRecordType.create({ name: 'Page 1', index: 'a1' }), + lastChangedClock: 0, + }, + ], + } + } + + this.clock = snapshot.clock + let didIncrementClock = false + const ensureClockDidIncrement = (_reason: string) => { + if (!didIncrementClock) { + didIncrementClock = true + this.clock++ + } + } + + const tombstones = { ...snapshot.tombstones } + const filteredDocuments = [] + for (const doc of snapshot.documents) { + if (this.documentTypes.has(doc.state.typeName)) { + filteredDocuments.push(doc) + } else { + ensureClockDidIncrement('doc type was not doc type') + tombstones[doc.state.id] = this.clock + } + } + + const documents: Record> = Object.fromEntries( + filteredDocuments.map((r) => [ + r.state.id, + DocumentState.createWithoutValidating( + r.state as R, + r.lastChangedClock, + assertExists(getOwnProperty(schema.types, r.state.typeName)) + ), + ]) + ) + + const migrationResult = schema.migrateStoreSnapshot({ + store: Object.fromEntries( + objectMapEntries(documents).map(([id, { state }]) => [id, state as R]) + ) as Record, R>, + schema: snapshot.schema ?? schema.serializeEarliestVersion(), + }) + + if (migrationResult.type === 'error') { + // TODO: Fault tolerance + throw new Error('Failed to migrate: ' + migrationResult.reason) + } + + for (const [id, r] of objectMapEntries(migrationResult.value)) { + const existing = documents[id] + if (!existing) { + // record was added during migration + ensureClockDidIncrement('record was added during migration') + documents[id] = DocumentState.createWithoutValidating( + r, + this.clock, + assertExists(getOwnProperty(schema.types, r.typeName)) as any + ) + } else if (!isEqual(existing.state, r)) { + // record was maybe updated during migration + ensureClockDidIncrement('record was maybe updated during migration') + existing.replaceState(r, this.clock) + } + } + + for (const id of objectMapKeys(documents)) { + if (!migrationResult.value[id as keyof typeof migrationResult.value]) { + // record was removed during migration + ensureClockDidIncrement('record was removed during migration') + tombstones[id] = this.clock + delete documents[id] + } + } + + this.state.set({ documents, tombstones }) + + this.pruneTombstones() + } + + private pruneTombstones = () => { + // avoid blocking any pending responses + this.state.update(({ tombstones, documents }) => { + const entries = Object.entries(this.state.get().tombstones) + if (entries.length > MAX_TOMBSTONES) { + // sort entries in ascending order by clock + entries.sort((a, b) => a[1] - b[1]) + // trim off the first bunch + const excessQuantity = entries.length - MAX_TOMBSTONES + tombstones = Object.fromEntries(entries.slice(excessQuantity + TOMBSTONE_PRUNE_BUFFER_SIZE)) + } + return { + documents, + tombstones, + } + }) + } + + private getDocument(id: string) { + return this.state.get().documents[id] + } + + private addDocument(id: string, state: R, clock: number): Result { + let { documents, tombstones } = this.state.get() + if (hasOwnProperty(tombstones, id)) { + tombstones = { ...tombstones } + delete tombstones[id] + } + const createResult = DocumentState.createAndValidate( + state, + clock, + assertExists(getOwnProperty(this.schema.types, state.typeName)) + ) + if (!createResult.ok) return createResult + documents = { ...documents, [id]: createResult.value } + this.state.set({ documents, tombstones }) + return Result.ok(undefined) + } + + private removeDocument(id: string, clock: number) { + this.state.update(({ documents, tombstones }) => { + documents = { ...documents } + delete documents[id] + tombstones = { ...tombstones, [id]: clock } + return { documents, tombstones } + }) + } + + getSnapshot(): RoomSnapshot { + const { documents, tombstones } = this.state.get() + return { + clock: this.clock, + tombstones, + schema: this.serializedSchema, + documents: Object.values(documents) + .map((doc) => ({ + state: doc.state, + lastChangedClock: doc.lastChangedClock, + })) + .filter((d) => this.documentTypes.has(d.state.typeName)), + } + } + + /** + * Send a message to a particular client. + * + * @param client - The client to send the message to. + * @param message - The message to send. + */ + private sendMessage(sessionKey: string, message: TLSocketServerSentEvent) { + const session = this.sessions.get(sessionKey) + if (!session) { + console.warn('Tried to send message to unknown session', message.type) + return + } + if (session.state !== RoomSessionState.CONNECTED) { + console.warn('Tried to send message to disconnected client', message.type) + return + } + if (session.socket.isOpen) { + session.socket.sendMessage(message) + } else { + this.cancelSession(session.sessionKey) + } + } + + private removeSession(sessionKey: string) { + const session = this.sessions.get(sessionKey) + if (!session) { + console.warn('Tried to remove unknown session') + return + } + + this.sessions.delete(sessionKey) + + const presence = this.getDocument(session.presenceId) + + try { + if (session.socket.isOpen) { + session.socket.close() + } + } catch (_e) { + // noop + } + + if (presence) { + this.state.update(({ tombstones, documents }) => { + documents = { ...documents } + delete documents[session.presenceId] + return { documents, tombstones } + }) + + this.broadcastPatch({ + diff: { [session.presenceId]: [RecordOpType.Remove] }, + sourceSessionKey: sessionKey, + }) + } + + this.events.emit('session_removed', { sessionKey }) + if (this.sessions.size === 0) { + this.events.emit('room_became_empty') + } + } + + private cancelSession(sessionKey: string) { + const session = this.sessions.get(sessionKey) + if (!session) { + return + } + + if (session.state === RoomSessionState.AWAITING_REMOVAL) { + console.warn('Tried to cancel session that is already awaiting removal') + return + } + + this.sessions.set(sessionKey, { + state: RoomSessionState.AWAITING_REMOVAL, + sessionKey, + presenceId: session.presenceId, + socket: session.socket, + cancellationTime: Date.now(), + }) + } + + /** + * Broadcast a message to all connected clients except the clientId provided. + * + * @param message - The message to broadcast. + * @param clientId - The client to exclude. + */ + broadcastPatch({ + diff, + sourceSessionKey: sourceSessionKey, + }: { + diff: NetworkDiff + sourceSessionKey: string + }) { + this.sessions.forEach((session) => { + if (session.state !== RoomSessionState.CONNECTED) return + if (sourceSessionKey === session.sessionKey) return + if (!session.socket.isOpen) { + this.cancelSession(session.sessionKey) + return + } + + const res = this.migrateDiffForSession(session.serializedSchema, diff) + + if (!res.ok) { + // disconnect client and send incompatibility error + this.rejectSession( + session, + res.error === MigrationFailureReason.TargetVersionTooNew + ? TLIncompatibilityReason.ServerTooOld + : TLIncompatibilityReason.ClientTooOld + ) + return + } + + this.sendMessage(session.sessionKey, { + type: 'patch', + diff: res.value, + serverClock: this.clock, + }) + }) + return this + } + + /** + * When a client connects to the room, add them to the list of clients and then merge the history + * down into the snapshots. + * + * @param client - The client that connected to the room. + */ + handleNewSession = (sessionKey: string, socket: TLRoomSocket) => { + const existing = this.sessions.get(sessionKey) + this.sessions.set(sessionKey, { + state: RoomSessionState.AWAITING_CONNECT_MESSAGE, + sessionKey, + socket, + presenceId: existing?.presenceId ?? this.presenceType.createId(), + sessionStartTime: Date.now(), + }) + return this + } + + /** + * When we send a diff to a client, if that client is on a lower version than us, we need to make + * the diff compatible with their version. At the moment this means migrating each affected record + * to the client's version and sending the whole record again. We can optimize this later by + * keeping the previous versions of records around long enough to recalculate these diffs for + * older client versions. + */ + private migrateDiffForSession( + serializedSchema: SerializedSchema, + diff: NetworkDiff + ): Result, MigrationFailureReason> { + // TODO: optimize this by recalculating patches using the previous versions of records + + // when the client connects we check whether the schema is identical and make sure + // to use the same object reference so that === works on this line + if (serializedSchema === this.serializedSchema) { + return Result.ok(diff) + } + + const result: NetworkDiff = {} + for (const [id, op] of Object.entries(diff)) { + if (op[0] === RecordOpType.Remove) { + result[id] = op + continue + } + + const migrationResult = this.schema.migratePersistedRecord( + this.getDocument(id).state, + serializedSchema, + 'down' + ) + + if (migrationResult.type === 'error') { + return Result.err(migrationResult.reason) + } + + result[id] = [RecordOpType.Put, migrationResult.value] + } + + return Result.ok(result) + } + + /** + * When the server receives a message from the clients Currently supports connect and patches. + * Invalid messages types log a warning. Currently doesn't validate data. + * + * @param client - The client that sent the message + * @param message - The message that was sent + */ + handleMessage = async (sessionKey: string, message: TLSocketClientSentEvent) => { + const session = this.sessions.get(sessionKey) + if (!session) { + console.warn('Received message from unknown session') + return + } + switch (message.type) { + case 'connect': { + return this.handleConnectRequest(session, message) + } + case 'push': { + return this.handlePushRequest(session, message) + } + case 'ping': { + if (session.state === RoomSessionState.CONNECTED) { + session.lastInteractionTime = Date.now() + } + return this.sendMessage(session.sessionKey, { type: 'pong' }) + } + default: { + exhaustiveSwitchError(message) + } + } + + return this + } + + /** If the client is out of date or we are out of date, we need to let them know */ + private rejectSession(session: RoomSession, reason: TLIncompatibilityReason) { + try { + if (session.socket.isOpen) { + session.socket.sendMessage({ + type: 'incompatibility_error', + reason, + }) + } + } catch (e) { + // noop + } finally { + this.removeSession(session.sessionKey) + } + } + + private handleConnectRequest( + session: RoomSession, + message: Extract, { type: 'connect' }> + ) { + // if the protocol versions don't match, disconnect the client + // we will eventually want to try to make our protocol backwards compatible to some degree + // and have a MIN_PROTOCOL_VERSION constant that the TLSyncRoom implements support for + if (message.protocolVersion == null || message.protocolVersion < TLSYNC_PROTOCOL_VERSION) { + this.rejectSession(session, TLIncompatibilityReason.ClientTooOld) + return + } else if (message.protocolVersion > TLSYNC_PROTOCOL_VERSION) { + this.rejectSession(session, TLIncompatibilityReason.ServerTooOld) + return + } + // If the client's store is at a different version to ours, it could cause corruption. + // We should disconnect the client and ask them to refresh. + if (message.schema == null || message.schema.storeVersion < this.schema.currentStoreVersion) { + this.rejectSession(session, TLIncompatibilityReason.ClientTooOld) + return + } else if (message.schema.storeVersion > this.schema.currentStoreVersion) { + this.rejectSession(session, TLIncompatibilityReason.ServerTooOld) + return + } + + const sessionSchema = isEqual(message.schema, this.serializedSchema) + ? this.serializedSchema + : message.schema + + const connect = (msg: TLSocketServerSentEvent) => { + this.sessions.set(session.sessionKey, { + state: RoomSessionState.CONNECTED, + sessionKey: session.sessionKey, + presenceId: session.presenceId, + socket: session.socket, + serializedSchema: sessionSchema, + lastInteractionTime: Date.now(), + }) + this.sendMessage(session.sessionKey, msg) + } + + transaction((rollback) => { + if ( + // if the client requests changes since a time before we have tombstone history, send them the full state + message.lastServerClock < this.tombstoneHistoryStartsAtClock || + // similarly, if they ask for a time we haven't reached yet, send them the full state + // this will only happen if the DB is reset (or there is no db) and the server restarts + // or if the server exits/crashes with unpersisted changes + message.lastServerClock > this.clock + ) { + const diff: NetworkDiff = {} + for (const [id, doc] of Object.entries(this.state.get().documents)) { + if (id !== session.presenceId) { + diff[id] = [RecordOpType.Put, doc.state] + } + } + const migrated = this.migrateDiffForSession(sessionSchema, diff) + if (!migrated.ok) { + rollback() + this.rejectSession( + session, + migrated.error === MigrationFailureReason.TargetVersionTooNew + ? TLIncompatibilityReason.ServerTooOld + : TLIncompatibilityReason.ClientTooOld + ) + return + } + connect({ + type: 'connect', + connectRequestId: message.connectRequestId, + hydrationType: 'wipe_all', + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: this.schema.serialize(), + serverClock: this.clock, + diff: migrated.value, + }) + } else { + // calculate the changes since the time the client last saw + const diff: NetworkDiff = {} + const updatedDocs = Object.values(this.state.get().documents).filter( + (doc) => doc.lastChangedClock > message.lastServerClock + ) + const presenceDocs = Object.values(this.state.get().documents).filter( + (doc) => + this.presenceType.typeName === doc.state.typeName && doc.state.id !== session.presenceId + ) + const deletedDocsIds = Object.entries(this.state.get().tombstones) + .filter(([_id, deletedAtClock]) => deletedAtClock > message.lastServerClock) + .map(([id]) => id) + + for (const doc of updatedDocs) { + diff[doc.state.id] = [RecordOpType.Put, doc.state] + } + for (const doc of presenceDocs) { + diff[doc.state.id] = [RecordOpType.Put, doc.state] + } + + for (const docId of deletedDocsIds) { + diff[docId] = [RecordOpType.Remove] + } + const migrated = this.migrateDiffForSession(sessionSchema, diff) + if (!migrated.ok) { + rollback() + this.rejectSession( + session, + migrated.error === MigrationFailureReason.TargetVersionTooNew + ? TLIncompatibilityReason.ServerTooOld + : TLIncompatibilityReason.ClientTooOld + ) + return + } + + connect({ + type: 'connect', + connectRequestId: message.connectRequestId, + hydrationType: 'wipe_presence', + schema: this.schema.serialize(), + protocolVersion: TLSYNC_PROTOCOL_VERSION, + serverClock: this.clock, + diff: migrated.value, + }) + } + }) + } + + private handlePushRequest( + session: RoomSession, + message: Extract, { type: 'push' }> + ) { + const isPresencePush = 'presence' in message + const clientClock = message.clientClock + + if (session.state !== RoomSessionState.CONNECTED) { + return + } + session.lastInteractionTime = Date.now() + + // increment the clock for this push + this.clock++ + + transaction((rollback) => { + // collect actual ops that resulted from the push + // these will be broadcast to other users + let mergedChanges: NetworkDiff | null = null + const propagateOp = (id: string, op: RecordOp) => { + if (!mergedChanges) mergedChanges = {} + mergedChanges[id] = op + } + + const fail = (reason: TLIncompatibilityReason): Result => { + rollback() + this.rejectSession(session, reason) + if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'test') { + console.error('failed to apply push', reason, message) + } + return Result.err(undefined) + } + + const addDocument = (id: string, _state: R): Result => { + const res = this.schema.migratePersistedRecord(_state, session.serializedSchema, 'up') + if (res.type === 'error') { + return fail( + res.reason === MigrationFailureReason.TargetVersionTooOld // target version is our version + ? TLIncompatibilityReason.ServerTooOld + : TLIncompatibilityReason.ClientTooOld + ) + } + const state = res.value + const doc = this.getDocument(id) + if (doc) { + // if we already have a document with this id, set it to the new value + // but propagate a diff rather than the entire value + const diff = doc.replaceState(state, this.clock) + if (!diff.ok) { + return fail(TLIncompatibilityReason.InvalidRecord) + } + if (diff.value) propagateOp(id, [RecordOpType.Patch, diff.value]) + } else { + // if we don't already have a document with this id, create it and propagate the put op + const result = this.addDocument(id, state, this.clock) + if (!result.ok) { + return fail(TLIncompatibilityReason.InvalidRecord) + } + propagateOp(id, [RecordOpType.Put, state]) + } + + return Result.ok(undefined) + } + + const patchDocument = (id: string, patch: ObjectDiff): Result => { + // if it was already deleted, there's no need to apply the patch + const doc = this.getDocument(id) + if (!doc) return Result.ok(undefined) + const theirVersion = getRecordVersion(doc.state, session.serializedSchema) + const ourVersion = getRecordVersion(doc.state, this.serializedSchema) + if (compareRecordVersions(ourVersion, theirVersion) === 1) { + // if the client's version of the record is older than ours, we apply the patch to the downgraded version of the record + const downgraded = this.schema.migratePersistedRecord( + doc.state, + session.serializedSchema, + 'down' + ) + if (downgraded.type === 'error') { + return fail(TLIncompatibilityReason.ClientTooOld) + } + const patched = applyObjectDiff(downgraded.value, patch) + // then upgrade the patched version and use that as the new state + const upgraded = this.schema.migratePersistedRecord( + patched, + session.serializedSchema, + 'up' + ) + if (upgraded.type === 'error') { + return fail(TLIncompatibilityReason.ClientTooOld) + } + const diff = doc.replaceState(upgraded.value, this.clock) + if (!diff.ok) { + return fail(TLIncompatibilityReason.InvalidRecord) + } + if (diff.value) propagateOp(id, [RecordOpType.Patch, diff.value]) + } else if (compareRecordVersions(ourVersion, theirVersion) === -1) { + // if the client's version of the record is newer than ours, we can't apply the patch + return fail(TLIncompatibilityReason.ServerTooOld) + } else { + // otherwise apply the patch and propagate the patch op if needed + const diff = doc.mergeDiff(patch, this.clock) + if (!diff.ok) { + return fail(TLIncompatibilityReason.InvalidRecord) + } + if (diff.value) propagateOp(id, [RecordOpType.Patch, diff.value]) + } + + return Result.ok(undefined) + } + + if (isPresencePush) { + const id = session.presenceId + const [type, val] = message.presence + if (type === RecordOpType.Put) { + if (!addDocument(id, { ...val, id, typeName: this.presenceType.typeName }).ok) return + } else { + if ( + !patchDocument(id, { + ...val, + id: [ValueOpType.Put, id], + typeName: [ValueOpType.Put, this.presenceType.typeName], + }).ok + ) + return + } + this.sendMessage(session.sessionKey, { + type: 'push_result', + clientClock, + action: 'commit', + serverClock: this.clock, + }) + } else { + const diff = message.diff + for (const [id, op] of Object.entries(diff)) { + if (op[0] === RecordOpType.Put) { + // if it's not a document record, fail + if (!this.documentTypes.has(op[1].typeName)) { + return fail(TLIncompatibilityReason.InvalidRecord) + } + if (!addDocument(id, op[1]).ok) return + } else if (op[0] === RecordOpType.Remove) { + // if it was already deleted, don't do anything, no need to propagate a delete op + const doc = this.getDocument(id) + if (!doc) continue + if (!this.documentTypes.has(doc.state.typeName)) { + return fail(TLIncompatibilityReason.InvalidOperation) + } + // otherwise delete the document and propagate the delete op + this.removeDocument(id, this.clock) + // schedule a pruneTombstones call to happen after we are done here + setTimeout(this.pruneTombstones, 0) + propagateOp(id, op) + } else if (op[0] === RecordOpType.Patch) { + if (!patchDocument(id, op[1]).ok) return + } + } + + if (!mergedChanges) { + // we applied the client's changes but they had no effect + // tell them to drop the diff + this.sendMessage(session.sessionKey, { + type: 'push_result', + serverClock: this.clock, + clientClock, + action: 'discard', + }) + } else if (isEqual(mergedChanges, diff)) { + // we applied the client's changes and they had the exact same effect + // on the server as they did on the client + // tell them to keep the diff + this.sendMessage(session.sessionKey, { + type: 'push_result', + serverClock: this.clock, + clientClock, + action: 'commit', + }) + } else { + // We applied the client's changes and they had a different non-empty effect + // on the server, so we need to tell the client to rebase with our gold standard diff + const migrateResult = this.migrateDiffForSession(session.serializedSchema, mergedChanges) + if (!migrateResult.ok) { + return fail( + migrateResult.error === MigrationFailureReason.TargetVersionTooNew + ? TLIncompatibilityReason.ServerTooOld + : TLIncompatibilityReason.ClientTooOld + ) + } + this.sendMessage(session.sessionKey, { + type: 'push_result', + serverClock: this.clock, + clientClock, + action: { rebaseWithDiff: migrateResult.value }, + }) + } + } + + if (mergedChanges) { + // let all other client know about the changes + this.broadcastPatch({ + sourceSessionKey: session.sessionKey, + diff: mergedChanges, + }) + } + + return + }) + } + + /** + * Handle the event when a client disconnects. + * + * @param client - The client that disconnected. + */ + handleClose = (sessionKey: string) => { + this.cancelSession(sessionKey) + } +} diff --git a/packages/tlsync/src/lib/chunk.ts b/packages/tlsync/src/lib/chunk.ts new file mode 100644 index 000000000..831d2963c --- /dev/null +++ b/packages/tlsync/src/lib/chunk.ts @@ -0,0 +1,78 @@ +// quarter of a megabyte, max possible utf-8 string size + +// cloudflare workers only accept messages of max 1mb +const MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024 +// utf-8 is max 4 bytes per char +const MAX_BYTES_PER_CHAR = 4 + +// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte +const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR + +export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) { + if (msg.length < maxSafeMessageSize) { + return [msg] + } else { + const chunks = [] + let chunkNumber = 0 + let offset = msg.length + while (offset > 0) { + const prefix = `${chunkNumber}_` + const chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1) + chunks.unshift(prefix + msg.slice(offset - chunkSize, offset)) + offset -= chunkSize + chunkNumber++ + } + return chunks + } +} + +const chunkRe = /^(\d+)_(.*)$/ + +export class JsonChunkAssembler { + state: + | 'idle' + | { + chunksReceived: string[] + totalChunks: number + } = 'idle' + + handleMessage(msg: string): { data?: object; error?: Error } | null { + if (msg.startsWith('{')) { + const error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message') + this.state = 'idle' + return { data: JSON.parse(msg), error } + } else { + const match = chunkRe.exec(msg)! + if (!match) { + this.state = 'idle' + return { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) } + } + const numChunksRemaining = Number(match[1]) + const data = match[2] + + if (this.state === 'idle') { + this.state = { + chunksReceived: [data], + totalChunks: numChunksRemaining + 1, + } + } else { + this.state.chunksReceived.push(data) + if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) { + this.state = 'idle' + return { error: new Error(`Chunks received in wrong order`) } + } + } + if (this.state.chunksReceived.length === this.state.totalChunks) { + try { + const data = JSON.parse(this.state.chunksReceived.join('')) + return { data } + } catch (e) { + return { error: e as Error } + } finally { + this.state = 'idle' + } + } + return null + } + } +} diff --git a/packages/tlsync/src/lib/diff.ts b/packages/tlsync/src/lib/diff.ts new file mode 100644 index 000000000..4867ad51c --- /dev/null +++ b/packages/tlsync/src/lib/diff.ts @@ -0,0 +1,260 @@ +import { RecordsDiff, UnknownRecord } from '@tldraw/store' +import { objectMapEntries, objectMapValues } from '@tldraw/utils' +import isEqual from 'lodash.isequal' + +/** @public */ +export enum RecordOpType { + Put = 'put', + Patch = 'patch', + Remove = 'remove', +} + +/** @public */ +export type RecordOp = + | [RecordOpType.Put, R] + | [RecordOpType.Patch, ObjectDiff] + | [RecordOpType.Remove] + +/** + * A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to + * be sent over the wire. Either as push requests from the client to the server, or as patch + * operations in the opposite direction. + * + * Each key in this object is the id of a record that has been added, updated, or removed. + * + * @public + */ +export type NetworkDiff = { + [id: string]: RecordOp +} + +/** + * Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff + * + * @public + */ +export const getNetworkDiff = ( + diff: RecordsDiff +): NetworkDiff | null => { + let res: NetworkDiff | null = null + + for (const [k, v] of objectMapEntries(diff.added)) { + if (!res) res = {} + res[k] = [RecordOpType.Put, v] + } + + for (const [from, to] of objectMapValues(diff.updated)) { + const diff = diffRecord(from, to) + if (diff) { + if (!res) res = {} + res[to.id] = [RecordOpType.Patch, diff] + } + } + + for (const removed of Object.keys(diff.removed)) { + if (!res) res = {} + res[removed] = [RecordOpType.Remove] + } + + return res +} + +/** @public */ +export enum ValueOpType { + Put = 'put', + Delete = 'delete', + Append = 'append', + Patch = 'patch', +} +/** @public */ +export type PutOp = [type: ValueOpType.Put, value: unknown] +/** @public */ +export type AppendOp = [type: ValueOpType.Append, values: unknown[], offset: number] +/** @public */ +export type PatchOp = [type: ValueOpType.Patch, diff: ObjectDiff] +/** @public */ +export type DeleteOp = [type: ValueOpType.Delete] + +/** @public */ +export type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp + +/** @public */ +export type ObjectDiff = { + [k: string]: ValueOp +} + +/** @public */ +export function diffRecord(prev: object, next: object): ObjectDiff | null { + return diffObject(prev, next, new Set(['props'])) +} + +function diffObject(prev: object, next: object, nestedKeys?: Set): ObjectDiff | null { + if (prev === next) { + return null + } + let result: ObjectDiff | null = null + for (const key of Object.keys(prev)) { + // if key is not in next then it was deleted + if (!(key in next)) { + if (!result) result = {} + result[key] = [ValueOpType.Delete] + continue + } + // if key is in both places, then compare values + const prevVal = (prev as any)[key] + const nextVal = (next as any)[key] + if (!isEqual(prevVal, nextVal)) { + if (nestedKeys?.has(key) && prevVal && nextVal) { + const diff = diffObject(prevVal, nextVal) + if (diff) { + if (!result) result = {} + result[key] = [ValueOpType.Patch, diff] + } + } else if (Array.isArray(nextVal) && Array.isArray(prevVal)) { + const op = diffArray(prevVal, nextVal) + if (op) { + if (!result) result = {} + result[key] = op + } + } else { + if (!result) result = {} + result[key] = [ValueOpType.Put, nextVal] + } + } + } + for (const key of Object.keys(next)) { + // if key is in next but not in prev then it was added + if (!(key in prev)) { + if (!result) result = {} + result[key] = [ValueOpType.Put, (next as any)[key]] + } + } + return result +} + +function diffValue(valueA: unknown, valueB: unknown): ValueOp | null { + if (Object.is(valueA, valueB)) return null + if (Array.isArray(valueA) && Array.isArray(valueB)) { + return diffArray(valueA, valueB) + } else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') { + return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB] + } else { + const diff = diffObject(valueA, valueB) + return diff ? [ValueOpType.Patch, diff] : null + } +} + +function diffArray(prevArray: unknown[], nextArray: unknown[]): PutOp | AppendOp | PatchOp | null { + if (Object.is(prevArray, nextArray)) return null + // if lengths are equal, check for patch operation + if (prevArray.length === nextArray.length) { + // bail out if more than len/5 items need patching + const maxPatchIndexes = Math.max(prevArray.length / 5, 1) + const toPatchIndexes = [] + for (let i = 0; i < prevArray.length; i++) { + if (!isEqual(prevArray[i], nextArray[i])) { + toPatchIndexes.push(i) + if (toPatchIndexes.length > maxPatchIndexes) { + return [ValueOpType.Put, nextArray] + } + } + } + if (toPatchIndexes.length === 0) { + // same length and no items changed, so no diff + return null + } + const diff: ObjectDiff = {} + for (const i of toPatchIndexes) { + const prevItem = prevArray[i] + const nextItem = nextArray[i] + if (!prevItem || !nextItem) { + diff[i] = [ValueOpType.Put, nextItem] + } else if (typeof prevItem === 'object' && typeof nextItem === 'object') { + const op = diffValue(prevItem, nextItem) + if (op) { + diff[i] = op + } + } else { + diff[i] = [ValueOpType.Put, nextItem] + } + } + return [ValueOpType.Patch, diff] + } + + // if lengths are not equal, check for append operation, and bail out + // to replace whole array if any shared elems changed + for (let i = 0; i < prevArray.length; i++) { + if (!isEqual(prevArray[i], nextArray[i])) { + return [ValueOpType.Put, nextArray] + } + } + + return [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length] +} + +/** @public */ +export function applyObjectDiff(object: T, objectDiff: ObjectDiff): T { + // don't patch nulls + if (!object || typeof object !== 'object') return object + const isArray = Array.isArray(object) + let newObject: any | undefined = undefined + const set = (k: any, v: any) => { + if (!newObject) { + if (isArray) { + newObject = [...object] + } else { + newObject = { ...object } + } + } + if (isArray) { + newObject[Number(k)] = v + } else { + newObject[k] = v + } + } + for (const [key, op] of Object.entries(objectDiff)) { + switch (op[0]) { + case ValueOpType.Put: { + const value = op[1] + if (!isEqual(object[key as keyof T], value)) { + set(key, value) + } + break + } + case ValueOpType.Append: { + const values = op[1] + const offset = op[2] + const arr = object[key as keyof T] + if (Array.isArray(arr) && arr.length === offset) { + set(key, [...arr, ...values]) + } + break + } + case ValueOpType.Patch: { + if (object[key as keyof T] && typeof object[key as keyof T] === 'object') { + const diff = op[1] + const patched = applyObjectDiff(object[key as keyof T] as object, diff) + if (patched !== object[key as keyof T]) { + set(key, patched) + } + } + break + } + case ValueOpType.Delete: { + if (key in object) { + if (!newObject) { + if (isArray) { + console.error("Can't delete array item yet (this should never happen)") + newObject = [...object] + } else { + newObject = { ...object } + } + } + delete newObject[key] + } + } + } + } + + return newObject ?? object +} diff --git a/packages/tlsync/src/lib/interval.ts b/packages/tlsync/src/lib/interval.ts new file mode 100644 index 000000000..2b38ec640 --- /dev/null +++ b/packages/tlsync/src/lib/interval.ts @@ -0,0 +1,4 @@ +export function interval(cb: () => void, timeout: number) { + const i = setInterval(cb, timeout) + return () => clearInterval(i) +} diff --git a/packages/tlsync/src/lib/protocol.ts b/packages/tlsync/src/lib/protocol.ts new file mode 100644 index 000000000..e1c7a4a5c --- /dev/null +++ b/packages/tlsync/src/lib/protocol.ts @@ -0,0 +1,80 @@ +import { SerializedSchema, UnknownRecord } from '@tldraw/store' +import { NetworkDiff, ObjectDiff, RecordOpType } from './diff' + +/** @public */ +export const TLSYNC_PROTOCOL_VERSION = 4 + +/** @public */ +export enum TLIncompatibilityReason { + ClientTooOld = 'clientTooOld', + ServerTooOld = 'serverTooOld', + InvalidRecord = 'invalidRecord', + InvalidOperation = 'invalidOperation', +} + +/** @public */ +export type TLSocketServerSentEvent = + | { + type: 'connect' + hydrationType: 'wipe_all' | 'wipe_presence' + connectRequestId: string + protocolVersion: number + schema: SerializedSchema + diff: NetworkDiff + serverClock: number + } + | { + type: 'incompatibility_error' + reason: TLIncompatibilityReason + } + | { + type: 'patch' + diff: NetworkDiff + serverClock: number + } + | { + type: 'error' + error?: any + } + | { + type: 'push_result' + clientClock: number + serverClock: number + action: 'discard' | 'commit' | { rebaseWithDiff: NetworkDiff } + } + | { + type: 'pong' + } + +/** @public */ +export type TLPushRequest = + | { + type: 'push' + clientClock: number + presence: [RecordOpType.Patch, ObjectDiff] | [RecordOpType.Put, R] + } + | { + type: 'push' + clientClock: number + diff: NetworkDiff + } + +/** @public */ +export type TLConnectRequest = { + type: 'connect' + connectRequestId: string + lastServerClock: number + protocolVersion: number + schema: SerializedSchema +} + +/** @public */ +export type TLPingRequest = { + type: 'ping' +} + +/** @public */ +export type TLSocketClientSentEvent = + | TLPushRequest + | TLConnectRequest + | TLPingRequest diff --git a/packages/tlsync/src/lib/requestAnimationFrame.polyfill.ts b/packages/tlsync/src/lib/requestAnimationFrame.polyfill.ts new file mode 100644 index 000000000..e2340d517 --- /dev/null +++ b/packages/tlsync/src/lib/requestAnimationFrame.polyfill.ts @@ -0,0 +1,7 @@ +globalThis.requestAnimationFrame = + globalThis.requestAnimationFrame || + function requestAnimationFrame(cb) { + return setTimeout(cb, 1000 / 60) + } + +export {} diff --git a/packages/tlsync/src/lib/schema.ts b/packages/tlsync/src/lib/schema.ts new file mode 100644 index 000000000..3dd462c84 --- /dev/null +++ b/packages/tlsync/src/lib/schema.ts @@ -0,0 +1,86 @@ +import { + arrowShapeMigrations, + arrowShapeProps, + bookmarkShapeMigrations, + bookmarkShapeProps, + createTLSchema, + drawShapeMigrations, + drawShapeProps, + embedShapeMigrations, + embedShapeProps, + frameShapeMigrations, + frameShapeProps, + geoShapeMigrations, + geoShapeProps, + groupShapeMigrations, + groupShapeProps, + highlightShapeMigrations, + highlightShapeProps, + imageShapeMigrations, + imageShapeProps, + lineShapeMigrations, + lineShapeProps, + noteShapeMigrations, + noteShapeProps, + textShapeMigrations, + textShapeProps, + videoShapeMigrations, + videoShapeProps, +} from '@tldraw/tlschema' + +export const schema = createTLSchema({ + shapes: { + group: { + props: groupShapeProps, + migrations: groupShapeMigrations, + }, + text: { + props: textShapeProps, + migrations: textShapeMigrations, + }, + bookmark: { + props: bookmarkShapeProps, + migrations: bookmarkShapeMigrations, + }, + draw: { + props: drawShapeProps, + migrations: drawShapeMigrations, + }, + geo: { + props: geoShapeProps, + migrations: geoShapeMigrations, + }, + note: { + props: noteShapeProps, + migrations: noteShapeMigrations, + }, + line: { + props: lineShapeProps, + migrations: lineShapeMigrations, + }, + frame: { + props: frameShapeProps, + migrations: frameShapeMigrations, + }, + arrow: { + props: arrowShapeProps, + migrations: arrowShapeMigrations, + }, + highlight: { + props: highlightShapeProps, + migrations: highlightShapeMigrations, + }, + embed: { + props: embedShapeProps, + migrations: embedShapeMigrations, + }, + image: { + props: imageShapeProps, + migrations: imageShapeMigrations, + }, + video: { + props: videoShapeProps, + migrations: videoShapeMigrations, + }, + }, +}) diff --git a/packages/tlsync/src/lib/serializeMessage.ts b/packages/tlsync/src/lib/serializeMessage.ts new file mode 100644 index 000000000..cc3ff11bc --- /dev/null +++ b/packages/tlsync/src/lib/serializeMessage.ts @@ -0,0 +1,22 @@ +import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol' + +type Message = TLSocketServerSentEvent | TLSocketClientSentEvent + +let _lastSentMessage: Message | null = null +let _lastSentMessageSerialized: string | null = null + +/** + * Serializes a message to a string. Caches the last serialized message to optimize for cases where + * the same message is broadcast to multiple places. + * + * @public + */ +export function serializeMessage(message: Message) { + if (message === _lastSentMessage) { + return _lastSentMessageSerialized as string + } else { + _lastSentMessage = message + _lastSentMessageSerialized = JSON.stringify(message) + return _lastSentMessageSerialized + } +} diff --git a/packages/tlsync/src/lib/server-types.ts b/packages/tlsync/src/lib/server-types.ts new file mode 100644 index 000000000..511ebf66c --- /dev/null +++ b/packages/tlsync/src/lib/server-types.ts @@ -0,0 +1,12 @@ +import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom' + +/** @public */ +export type RoomState = { + // the slug of the room + persistenceKey: string + // the room + room: TLSyncRoom +} + +/** @public */ +export type PersistedRoomSnapshotForSupabase = { id: string; slug: string; drawing: RoomSnapshot } diff --git a/packages/tlsync/src/test/FuzzEditor.ts b/packages/tlsync/src/test/FuzzEditor.ts new file mode 100644 index 000000000..55f7dc418 --- /dev/null +++ b/packages/tlsync/src/test/FuzzEditor.ts @@ -0,0 +1,394 @@ +import { + Editor, + PageRecordType, + TLArrowShapeTerminal, + TLPage, + TLPageId, + TLShape, + TLShapeId, + TLStore, + createShapeId, + defaultShapeUtils, + defaultTools, +} from '@tldraw/tldraw' +import { RandomSource } from './RandomSource' + +export type Op = + | { + type: 'create-box' + parentId?: TLShapeId + x: number + y: number + width: number + height: number + } + | { + type: 'create-frame' + x: number + y: number + width: number + height: number + } + | { + type: 'group-selection' + } + | { + type: 'ungroup-selection' + } + | { + type: 'create-arrow' + start: TLArrowShapeTerminal + end: TLArrowShapeTerminal + } + | { + type: 'delete-shape' + id: TLShapeId + } + | { + type: 'create-page' + id: TLPageId + } + | { + type: 'delete-page' + id: TLPageId + } + | { + type: 'undo' + } + | { + type: 'redo' + } + | { + type: 'switch-page' + id: TLPageId + } + | { + type: 'select-shape' + id: TLShapeId + } + | { + type: 'deselect-shape' + id: TLShapeId + } + | { + type: 'move-selection' + dx: number + dy: number + } + | { + type: 'delete-selection' + } + | { + type: 'move-selected-shapes-to-page' + pageId: TLPageId + } + | { + type: 'mark-stopping-point' + } + +export class FuzzEditor extends RandomSource { + editor: Editor + + constructor( + public readonly id: string, + _seed: number, + public readonly store: TLStore + ) { + super(_seed) + this.editor = new Editor({ + shapeUtils: defaultShapeUtils, + tools: defaultTools, + initialState: 'select', + store, + getContainer: () => document.createElement('div'), + }) + } + + ops: Op[] = [] + + getRandomShapeId({ selected }: { selected?: boolean } = {}): TLShapeId | undefined { + return this.randomElement( + selected ? this.editor.getSelectedShapes() : this.editor.getCurrentPageShapes() + )?.id + } + + getRandomOp(): Op { + const op = this.randomAction( + [ + () => { + const x = this.randomInt(1000) + const y = this.randomInt(1000) + const width = this.randomInt(1, 1000) + const height = this.randomInt(1, 1000) + let parentId: TLShapeId | undefined + if (this.randomInt(2) === 0) { + parentId = this.randomElement( + this.editor.getCurrentPageShapes().filter((s) => s.type === 'frame') + )?.id + } + return { type: 'create-box', x, y, width, height, parentId } + }, + () => { + const x = this.randomInt(1000) + const y = this.randomInt(1000) + const width = this.randomInt(1, 1000) + const height = this.randomInt(1, 1000) + return { type: 'create-frame', x, y, width, height } + }, + // Need to disable arrows for the time being, the cleanup logic leads to state inconsistency. + // We need a better way to handle state updates. + // () => { + // let start: TLArrowTerminal = { + // type: 'point', + // x: this.randomInt(1000), + // y: this.randomInt(1000), + // } + // let end: TLArrowTerminal = { + // type: 'point', + // x: this.randomInt(1000), + // y: this.randomInt(1000), + // } + + // if (this.randomInt(2) === 0) { + // const boundShapeId = this.getRandomShapeId() + // if (boundShapeId) { + // start = { + // type: 'binding', + // boundShapeId: boundShapeId, + // isExact: true, + // normalizedAnchor: { x: 0.5, y: 0.5 }, + // } + // } + // } + + // if (this.randomInt(2) === 0) { + // const boundShapeId = this.getRandomShapeId() + // if (boundShapeId) { + // end = { + // type: 'binding', + // boundShapeId: boundShapeId, + // isExact: true, + // normalizedAnchor: { x: 0.5, y: 0.5 }, + // } + // } + // } + + // return { type: 'create-arrow', start, end } + // }, + () => { + const id = this.getRandomShapeId() + if (id) { + return { type: 'delete-shape', id } + } + return this.getRandomOp() + }, + () => { + return { type: 'create-page', id: PageRecordType.createId() } + }, + () => { + const id = this.randomElement(this.editor.getPages())?.id + if (id) { + return { type: 'delete-page', id } + } + return this.getRandomOp() + }, + () => { + return { type: 'undo' } + }, + () => { + return { type: 'redo' } + }, + () => { + return { type: 'mark-stopping-point' } + }, + () => { + if (this.editor.getSelectedShapes().length > 1) { + return { type: 'group-selection' } + } + return this.getRandomOp() + }, + () => { + if (this.editor.getSelectedShapes().some((s) => s.type === 'group')) { + return { type: 'ungroup-selection' } + } + return this.getRandomOp() + }, + () => { + const id = this.randomElement(this.editor.getPages())?.id + if (id) { + return { type: 'switch-page', id } + } + return this.getRandomOp() + }, + () => { + const id = this.getRandomShapeId() + if (id) { + return { type: 'select-shape', id } + } + return this.getRandomOp() + }, + () => { + const id = this.getRandomShapeId({ selected: true }) + if (id) { + return { type: 'deselect-shape', id } + } + return this.getRandomOp() + }, + () => { + if (this.editor.getSelectedShapes().length) { + const dx = this.randomInt(1000) + const dy = this.randomInt(1000) + return { type: 'move-selection', dx, dy } + } + return this.getRandomOp() + }, + () => { + if (this.editor.getSelectedShapes().length) { + return { type: 'delete-selection' } + } + return this.getRandomOp() + }, + () => { + if (this.editor.getSelectedShapes().length) { + const pageId = this.randomElement( + this.editor.getPages().filter((p) => p.id !== this.editor.getCurrentPageId()) + )?.id + if (pageId) { + return { type: 'move-selected-shapes-to-page', pageId } + } + } + return this.getRandomOp() + }, + ], + true + ) + this.ops.push(op) + return op + } + + applyOp(op: Op) { + switch (op.type) { + case 'create-box': { + this.editor.createShape({ + type: 'geo', + id: createShapeId(), + x: op.x, + y: op.y, + parentId: op.parentId, + props: { + w: op.width, + h: op.height, + }, + }) + break + } + + case 'create-frame': { + this.editor.createShape({ + type: 'frame', + id: createShapeId(), + x: op.x, + y: op.y, + props: { + w: op.width, + h: op.height, + }, + }) + break + } + + case 'create-arrow': { + this.editor.createShape({ + type: 'arrow', + id: createShapeId(), + x: 0, + y: 0, + props: { + start: op.start, + end: op.end, + }, + }) + break + } + + case 'delete-shape': { + this.editor.deleteShape(op.id) + break + } + + case 'create-page': { + this.editor.createPage({ id: op.id, name: op.id }) + break + } + + case 'delete-page': { + this.editor.deletePage(op.id) + break + } + + case 'undo': { + this.editor.undo() + break + } + + case 'redo': { + this.editor.redo() + break + } + + case 'group-selection': { + this.editor.groupShapes(this.editor.getSelectedShapeIds()) + break + } + + case 'ungroup-selection': { + this.editor.ungroupShapes(this.editor.getSelectedShapeIds()) + break + } + + case 'mark-stopping-point': { + this.editor.mark() + break + } + + case 'switch-page': { + this.editor.setCurrentPage(op.id) + break + } + + case 'select-shape': { + this.editor.select(op.id) + break + } + + case 'deselect-shape': { + this.editor.deselect(op.id) + break + } + + case 'move-selection': { + this.editor.updateShapes( + this.editor.getSelectedShapes().map((s) => ({ + ...s, + x: s.x + op.dx, + y: s.y + op.dy, + })) + ) + break + } + + case 'delete-selection': { + this.editor.deleteShapes(this.editor.getSelectedShapeIds()) + break + } + + case 'move-selected-shapes-to-page': { + this.editor.moveShapesToPage(this.editor.getSelectedShapeIds(), op.pageId) + break + } + + default: + throw new Error(`Unknown op type: ${JSON.stringify((op as any).type)}`) + } + } +} diff --git a/packages/tlsync/src/test/RandomSource.ts b/packages/tlsync/src/test/RandomSource.ts new file mode 100644 index 000000000..55136be2a --- /dev/null +++ b/packages/tlsync/src/test/RandomSource.ts @@ -0,0 +1,45 @@ +export class RandomSource { + constructor(private _seed: number) {} + + randomInt(): number + randomInt(lessThan: number): number + randomInt(fromInclusive: number, toExclusive: number): number + randomInt(lo?: number, hi?: number) { + if (lo === undefined) { + lo = Number.MAX_SAFE_INTEGER + } + if (hi === undefined) { + hi = lo + lo = 0 + } + this._seed = (this._seed * 9301 + 49297) % 233280 + // float is a number between 0 and 1 + const float = this._seed / 233280 + return lo + Math.floor(float * (hi - lo)) + } + + randomAction( + choices: Array<(() => Result) | { weight: number; do: () => any }>, + randomWeights?: boolean + ): Result { + type Choice = (typeof choices)[number] + const getWeightFromChoice = (choice: Choice) => + 'weight' in choice ? choice.weight : randomWeights ? this.randomInt(0, 10) : 1 + const weights = choices.map(getWeightFromChoice) + const totalWeight = weights.reduce((total, w) => total + w, 0) + const randomWeight = this.randomInt(totalWeight) + let weight = 0 + for (let i = 0; i < choices.length; i++) { + weight += weights[i] + const choice = choices[i] + if (randomWeight < weight) { + return 'do' in choice ? choice.do() : choice() + } + } + throw new Error('unreachable') + } + + randomElement(items: Elem[]): Elem | undefined { + return items[this.randomInt(items.length)] + } +} diff --git a/packages/tlsync/src/test/TLServer.test.ts b/packages/tlsync/src/test/TLServer.test.ts new file mode 100644 index 000000000..b645275ba --- /dev/null +++ b/packages/tlsync/src/test/TLServer.test.ts @@ -0,0 +1,165 @@ +import { TLRecord, createTLStore, defaultShapeUtils } from '@tldraw/tldraw' +import { type WebSocket } from 'ws' +import { RoomSessionState } from '../lib/RoomSession' +import { DBLoadResult, TLServer } from '../lib/TLServer' +import { chunk } from '../lib/chunk' +import { RecordOpType } from '../lib/diff' +import { TLSYNC_PROTOCOL_VERSION, TLSocketClientSentEvent } from '../lib/protocol' +import { RoomState } from '../lib/server-types' + +// Because we are using jsdom in this package, jest tries to load the 'browser' version of the ws library +// which doesn't do anything except throw an error. So we need to sneakily load the node version of ws. +const wsPath = require.resolve('ws').replace('/browser.js', '/index.js') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const ws = require(wsPath) as typeof import('ws') + +const PORT = 23473 + +const disposables: (() => void)[] = [] + +class TLServerTestImpl extends TLServer { + wsServer = new ws.Server({ port: PORT }) + async close() { + await new Promise((resolve) => { + this.wsServer.close((err) => { + if (err) { + console.error(err) + } + resolve(err) + }) + }) + } + async createSocketPair() { + const connectionPromise = new Promise((resolve) => { + this.wsServer.on('connection', resolve) + }) + + const client = new ws.WebSocket('ws://localhost:' + PORT) + disposables.push(() => { + client.close() + }) + const openPromise = new Promise((resolve) => { + client.on('open', resolve) + }) + + const server = await connectionPromise + disposables.push(() => { + server.close() + }) + await openPromise + + return { + client, + server, + } + } + override async loadFromDatabase?(_roomId: string): Promise { + return { type: 'room_not_found' } + } + override async persistToDatabase?(_roomId: string): Promise { + return + } + override logEvent(_event: any): void { + return + } + roomState: RoomState | undefined = undefined + override getRoomForPersistenceKey(_persistenceKey: string): RoomState | undefined { + return this.roomState + } + override setRoomState(_persistenceKey: string, roomState: RoomState): void { + this.roomState = roomState + } + override deleteRoomState(_persistenceKey: string): void { + this.roomState = undefined + } +} +type UnpackPromise = T extends Promise ? U : T + +const schema = createTLStore({ shapeUtils: defaultShapeUtils }).schema.serialize() + +let server: TLServerTestImpl +let sockets: UnpackPromise> +beforeEach(async () => { + server = new TLServerTestImpl() + sockets = await server.createSocketPair() + expect(sockets.client.readyState).toBe(ws.OPEN) + expect(sockets.server.readyState).toBe(ws.OPEN) +}) + +const openConnection = async () => { + await server.handleConnection({ + persistenceKey: 'test-persistence-key', + sessionKey: 'test-session-key', + socket: sockets.server, + storeId: 'test-store-id', + }) +} + +afterEach(async () => { + disposables.forEach((d) => d()) + disposables.length = 0 + await server.close() +}) + +describe('TLServer', () => { + it('accepts new connections', async () => { + await openConnection() + + expect(server.roomState).not.toBeUndefined() + expect(server.roomState?.persistenceKey).toBe('test-persistence-key') + expect(server.roomState?.room.sessions.size).toBe(1) + expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( + RoomSessionState.AWAITING_CONNECT_MESSAGE + ) + }) + + it('allows requests to be chunked', async () => { + await openConnection() + + const connectMsg: TLSocketClientSentEvent = { + type: 'connect', + lastServerClock: 0, + connectRequestId: 'test-connect-request-id', + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema, + } + + const chunks = chunk(JSON.stringify(connectMsg), 200) + expect(chunks.length).toBeGreaterThan(1) + + const onClientMessage = jest.fn() + const receivedPromise = new Promise((resolve) => { + onClientMessage.mockImplementationOnce(resolve) + }) + + sockets.client.on('message', onClientMessage) + + expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( + RoomSessionState.AWAITING_CONNECT_MESSAGE + ) + + for (const chunk of chunks) { + sockets.client.send(chunk) + } + + await receivedPromise + + expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( + RoomSessionState.CONNECTED + ) + + expect(onClientMessage).toHaveBeenCalledTimes(1) + expect(JSON.parse(onClientMessage.mock.calls[0][0])).toMatchObject({ + connectRequestId: 'test-connect-request-id', + hydrationType: 'wipe_all', + diff: { + 'document:document': [ + RecordOpType.Put, + { + /* ... */ + }, + ], + }, + }) + }) +}) diff --git a/packages/tlsync/src/test/TLSyncRoom.test.ts b/packages/tlsync/src/test/TLSyncRoom.test.ts new file mode 100644 index 000000000..2232492f8 --- /dev/null +++ b/packages/tlsync/src/test/TLSyncRoom.test.ts @@ -0,0 +1,156 @@ +import { SerializedSchema } from '@tldraw/store' +import { + CameraRecordType, + DocumentRecordType, + InstancePageStateRecordType, + PageRecordType, + TLArrowShape, + TLArrowShapeProps, + TLBaseShape, + TLRecord, + TLShapeId, + createTLSchema, +} from '@tldraw/tlschema' +import { sortById } from '@tldraw/utils' +import { + MAX_TOMBSTONES, + RoomSnapshot, + TLSyncRoom, + TOMBSTONE_PRUNE_BUFFER_SIZE, +} from '../lib/TLSyncRoom' +import { schema } from '../lib/schema' + +const compareById = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id) + +const records = [ + DocumentRecordType.create({}), + PageRecordType.create({ index: 'a0', name: 'page 2' }), +].sort(compareById) + +const makeSnapshot = (records: TLRecord[], others: Partial = {}) => ({ + documents: records.map((r) => ({ state: r, lastChangedClock: 0 })), + clock: 0, + ...others, +}) + +const oldArrow: TLBaseShape<'arrow', Omit> = { + typeName: 'shape', + type: 'arrow', + id: 'shape:old_arrow' as TLShapeId, + index: 'a0', + isLocked: false, + parentId: PageRecordType.createId(), + rotation: 0, + x: 0, + y: 0, + opacity: 1, + props: { + dash: 'draw', + size: 'm', + fill: 'none', + color: 'black', + bend: 0, + start: { type: 'point', x: 0, y: 0 }, + end: { type: 'point', x: 0, y: 0 }, + arrowheadStart: 'none', + arrowheadEnd: 'arrow', + text: '', + font: 'draw', + }, + meta: {}, +} + +describe('TLSyncRoom', () => { + it('can be constructed with a schema alone', () => { + const room = new TLSyncRoom(schema) + + // we populate the store with a default document if none is given + expect(room.getSnapshot().documents.length).toBeGreaterThan(0) + }) + + it('can be constructed with a snapshot', () => { + const room = new TLSyncRoom(schema, makeSnapshot(records)) + + expect( + room + .getSnapshot() + .documents.map((r) => r.state) + .sort(sortById) + ).toEqual(records) + + expect(room.getSnapshot().documents.map((r) => r.lastChangedClock)).toEqual([0, 0]) + }) + + it('trims tombstones down if you pass too many in the snapshot', () => { + const room = new TLSyncRoom(schema, { + documents: [], + clock: MAX_TOMBSTONES + 100, + tombstones: Object.fromEntries( + Array.from({ length: MAX_TOMBSTONES + 100 }, (_, i) => [PageRecordType.createId(), i]) + ), + }) + + expect(Object.keys(room.getSnapshot().tombstones ?? {})).toHaveLength( + MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE + ) + }) + + it('migrates the snapshot if it is dealing with old data', () => { + const serializedSchema = schema.serialize() + const oldSerializedSchema: SerializedSchema = { + ...serializedSchema, + recordVersions: { + ...serializedSchema.recordVersions, + shape: { + ...serializedSchema.recordVersions.shape, + subTypeVersions: { + ...('subTypeVersions' in serializedSchema.recordVersions.shape + ? serializedSchema.recordVersions.shape.subTypeVersions + : {}), + // we add a labelColor to arrow in v1 + arrow: 0, + }, + }, + }, + } + + const room = new TLSyncRoom( + schema, + makeSnapshot([...records, oldArrow], { + schema: oldSerializedSchema, + }) + ) + + const arrow = room.getSnapshot().documents.find((r) => r.state.id === oldArrow.id) + ?.state as TLArrowShape + expect(arrow.props.labelColor).toBe('black') + }) + + it('filters out instance state records', () => { + const schema = createTLSchema({ shapes: {} }) + const room = new TLSyncRoom( + schema, + makeSnapshot([ + ...records, + schema.types.instance.create({ + currentPageId: PageRecordType.createId('page_1'), + id: schema.types.instance.createId('instance_1'), + }), + InstancePageStateRecordType.create({ + id: InstancePageStateRecordType.createId(PageRecordType.createId('page_1')), + pageId: PageRecordType.createId('page_1'), + }), + CameraRecordType.create({ + id: CameraRecordType.createId('camera_1'), + }), + ]) + ) + + expect( + room + .getSnapshot() + .documents.map((r) => r.state) + .sort(sortById) + ).toEqual(records) + }) +}) diff --git a/packages/tlsync/src/test/TestServer.ts b/packages/tlsync/src/test/TestServer.ts new file mode 100644 index 000000000..66dabcffb --- /dev/null +++ b/packages/tlsync/src/test/TestServer.ts @@ -0,0 +1,24 @@ +import { StoreSchema, UnknownRecord } from '@tldraw/store' +import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom' +import { TestSocketPair } from './TestSocketPair' + +export class TestServer { + room: TLSyncRoom + constructor(schema: StoreSchema, snapshot?: RoomSnapshot) { + this.room = new TLSyncRoom(schema, snapshot) + } + + connect(socketPair: TestSocketPair): void { + this.room.handleNewSession(socketPair.id, socketPair.roomSocket) + + socketPair.clientSocket.connectionStatus = 'online' + socketPair.didReceiveFromClient = (msg) => { + this.room.handleMessage(socketPair.id, msg) + } + socketPair.clientDisconnected = () => { + this.room.handleClose(socketPair.id) + } + + socketPair.callbacks.onStatusChange?.('online') + } +} diff --git a/packages/tlsync/src/test/TestSocketPair.ts b/packages/tlsync/src/test/TestSocketPair.ts new file mode 100644 index 000000000..db90039ca --- /dev/null +++ b/packages/tlsync/src/test/TestSocketPair.ts @@ -0,0 +1,102 @@ +import { UnknownRecord } from '@tldraw/store' +import { TLPersistentClientSocket, TLPersistentClientSocketStatus } from '../lib/TLSyncClient' +import { TLRoomSocket } from '../lib/TLSyncRoom' +import { TLSocketClientSentEvent, TLSocketServerSentEvent } from '../lib/protocol' +import { TestServer } from './TestServer' + +export class TestSocketPair { + clientSentEventQueue: TLSocketClientSentEvent[] = [] + serverSentEventQueue: TLSocketServerSentEvent[] = [] + flushServerSentEvents() { + const queue = this.serverSentEventQueue + this.serverSentEventQueue = [] + queue.forEach((msg) => { + this.callbacks.onReceiveMessage?.(msg) + }) + } + flushClientSentEvents() { + const queue = this.clientSentEventQueue + this.clientSentEventQueue = [] + queue.forEach((msg) => { + this.didReceiveFromClient?.(msg) + }) + } + + getNeedsFlushing() { + return this.serverSentEventQueue.length > 0 || this.clientSentEventQueue.length > 0 + } + + roomSocket: TLRoomSocket = { + close: () => { + this.flushServerSentEvents() + this.disconnect() + }, + get isOpen() { + return true + }, + sendMessage: (msg: TLSocketServerSentEvent) => { + if (!this.callbacks.onReceiveMessage) { + throw new Error('Socket is closed') + } + if (this.clientSocket.connectionStatus !== 'online') { + // client was closed, drop the packet + return + } + this.serverSentEventQueue.push(msg) + }, + } + didReceiveFromClient?: (msg: TLSocketClientSentEvent) => void = undefined + clientDisconnected?: () => void = undefined + clientSocket: TLPersistentClientSocket = { + connectionStatus: 'offline', + onStatusChange: (cb) => { + this.callbacks.onStatusChange = cb + return () => { + this.callbacks.onStatusChange = null + } + }, + onReceiveMessage: (cb) => { + this.callbacks.onReceiveMessage = cb + return () => { + this.callbacks.onReceiveMessage = null + } + }, + sendMessage: (msg: TLSocketClientSentEvent) => { + if (this.clientSocket.connectionStatus !== 'online') { + throw new Error('trying to send before open') + } + this.clientSentEventQueue.push(msg) + }, + restart: () => { + this.disconnect() + this.connect() + }, + } + + callbacks = { + onReceiveMessage: null as null | ((msg: TLSocketServerSentEvent) => void), + onStatusChange: null as null | ((status: TLPersistentClientSocketStatus) => void), + } + + // eslint-disable-next-line no-restricted-syntax + get isConnected() { + return this.clientSocket.connectionStatus === 'online' + } + + connect() { + this.server.connect(this) + } + + disconnect() { + this.clientSocket.connectionStatus = 'offline' + this.serverSentEventQueue = [] + this.clientSentEventQueue = [] + this.callbacks.onStatusChange?.('offline') + this.clientDisconnected?.() + } + + constructor( + public readonly id: string, + public readonly server: TestServer + ) {} +} diff --git a/packages/tlsync/src/test/chunk.test.ts b/packages/tlsync/src/test/chunk.test.ts new file mode 100644 index 000000000..e19edf082 --- /dev/null +++ b/packages/tlsync/src/test/chunk.test.ts @@ -0,0 +1,161 @@ +import { JsonChunkAssembler, chunk } from '../lib/chunk' + +describe('chunk', () => { + it('chunks a string', () => { + expect(chunk('hello there my good world', 5)).toMatchInlineSnapshot(` + Array [ + "8_h", + "7_ell", + "6_o t", + "5_her", + "4_e m", + "3_y g", + "2_ood", + "1_ wo", + "0_rld", + ] + `) + + expect(chunk('hello there my good world', 10)).toMatchInlineSnapshot(` + Array [ + "3_h", + "2_ello the", + "1_re my go", + "0_od world", + ] + `) + }) + + it('does not chunk the string if it is small enough', () => { + const chunks = chunk('hello', 100) + expect(chunks).toMatchInlineSnapshot(` + Array [ + "hello", + ] + `) + }) + + it('makes sure the chunk length does not exceed the given message size', () => { + const chunks = chunk('dark and stormy tonight', 4) + expect(chunks).toMatchInlineSnapshot(` + Array [ + "12_d", + "11_a", + "10_r", + "9_k ", + "8_an", + "7_d ", + "6_st", + "5_or", + "4_my", + "3_ t", + "2_on", + "1_ig", + "0_ht", + ] + `) + }) + + it('does its best if the chunk size is too small', () => { + const chunks = chunk('once upon a time', 1) + expect(chunks).toMatchInlineSnapshot(` + Array [ + "15_o", + "14_n", + "13_c", + "12_e", + "11_ ", + "10_u", + "9_p", + "8_o", + "7_n", + "6_ ", + "5_a", + "4_ ", + "3_t", + "2_i", + "1_m", + "0_e", + ] + `) + }) +}) + +const testObject = {} as any +for (let i = 0; i < 1000; i++) { + testObject['key_' + i] = 'value_' + i +} + +describe('json unchunker', () => { + it.each([1, 5, 20, 200])('unchunks a json string split at %s bytes', (size) => { + const chunks = chunk(JSON.stringify(testObject), size) + + const unchunker = new JsonChunkAssembler() + for (const chunk of chunks.slice(0, -1)) { + const result = unchunker.handleMessage(chunk) + expect(result).toBeNull() + } + expect(unchunker.handleMessage(chunks[chunks.length - 1])).toEqual({ data: testObject }) + + // and the next one should be fine + expect(unchunker.handleMessage('{"ok": true}')).toEqual({ data: { ok: true } }) + }) + + // todo: test error cases + it('returns an error if the json is whack', () => { + const chunks = chunk('{"hello": world"}', 5) + const unchunker = new JsonChunkAssembler() + for (const chunk of chunks.slice(0, -1)) { + const result = unchunker.handleMessage(chunk) + expect(result).toBeNull() + } + expect( + unchunker.handleMessage(chunks[chunks.length - 1])?.error?.message + ).toMatchInlineSnapshot(`"Unexpected token w in JSON at position 10"`) + + // and the next one should be fine + expect(unchunker.handleMessage('{"ok": true}')).toEqual({ data: { ok: true } }) + }) + it('returns an error if one of the chunks was missing', () => { + const chunks = chunk('{"hello": world"}', 10) + expect(chunks).toHaveLength(3) + + const unchunker = new JsonChunkAssembler() + expect(unchunker.handleMessage(chunks[0])).toBeNull() + expect(unchunker.handleMessage(chunks[2])?.error?.message).toMatchInlineSnapshot( + `"Chunks received in wrong order"` + ) + + // and the next one should be fine + expect(unchunker.handleMessage('{"ok": true}')).toEqual({ data: { ok: true } }) + }) + + it('returns an error if the chunk stream ends abruptly', () => { + const chunks = chunk('{"hello": world"}', 10) + expect(chunks).toHaveLength(3) + + const unchunker = new JsonChunkAssembler() + expect(unchunker.handleMessage(chunks[0])).toBeNull() + expect(unchunker.handleMessage(chunks[1])).toBeNull() + + // it should still return the data for the next message + // even if there was an unexpected end to the chunks stream + const res = unchunker.handleMessage('{"hello": "world"}') + expect(res?.data).toEqual({ hello: 'world' }) + expect(res?.error?.message).toMatchInlineSnapshot(`"Unexpected non-chunk message"`) + + // and the next one should be fine + expect(unchunker.handleMessage('{"ok": true}')).toEqual({ data: { ok: true } }) + }) + + it('returns an error if the chunk syntax is wrong', () => { + // it only likes json objects + const unchunker = new JsonChunkAssembler() + expect(unchunker.handleMessage('["yo"]')?.error?.message).toMatchInlineSnapshot( + `"Invalid chunk: \\"[\\\\\\"yo\\\\\\"]...\\""` + ) + + // and the next one should be fine + expect(unchunker.handleMessage('{"ok": true}')).toEqual({ data: { ok: true } }) + }) +}) diff --git a/packages/tlsync/src/test/diff.test.ts b/packages/tlsync/src/test/diff.test.ts new file mode 100644 index 000000000..e69373f27 --- /dev/null +++ b/packages/tlsync/src/test/diff.test.ts @@ -0,0 +1,189 @@ +import { applyObjectDiff, diffRecord } from '../lib/diff' + +describe('nested arrays', () => { + it('should be patchable at the end', () => { + const a = { + arr: [ + [1, 2, 3], + [4, 5, 6], + ], + } + const b = { + arr: [ + [1, 2, 3], + [4, 5, 6, 7, 8], + ], + } + + expect(diffRecord(a, b)).toMatchInlineSnapshot(` + Object { + "arr": Array [ + "patch", + Object { + "1": Array [ + "append", + Array [ + 7, + 8, + ], + 3, + ], + }, + ], + } + `) + }) + + it('should be patchable at the beginning', () => { + const a = { + arr: [ + [1, 2, 3], + [4, 5, 6], + ], + } + const b = { + arr: [ + [1, 2, 3, 4, 5, 6], + [4, 5, 6], + ], + } + + expect(diffRecord(a, b)).toMatchInlineSnapshot(` + Object { + "arr": Array [ + "patch", + Object { + "0": Array [ + "append", + Array [ + 4, + 5, + 6, + ], + 3, + ], + }, + ], + } + `) + }) +}) + +describe('objects inside arrays', () => { + it('should be patchable if only item changes', () => { + const a = { + arr: [ + { a: 1, b: 2, c: 3 }, + { a: 4, b: 5, c: 6 }, + ], + } + const b = { + arr: [ + { a: 1, b: 2, c: 3 }, + { a: 4, b: 5, c: 7 }, + ], + } + + expect(diffRecord(a, b)).toMatchInlineSnapshot(` + Object { + "arr": Array [ + "patch", + Object { + "1": Array [ + "patch", + Object { + "c": Array [ + "put", + 7, + ], + }, + ], + }, + ], + } + `) + }) + + it('should return a put op if many items change', () => { + const a = { + arr: [ + { a: 1, b: 2, c: 3 }, + { a: 4, b: 5, c: 6 }, + ], + } + const b = { + arr: [ + { a: 1, b: 2, c: 5 }, + { a: 4, b: 5, c: 7 }, + ], + } + + expect(diffRecord(a, b)).toMatchInlineSnapshot(` + Object { + "arr": Array [ + "put", + Array [ + Object { + "a": 1, + "b": 2, + "c": 5, + }, + Object { + "a": 4, + "b": 5, + "c": 7, + }, + ], + ], + } + `) + }) +}) + +test('deleting things from a record', () => { + const a = { + a: 1, + b: 2, + c: 3, + } + const b = { + a: 1, + b: 2, + } + + const patch = diffRecord(a, b) + expect(patch).toMatchInlineSnapshot(` + Object { + "c": Array [ + "delete", + ], + } + `) + + expect(applyObjectDiff(a, patch!)).toEqual(b) +}) + +test('adding things things to a record', () => { + const a = { + a: 1, + b: 2, + } + const b = { + a: 1, + b: 2, + c: 3, + } + + const patch = diffRecord(a, b) + + expect(patch).toMatchInlineSnapshot(` + Object { + "c": Array [ + "put", + 3, + ], + } + `) + + expect(applyObjectDiff(a, patch!)).toEqual(b) +}) diff --git a/packages/tlsync/src/test/schema.test.ts b/packages/tlsync/src/test/schema.test.ts new file mode 100644 index 000000000..45b1aca27 --- /dev/null +++ b/packages/tlsync/src/test/schema.test.ts @@ -0,0 +1,9 @@ +import { coreShapes, defaultShapeUtils } from '@tldraw/tldraw' +import { schema } from '../lib/schema' + +describe('schema', () => { + test('shape types match core+default shapes', () => { + const shapeTypes = Object.keys(schema.types.shape.migrations.subTypeMigrations!) + expect(shapeTypes).toEqual([...coreShapes, ...defaultShapeUtils].map((s) => s.type)) + }) +}) diff --git a/packages/tlsync/src/test/syncFuzz.test.ts b/packages/tlsync/src/test/syncFuzz.test.ts new file mode 100644 index 000000000..70f8166f6 --- /dev/null +++ b/packages/tlsync/src/test/syncFuzz.test.ts @@ -0,0 +1,290 @@ +import { + Editor, + TLArrowShape, + TLRecord, + TLStore, + computed, + createPresenceStateDerivation, + createTLStore, +} from '@tldraw/tldraw' +import isEqual from 'lodash.isequal' +import { nanoid } from 'nanoid' +import { TLSyncClient } from '../lib/TLSyncClient' +import { schema } from '../lib/schema' +import { FuzzEditor, Op } from './FuzzEditor' +import { RandomSource } from './RandomSource' +import { TestServer } from './TestServer' +import { TestSocketPair } from './TestSocketPair' + +jest.mock('@tldraw/editor/src/lib/editor/managers/TickManager.ts', () => { + return { + TickManager: class { + start() { + // noop + } + }, + } +}) + +// @ts-expect-error +global.requestAnimationFrame = (cb: () => any) => { + cb() +} + +jest.mock('nanoid', () => { + const { RandomSource } = jest.requireActual('./RandomSource') + let source = new RandomSource(0) + // eslint-disable-next-line @typescript-eslint/no-var-requires + const readable = require('uuid-readable') + // eslint-disable-next-line @typescript-eslint/no-var-requires + const uuid = require('uuid-by-string') + const nanoid = () => { + return readable.short(uuid(source.randomInt().toString(16))).replaceAll(' ', '_') + } + return { + nanoid, + default: nanoid, + __reseed(seed: number) { + source = new RandomSource(seed) + }, + } +}) + +const disposables: Array<() => void> = [] + +afterEach(() => { + for (const dispose of disposables) { + dispose() + } + disposables.length = 0 +}) + +class FuzzTestInstance extends RandomSource { + store: TLStore + editor: FuzzEditor | null = null + client: TLSyncClient + socketPair: TestSocketPair + id: string + + hasLoaded = false + + constructor( + public readonly seed: number, + server: TestServer + ) { + super(seed) + + this.store = createTLStore({ schema }) + this.id = nanoid() + this.socketPair = new TestSocketPair(this.id, server) + this.client = new TLSyncClient({ + store: this.store, + socket: this.socketPair.clientSocket, + onSyncError: (reason) => { + throw new Error('onSyncError:' + reason) + }, + onLoad: () => { + this.editor = new FuzzEditor(this.id, this.seed, this.store) + }, + onLoadError: (e) => { + throw new Error('onLoadError', e) + }, + presence: createPresenceStateDerivation( + computed('', () => ({ + id: this.id, + name: 'test', + color: 'red', + locale: 'en', + })) + )(this.store), + }) + + disposables.push(() => { + this.client.close() + }) + } +} + +let totalNumShapes = 0 +let totalNumPages = 0 + +function arrowsAreSound(editor: Editor) { + const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow') as TLArrowShape[] + for (const arrow of arrows) { + for (const terminal of [arrow.props.start, arrow.props.end]) { + if (terminal.type === 'binding' && !editor.store.has(terminal.boundShapeId)) { + return false + } + } + } + return true +} + +function runTest(seed: number) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('nanoid').__reseed(seed) + const server = new TestServer(schema) + const instance = new FuzzTestInstance(seed, server) + + const peers = [instance, new FuzzTestInstance(instance.randomInt(), server)] + const numExtraPeers = instance.randomInt(MAX_PEERS - 2) + for (let i = 0; i < numExtraPeers; i++) { + peers.push(new FuzzTestInstance(instance.randomInt(), server)) + } + + const allOk = (when: string) => { + if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPage())) { + throw new Error(`not all peer editors have current page (${when})`) + } + if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPageState())) { + throw new Error(`not all peer editors have page states (${when})`) + } + if ( + peers.some( + (p) => p.client.isConnectedToRoom && p.socketPair.clientSocket.connectionStatus !== 'online' + ) + ) { + throw new Error(`peer client connection status mismatch (${when})`) + } + if (peers.some((p) => p.editor?.editor && !arrowsAreSound(p.editor.editor))) { + throw new Error(`peer editor arrows are not sound (${when})`) + } + const numOtherPeersConnected = peers.filter((p) => p.hasLoaded).length - 1 + if ( + peers.some( + (p) => + p.hasLoaded && + p.editor?.editor.store.query.ids('instance_presence').get().size !== + numOtherPeersConnected + ) + ) { + throw new Error(`not all peer editors have instance presence (${when})`) + } + } + + const ops: Array<{ peerId: string; op: Op; id: number }> = [] + try { + for (let i = 0; i < NUM_OPS_PER_TEST; i++) { + const peer = peers[instance.randomInt(peers.length)] + + if (peer.editor) { + const op = peer.editor.getRandomOp() + ops.push({ peerId: peer.id, op, id: ops.length }) + + allOk('before applyOp') + peer.editor.applyOp(op) + allOk('after applyOp') + + if (peer.socketPair.isConnected && peer.randomInt(6) === 0) { + // randomly disconnect a peer + peer.socketPair.disconnect() + allOk('disconnect') + } else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { + // randomly reconnect a peer + peer.socketPair.connect() + allOk('connect') + } + } else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { + peer.socketPair.connect() + allOk('connect 2') + } + + const peersThatNeedFlushing = peers.filter((p) => p.socketPair.getNeedsFlushing()) + for (const peer of peersThatNeedFlushing) { + if (peer.randomInt(10) < 4) { + allOk('before flush server ' + i) + peer.socketPair.flushServerSentEvents() + allOk('flush server ' + i) + } else if (peer.randomInt(10) < 2) { + peer.socketPair.flushClientSentEvents() + allOk('flush client') + } + } + } + + // bring all clients online and flush all messages to make sure everyone has seen all messages + while (peers.some((p) => !p.socketPair.isConnected)) { + for (const peer of peers) { + if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { + peer.socketPair.connect() + allOk('final connect') + } + } + } + + while (peers.some((p) => p.socketPair.getNeedsFlushing())) { + for (const peer of peers) { + if (peer.socketPair.getNeedsFlushing()) { + peer.socketPair.flushServerSentEvents() + allOk('final flushServer') + peer.socketPair.flushClientSentEvents() + allOk('final flushClient') + } + } + } + + const equalityResults = [] + for (let i = 0; i < peers.length; i++) { + const row = [] + for (let j = 0; j < peers.length; j++) { + row.push( + isEqual( + peers[i].editor?.store.serialize('document'), + peers[j].editor?.store.serialize('document') + ) + ) + } + equalityResults.push(row) + } + + const [first, ...rest] = peers.map((peer) => peer.editor?.store.serialize('document')) + + // writeFileSync(`./test-results.${seed}.json`, JSON.stringify(ops, null, 2)) + + expect(first).toEqual(rest[0]) + // all snapshots should be the same + expect(rest.every((other) => isEqual(other, first))).toBe(true) + totalNumPages += Object.values(first!).filter((v) => v.typeName === 'page').length + totalNumShapes += Object.values(first!).filter((v) => v.typeName === 'shape').length + } catch (e) { + console.error('seed', seed) + console.error( + 'peers', + JSON.stringify( + peers.map((p) => p.id), + null, + 2 + ) + ) + console.error('ops', JSON.stringify(ops, null, 2)) + throw e + } +} + +const NUM_TESTS = 50 +const NUM_OPS_PER_TEST = 100 +const MAX_PEERS = 4 + +// test.only('seed 8343632005032947', () => { +// runTest(8343632005032947) +// }) + +test('fuzzzzz', () => { + for (let i = 0; i < NUM_TESTS; i++) { + const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) + try { + runTest(seed) + } catch (e) { + console.error('seed', seed) + throw e + } + } +}) + +test('totalNumPages', () => { + expect(totalNumPages).not.toBe(0) +}) + +test('totalNumShapes', () => { + expect(totalNumShapes).not.toBe(0) +}) diff --git a/packages/tlsync/src/test/upgradeDowngrade.test.ts b/packages/tlsync/src/test/upgradeDowngrade.test.ts new file mode 100644 index 000000000..d6f27f778 --- /dev/null +++ b/packages/tlsync/src/test/upgradeDowngrade.test.ts @@ -0,0 +1,839 @@ +import { computed } from '@tldraw/state' +import { + BaseRecord, + RecordId, + SerializedStore, + Store, + StoreSchema, + UnknownRecord, + createRecordType, + defineMigrations, +} from '@tldraw/store' +import { TLSyncClient } from '../lib/TLSyncClient' +import { RoomSnapshot, TLRoomSocket } from '../lib/TLSyncRoom' +import { RecordOpType, ValueOpType } from '../lib/diff' +import { + TLIncompatibilityReason, + TLSYNC_PROTOCOL_VERSION, + TLSocketServerSentEvent, +} from '../lib/protocol' +import { TestServer } from './TestServer' +import { TestSocketPair } from './TestSocketPair' + +function mockSocket(): TLRoomSocket { + return { + isOpen: true, + sendMessage: jest.fn(), + close() { + // noop + }, + } +} + +// @ts-expect-error +global.requestAnimationFrame = (cb: () => any) => { + cb() +} + +const disposables: Array<() => void> = [] + +afterEach(() => { + for (const dispose of disposables) { + dispose() + } + disposables.length = 0 +}) + +const UserVersions = { + ReplaceAgeWithBirthdate: 1, +} as const + +interface UserV1 extends BaseRecord<'user', RecordId> { + name: string + age: number +} +interface PresenceV1 extends BaseRecord<'presence', RecordId> { + name: string + age: number +} + +const PresenceV1 = createRecordType('presence', { + scope: 'presence', + validator: { validate: (value) => value as PresenceV1 }, +}) + +const UserV1 = createRecordType('user', { + scope: 'document', + migrations: defineMigrations({}), + validator: { validate: (value) => value as UserV1 }, +}) + +interface UserV2 extends BaseRecord<'user', RecordId> { + name: string + birthdate: string | null +} + +const UserV2 = createRecordType('user', { + scope: 'document', + migrations: defineMigrations({ + currentVersion: UserVersions.ReplaceAgeWithBirthdate, + migrators: { + [UserVersions.ReplaceAgeWithBirthdate]: { + up({ age: _age, ...user }) { + return { + ...user, + birthdate: null, + } + }, + down({ birthdate: _birthdate, ...user }) { + return { + ...user, + age: 0, + } + }, + }, + }, + }), + validator: { validate: (value) => value as UserV2 }, +}) + +type RV1 = UserV1 | PresenceV1 +type RV2 = UserV2 | PresenceV1 + +const schemaV1 = StoreSchema.create( + { user: UserV1, presence: PresenceV1 }, + { + snapshotMigrations: defineMigrations({}), + } +) + +const schemaV2 = StoreSchema.create( + { user: UserV2, presence: PresenceV1 }, + { + snapshotMigrations: defineMigrations({}), + } +) + +const schemaV3 = StoreSchema.create( + { user: UserV2, presence: PresenceV1 }, + { + snapshotMigrations: defineMigrations({ + currentVersion: 1, + migrators: { + 1: { + up(store: SerializedStore) { + // remove any users called joe + const result = Object.fromEntries( + Object.entries(store).filter(([_, r]) => r.typeName !== 'user' || r.name !== 'joe') + ) + // add a user called steve + const id = UserV2.createId('steve') + result[id] = UserV2.create({ + id, + name: 'steve', + birthdate: '2022-02-02', + }) + return result + }, + down(store: SerializedStore) { + return store + }, + }, + }, + }), + } +) + +class TestInstance { + server: TestServer + oldSocketPair: TestSocketPair + newSocketPair: TestSocketPair + oldClient: TLSyncClient + newClient: TLSyncClient + + hasLoaded = false + + constructor(snapshot?: RoomSnapshot, oldSchema = schemaV1, newSchema = schemaV2) { + this.server = new TestServer(newSchema, snapshot) + this.oldSocketPair = new TestSocketPair('test_upgrade_old', this.server) + this.newSocketPair = new TestSocketPair('test_upgrade_new', this.server) + + this.oldClient = new TLSyncClient({ + store: new Store({ schema: oldSchema, props: {} }), + socket: this.oldSocketPair.clientSocket as any, + onLoad: () => { + this.hasLoaded = true + }, + onLoadError: (e) => { + throw new Error('onLoadError', e) + }, + onSyncError: jest.fn((reason) => { + throw new Error('onSyncError: ' + reason) + }), + presence: computed('', () => null), + }) + + this.newClient = new TLSyncClient({ + store: new Store({ schema: newSchema, props: {} }), + socket: this.newSocketPair.clientSocket, + onLoad: () => { + this.hasLoaded = true + }, + onLoadError: (e) => { + throw new Error('onLoadError', e) + }, + onSyncError: jest.fn((reason) => { + throw new Error('onSyncError: ' + reason) + }), + presence: computed('', () => null), + }) + + disposables.push(() => { + this.oldClient.close() + this.newClient.close() + }) + } + + flush() { + while (this.oldSocketPair.getNeedsFlushing() || this.newSocketPair.getNeedsFlushing()) { + this.oldSocketPair.flushClientSentEvents() + this.oldSocketPair.flushServerSentEvents() + this.newSocketPair.flushClientSentEvents() + this.newSocketPair.flushServerSentEvents() + } + } +} + +test('the server can handle receiving v1 stuff from the client', () => { + const t = new TestInstance() + t.oldSocketPair.connect() + t.newSocketPair.connect() + + const user = UserV1.create({ name: 'bob', age: 10 }) + t.flush() + t.oldClient.store.put([user]) + t.flush() + + expect(t.server.room.state.get().documents[user.id].state).toMatchObject({ + name: 'bob', + birthdate: null, + }) + expect(t.server.room.state.get().documents[user.id].state).not.toMatchObject({ + name: 'bob', + age: 10, + }) + + expect(t.newClient.store.get(user.id as any)).toMatchObject({ + name: 'bob', + birthdate: null, + }) + expect(t.newClient.store.get(user.id as any)).not.toMatchObject({ name: 'bob', age: 10 }) +}) + +test('the server can send v2 stuff to the v1 client', () => { + const t = new TestInstance() + t.oldSocketPair.connect() + t.newSocketPair.connect() + + const user = UserV2.create({ name: 'bob', birthdate: '2022-01-09' }) + t.flush() + t.newClient.store.put([user]) + t.flush() + + expect(t.server.room.state.get().documents[user.id].state).toMatchObject({ + name: 'bob', + birthdate: '2022-01-09', + }) + + expect(t.oldClient.store.get(user.id as any)).toMatchObject({ + name: 'bob', + age: 0, + }) + expect(t.oldClient.store.get(user.id as any)).not.toMatchObject({ + name: 'bob', + birthdate: '2022-01-09', + }) +}) + +test('the server will run schema migrations on a snapshot', () => { + const bob = UserV1.create({ name: 'bob', age: 10 }) + // joe will be deleted + const joe = UserV1.create({ name: 'joe', age: 10 }) + const t = new TestInstance( + { + documents: [ + { state: bob, lastChangedClock: 5 }, + { state: joe, lastChangedClock: 5 }, + ], + clock: 10, + schema: schemaV1.serialize(), + tombstones: {}, + }, + schemaV1, + schemaV3 + ) + + expect(t.server.room.state.get().documents[bob.id].state).toMatchObject({ + name: 'bob', + birthdate: null, + }) + expect(t.server.room.state.get().documents[joe.id]).toBeUndefined() + + // there should be someone named steve + const snapshot = t.server.room.getSnapshot() + expect(snapshot.documents.find((u: any) => u.state.name === 'steve')).toBeDefined() +}) + +test('clients will receive updates from a snapshot migration upon connection', () => { + const t = new TestInstance() + t.oldSocketPair.connect() + t.newSocketPair.connect() + + const bob = UserV2.create({ name: 'bob', birthdate: '2022-01-09' }) + const joe = UserV2.create({ name: 'joe', birthdate: '2022-01-09' }) + t.flush() + t.newClient.store.put([bob, joe]) + t.flush() + + const snapshot = t.server.room.getSnapshot() + + t.oldSocketPair.disconnect() + t.newSocketPair.disconnect() + + const newServer = new TestServer(schemaV3, snapshot) + + const newClientSocketPair = new TestSocketPair('test_upgrade__brand_new', newServer) + + // need to set these two things to get the message through + newClientSocketPair.callbacks['onReceiveMessage'] = jest.fn() + newClientSocketPair.clientSocket.connectionStatus = 'online' + + const id = 'test_upgrade_brand_new' + const newClientSocket = mockSocket() + newServer.room.handleNewSession(id, newClientSocket) + newServer.room.handleMessage(id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: snapshot.clock, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV3.serialize(), + }) + + expect((newClientSocket.sendMessage as jest.Mock).mock.calls[0][0]).toMatchObject({ + // we should have added steve and deleted joe + diff: { + [joe.id]: [RecordOpType.Remove], + ['user:steve']: [RecordOpType.Put, { name: 'steve', birthdate: '2022-02-02' }], + }, + }) +}) + +test('out-of-date clients will receive incompatibility errors', () => { + const v3server = new TestServer(schemaV3) + + const id = 'test_upgrade_v2' + const socket = mockSocket() + + v3server.room.handleNewSession(id, socket) + v3server.room.handleMessage(id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 0, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + }) + + expect(socket.sendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ClientTooOld, + }) +}) + +test('clients using an out-of-date protocol will receive compatibility errors', () => { + const v2server = new TestServer(schemaV2) + + const id = 'test_upgrade_v3' + const socket = mockSocket() + + v2server.room.handleNewSession(id, socket) + v2server.room.handleMessage(id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 0, + protocolVersion: TLSYNC_PROTOCOL_VERSION - 1, + schema: schemaV2.serialize(), + }) + + expect(socket.sendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ClientTooOld, + }) +}) + +test('clients using a too-new protocol will receive compatibility errors', () => { + const v2server = new TestServer(schemaV2) + + const id = 'test_upgrade_v3' + const socket = mockSocket() + + v2server.room.handleNewSession(id, socket) + v2server.room.handleMessage(id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 0, + protocolVersion: TLSYNC_PROTOCOL_VERSION + 1, + schema: schemaV2.serialize(), + }) + + expect(socket.sendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ServerTooOld, + }) +}) + +describe('when the client is too new', () => { + function setup() { + const steve = UserV1.create({ id: UserV1.createId('steve'), name: 'steve', age: 23 }) + const jeff = UserV1.create({ id: UserV1.createId('jeff'), name: 'jeff', age: 23 }) + const annie = UserV1.create({ id: UserV1.createId('annie'), name: 'annie', age: 23 }) + const v1Server = new TestServer(schemaV1, { + clock: 10, + documents: [ + { + state: steve, + lastChangedClock: 10, + }, + { + state: jeff, + lastChangedClock: 10, + }, + { + state: annie, + lastChangedClock: 10, + }, + ], + schema: schemaV1.serialize(), + tombstones: {}, + }) + + const v2_id = 'test_upgrade_v2' + const v2_socket = mockSocket() + + const v1_id = 'test_upgrade_v1' + const v1_socket = mockSocket() + + v1Server.room.handleNewSession(v1_id, v1_socket) + v1Server.room.handleMessage(v1_id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV1.serialize(), + }) + + v1Server.room.handleNewSession(v2_id, v2_socket as any) + v1Server.room.handleMessage(v2_id as any, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + }) + + expect(v2_socket.sendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV1.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + + expect(v1_socket.sendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV1.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + ;(v2_socket.sendMessage as jest.Mock).mockClear() + ;(v1_socket.sendMessage as jest.Mock).mockClear() + + return { + v1Server, + v1_id, + v2_id, + v2SendMessage: v2_socket.sendMessage as jest.Mock, + v1SendMessage: v1_socket.sendMessage as jest.Mock, + steve, + jeff, + annie, + } + } + + let data: ReturnType + + beforeEach(() => { + data = setup() + }) + + it('allows deletions from v2 client', () => { + const { v1Server, v2_id, v2SendMessage, steve } = data + v1Server.room.handleMessage(v2_id as any, { + type: 'push', + clientClock: 1, + diff: { + [steve.id]: [RecordOpType.Remove], + }, + }) + + expect(v2SendMessage).toHaveBeenCalledWith({ + type: 'push_result', + action: 'commit', + clientClock: 1, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + }) + + it('applies changes atomically', () => { + data.v1Server.room.handleMessage(data.v2_id, { + type: 'push', + clientClock: 1, + diff: { + [data.jeff.id]: [RecordOpType.Remove], + [data.steve.id]: [RecordOpType.Remove], + [data.annie.id]: [RecordOpType.Put, { ...data.annie, birthdate: '1999-02-21' } as any], + }, + }) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ServerTooOld, + } satisfies TLSocketServerSentEvent) + + expect(data.v1SendMessage).not.toHaveBeenCalled() + expect(data.v1Server.room.state.get().documents[data.jeff.id]).toBeDefined() + expect(data.v1Server.room.state.get().documents[data.steve.id]).toBeDefined() + }) + + it('cannot send patches to v2 clients', () => { + data.v1Server.room.handleMessage(data.v1_id, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Patch, { age: [ValueOpType.Put, 24] }], + }, + }) + + expect(data.v1SendMessage).toHaveBeenCalledWith({ + type: 'push_result', + action: 'commit', + clientClock: 1, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ServerTooOld, + } satisfies TLSocketServerSentEvent) + }) + + it('cannot apply patches from v2 clients', () => { + data.v1Server.room.handleMessage(data.v2_id, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Patch, { birthdate: [ValueOpType.Put, 'tomorrow'] }], + }, + }) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ServerTooOld, + } satisfies TLSocketServerSentEvent) + + expect(data.v1SendMessage).not.toHaveBeenCalled() + }) + + it('cannot apply puts from v2 clients', () => { + data.v1Server.room.handleMessage(data.v2_id, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Put, { ...data.steve, birthdate: 'today' } as any], + }, + }) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'incompatibility_error', + reason: TLIncompatibilityReason.ServerTooOld, + } satisfies TLSocketServerSentEvent) + + expect(data.v1SendMessage).not.toHaveBeenCalled() + }) +}) + +describe('when the client is too old', () => { + function setup() { + const steve = UserV2.create({ + id: UserV2.createId('steve'), + name: 'steve', + birthdate: null, + }) + const jeff = UserV2.create({ id: UserV2.createId('jeff'), name: 'jeff', birthdate: null }) + const annie = UserV2.create({ + id: UserV2.createId('annie'), + name: 'annie', + birthdate: null, + }) + const v2Server = new TestServer(schemaV2, { + clock: 10, + documents: [ + { + state: steve, + lastChangedClock: 10, + }, + { + state: jeff, + lastChangedClock: 10, + }, + { + state: annie, + lastChangedClock: 10, + }, + ], + schema: schemaV1.serialize(), + tombstones: {}, + }) + + const v2Id = 'test_upgrade_v2' + const v2Socket = mockSocket() + + const v2SendMessage = v2Socket.sendMessage as jest.Mock + + const v1Id = 'test_upgrade_v1' + const v1Socket = mockSocket() + + const v1SendMessage = v1Socket.sendMessage as jest.Mock + + v2Server.room.handleNewSession(v1Id, v1Socket as any) + v2Server.room.handleMessage(v1Id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV1.serialize(), + }) + + v2Server.room.handleNewSession(v2Id, v2Socket) + v2Server.room.handleMessage(v2Id, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + }) + + expect(v2SendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + + expect(v1SendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + + v2SendMessage.mockClear() + v1SendMessage.mockClear() + + return { + v2Server, + v2Id, + v1Id, + v2SendMessage, + v1SendMessage, + steve, + jeff, + annie, + } + } + + let data: ReturnType + + beforeEach(() => { + data = setup() + }) + + it('allows deletions from v1 client', () => { + data.v2Server.room.handleMessage(data.v2Id, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Remove], + }, + }) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'push_result', + action: 'commit', + clientClock: 1, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + }) + + it('can handle patches from older clients', () => { + data.v2Server.room.handleMessage(data.v1Id, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'Jeff'] }], + }, + }) + + expect(data.v1SendMessage).toHaveBeenCalledWith({ + type: 'push_result', + action: 'commit', + clientClock: 1, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + + expect(data.v2SendMessage).toHaveBeenCalledWith({ + type: 'patch', + diff: { + [data.steve.id]: [ + RecordOpType.Patch, + { + name: [ValueOpType.Put, 'Jeff'], + }, + ], + }, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + }) +}) + +describe('when the client is the same version', () => { + function setup() { + const steve = UserV2.create({ + id: UserV2.createId('steve'), + name: 'steve', + birthdate: null, + }) + const v2Server = new TestServer(schemaV2, { + clock: 10, + documents: [ + { + state: steve, + lastChangedClock: 10, + }, + ], + schema: schemaV2.serialize(), + tombstones: {}, + }) + + const aId = 'v2ClientA' + const aSocket = mockSocket() + + const bId = 'v2ClientB' + const bSocket = mockSocket() + + v2Server.room.handleNewSession(aId, aSocket) + v2Server.room.handleMessage(aId, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: JSON.parse(JSON.stringify(schemaV2.serialize())), + }) + + v2Server.room.handleNewSession(bId, bSocket) + v2Server.room.handleMessage(bId, { + type: 'connect', + connectRequestId: 'test', + lastServerClock: 10, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: JSON.parse(JSON.stringify(schemaV2.serialize())), + }) + + expect(aSocket.sendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + + expect(bSocket.sendMessage).toHaveBeenCalledWith({ + type: 'connect', + connectRequestId: 'test', + hydrationType: 'wipe_presence', + diff: {}, + protocolVersion: TLSYNC_PROTOCOL_VERSION, + schema: schemaV2.serialize(), + serverClock: 10, + } satisfies TLSocketServerSentEvent) + ;(aSocket.sendMessage as jest.Mock).mockClear() + ;(bSocket.sendMessage as jest.Mock).mockClear() + + return { + v2Server, + aId, + bId, + v2ClientASendMessage: aSocket.sendMessage as jest.Mock, + v2ClientBSendMessage: bSocket.sendMessage as jest.Mock, + steve, + } + } + + let data: ReturnType + + beforeEach(() => { + data = setup() + }) + + it('sends minimal patches', () => { + data.v2Server.room.handleMessage(data.aId, { + type: 'push', + clientClock: 1, + diff: { + [data.steve.id]: [RecordOpType.Patch, { name: [ValueOpType.Put, 'Jeff'] }], + }, + }) + + expect(data.v2ClientASendMessage).toHaveBeenCalledWith({ + type: 'push_result', + action: 'commit', + clientClock: 1, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + + expect(data.v2ClientBSendMessage).toHaveBeenCalledWith({ + type: 'patch', + diff: { + [data.steve.id]: [ + RecordOpType.Patch, + { + name: [ValueOpType.Put, 'Jeff'], + }, + ], + }, + serverClock: 11, + } satisfies TLSocketServerSentEvent) + }) +}) diff --git a/packages/tlsync/src/test/validation.test.ts b/packages/tlsync/src/test/validation.test.ts new file mode 100644 index 000000000..bcac51cb5 --- /dev/null +++ b/packages/tlsync/src/test/validation.test.ts @@ -0,0 +1,194 @@ +import { computed } from '@tldraw/state' +import { + RecordId, + Store, + StoreSchema, + UnknownRecord, + createRecordType, + defineMigrations, +} from '@tldraw/store' +import { TLSyncClient } from '../lib/TLSyncClient' +import { RecordOpType } from '../lib/diff' +import { TestServer } from './TestServer' +import { TestSocketPair } from './TestSocketPair' + +// @ts-expect-error +global.requestAnimationFrame = (cb: () => any) => { + cb() +} + +interface Book { + typeName: 'book' + id: RecordId + title: string +} +const Book = createRecordType('book', { + scope: 'document', + validator: { + validate: (record: unknown): Book => { + if (typeof record !== 'object' || record === null) { + throw new Error('Expected object') + } + if (!('title' in record)) { + throw new Error('Expected title') + } + if (typeof record.title !== 'string') { + throw new Error('Expected title to be a string') + } + return record as Book + }, + }, +}) +const BookWithoutValidator = createRecordType('book', { + scope: 'document', + validator: { validate: (record) => record as Book }, +}) +type Presence = UnknownRecord & { typeName: 'presence' } +const presenceType = createRecordType('presence', { + scope: 'presence', + validator: { validate: (record) => record as Presence }, +}) + +const schema = StoreSchema.create( + { book: Book, presence: presenceType }, + { + snapshotMigrations: defineMigrations({}), + } +) +const schemaWithoutValidator = StoreSchema.create( + { book: BookWithoutValidator, presence: presenceType }, + { + snapshotMigrations: defineMigrations({}), + } +) + +const disposables: Array<() => void> = [] +afterEach(() => { + for (const dispose of disposables) { + dispose() + } + disposables.length = 0 +}) + +async function makeTestInstance() { + const server = new TestServer(schema) + const socketPair = new TestSocketPair('test', server) + socketPair.connect() + + const flush = async () => { + await Promise.resolve() + while (socketPair.getNeedsFlushing()) { + socketPair.flushClientSentEvents() + socketPair.flushServerSentEvents() + } + } + const onSyncError = jest.fn() + const client = await new Promise>((resolve, reject) => { + const client = new TLSyncClient({ + store: new Store({ schema: schemaWithoutValidator, props: {} }), + socket: socketPair.clientSocket as any, + onLoad: resolve, + onLoadError: reject, + onSyncError, + presence: computed('', () => null), + }) + disposables.push(() => client.close()) + flush() + }) + + return { + server, + socketPair, + client, + flush, + onSyncError, + } +} + +it('rejects invalid put operations that create a new document', async () => { + const { client, flush, onSyncError, server } = await makeTestInstance() + + const prevServerDocs = server.room.getSnapshot().documents + + client.store.put([ + { + typeName: 'book', + id: Book.createId('1'), + // @ts-expect-error - deliberate invalid data + title: 123 as string, + }, + ]) + await flush() + + expect(onSyncError).toHaveBeenCalledTimes(1) + expect(onSyncError).toHaveBeenLastCalledWith('invalidRecord') + expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) +}) + +it('rejects invalid put operations that replace an existing document', async () => { + const { client, flush, onSyncError, server } = await makeTestInstance() + + let prevServerDocs = server.room.getSnapshot().documents + const book: Book = { typeName: 'book', id: Book.createId('1'), title: 'Annihilation' } + client.store.put([book]) + await flush() + + expect(onSyncError).toHaveBeenCalledTimes(0) + expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs) + prevServerDocs = server.room.getSnapshot().documents + + client.socket.sendMessage({ + type: 'push', + // @ts-expect-error clientClock is private + clientClock: client.clientClock++, + diff: { + [book.id]: [ + RecordOpType.Put, + { + ...book, + // @ts-expect-error - deliberate invalid data + title: 123 as string, + }, + ], + }, + }) + await flush() + + expect(onSyncError).toHaveBeenCalledTimes(1) + expect(onSyncError).toHaveBeenLastCalledWith('invalidRecord') + expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) +}) + +it('rejects invalid update operations', async () => { + const { client, flush, onSyncError, server } = await makeTestInstance() + + let prevServerDocs = server.room.getSnapshot().documents + + // create the book + client.store.put([ + { + typeName: 'book', + id: Book.createId('1'), + title: 'The silence of the girls', + }, + ]) + await flush() + + expect(onSyncError).toHaveBeenCalledTimes(0) + expect(server.room.getSnapshot().documents).not.toStrictEqual(prevServerDocs) + prevServerDocs = server.room.getSnapshot().documents + + // update the title to be wrong + client.store.put([ + { + typeName: 'book', + id: Book.createId('1'), + // @ts-expect-error - deliberate invalid data + title: 123 as string, + }, + ]) + await flush() + expect(onSyncError).toHaveBeenCalledTimes(1) + expect(onSyncError).toHaveBeenLastCalledWith('invalidRecord') + expect(server.room.getSnapshot().documents).toStrictEqual(prevServerDocs) +}) diff --git a/packages/tlsync/tsconfig.json b/packages/tlsync/tsconfig.json new file mode 100644 index 000000000..436778805 --- /dev/null +++ b/packages/tlsync/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../config/tsconfig.base.json", + "include": ["src"], + "exclude": ["node_modules", "docs", ".tsbuild*"], + "compilerOptions": { + "outDir": "./.tsbuild", + "rootDir": "src" + }, + "references": [ + { "path": "../tldraw" }, + { "path": "../tlschema" }, + { "path": "../state" }, + { "path": "../store" }, + { "path": "../utils" } + ] +} diff --git a/scripts/check-scripts.ts b/scripts/check-scripts.ts index 0f7a54249..0f0e52b51 100644 --- a/scripts/check-scripts.ts +++ b/scripts/check-scripts.ts @@ -65,8 +65,7 @@ async function main({ fix }: { fix?: boolean }) { const packageScripts = packageJson.scripts let expected = - name.startsWith('@tldraw/') && - (relativePath.startsWith('bublic/packages/') || relativePath.startsWith('packages/')) + name.startsWith('@tldraw/') && relativePath.startsWith('packages/') ? packageJson.private ? expectedPackageScripts : expectedPublishedPackageScripts diff --git a/scripts/clean.sh b/scripts/clean.sh index d434a8e42..38c92e4b3 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -29,11 +29,11 @@ goodbye .tsbuild-dev goodbye .tsbuild-api goodbye .next -rm -rf {packages,bublic/packages}/*/api -rm -rf {packages,apps,bublic/packages,bublic/apps}/*/*.tgz -rm -rf {packages,apps,bublic/packages,bublic/apps}/vscode/extension/temp -rm -rf {packages,apps,bublic/packages,bublic/apps}/vscode/extension/editor -rm -rf bublic/apps/docs/content.json +rm -rf packages/*/api +rm -rf {packages,apps}/*/*.tgz +rm -rf {packages,apps}/vscode/extension/temp +rm -rf {packages,apps}/vscode/extension/editor +rm -rf apps/docs/content.json # need to run yarn directly # because yarn messes with the PATH, aliasing itself to some tmp dir diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 000000000..531c4dd22 --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,486 @@ +import * as github from '@actions/github' +import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' +import { Upload } from '@aws-sdk/lib-storage' +import assert from 'assert' +import { execSync } from 'child_process' +import { appendFileSync, existsSync, readdirSync, writeFileSync } from 'fs' +import path, { join } from 'path' +import { PassThrough } from 'stream' +import tar from 'tar' +import { exec } from './lib/exec' +import { makeEnv } from './lib/makeEnv' +import { nicelog } from './lib/nicelog' + +const worker = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dotcom-worker')) +const assetUpload = path.relative( + process.cwd(), + path.resolve(__dirname, '../apps/dotcom-asset-upload') +) +const dotcom = path.relative(process.cwd(), path.resolve(__dirname, '../apps/dotcom')) + +// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via +// `env` instead. This makes sure that all required env vars are present. +const env = makeEnv([ + 'APP_ORIGIN', + 'ASSET_UPLOAD', + 'CLOUDFLARE_ACCOUNT_ID', + 'CLOUDFLARE_API_TOKEN', + 'DISCORD_DEPLOY_WEBHOOK_URL', + 'GC_MAPS_API_KEY', + 'RELEASE_COMMIT_HASH', + 'SENTRY_AUTH_TOKEN', + 'SENTRY_DSN', + 'SUPABASE_LITE_ANON_KEY', + 'SUPABASE_LITE_URL', + 'TLDRAW_ENV', + 'VERCEL_PROJECT_ID', + 'VERCEL_ORG_ID', + 'VERCEL_TOKEN', + 'WORKER_SENTRY_DSN', + 'MULTIPLAYER_SERVER', + 'GH_TOKEN', + 'R2_ACCESS_KEY_ID', + 'R2_ACCESS_KEY_SECRET', +]) + +const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1] +function getPreviewId() { + if (env.TLDRAW_ENV !== 'preview') return undefined + if (githubPrNumber) return `pr-${githubPrNumber}` + return process.env.TLDRAW_PREVIEW_ID ?? undefined +} +const previewId = getPreviewId() + +if (env.TLDRAW_ENV === 'preview' && !previewId) { + throw new Error( + 'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set' + ) +} +const sha = + // if the event is 'pull_request', github.context.sha is an ephemeral merge commit + // while the actual commit we want to create the deployment for is the 'head' of the PR. + github.context.eventName === 'pull_request' + ? github.context.payload.pull_request?.head.sha + : github.context.sha + +const sentryReleaseName = `${env.TLDRAW_ENV}-${previewId ? previewId + '-' : ''}-${sha}` + +async function main() { + assert( + env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview', + 'TLDRAW_ENV must be staging or production or preview' + ) + + await discordMessage(`--- **${env.TLDRAW_ENV} deploy pre-flight** ---`) + + await discordStep('[1/6] setting up deploy', async () => { + // make sure the tldraw .css files are built: + await exec('yarn', ['lazy', 'prebuild']) + + // link to vercel and supabase projects: + await vercelCli('link', ['--project', env.VERCEL_PROJECT_ID]) + }) + + // deploy pre-flight steps: + // 1. get the dotcom app ready to go (env vars and pre-build) + await discordStep('[2/6] building dotcom app', async () => { + await createSentryRelease() + await prepareDotcomApp() + await uploadSourceMaps() + await coalesceWithPreviousAssets(`${dotcom}/.vercel/output/static/assets`) + }) + + await discordStep('[3/6] cloudflare deploy dry run', async () => { + await deployAssetUploadWorker({ dryRun: true }) + await deployTlsyncWorker({ dryRun: true }) + }) + + // --- point of no return! do the deploy for real --- // + + await discordMessage(`--- **pre-flight complete, starting real deploy** ---`) + + // 2. deploy the cloudflare workers: + await discordStep('[4/6] deploying asset uploader to cloudflare', async () => { + await deployAssetUploadWorker({ dryRun: false }) + }) + await discordStep('[5/6] deploying multiplayer worker to cloudflare', async () => { + await deployTlsyncWorker({ dryRun: false }) + }) + + // 3. deploy the pre-build dotcom app: + const { deploymentUrl, inspectUrl } = await discordStep( + '[6/6] deploying dotcom app to vercel', + async () => { + return await deploySpa() + } + ) + + let deploymentAlias = null as null | string + + if (previewId) { + const aliasDomain = `${previewId}-preview-deploy.tldraw.com` + await discordStep('[7/6] aliasing preview deployment', async () => { + await vercelCli('alias', ['set', deploymentUrl, aliasDomain]) + }) + + deploymentAlias = `https://${aliasDomain}` + } + + nicelog('Creating deployment for', deploymentUrl) + await createGithubDeployment(deploymentAlias ?? deploymentUrl, inspectUrl) + + await discordMessage(`**Deploy complete!**`) +} + +async function prepareDotcomApp() { + // pre-build the app: + await exec('yarn', ['build-app'], { + env: { + NEXT_PUBLIC_TLDRAW_RELEASE_INFO: `${env.RELEASE_COMMIT_HASH} ${new Date().toISOString()}`, + ASSET_UPLOAD: previewId + ? `https://${previewId}-tldraw-assets.tldraw.workers.dev` + : env.ASSET_UPLOAD, + MULTIPLAYER_SERVER: previewId + ? `https://${previewId}-tldraw-multiplayer.tldraw.workers.dev` + : env.MULTIPLAYER_SERVER, + NEXT_PUBLIC_CONTROL_SERVER: 'https://control.tldraw.com', + NEXT_PUBLIC_GC_API_KEY: env.GC_MAPS_API_KEY, + SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN, + SENTRY_ORG: 'tldraw', + SENTRY_PROJECT: 'lite', + SUPABASE_KEY: env.SUPABASE_LITE_ANON_KEY, + SUPABASE_URL: env.SUPABASE_LITE_URL, + TLDRAW_ENV: env.TLDRAW_ENV, + }, + }) +} + +let didUpdateAssetUploadWorker = false +async function deployAssetUploadWorker({ dryRun }: { dryRun: boolean }) { + if (previewId && !didUpdateAssetUploadWorker) { + appendFileSync( + join(assetUpload, 'wrangler.toml'), + ` +[env.preview] +name = "${previewId}-tldraw-assets"` + ) + didUpdateAssetUploadWorker = true + } + await exec('yarn', ['wrangler', 'deploy', dryRun ? '--dry-run' : null, '--env', env.TLDRAW_ENV], { + pwd: assetUpload, + env: { + NODE_ENV: 'production', + // wrangler needs CI=1 set to prevent it from trying to do interactive prompts + CI: '1', + }, + }) +} + +let didUpdateTlsyncWorker = false +async function deployTlsyncWorker({ dryRun }: { dryRun: boolean }) { + if (previewId && !didUpdateTlsyncWorker) { + appendFileSync( + join(worker, 'wrangler.toml'), + ` +[env.preview] +name = "${previewId}-tldraw-multiplayer"` + ) + didUpdateTlsyncWorker = true + } + await exec( + 'yarn', + [ + 'wrangler', + 'deploy', + dryRun ? '--dry-run' : null, + '--env', + env.TLDRAW_ENV, + '--var', + `SUPABASE_URL:${env.SUPABASE_LITE_URL}`, + '--var', + `SUPABASE_KEY:${env.SUPABASE_LITE_ANON_KEY}`, + '--var', + `SENTRY_DSN:${env.WORKER_SENTRY_DSN}`, + '--var', + `TLDRAW_ENV:${env.TLDRAW_ENV}`, + '--var', + `APP_ORIGIN:${env.APP_ORIGIN}`, + ], + { + pwd: worker, + env: { + NODE_ENV: 'production', + // wrangler needs CI=1 set to prevent it from trying to do interactive prompts + CI: '1', + }, + } + ) +} + +type ExecOpts = NonNullable[2]> +async function vercelCli(command: string, args: string[], opts?: ExecOpts) { + return exec( + 'yarn', + [ + 'run', + '-T', + 'vercel', + command, + '--token', + env.VERCEL_TOKEN, + '--scope', + env.VERCEL_ORG_ID, + '--yes', + ...args, + ], + { + ...opts, + env: { + // specify org id via args instead of via env vars because otherwise it gets upset + // that there's no project id either + VERCEL_ORG_ID: env.VERCEL_ORG_ID, + VERCEL_PROJECT_ID: env.VERCEL_PROJECT_ID, + ...opts?.env, + }, + } + ) +} + +function sanitizeVariables(errorOutput: string): string { + const regex = /(--var\s+(\w+):[^ \n]+)/g + + const sanitizedOutput = errorOutput.replace(regex, (_, match) => { + const [variable] = match.split(':') + return `${variable}:*` + }) + + return sanitizedOutput +} + +async function discord(method: string, url: string, body: unknown): Promise { + const response = await fetch(`${env.DISCORD_DEPLOY_WEBHOOK_URL}${url}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + throw new Error(`Discord webhook request failed: ${response.status} ${response.statusText}`) + } + return response.json() +} + +const AT_TEAM_MENTION = '<@&959380625100513310>' +async function discordMessage(content: string, { always = false }: { always?: boolean } = {}) { + const shouldNotify = env.TLDRAW_ENV === 'production' || always + if (!shouldNotify) { + return { + edit: () => { + // noop + }, + } + } + + const message = await discord('POST', '?wait=true', { content: sanitizeVariables(content) }) + + return { + edit: async (newContent: string) => { + await discord('PATCH', `/messages/${message.id}`, { content: sanitizeVariables(newContent) }) + }, + } +} + +async function discordStep(content: string, cb: () => Promise): Promise { + const message = await discordMessage(`${content}...`) + try { + const result = await cb() + await message.edit(`${content} ✅`) + return result + } catch (err) { + await message.edit(`${content} ❌`) + throw err + } +} + +async function deploySpa(): Promise<{ deploymentUrl: string; inspectUrl: string }> { + // both 'staging' and 'production' are deployed to vercel as 'production' deploys + // in separate 'projects' + const prod = env.TLDRAW_ENV !== 'preview' + const out = await vercelCli('deploy', ['--prebuilt', ...(prod ? ['--prod'] : [])], { + pwd: dotcom, + }) + + const previewURL = out.match(/Preview: (https:\/\/\S*)/)?.[1] + const inspectUrl = out.match(/Inspect: (https:\/\/\S*)/)?.[1] + const productionURL = out.match(/Production: (https:\/\/\S*)/)?.[1] + const deploymentUrl = previewURL ?? productionURL + + if (!deploymentUrl) { + throw new Error('Could not find deployment URL in vercel output ' + out) + } + if (!inspectUrl) { + throw new Error('Could not find inspect URL in vercel output ' + out) + } + + return { deploymentUrl, inspectUrl } +} + +// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline. +async function createGithubDeployment(deploymentUrl: string, inspectUrl: string) { + const client = github.getOctokit(env.GH_TOKEN) + + const deployment = await client.rest.repos.createDeployment({ + owner: 'tldraw', + repo: 'tldraw', + ref: sha, + payload: { web_url: deploymentUrl }, + environment: env.TLDRAW_ENV, + transient_environment: true, + required_contexts: [], + auto_merge: false, + task: 'deploy', + }) + + await client.rest.repos.createDeploymentStatus({ + owner: 'tldraw', + repo: 'tldraw', + deployment_id: (deployment.data as any).id, + state: 'success', + environment_url: deploymentUrl, + log_url: inspectUrl, + }) +} + +const sentryEnv = { + SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN, + SENTRY_ORG: 'tldraw', + SENTRY_PROJECT: 'lite', +} + +const execSentry = (command: string, args: string[]) => + exec(`yarn`, ['run', '-T', 'sentry-cli', command, ...args], { env: sentryEnv }) + +async function createSentryRelease() { + await execSentry('releases', ['new', sentryReleaseName]) + if (!existsSync(`${dotcom}/sentry-release-name.ts`)) { + throw new Error('sentry-release-name.ts does not exist') + } + writeFileSync( + `${dotcom}/sentry-release-name.ts`, + `// 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\n` + + `export const sentryReleaseName = '${sentryReleaseName}'` + ) +} + +async function uploadSourceMaps() { + const sourceMapDir = `${dotcom}/dist/assets` + + await execSentry('sourcemaps', ['upload', '--release', sentryReleaseName, sourceMapDir]) + execSync('rm -rf ./.next/static/chunks/**/*.map') +} + +const R2_URL = `https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com` +const R2_BUCKET = `dotcom-deploy-assets-cache` + +const R2 = new S3Client({ + region: 'auto', + endpoint: R2_URL, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_ACCESS_KEY_SECRET, + }, +}) + +/** + * When we run a vite prod build it creates a folder in the output dir called assets in which + * every file includes a hash of its contents in the filename. These files include files that + * are 'imported' by the js bundle, e.g. svg and css files, along with the js bundle itself + * (split into chunks). + * + * By default, when we deploy a new version of the app it will replace the previous versions + * of the files with new versions. This is problematic when we make a new deploy because if + * existing users have tldraw open in tabs while we make the new deploy, they might still try + * to fetch .js chunks or images which are no longer present on the server and may not have + * been cached by vercel's CDN in their location (or at all). + * + * To avoid this, we keep track of the assets from previous deploys in R2 and include them in the + * new deploy. This way, if a user has an old version of the app open in a tab, they will still + * be able to fetch the old assets from the previous deploy. + */ +async function coalesceWithPreviousAssets(assetsDir: string) { + nicelog('Saving assets to R2 bucket') + const objectKey = `${previewId ?? env.TLDRAW_ENV}/${new Date().toISOString()}+${sha}.tar.gz` + const pack = tar.c({ gzip: true, cwd: assetsDir }, readdirSync(assetsDir)) + // Need to pipe through a PassThrough here because the tar stream is not a first class node stream + // and AWS's sdk expects a node stream (it checks `Body instanceof streams.Readable`) + const Body = new PassThrough() + pack.pipe(Body) + await new Upload({ + client: R2, + params: { + Bucket: R2_BUCKET, + Key: objectKey, + Body, + }, + }).done() + + nicelog('Extracting previous assets from R2 bucket') + const { Contents } = await R2.send( + new ListObjectsV2Command({ + Bucket: R2_BUCKET, + Prefix: `${previewId ?? env.TLDRAW_ENV}/`, + }) + ) + const [mostRecent, ...others] = + // filter out the one we just uploaded + Contents?.filter((obj) => obj.Key !== objectKey).sort( + (a, b) => (b.LastModified?.getTime() ?? 0) - (a.LastModified?.getTime() ?? 0) + ) ?? [] + + if (!mostRecent) { + nicelog('No previous assets found') + return + } + + // Always include the assets from the directly previous build, but also if there + // have been more deploys in the last two weeks, include those too. + const twoWeeks = 1000 * 60 * 60 * 24 * 14 + const recentOthers = others.filter( + (o) => (o.LastModified?.getTime() ?? 0) > Date.now() - twoWeeks + ) + const objectsToFetch = [mostRecent, ...recentOthers] + + nicelog( + `Fetching ${objectsToFetch.length} previous assets from R2 bucket:`, + objectsToFetch.map((k) => k.Key) + ) + for (const obj of objectsToFetch) { + const { Body } = await R2.send( + new GetObjectCommand({ + Bucket: R2_BUCKET, + Key: obj.Key, + }) + ) + if (!Body) { + throw new Error(`Could not fetch object ${obj.Key}`) + } + // pipe into untar + // `keep-existing` is important here because we don't want to overwrite the new assets + // if they have the same name as the old assets becuase they will have different sentry debugIds + // and it will mess up the inline source viewer on sentry errors. + const out = tar.x({ cwd: assetsDir, 'keep-existing': true }) + for await (const chunk of Body?.transformToWebStream() as any as AsyncIterable) { + out.write(chunk) + } + out.end() + } +} + +main().catch(async (err) => { + // don't notify discord on preview builds + if (env.TLDRAW_ENV !== 'preview') { + await discordMessage(`${AT_TEAM_MENTION} Deploy failed: ${err.stack}`, { always: true }) + } + console.error(err) + process.exit(1) +}) diff --git a/scripts/lib/file.ts b/scripts/lib/file.ts index 22d712575..468489ea2 100644 --- a/scripts/lib/file.ts +++ b/scripts/lib/file.ts @@ -1,15 +1,23 @@ +import { existsSync, readFileSync } from 'fs' import { readFile, writeFile as writeFileUnchecked } from 'fs/promises' import json5 from 'json5' -import { basename, dirname, join, relative } from 'path' +import { dirname, join, relative } from 'path' import prettier from 'prettier' import { fileURLToPath } from 'url' import { nicelog } from './nicelog' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const isBublic = basename(join(__dirname, '../..')) === 'bublic' -export const REPO_ROOT = join(__dirname, isBublic ? '../../../' : '../..') -export const BUBLIC_ROOT = join(__dirname, '../..') +export const REPO_ROOT = join(__dirname, '../..') + +const _rootPackageJsonPath = join(REPO_ROOT, 'package.json') +if (!existsSync(_rootPackageJsonPath)) { + throw new Error('expected to find package.json in REPO_ROOT') +} +const _rootPackageJson = JSON.parse(readFileSync(_rootPackageJsonPath, 'utf8')) +if (_rootPackageJson['name'] !== '@tldraw/monorepo') { + throw new Error('expected to find @tldraw/monorepo in REPO_ROOT package.json') +} export async function readJsonIfExists(file: string) { const fileContents = await readFileIfExists(file) diff --git a/scripts/lib/makeEnv.ts b/scripts/lib/makeEnv.ts new file mode 100644 index 000000000..27f5bd3cc --- /dev/null +++ b/scripts/lib/makeEnv.ts @@ -0,0 +1,18 @@ +export function makeEnv( + keys: Keys +): Record { + const env = {} as Record + const missingVars = [] + for (const key of keys) { + const value = process.env[key] + if (value === undefined) { + missingVars.push(key) + continue + } + env[key] = value + } + if (missingVars.length > 0) { + throw new Error(`Missing environment variables: ${missingVars.join(', ')}`) + } + return env as Record +} diff --git a/scripts/lib/nicelog.ts b/scripts/lib/nicelog.ts index c02bc689b..72d89109e 100644 --- a/scripts/lib/nicelog.ts +++ b/scripts/lib/nicelog.ts @@ -1,4 +1,3 @@ export function nicelog(...args: any[]) { - // eslint-disable-next-line no-console console.log(...args) } diff --git a/scripts/lib/publishing.ts b/scripts/lib/publishing.ts index 4675f4d05..87fc114c9 100644 --- a/scripts/lib/publishing.ts +++ b/scripts/lib/publishing.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs' import path, { join } from 'path' import { compare, parse } from 'semver' import { exec } from './exec' -import { BUBLIC_ROOT } from './file' +import { REPO_ROOT } from './file' import { nicelog } from './nicelog' export type PackageDetails = { @@ -34,9 +34,9 @@ function getPackageDetails(dir: string): PackageDetails | null { } export function getAllPackageDetails(): Record { - const dirs = readdirSync(join(BUBLIC_ROOT, 'packages')) + const dirs = readdirSync(join(REPO_ROOT, 'packages')) const results = dirs - .map((dir) => getPackageDetails(path.join(BUBLIC_ROOT, 'packages', dir))) + .map((dir) => getPackageDetails(path.join(REPO_ROOT, 'packages', dir))) .filter((x): x is PackageDetails => Boolean(x)) return Object.fromEntries(results.map((result) => [result.name, result])) diff --git a/scripts/package.json b/scripts/package.json index f7541b44b..1c1c90b0a 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -27,9 +27,13 @@ "infinite" ], "devDependencies": { + "@actions/github": "^6.0.0", "@auto-it/core": "^10.45.0", + "@aws-sdk/client-s3": "^3.440.0", + "@aws-sdk/lib-storage": "^3.440.0", "@types/is-ci": "^3.0.0", "@types/node": "^18.7.3", + "@types/tar": "^6.1.7", "@typescript-eslint/utils": "^5.59.0", "ast-types": "^0.14.2", "cross-fetch": "^3.1.5", @@ -50,6 +54,7 @@ "lint": "yarn run -T tsx lint.ts" }, "dependencies": { - "ignore": "^5.2.4" + "ignore": "^5.2.4", + "tar": "^6.2.0" } } diff --git a/scripts/prune-preview-deploys.ts b/scripts/prune-preview-deploys.ts new file mode 100644 index 000000000..36ec76d9c --- /dev/null +++ b/scripts/prune-preview-deploys.ts @@ -0,0 +1,84 @@ +import * as github from '@actions/github' +import { makeEnv } from './lib/makeEnv' +import { nicelog } from './lib/nicelog' + +// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via +// `env` instead. This makes sure that all required env vars are present. +const env = makeEnv(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', 'GH_TOKEN']) + +type ListWorkersResult = { + success: boolean + result: { id: string }[] +} + +const _isPrClosedCache = new Map() +async function isPrClosedForAWhile(prNumber: number) { + if (_isPrClosedCache.has(prNumber)) { + return _isPrClosedCache.get(prNumber)! + } + const prResult = await github.getOctokit(env.GH_TOKEN).rest.pulls.get({ + owner: 'tldraw', + repo: 'tldraw', + pull_number: prNumber, + }) + const twoDays = 1000 * 60 * 60 * 24 * 2 + const result = + prResult.data.state === 'closed' && + Date.now() - new Date(prResult.data.closed_at!).getTime() > twoDays + _isPrClosedCache.set(prNumber, result) + return result +} + +async function ListPreviewWorkerDeployments() { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts`, + { + headers: { + Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ) + + const data = (await res.json()) as ListWorkersResult + + if (!data.success) { + throw new Error('Failed to list workers ' + JSON.stringify(data)) + } + + return data.result.map((r) => r.id).filter((id) => id.match(/^pr-(\d+)-/)) +} + +async function deletePreviewWorkerDeployment(id: string) { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${id}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!res.ok) { + throw new Error('Failed to delete worker ' + JSON.stringify(res)) + } +} + +async function main() { + const previewDeployments = await ListPreviewWorkerDeployments() + for (const deployment of previewDeployments) { + const prNumber = Number(deployment.match(/^pr-(\d+)-/)![1]) + if (await isPrClosedForAWhile(prNumber)) { + nicelog(`Deleting ${deployment} because PR is closed`) + await deletePreviewWorkerDeployment(deployment) + } else { + nicelog(`Skipping ${deployment} because PR is still open`) + } + } +} + +main() + +// clean up cloudflare preview deploys diff --git a/scripts/publish-new.ts b/scripts/publish-new.ts index 08efbd93b..7691b2cbb 100644 --- a/scripts/publish-new.ts +++ b/scripts/publish-new.ts @@ -3,7 +3,7 @@ import fetch from 'cross-fetch' import { assert } from 'node:console' import { parse } from 'semver' import { exec } from './lib/exec' -import { BUBLIC_ROOT } from './lib/file' +import { REPO_ROOT } from './lib/file' import { nicelog } from './lib/nicelog' import { getLatestVersion, publish, setAllVersions } from './lib/publishing' import { getAllWorkspacePackages } from './lib/workspace' @@ -62,7 +62,7 @@ async function main() { 'add', 'lerna.json', ...packageJsonFilesToAdd, - BUBLIC_ROOT + '/packages/*/src/**/version.ts', + REPO_ROOT + '/packages/*/src/**/version.ts', ]) // this creates a new commit diff --git a/scripts/refresh-assets.ts b/scripts/refresh-assets.ts index 7323069db..8942c96bc 100644 --- a/scripts/refresh-assets.ts +++ b/scripts/refresh-assets.ts @@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'fs' import { join } from 'path' import { optimize } from 'svgo' import { - BUBLIC_ROOT, readJsonIfExists, + REPO_ROOT, writeCodeFile, writeFile, writeJsonFile, @@ -12,7 +12,7 @@ import { import { nicelog } from './lib/nicelog' // We'll need to copy the assets into these folders -const PUBLIC_FOLDER_PATHS = [join(BUBLIC_ROOT, 'packages', 'assets')] +const PUBLIC_FOLDER_PATHS = [join(REPO_ROOT, 'packages', 'assets')] const FONT_MAPPING: Record = { 'IBMPlexMono-Medium': 'monospace', @@ -21,7 +21,7 @@ const FONT_MAPPING: Record = { 'Shantell_Sans-Tldrawish': 'draw', } -const ASSETS_FOLDER_PATH = join(BUBLIC_ROOT, 'assets') +const ASSETS_FOLDER_PATH = join(REPO_ROOT, 'assets') const collectedAssetUrls: { fonts: Record @@ -92,7 +92,7 @@ async function copyIcons() { await writeCodeFile( 'scripts/refresh-assets.ts', 'typescript', - join(BUBLIC_ROOT, 'packages', 'tldraw', 'src', 'lib', 'ui', 'icon-types.ts'), + join(REPO_ROOT, 'packages', 'tldraw', 'src', 'lib', 'ui', 'icon-types.ts'), iconTypeFile ) @@ -202,7 +202,7 @@ async function copyTranslations() { // Create hardcoded files const uiPath = join( - BUBLIC_ROOT, + REPO_ROOT, 'packages', 'tldraw', 'src', @@ -219,7 +219,7 @@ async function copyTranslations() { /** @public */ export const LANGUAGES = ${JSON.stringify(languagesSource)} as const ` - const schemaPath = join(BUBLIC_ROOT, 'packages', 'tlschema', 'src', 'translations') + const schemaPath = join(REPO_ROOT, 'packages', 'tlschema', 'src', 'translations') const schemaLanguagesFilePath = join(schemaPath, 'languages.ts') await writeCodeFile( 'scripts/refresh-assets.ts', @@ -267,7 +267,7 @@ async function copyTranslations() { // 4. ASSET DECLARATION FILES async function writeUrlBasedAssetDeclarationFile() { - const codeFilePath = join(BUBLIC_ROOT, 'packages', 'assets', 'urls.js') + const codeFilePath = join(REPO_ROOT, 'packages', 'assets', 'urls.js') const codeFile = ` // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// @@ -333,7 +333,7 @@ async function writeImportBasedAssetDeclarationFile(): Promise { } ` - const codeFilePath = join(BUBLIC_ROOT, 'packages', 'assets', 'imports.js') + const codeFilePath = join(REPO_ROOT, 'packages', 'assets', 'imports.js') await writeCodeFile( 'scripts/refresh-assets.ts', 'javascript', @@ -343,7 +343,7 @@ async function writeImportBasedAssetDeclarationFile(): Promise { } async function writeSelfHostedAssetDeclarationFile(): Promise { - const codeFilePath = join(BUBLIC_ROOT, 'packages', 'assets', 'selfHosted.js') + const codeFilePath = join(REPO_ROOT, 'packages', 'assets', 'selfHosted.js') const codeFile = ` // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// @@ -391,7 +391,7 @@ async function writeAssetDeclarationDTSFile() { } ` - const assetDeclarationFilePath = join(BUBLIC_ROOT, 'packages', 'assets', 'types.d.ts') + const assetDeclarationFilePath = join(REPO_ROOT, 'packages', 'assets', 'types.d.ts') await writeCodeFile('scripts/refresh-assets.ts', 'typescript', assetDeclarationFilePath, dts) } diff --git a/public-yarn.lock b/yarn.lock similarity index 84% rename from public-yarn.lock rename to yarn.lock index 284655d04..25fd19340 100644 --- a/public-yarn.lock +++ b/yarn.lock @@ -12,6 +12,28 @@ __metadata: languageName: node linkType: hard +"@actions/github@npm:^6.0.0": + version: 6.0.0 + resolution: "@actions/github@npm:6.0.0" + dependencies: + "@actions/http-client": ^2.2.0 + "@octokit/core": ^5.0.1 + "@octokit/plugin-paginate-rest": ^9.0.0 + "@octokit/plugin-rest-endpoint-methods": ^10.0.0 + checksum: 81831a78377175d8825fc0b94247ff366c0e87ad1dfa48df9b30b8659506f216dcf1e2d3124fcd318839b92c24ba20165e238b3cc11a34db89c69c40825e9ccf + languageName: node + linkType: hard + +"@actions/http-client@npm:^2.2.0": + version: 2.2.0 + resolution: "@actions/http-client@npm:2.2.0" + dependencies: + tunnel: ^0.0.6 + undici: ^5.25.4 + checksum: 075fc21e8c05e865239bfc5cc91ce42aff7ac7877a5828145545cb27c572f74af8f96f90233f3ba2376525a9032bb8eadebd7221c007ce62459b99d5d2362f94 + languageName: node + linkType: hard + "@adobe/css-tools@npm:^4.0.1": version: 4.3.2 resolution: "@adobe/css-tools@npm:4.3.2" @@ -29,6 +51,19 @@ __metadata: languageName: node linkType: hard +"@apideck/better-ajv-errors@npm:^0.3.1": + version: 0.3.6 + resolution: "@apideck/better-ajv-errors@npm:0.3.6" + dependencies: + json-schema: ^0.4.0 + jsonpointer: ^5.0.0 + leven: ^3.1.0 + peerDependencies: + ajv: ">=8" + checksum: b70ec9aae3b30ba1ac06948e585cd96aabbfe7ef6a1c27dc51e56c425f01290a58e9beb19ed95ee64da9f32df3e9276cd1ea58e78792741d74a519cb56955491 + languageName: node + linkType: hard + "@auto-it/bot-list@npm:10.46.0": version: 10.46.0 resolution: "@auto-it/bot-list@npm:10.46.0" @@ -148,6 +183,684 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/crc32@npm:3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/crc32@npm:3.0.0" + dependencies: + "@aws-crypto/util": ^3.0.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^1.11.1 + checksum: 9fdb3e837fc54119b017ea34fd0a6d71d2c88075d99e1e818a5158e0ad30ced67ddbcc423a11ceeef6cc465ab5ffd91830acab516470b48237ca7abd51be9642 + languageName: node + linkType: hard + +"@aws-crypto/crc32c@npm:3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/crc32c@npm:3.0.0" + dependencies: + "@aws-crypto/util": ^3.0.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^1.11.1 + checksum: 0a116b5d1c5b09a3dde65aab04a07b32f543e87b68f2d175081e3f4a1a17502343f223d691dd883ace1ddce65cd40093673e7c7415dcd99062202ba87ffb4038 + languageName: node + linkType: hard + +"@aws-crypto/ie11-detection@npm:^3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/ie11-detection@npm:3.0.0" + dependencies: + tslib: ^1.11.1 + checksum: 299b2ddd46eddac1f2d54d91386ceb37af81aef8a800669281c73d634ed17fd855dcfb8b3157f2879344b93a2666a6d602550eb84b71e4d7868100ad6da8f803 + languageName: node + linkType: hard + +"@aws-crypto/sha1-browser@npm:3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/sha1-browser@npm:3.0.0" + dependencies: + "@aws-crypto/ie11-detection": ^3.0.0 + "@aws-crypto/supports-web-crypto": ^3.0.0 + "@aws-crypto/util": ^3.0.0 + "@aws-sdk/types": ^3.222.0 + "@aws-sdk/util-locate-window": ^3.0.0 + "@aws-sdk/util-utf8-browser": ^3.0.0 + tslib: ^1.11.1 + checksum: 78c379e105a0c4e7b2ed745dffd8f55054d7dde8b350b61de682bbc3cd081a50e2f87861954fa9cd53c7ea711ebca1ca0137b14cb36483efc971f60f573cf129 + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/sha256-browser@npm:3.0.0" + dependencies: + "@aws-crypto/ie11-detection": ^3.0.0 + "@aws-crypto/sha256-js": ^3.0.0 + "@aws-crypto/supports-web-crypto": ^3.0.0 + "@aws-crypto/util": ^3.0.0 + "@aws-sdk/types": ^3.222.0 + "@aws-sdk/util-locate-window": ^3.0.0 + "@aws-sdk/util-utf8-browser": ^3.0.0 + tslib: ^1.11.1 + checksum: ca89456bf508db2e08060a7f656460db97ac9a15b11e39d6fa7665e2b156508a1758695bff8e82d0a00178d6ac5c36f35eb4bcfac2e48621265224ca14a19bd2 + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:3.0.0, @aws-crypto/sha256-js@npm:^3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/sha256-js@npm:3.0.0" + dependencies: + "@aws-crypto/util": ^3.0.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^1.11.1 + checksum: 644ded32ea310237811afae873d3c7320739cb6f6cc39dced9c94801379e68e5ee2cca0c34f0384793fa9e750a7e0a5e2468f95754bd08e6fd72ab833c8fe23c + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/supports-web-crypto@npm:3.0.0" + dependencies: + tslib: ^1.11.1 + checksum: 35479a1558db9e9a521df6877a99f95670e972c602f2a0349303477e5d638a5baf569fb037c853710e382086e6fd77e8ed58d3fb9b49f6e1186a9d26ce7be006 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:^3.0.0": + version: 3.0.0 + resolution: "@aws-crypto/util@npm:3.0.0" + dependencies: + "@aws-sdk/types": ^3.222.0 + "@aws-sdk/util-utf8-browser": ^3.0.0 + tslib: ^1.11.1 + checksum: d29d5545048721aae3d60b236708535059733019a105f8a64b4e4a8eab7cf8dde1546dc56bff7de20d36140a4d1f0f4693e639c5732a7059273a7b1e56354776 + languageName: node + linkType: hard + +"@aws-sdk/client-s3@npm:^3.440.0": + version: 3.490.0 + resolution: "@aws-sdk/client-s3@npm:3.490.0" + dependencies: + "@aws-crypto/sha1-browser": 3.0.0 + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.490.0 + "@aws-sdk/core": 3.490.0 + "@aws-sdk/credential-provider-node": 3.490.0 + "@aws-sdk/middleware-bucket-endpoint": 3.489.0 + "@aws-sdk/middleware-expect-continue": 3.489.0 + "@aws-sdk/middleware-flexible-checksums": 3.489.0 + "@aws-sdk/middleware-host-header": 3.489.0 + "@aws-sdk/middleware-location-constraint": 3.489.0 + "@aws-sdk/middleware-logger": 3.489.0 + "@aws-sdk/middleware-recursion-detection": 3.489.0 + "@aws-sdk/middleware-sdk-s3": 3.489.0 + "@aws-sdk/middleware-signing": 3.489.0 + "@aws-sdk/middleware-ssec": 3.489.0 + "@aws-sdk/middleware-user-agent": 3.489.0 + "@aws-sdk/region-config-resolver": 3.489.0 + "@aws-sdk/signature-v4-multi-region": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-endpoints": 3.489.0 + "@aws-sdk/util-user-agent-browser": 3.489.0 + "@aws-sdk/util-user-agent-node": 3.489.0 + "@aws-sdk/xml-builder": 3.485.0 + "@smithy/config-resolver": ^2.0.23 + "@smithy/core": ^1.2.2 + "@smithy/eventstream-serde-browser": ^2.0.16 + "@smithy/eventstream-serde-config-resolver": ^2.0.16 + "@smithy/eventstream-serde-node": ^2.0.16 + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/hash-blob-browser": ^2.0.17 + "@smithy/hash-node": ^2.0.18 + "@smithy/hash-stream-node": ^2.0.18 + "@smithy/invalid-dependency": ^2.0.16 + "@smithy/md5-js": ^2.0.18 + "@smithy/middleware-content-length": ^2.0.18 + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/protocol-http": ^3.0.12 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.24 + "@smithy/util-defaults-mode-node": ^2.0.32 + "@smithy/util-endpoints": ^1.0.8 + "@smithy/util-retry": ^2.0.9 + "@smithy/util-stream": ^2.0.24 + "@smithy/util-utf8": ^2.0.2 + "@smithy/util-waiter": ^2.0.16 + fast-xml-parser: 4.2.5 + tslib: ^2.5.0 + checksum: 28fc1c384bdba8c374ac8f150350feb5b6e8afaef76aaba2705d8d06d60dec9a209869888a3e91e75c88454084940916b1bc6070cc422d50712af7dd20a66ccf + languageName: node + linkType: hard + +"@aws-sdk/client-sso@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/client-sso@npm:3.490.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.490.0 + "@aws-sdk/middleware-host-header": 3.489.0 + "@aws-sdk/middleware-logger": 3.489.0 + "@aws-sdk/middleware-recursion-detection": 3.489.0 + "@aws-sdk/middleware-user-agent": 3.489.0 + "@aws-sdk/region-config-resolver": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-endpoints": 3.489.0 + "@aws-sdk/util-user-agent-browser": 3.489.0 + "@aws-sdk/util-user-agent-node": 3.489.0 + "@smithy/config-resolver": ^2.0.23 + "@smithy/core": ^1.2.2 + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/hash-node": ^2.0.18 + "@smithy/invalid-dependency": ^2.0.16 + "@smithy/middleware-content-length": ^2.0.18 + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/protocol-http": ^3.0.12 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.24 + "@smithy/util-defaults-mode-node": ^2.0.32 + "@smithy/util-endpoints": ^1.0.8 + "@smithy/util-retry": ^2.0.9 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: f09172f7af1de8371dc4bd03ef18f2a260bd9868db9460912d392d0f2bcf4101e8f78eed440db6bd99437a3b8d1c1a107fb3bc904f20f4a99eb0a262c0644b14 + languageName: node + linkType: hard + +"@aws-sdk/client-sts@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/client-sts@npm:3.490.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.490.0 + "@aws-sdk/credential-provider-node": 3.490.0 + "@aws-sdk/middleware-host-header": 3.489.0 + "@aws-sdk/middleware-logger": 3.489.0 + "@aws-sdk/middleware-recursion-detection": 3.489.0 + "@aws-sdk/middleware-user-agent": 3.489.0 + "@aws-sdk/region-config-resolver": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-endpoints": 3.489.0 + "@aws-sdk/util-user-agent-browser": 3.489.0 + "@aws-sdk/util-user-agent-node": 3.489.0 + "@smithy/config-resolver": ^2.0.23 + "@smithy/core": ^1.2.2 + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/hash-node": ^2.0.18 + "@smithy/invalid-dependency": ^2.0.16 + "@smithy/middleware-content-length": ^2.0.18 + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/protocol-http": ^3.0.12 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.24 + "@smithy/util-defaults-mode-node": ^2.0.32 + "@smithy/util-endpoints": ^1.0.8 + "@smithy/util-middleware": ^2.0.9 + "@smithy/util-retry": ^2.0.9 + "@smithy/util-utf8": ^2.0.2 + fast-xml-parser: 4.2.5 + tslib: ^2.5.0 + checksum: 0fffd52bfae00c2e1beacf2cc58924131097efb7022a72f2d8c00e93e9c00ceb584d085b92160a2798d761726c7ca8492da5a7e8665d103a658ad96f9633e04e + languageName: node + linkType: hard + +"@aws-sdk/core@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/core@npm:3.490.0" + dependencies: + "@smithy/core": ^1.2.2 + "@smithy/protocol-http": ^3.0.12 + "@smithy/signature-v4": ^2.0.0 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 8d711ba4373b4ee074f5a225642cb91cd0052daf1e2880da1f5bd1b38197ea5c7888a4181710f715393f83618aacc4465262f9a80de8f4faf2644d5832e455b5 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: ef1c3fb9e12cb3b62be41d87ce168b704d462b6fb27d08eab58b003940e3b24b043f122efab42cdc41d0bd632074114f39b474c480705dc61ed6e879690fa93f + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.490.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.489.0 + "@aws-sdk/credential-provider-process": 3.489.0 + "@aws-sdk/credential-provider-sso": 3.490.0 + "@aws-sdk/credential-provider-web-identity": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@smithy/credential-provider-imds": ^2.0.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 5dd24af06b3a2977c1ebd47621619b18b8d9f9bed04e8ac5daae9faedabe66d6c6792a62da54513d668f0b11315cab4fd41a85637e35646cec46588742e36b1f + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.490.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.489.0 + "@aws-sdk/credential-provider-ini": 3.490.0 + "@aws-sdk/credential-provider-process": 3.489.0 + "@aws-sdk/credential-provider-sso": 3.490.0 + "@aws-sdk/credential-provider-web-identity": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@smithy/credential-provider-imds": ^2.0.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 52820573a6aff0ea8c7ca0699fb374ba9a94a9e9eb777c2b1225d3565fdde7a65c70fcbe9ec887cf555c2df73cdc341641791f1358a102e052869d0f4978d4d0 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 19eb75f0222176f33ad5fbeb5a02b312a37a48966b73ff5c1af9974d439dc4c6faeffe745c2a23b0abb67ab876f9e10099b4a22c2ee9d15be5290556331a7a9a + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:3.490.0": + version: 3.490.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.490.0" + dependencies: + "@aws-sdk/client-sso": 3.490.0 + "@aws-sdk/token-providers": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: ea3c9d180f13f676b7717a8e0aabdca33b36f2dbf27128dfa6f8172040aa895437ef03e08802b9d6b104a783b8606b8af923193ff54f79e92118aa54babaa311 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 6da681688c9a61b6e100c71983a1ba19ef34bcbd8d1e4e85e3399d86e74cdc8d81f57b6da1983319596c87ef1600471e82c5243fb909f9752eff99a6ef26a8ea + languageName: node + linkType: hard + +"@aws-sdk/lib-storage@npm:^3.440.0": + version: 3.490.0 + resolution: "@aws-sdk/lib-storage@npm:3.490.0" + dependencies: + "@smithy/abort-controller": ^2.0.1 + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/smithy-client": ^2.2.1 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: ^2.5.0 + peerDependencies: + "@aws-sdk/client-s3": ^3.0.0 + checksum: 253dc2e4fd33efcf5ee8d3ad76bb3f2ed1d4573e27fa8be23dc3b9b60bcf22e769684f3ab639c3551a66282e160e4b1693527ac49062c6a42b55716e7aadc6df + languageName: node + linkType: hard + +"@aws-sdk/middleware-bucket-endpoint@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-arn-parser": 3.465.0 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + "@smithy/util-config-provider": ^2.1.0 + tslib: ^2.5.0 + checksum: 209278da47bf7e3b0e3cd6bc0f80d56bd6fc06400c12041474d1fdef7d227b8120bd7ac0b471fd15e03433e8c9e5e8c7a20d4f5f057dc23edfecaf4b61ad1378 + languageName: node + linkType: hard + +"@aws-sdk/middleware-expect-continue@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 2e4bc714e5c410d2362ec274e2f36ffbb35dc826030ac64ec2d34bf9a89b1defa3c8cd1e0d92c4f41ddb64035039ddfac927069152ea70437061f2f078d43cc1 + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.489.0" + dependencies: + "@aws-crypto/crc32": 3.0.0 + "@aws-crypto/crc32c": 3.0.0 + "@aws-sdk/types": 3.489.0 + "@smithy/is-array-buffer": ^2.0.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 522f234ba9fdf5fe1476c72b7480158723a52e15db0560a4d79fa00dd43935633104648a4be2d0d741afa90f5b4f5ced3e99ffdece9b1aa75f4c08389dbe5f10 + languageName: node + linkType: hard + +"@aws-sdk/middleware-host-header@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 15072d8409066de23ae815ce84c9ab9ce509e368654a3438b398e0f1bec5e215a52a59388ede1fe4c7a40cb639759c50aab3a3562ffe17352a24e6f92f2ddd1e + languageName: node + linkType: hard + +"@aws-sdk/middleware-location-constraint@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: d9499c897ef1f89afafdc2b4ebaa32cb31396cd7412ddaab545427820c33ef90e6f3e9f16b82607cc7a0d402994034b0c51255c06e2bd15f30d618d10f8bcbc6 + languageName: node + linkType: hard + +"@aws-sdk/middleware-logger@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-logger@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 10fa2538663512158f7a5889da693b0351f1f464457e45bc02199540adf6985ddfda81fe36dbdddabe1fe12709118fa106a365988e691fab834c9d93bf34329e + languageName: node + linkType: hard + +"@aws-sdk/middleware-recursion-detection@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: bf1592f0577e8a5be5857100c288c0de0895b98b00d1947403d9ff99936d366cd3e8ae4838560ee68ff0d5fb74f26c2f139f23fee8b72ce8f753a742b467bb02 + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-arn-parser": 3.465.0 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/protocol-http": ^3.0.12 + "@smithy/signature-v4": ^2.0.0 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/util-config-provider": ^2.1.0 + tslib: ^2.5.0 + checksum: 4a1541d7e21172a270cf3965171abcfd1dcb1fc98eac982fb2d3f8dbc10cfecec5ea13bfb27ada66c1a40a6446d027399818edabf1180b340e496eb6826fa8d7 + languageName: node + linkType: hard + +"@aws-sdk/middleware-signing@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-signing@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/property-provider": ^2.0.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/signature-v4": ^2.0.0 + "@smithy/types": ^2.8.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: bf50942d8ca7baf2d27b0a6d8fb06219971da9863ee0fa7a2ab56c85d344b0374724f608cec371b75a2ea2a0cd4da6d7d431f8bf1f3d9fe5f1797a4d15272962 + languageName: node + linkType: hard + +"@aws-sdk/middleware-ssec@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-ssec@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: c3666f606bcdcbe2c36a21e0460fd9514ea6f7a3faf09fb392457ca7fc9f41da81351a50f4432ce748a24daad5715736eddf7e59694f0fbbcc530b395c2bf12c + languageName: node + linkType: hard + +"@aws-sdk/middleware-user-agent@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-endpoints": 3.489.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: b5254863f2203b598199ad65ca87a5a3b92a3ecb51cabbb910819b0f03f61b26b553364e047b9b3329463f8dd4324a650d045deaf548cb5e7de2c08ed2f5dfd6 + languageName: node + linkType: hard + +"@aws-sdk/region-config-resolver@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + "@smithy/util-config-provider": ^2.1.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 2352d0b3409e6d5225fd3f6f5da81164fcb93bb528579eacefea75ef8760f8626434e377eb3c7785046ce996732209f300de70958905124acbd1eb45a192e24b + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.489.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/signature-v4": ^2.0.0 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 613ab4f64682844dd9a6c6675f7db218185d3415a40b255e1346a0217576bad431156324739d148542421c67036462476b95ac801ea8c7f24f5abac96514ee23 + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/token-providers@npm:3.489.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/middleware-host-header": 3.489.0 + "@aws-sdk/middleware-logger": 3.489.0 + "@aws-sdk/middleware-recursion-detection": 3.489.0 + "@aws-sdk/middleware-user-agent": 3.489.0 + "@aws-sdk/region-config-resolver": 3.489.0 + "@aws-sdk/types": 3.489.0 + "@aws-sdk/util-endpoints": 3.489.0 + "@aws-sdk/util-user-agent-browser": 3.489.0 + "@aws-sdk/util-user-agent-node": 3.489.0 + "@smithy/config-resolver": ^2.0.23 + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/hash-node": ^2.0.18 + "@smithy/invalid-dependency": ^2.0.16 + "@smithy/middleware-content-length": ^2.0.18 + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/property-provider": ^2.0.0 + "@smithy/protocol-http": ^3.0.12 + "@smithy/shared-ini-file-loader": ^2.0.6 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-body-length-browser": ^2.0.1 + "@smithy/util-body-length-node": ^2.1.0 + "@smithy/util-defaults-mode-browser": ^2.0.24 + "@smithy/util-defaults-mode-node": ^2.0.32 + "@smithy/util-endpoints": ^1.0.8 + "@smithy/util-retry": ^2.0.9 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: cf2e14a09ead031e9ac3426fd0e5bc71656f1ce9ba470c860af26d8935dce65b28365973388175f982dd2d8fb6c470520dee426045b26174663a5adc6ac5928c + languageName: node + linkType: hard + +"@aws-sdk/types@npm:3.489.0, @aws-sdk/types@npm:^3.222.0": + version: 3.489.0 + resolution: "@aws-sdk/types@npm:3.489.0" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: e4692f04daee278e4fc7faeadb8fee077aee5b1210a6c237c203b4688b714d1efc96c9f4574b71e264f71d8f4aad06b5728bcba13d12242c92f93dec81e3d3da + languageName: node + linkType: hard + +"@aws-sdk/util-arn-parser@npm:3.465.0": + version: 3.465.0 + resolution: "@aws-sdk/util-arn-parser@npm:3.465.0" + dependencies: + tslib: ^2.5.0 + checksum: ce2dd638e9b8ef3260ce1c1ae299a4e44cbaa28a07cda9f1033b763cea8b1d901b7a963338e8a172af17074d06811f09605f3f903318c4bd2bbf102e92d02546 + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/util-endpoints@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/types": ^2.8.0 + "@smithy/util-endpoints": ^1.0.8 + tslib: ^2.5.0 + checksum: 5c225b12ce5c18ecd64079d2e4133374244728ac7c8055efb07ca5b274266cb0e2d6c88ce5ae4385d45d67b2999fcd5bbe5e8dd4299792542683682ac05950e8 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.465.0 + resolution: "@aws-sdk/util-locate-window@npm:3.465.0" + dependencies: + tslib: ^2.5.0 + checksum: 3ec2c40bea7976bf403fc7f227e70180ed1016f5a76e33d57148fc6b6edb24b73b16a5e5c3e32003d6c0783339984c7e50fa997a54d414937c6de14180ee419a + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-browser@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/types": ^2.8.0 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: b24009655bd5a7755575d6ed5c955d6fcfa3a70248e1a9c9d4a58cc7ed4bb25936a276917a0ab60b6e11e7ed915d5dcfdd5e9112e373bd23b24d24963324e516 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-node@npm:3.489.0": + version: 3.489.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.489.0" + dependencies: + "@aws-sdk/types": 3.489.0 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 755845ce1cebdc78d3bb2bab058cf8e966658373daa7c62ae808fc0fc733248ed019042b1eed4ad3274ae08f23506cc000e1b0d41b6f01fd29cccad4275b3f8e + languageName: node + linkType: hard + +"@aws-sdk/util-utf8-browser@npm:^3.0.0": + version: 3.259.0 + resolution: "@aws-sdk/util-utf8-browser@npm:3.259.0" + dependencies: + tslib: ^2.3.1 + checksum: b6a1e580da1c9b62c749814182a7649a748ca4253edb4063aa521df97d25b76eae3359eb1680b86f71aac668e05cc05c514379bca39ebf4ba998ae4348412da8 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:3.485.0": + version: 3.485.0 + resolution: "@aws-sdk/xml-builder@npm:3.485.0" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 97eacc7fff1161876cb672051905156d6aec8116f76b35d5f2ca87b676b5b270fc6de0b9bed53792272a7712d835077ac64b4135fed7ea81c3e4cbf070f1be58 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.23.5": version: 7.23.5 resolution: "@babel/code-frame@npm:7.23.5" @@ -165,7 +878,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.6, @babel/core@npm:^7.20.7, @babel/core@npm:^7.23.5": +"@babel/core@npm:^7.11.1, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.6, @babel/core@npm:^7.20.7, @babel/core@npm:^7.23.5": version: 7.23.7 resolution: "@babel/core@npm:7.23.7" dependencies: @@ -313,7 +1026,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-imports@npm:^7.22.15": +"@babel/helper-module-imports@npm:^7.10.4, @babel/helper-module-imports@npm:^7.22.15": version: 7.22.15 resolution: "@babel/helper-module-imports@npm:7.22.15" dependencies: @@ -1386,7 +2099,7 @@ __metadata: languageName: node linkType: hard -"@babel/preset-env@npm:^7.18.6": +"@babel/preset-env@npm:^7.11.0, @babel/preset-env@npm:^7.18.6": version: 7.23.8 resolution: "@babel/preset-env@npm:7.23.8" dependencies: @@ -1520,7 +2233,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.23.8 resolution: "@babel/runtime@npm:7.23.8" dependencies: @@ -1576,6 +2289,57 @@ __metadata: languageName: node linkType: hard +"@cloudflare/kv-asset-handler@npm:^0.2.0": + version: 0.2.0 + resolution: "@cloudflare/kv-asset-handler@npm:0.2.0" + dependencies: + mime: ^3.0.0 + checksum: bc6a02a9c80be6de90e46454ef4de09301e68726eaa4835de0e30216e50fffcc5612274a17dfb455916cf3418f0cb25fefd2b561a9d2282f4cc10d40527f0acb + languageName: node + linkType: hard + +"@cloudflare/workerd-darwin-64@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "@cloudflare/workerd-darwin-64@npm:1.20231030.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@cloudflare/workerd-darwin-arm64@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20231030.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@cloudflare/workerd-linux-64@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "@cloudflare/workerd-linux-64@npm:1.20231030.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@cloudflare/workerd-linux-arm64@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "@cloudflare/workerd-linux-arm64@npm:1.20231030.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@cloudflare/workerd-windows-64@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "@cloudflare/workerd-windows-64@npm:1.20231030.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@cloudflare/workers-types@npm:^4.20230821.0": + version: 4.20231218.0 + resolution: "@cloudflare/workers-types@npm:4.20231218.0" + checksum: b7e50a76ee8e9d662227bbb74798b93b6102acc224f1071a9c99a9adb419ad0b3bdabf7561e7e1b4a320a6a4616badeecdfb1848fbdaada197c7b37d845b8774 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -1645,6 +2409,15 @@ __metadata: languageName: node linkType: hard +"@esbuild-plugins/node-globals-polyfill@npm:^0.2.3": + version: 0.2.3 + resolution: "@esbuild-plugins/node-globals-polyfill@npm:0.2.3" + peerDependencies: + esbuild: "*" + checksum: f83eeaa382680b26a3b1cf6c396450332c41d2dc0f9fd935d3f4bacf5412bef7383d2aeb4246a858781435b7c005a570dadc81051f8a038f1ef2111f17d3d8b0 + languageName: node + linkType: hard + "@esbuild-plugins/node-modules-polyfill@npm:^0.1.4": version: 0.1.4 resolution: "@esbuild-plugins/node-modules-polyfill@npm:0.1.4" @@ -1657,6 +2430,18 @@ __metadata: languageName: node linkType: hard +"@esbuild-plugins/node-modules-polyfill@npm:^0.2.2": + version: 0.2.2 + resolution: "@esbuild-plugins/node-modules-polyfill@npm:0.2.2" + dependencies: + escape-string-regexp: ^4.0.0 + rollup-plugin-node-polyfills: ^0.2.1 + peerDependencies: + esbuild: "*" + checksum: 73c247a7559c68b7df080ab08dd3d0b0ab44b934840a4933df9626357b7183a9a5d8cf4ffa9c744f1bad8d7131bce0fde14a23203f7b262f9f14f7b3485bfdb1 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.19.11": version: 0.19.11 resolution: "@esbuild/aix-ppc64@npm:0.19.11" @@ -2483,6 +3268,20 @@ __metadata: languageName: node linkType: hard +"@fastify/busboy@npm:^2.0.0": + version: 2.1.0 + resolution: "@fastify/busboy@npm:2.1.0" + checksum: 3233abd10f73e50668cb4bb278a79b7b3fadd30215ac6458299b0e5a09a29c3586ec07597aae6bd93f5cbedfcef43a8aeea51829cd28fc13850cdbcd324c28d5 + languageName: node + linkType: hard + +"@floating-ui/core@npm:^0.7.3": + version: 0.7.3 + resolution: "@floating-ui/core@npm:0.7.3" + checksum: f48f9fb0d19dcbe7a68c38e8de7fabb11f0c0e6e0ef215ae60b5004900bacb1386e7b89cb377d91a90ff7d147ea1f06c2905136ecf34dea162d9696d8f448d5f + languageName: node + linkType: hard + "@floating-ui/core@npm:^1.5.3": version: 1.5.3 resolution: "@floating-ui/core@npm:1.5.3" @@ -2492,6 +3291,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^0.5.3": + version: 0.5.4 + resolution: "@floating-ui/dom@npm:0.5.4" + dependencies: + "@floating-ui/core": ^0.7.3 + checksum: 9f9d8a51a828c6be5f187204aa6d293c6c9ef70d51dcc5891a4d85683745fceebf79ff8826d0f75ae41b45c3b138367d339756f27f41be87a8770742ebc0de42 + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.5.4": version: 1.5.4 resolution: "@floating-ui/dom@npm:1.5.4" @@ -2502,6 +3310,19 @@ __metadata: languageName: node linkType: hard +"@floating-ui/react-dom@npm:0.7.2": + version: 0.7.2 + resolution: "@floating-ui/react-dom@npm:0.7.2" + dependencies: + "@floating-ui/dom": ^0.5.3 + use-isomorphic-layout-effect: ^1.1.1 + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: bc3f2b5557f87f6f4bbccfe3e8d097abafad61a41083d3b79f3499f27590e273bcb3dc7136c2444841ee7a8c0d2a70cc1385458c16103fa8b70eade80c24af52 + languageName: node + linkType: hard + "@floating-ui/react-dom@npm:^2.0.0": version: 2.0.5 resolution: "@floating-ui/react-dom@npm:2.0.5" @@ -2923,6 +3744,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/source-map@npm:^0.3.3": + version: 0.3.5 + resolution: "@jridgewell/source-map@npm:0.3.5" + dependencies: + "@jridgewell/gen-mapping": ^0.3.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 1ad4dec0bdafbade57920a50acec6634f88a0eb735851e0dda906fa9894e7f0549c492678aad1a10f8e144bfe87f238307bf2a914a1bc85b7781d345417e9f6f + languageName: node + linkType: hard + "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" @@ -3091,6 +3922,13 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:13.5.6": + version: 13.5.6 + resolution: "@next/env@npm:13.5.6" + checksum: 5e8f3f6f987a15dad3cd7b2bcac64a6382c2ec372d95d0ce6ab295eb59c9731222017eebf71ff3005932de2571f7543bce7e5c6a8c90030207fb819404138dc2 + languageName: node + linkType: hard + "@next/env@npm:14.0.4": version: 14.0.4 resolution: "@next/env@npm:14.0.4" @@ -3098,6 +3936,15 @@ __metadata: languageName: node linkType: hard +"@next/eslint-plugin-next@npm:12.2.5": + version: 12.2.5 + resolution: "@next/eslint-plugin-next@npm:12.2.5" + dependencies: + glob: 7.1.7 + checksum: 0d6faf895d4952fc2a5da3f2e86a9e1903f37b44201e7fabfcc994f6989dfceb974f354a7abb1779b318f14ada57925d82eb6c22628265c7f6b36f232edc93ee + languageName: node + linkType: hard + "@next/eslint-plugin-next@npm:13.2.4": version: 13.2.4 resolution: "@next/eslint-plugin-next@npm:13.2.4" @@ -3116,6 +3963,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-arm64@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-darwin-arm64@npm:13.5.6" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-darwin-arm64@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-darwin-arm64@npm:14.0.4" @@ -3123,6 +3977,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-x64@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-darwin-x64@npm:13.5.6" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@next/swc-darwin-x64@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-darwin-x64@npm:14.0.4" @@ -3130,6 +3991,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-gnu@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-linux-arm64-gnu@npm:13.5.6" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-arm64-gnu@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-arm64-gnu@npm:14.0.4" @@ -3137,6 +4005,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-musl@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-linux-arm64-musl@npm:13.5.6" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-arm64-musl@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-arm64-musl@npm:14.0.4" @@ -3144,6 +4019,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-gnu@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-linux-x64-gnu@npm:13.5.6" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-x64-gnu@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-x64-gnu@npm:14.0.4" @@ -3151,6 +4033,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-musl@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-linux-x64-musl@npm:13.5.6" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-x64-musl@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-linux-x64-musl@npm:14.0.4" @@ -3158,6 +4047,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-arm64-msvc@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-win32-arm64-msvc@npm:13.5.6" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-win32-arm64-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-arm64-msvc@npm:14.0.4" @@ -3165,6 +4061,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-ia32-msvc@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-win32-ia32-msvc@npm:13.5.6" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@next/swc-win32-ia32-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-ia32-msvc@npm:14.0.4" @@ -3172,6 +4075,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-x64-msvc@npm:13.5.6": + version: 13.5.6 + resolution: "@next/swc-win32-x64-msvc@npm:13.5.6" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@next/swc-win32-x64-msvc@npm:14.0.4": version: 14.0.4 resolution: "@next/swc-win32-x64-msvc@npm:14.0.4" @@ -3371,7 +4281,7 @@ __metadata: languageName: node linkType: hard -"@octokit/core@npm:^5.0.0": +"@octokit/core@npm:^5.0.0, @octokit/core@npm:^5.0.1": version: 5.0.2 resolution: "@octokit/core@npm:5.0.2" dependencies: @@ -3562,7 +4472,7 @@ __metadata: languageName: node linkType: hard -"@octokit/plugin-retry@npm:^6.0.0": +"@octokit/plugin-retry@npm:^6.0.0, @octokit/plugin-retry@npm:^6.0.1": version: 6.0.1 resolution: "@octokit/plugin-retry@npm:6.0.1" dependencies: @@ -3691,6 +4601,13 @@ __metadata: languageName: node linkType: hard +"@octokit/webhooks-types@npm:^6.11.0": + version: 6.11.0 + resolution: "@octokit/webhooks-types@npm:6.11.0" + checksum: af35ac7a3d8d95bf9906fb3a8f6075cf9cb10707c79444fa82df2d64596125f515a35a4995b4548b84ee042c7c1b1cc120e05ece4a197af541a52f154bf4bcce + languageName: node + linkType: hard + "@octokit/webhooks@npm:^12.0.4": version: 12.0.11 resolution: "@octokit/webhooks@npm:12.0.11" @@ -3763,6 +4680,15 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/primitive@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + checksum: 72996afaf346ec4f4c73422f14f6cb2d0de994801ba7cbb9a4a67b0050e0cd74625182c349ef8017ccae1406579d4b74a34a225ef2efe61e8e5337decf235deb + languageName: node + linkType: hard + "@radix-ui/primitive@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/primitive@npm:1.0.1" @@ -3797,6 +4723,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-arrow@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-arrow@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.2 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: d9c4a810376b686edfedfab15c3ef739bb2b388211fbf33191648149aba5064bfff8a5de05b264ad0b76c4a4df98fd8267002580e3515b7f5ad31b9495bfda21 + languageName: node + linkType: hard + "@radix-ui/react-arrow@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-arrow@npm:1.0.3" @@ -3840,6 +4779,17 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-compose-refs@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-compose-refs@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: fb98be2e275a1a758ccac647780ff5b04be8dcf25dcea1592db3b691fecf719c4c0700126da605b2f512dd89caa111352b9fad59528d736b4e0e9a0e134a74a1 + languageName: node + linkType: hard + "@radix-ui/react-compose-refs@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-compose-refs@npm:1.0.1" @@ -3880,6 +4830,17 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-context@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-context@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: 43c6b6f2183398161fe6b109e83fff240a6b7babbb27092b815932342a89d5ca42aa9806bfae5927970eed5ff90feed04c67aa29c6721f84ae826f17fcf34ce0 + languageName: node + linkType: hard + "@radix-ui/react-context@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-context@npm:1.0.1" @@ -3943,6 +4904,23 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-dismissable-layer@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-dismissable-layer@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-primitive": 1.0.2 + "@radix-ui/react-use-callback-ref": 1.0.0 + "@radix-ui/react-use-escape-keydown": 1.0.2 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: cb2a38a65dd129d1fd58436bedee765f46f6a6edc2ec15d534a1499c10f768ae06ad874704e030c85869b3ee4b61103076a116dfdb7e0c761a8c8cdc30a5c951 + languageName: node + linkType: hard + "@radix-ui/react-dismissable-layer@npm:1.0.4": version: 1.0.4 resolution: "@radix-ui/react-dismissable-layer@npm:1.0.4" @@ -4017,6 +4995,17 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-guards@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-focus-guards@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: 8c714e8caa6032f5402eecb0323addd7456d3496946dbad1b9ee8ebf5845943876945e7af9bca179e9f8ffe5100e61cb4ba54a185873949125c310c406be5aa4 + languageName: node + linkType: hard + "@radix-ui/react-focus-guards@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-focus-guards@npm:1.0.1" @@ -4032,6 +5021,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-focus-scope@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-focus-scope@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-primitive": 1.0.2 + "@radix-ui/react-use-callback-ref": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: f04f7412c8d9d2e0f431a0360ec10415f085a530322f693220e0ad36ad8ffd9f637c4b0d9bc35da09621ac0ad97ff33d382c84853c827825a9f9c924843fd339 + languageName: node + linkType: hard + "@radix-ui/react-focus-scope@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-focus-scope@npm:1.0.3" @@ -4076,6 +5080,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-id@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-id@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-layout-effect": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: ba323cedd6a6df6f6e51ed1f7f7747988ce432b47fd94d860f962b14b342dcf049eae33f8ad0b72fd7df6329a7375542921132271fba64ab0a271c93f09c48d1 + languageName: node + linkType: hard + "@radix-ui/react-id@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-id@npm:1.0.1" @@ -4129,6 +5145,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-popover@npm:1.0.6-rc.5": + version: 1.0.6-rc.5 + resolution: "@radix-ui/react-popover@npm:1.0.6-rc.5" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-context": 1.0.0 + "@radix-ui/react-dismissable-layer": 1.0.3 + "@radix-ui/react-focus-guards": 1.0.0 + "@radix-ui/react-focus-scope": 1.0.2 + "@radix-ui/react-id": 1.0.0 + "@radix-ui/react-popper": 1.1.2-rc.5 + "@radix-ui/react-portal": 1.0.2 + "@radix-ui/react-presence": 1.0.0 + "@radix-ui/react-primitive": 1.0.2 + "@radix-ui/react-slot": 1.0.1 + "@radix-ui/react-use-controllable-state": 1.0.0 + aria-hidden: ^1.1.1 + react-remove-scroll: 2.5.5 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: a9d758eaf1b1e0714e331be5aa4221a870676960e1c89f2e55c399a09480125792145a3fe329040aae302e03ea9008f1d775f801a2fdf54281eb06a8b1bc63c0 + languageName: node + linkType: hard + "@radix-ui/react-popover@npm:^1.0.7": version: 1.0.7 resolution: "@radix-ui/react-popover@npm:1.0.7" @@ -4192,6 +5235,28 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-popper@npm:1.1.2-rc.5": + version: 1.1.2-rc.5 + resolution: "@radix-ui/react-popper@npm:1.1.2-rc.5" + dependencies: + "@babel/runtime": ^7.13.10 + "@floating-ui/react-dom": 0.7.2 + "@radix-ui/react-arrow": 1.0.2 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-context": 1.0.0 + "@radix-ui/react-primitive": 1.0.2 + "@radix-ui/react-use-callback-ref": 1.0.0 + "@radix-ui/react-use-layout-effect": 1.0.0 + "@radix-ui/react-use-rect": 1.0.0 + "@radix-ui/react-use-size": 1.0.0 + "@radix-ui/rect": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: 9a6a1773bec3ff7054e8cf815c89e490e1426ceb624e46ff3601dde18fa2a06e5ff9d0b5d54cce9a5a46ac3cf5344de76df757812c41cb733a5552c7a5273cf8 + languageName: node + linkType: hard + "@radix-ui/react-popper@npm:1.1.3": version: 1.1.3 resolution: "@radix-ui/react-popper@npm:1.1.3" @@ -4221,6 +5286,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-portal@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-portal@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.2 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: 1165b4bced8057021ea9ac4f568c6e0ea6f190936f07dc96780d67488b9222021444bbb8e04e506eea84d9219a5caacae8d0974e745182d4f398aa903b982e19 + languageName: node + linkType: hard + "@radix-ui/react-portal@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-portal@npm:1.0.3" @@ -4261,6 +5339,20 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-presence@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-presence@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-use-layout-effect": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: a607d67795aa265e88f1765dcc7c18bebf6d88d116cb7f529ebe5a3fbbe751a42763aff0c1c89cdd8ce7f7664355936c4070fd3d4685774aff1a80fa95f4665b + languageName: node + linkType: hard + "@radix-ui/react-presence@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-presence@npm:1.0.1" @@ -4282,6 +5374,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-primitive@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-slot": 1.0.1 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: 070b1770749eb629425ef959c4cdbd86957b83c5286ae4423e55ab1a89fa286a51f5aeee44e3a13eb6ca44771415ac1acbaeb0ba03013b49ecb5253e1a5a8996 + languageName: node + linkType: hard + "@radix-ui/react-primitive@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-primitive@npm:1.0.3" @@ -4400,6 +5505,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-slot@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: a20693f8ce532bd6cbff12ba543dfcf90d451f22923bd60b57dc9e639f6e53348915e182002b33444feb6ab753434e78e2a54085bf7092aadda4418f0423763f + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.0.2": version: 1.0.2 resolution: "@radix-ui/react-slot@npm:1.0.2" @@ -4447,6 +5564,17 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-callback-ref@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-use-callback-ref@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: a8dda76ba0a26e23dc6ab5003831ad7439f59ba9d696a517643b9ee6a7fb06b18ae7a8f5a3c00c530d5c8104745a466a077b7475b99b4c0f5c15f5fc29474471 + languageName: node + linkType: hard + "@radix-ui/react-use-callback-ref@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" @@ -4462,6 +5590,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-controllable-state@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-use-controllable-state@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-callback-ref": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: 35f1e714bbe3fc9f5362a133339dd890fb96edb79b63168a99403c65dd5f2b63910e0c690255838029086719e31360fa92544a55bc902cfed4442bb3b55822e2 + languageName: node + linkType: hard + "@radix-ui/react-use-controllable-state@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1" @@ -4478,6 +5618,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-escape-keydown@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-callback-ref": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: 5bec1b73ed6c38139bf1db3c626c0474ca6221ae55f154ef83f1c6429ea866280b2a0ba9436b807334d0215bb4389f0b492c65471cf565635957a8ee77cce98a + languageName: node + linkType: hard + "@radix-ui/react-use-escape-keydown@npm:1.0.3": version: 1.0.3 resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3" @@ -4494,6 +5646,17 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-layout-effect@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-use-layout-effect@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: fcdc8cfa79bd45766ebe3de11039c58abe3fed968cb39c12b2efce5d88013c76fe096ea4cee464d42576d02fe7697779b682b4268459bca3c4e48644f5b4ac5e + languageName: node + linkType: hard + "@radix-ui/react-use-layout-effect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-layout-effect@npm:1.0.1" @@ -4524,6 +5687,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-rect@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-use-rect@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/rect": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: c755cee1a8846a74d4f6f486c65134a552c65d0bfb934d1d3d4f69f331c32cfd8b279c08c8907d64fbb68388fc3683f854f336e4f9549e1816fba32156bb877b + languageName: node + linkType: hard + "@radix-ui/react-use-rect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-rect@npm:1.0.1" @@ -4540,6 +5715,18 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-size@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-use-size@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-layout-effect": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + checksum: b319564668512bb5c8c64530e3c12810c4b7c75c19a00d5ef758c246e8d85cd5015df19688e174db1cc44b0584c8d7f22411eb00af5f8ac6c2e789aa5c8e34f5 + languageName: node + linkType: hard + "@radix-ui/react-use-size@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-size@npm:1.0.1" @@ -4576,6 +5763,15 @@ __metadata: languageName: node linkType: hard +"@radix-ui/rect@npm:1.0.0": + version: 1.0.0 + resolution: "@radix-ui/rect@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + checksum: d5b54984148ac52e30c6a92834deb619cf74b4af02709a20eb43e7895f98fed098968b597a715bf5b5431ae186372e65499a801d93e835f53bbc39e3a549f664 + languageName: node + linkType: hard + "@radix-ui/rect@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/rect@npm:1.0.1" @@ -4682,6 +5878,64 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-babel@npm:^5.2.0": + version: 5.3.1 + resolution: "@rollup/plugin-babel@npm:5.3.1" + dependencies: + "@babel/helper-module-imports": ^7.10.4 + "@rollup/pluginutils": ^3.1.0 + peerDependencies: + "@babel/core": ^7.0.0 + "@types/babel__core": ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + "@types/babel__core": + optional: true + checksum: 220d71e4647330f252ef33d5f29700aef2e8284a0b61acfcceb47617a7f96208aa1ed16eae75619424bf08811ede5241e271a6d031f07026dee6b3a2bdcdc638 + languageName: node + linkType: hard + +"@rollup/plugin-node-resolve@npm:^11.2.1": + version: 11.2.1 + resolution: "@rollup/plugin-node-resolve@npm:11.2.1" + dependencies: + "@rollup/pluginutils": ^3.1.0 + "@types/resolve": 1.17.1 + builtin-modules: ^3.1.0 + deepmerge: ^4.2.2 + is-module: ^1.0.0 + resolve: ^1.19.0 + peerDependencies: + rollup: ^1.20.0||^2.0.0 + checksum: 6f3b3ecf9a0596a5db4212984bdeb13bb7612693602407e9457ada075dea5a5f2e4e124c592352cf27066a88b194de9b9a95390149b52cf335d5b5e17b4e265b + languageName: node + linkType: hard + +"@rollup/plugin-replace@npm:^2.4.1": + version: 2.4.2 + resolution: "@rollup/plugin-replace@npm:2.4.2" + dependencies: + "@rollup/pluginutils": ^3.1.0 + magic-string: ^0.25.7 + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + checksum: b2f1618ee5526d288e2f8ae328dcb326e20e8dc8bd1f60d3e14d6708a5832e4aa44811f7d493f4aed2deeadca86e3b6b0503cd39bf50cfb4b595bb9da027fad0 + languageName: node + linkType: hard + +"@rollup/pluginutils@npm:^3.1.0": + version: 3.1.0 + resolution: "@rollup/pluginutils@npm:3.1.0" + dependencies: + "@types/estree": 0.0.39 + estree-walker: ^1.0.1 + picomatch: ^2.2.2 + peerDependencies: + rollup: ^1.20.0||^2.0.0 + checksum: 8be16e27863c219edbb25a4e6ec2fe0e1e451d9e917b6a43cf2ae5bc025a6b8faaa40f82a6e53b66d0de37b58ff472c6c3d57a83037ae635041f8df959d6d9aa + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^4.0.0": version: 4.2.1 resolution: "@rollup/pluginutils@npm:4.2.1" @@ -4692,6 +5946,97 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.9.5" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-android-arm64@npm:4.9.5" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-darwin-arm64@npm:4.9.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-darwin-x64@npm:4.9.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.9.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.9.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.9.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.9.5" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.9.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.9.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.9.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.9.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.9.5": + version: 4.9.5 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.9.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rushstack/eslint-patch@npm:^1.1.3": version: 1.6.1 resolution: "@rushstack/eslint-patch@npm:1.6.1" @@ -4761,6 +6106,245 @@ __metadata: languageName: node linkType: hard +"@sentry-internal/feedback@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry-internal/feedback@npm:7.93.0" + dependencies: + "@sentry/core": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + checksum: 6a6aff87ce41c2882951e2ba70127dbc38b5c5c451736f4ef45edebf68c798b1b901c0ea412d2b4e2234fa39e4feeee5700fe3c46131ba0c01380fdb476396d1 + languageName: node + linkType: hard + +"@sentry-internal/tracing@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry-internal/tracing@npm:7.93.0" + dependencies: + "@sentry/core": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + checksum: d3e3536c2be747f6e02b844932ae251d52086a7c40bdcb4ddf32de5d9cee7d120f08cf494de887bda4228e9a43f9fccfc3610cba831a79eab147d607981e1388 + languageName: node + linkType: hard + +"@sentry/browser@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry/browser@npm:7.93.0" + dependencies: + "@sentry-internal/feedback": 7.93.0 + "@sentry-internal/tracing": 7.93.0 + "@sentry/core": 7.93.0 + "@sentry/replay": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + checksum: 420c9a76c55d72654b6afa767f72f25b50234b9e14724a6162c780b12e1eb14a1a52bba182bd73c17346a1bb5debac73cbfac497fc64d86e1fd1ec9c03b4fb44 + languageName: node + linkType: hard + +"@sentry/cli-darwin@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-darwin@npm:2.25.0" + conditions: os=darwin + languageName: node + linkType: hard + +"@sentry/cli-linux-arm64@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-linux-arm64@npm:2.25.0" + conditions: (os=linux | os=freebsd) & cpu=arm64 + languageName: node + linkType: hard + +"@sentry/cli-linux-arm@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-linux-arm@npm:2.25.0" + conditions: (os=linux | os=freebsd) & cpu=arm + languageName: node + linkType: hard + +"@sentry/cli-linux-i686@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-linux-i686@npm:2.25.0" + conditions: (os=linux | os=freebsd) & (cpu=x86 | cpu=ia32) + languageName: node + linkType: hard + +"@sentry/cli-linux-x64@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-linux-x64@npm:2.25.0" + conditions: (os=linux | os=freebsd) & cpu=x64 + languageName: node + linkType: hard + +"@sentry/cli-win32-i686@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-win32-i686@npm:2.25.0" + conditions: os=win32 & (cpu=x86 | cpu=ia32) + languageName: node + linkType: hard + +"@sentry/cli-win32-x64@npm:2.25.0": + version: 2.25.0 + resolution: "@sentry/cli-win32-x64@npm:2.25.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@sentry/cli@npm:^2.25.0": + version: 2.25.0 + resolution: "@sentry/cli@npm:2.25.0" + dependencies: + "@sentry/cli-darwin": 2.25.0 + "@sentry/cli-linux-arm": 2.25.0 + "@sentry/cli-linux-arm64": 2.25.0 + "@sentry/cli-linux-i686": 2.25.0 + "@sentry/cli-linux-x64": 2.25.0 + "@sentry/cli-win32-i686": 2.25.0 + "@sentry/cli-win32-x64": 2.25.0 + https-proxy-agent: ^5.0.0 + node-fetch: ^2.6.7 + progress: ^2.0.3 + proxy-from-env: ^1.1.0 + which: ^2.0.2 + dependenciesMeta: + "@sentry/cli-darwin": + optional: true + "@sentry/cli-linux-arm": + optional: true + "@sentry/cli-linux-arm64": + optional: true + "@sentry/cli-linux-i686": + optional: true + "@sentry/cli-linux-x64": + optional: true + "@sentry/cli-win32-i686": + optional: true + "@sentry/cli-win32-x64": + optional: true + bin: + sentry-cli: bin/sentry-cli + checksum: 44a6a7dd34a6553afad65a60f97533a5fe58a6166162bf7dac0651a62080f9dc5ed8046a404ddaba6a65936d377081289191f00154716107409e08344a310235 + languageName: node + linkType: hard + +"@sentry/core@npm:6.19.6": + version: 6.19.6 + resolution: "@sentry/core@npm:6.19.6" + dependencies: + "@sentry/hub": 6.19.6 + "@sentry/minimal": 6.19.6 + "@sentry/types": 6.19.6 + "@sentry/utils": 6.19.6 + tslib: ^1.9.3 + checksum: b714f12c8a59db845cbc05074818725dd37d0a5f31f2abdb12a07c1b7f0a759e8d876b1a6e58e5c4af8a593754bb8b7c66810d3e59a7cf7ead76f439b4802105 + languageName: node + linkType: hard + +"@sentry/core@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry/core@npm:7.93.0" + dependencies: + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + checksum: cbf9e944d985605bc38cc0d6b7ebeee10fa6d0f9ff9443d27c18a022e7ddcc1a70974c7ed9aafb2be094398f360c705e3464bf7359c3bdefd03724eac23048f4 + languageName: node + linkType: hard + +"@sentry/hub@npm:6.19.6": + version: 6.19.6 + resolution: "@sentry/hub@npm:6.19.6" + dependencies: + "@sentry/types": 6.19.6 + "@sentry/utils": 6.19.6 + tslib: ^1.9.3 + checksum: 150fdcb06c3107016aedf880e7c806aa8279a1d91c2651d9e3439bc752335ce5eec5ac8af4e515b73d29ec5a9f4bc717f7537245cb3675be89f15e01195a21a8 + languageName: node + linkType: hard + +"@sentry/integrations@npm:^7.34.0": + version: 7.93.0 + resolution: "@sentry/integrations@npm:7.93.0" + dependencies: + "@sentry/core": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + localforage: ^1.8.1 + checksum: e37f6fa609d7d14ebee2018f2c409fe1b57a5ff0ea1aae2d6d5b8c53414030983c2f21ce7fbe952aae6928b551dc2084f025580a4e0d4b99704186a83094f734 + languageName: node + linkType: hard + +"@sentry/minimal@npm:6.19.6": + version: 6.19.6 + resolution: "@sentry/minimal@npm:6.19.6" + dependencies: + "@sentry/hub": 6.19.6 + "@sentry/types": 6.19.6 + tslib: ^1.9.3 + checksum: f6ee93095076b4ec2906fa6e796b91b461e33ed172222bc717e5621a7042c74eb2358e0b7c4b68079a60ae790c19de103f14207058a986eb0612fc79dcbba4c8 + languageName: node + linkType: hard + +"@sentry/react@npm:^7.77.0": + version: 7.93.0 + resolution: "@sentry/react@npm:7.93.0" + dependencies: + "@sentry/browser": 7.93.0 + "@sentry/core": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + hoist-non-react-statics: ^3.3.2 + peerDependencies: + react: 15.x || 16.x || 17.x || 18.x + checksum: 3034e9ccb64e301e7963dcdddc60d3c760c06d0da5b6da2a455f842196708021160c04c0df2b1922d33bcb90126c6bdb7f1c1a6e942f4c15bdc487fdda9aa606 + languageName: node + linkType: hard + +"@sentry/replay@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry/replay@npm:7.93.0" + dependencies: + "@sentry-internal/tracing": 7.93.0 + "@sentry/core": 7.93.0 + "@sentry/types": 7.93.0 + "@sentry/utils": 7.93.0 + checksum: 683d946da88ff530d4f964e63e58cf07e4d662710595bf903ac8ac742ffd7105efe38f612ff411248250095901ea47c24301c774f05e68e6f54433a6383fb35d + languageName: node + linkType: hard + +"@sentry/types@npm:6.19.6": + version: 6.19.6 + resolution: "@sentry/types@npm:6.19.6" + checksum: a1564dabb657bf44a6ee380eb58dabcc6ea1c21b4060127469f8d39c76376d0e3f8c6b3fa27193fc50d9f1415ce63312fc3926d271918bba3e75f1f39691223a + languageName: node + linkType: hard + +"@sentry/types@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry/types@npm:7.93.0" + checksum: 43d4bc3215f7bf916404608907d51c0c1bb22b28065a4633e5fc7d244a3f66965857f9ea8464a71040dd752b93035b0fdcd61880f14908f75c6bbdc9d55b882f + languageName: node + linkType: hard + +"@sentry/utils@npm:6.19.6": + version: 6.19.6 + resolution: "@sentry/utils@npm:6.19.6" + dependencies: + "@sentry/types": 6.19.6 + tslib: ^1.9.3 + checksum: 73a582b6827ea519f8a352f3bdc8b0f22fbbced99506af56f566a6567ad0d6df2f8c3e39532f8588bdccb93f9bf03289681ae90404fd1dd9904c3175d314136a + languageName: node + linkType: hard + +"@sentry/utils@npm:7.93.0": + version: 7.93.0 + resolution: "@sentry/utils@npm:7.93.0" + dependencies: + "@sentry/types": 7.93.0 + checksum: d668bfce46b9ea58778a4c2cbc8df5d16c781187dde10459c61acc57cc1e86028e08fb7dd49506137ce71f5b5280c03e0c3bb94a32b215a713bf422f3d880f7d + languageName: node + linkType: hard + "@sinclair/typebox@npm:0.25.24": version: 0.25.24 resolution: "@sinclair/typebox@npm:0.25.24" @@ -4844,6 +6428,675 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^2.0.1, @smithy/abort-controller@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/abort-controller@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f91824aa8c8c39223b2d52c88fe123b36e5823acf8ce9316ce3b38245202cc6d31e1c172ebfec9fda47466d0b31f1df1add7e64d7161695fd27f96992037b18f + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader-native@npm:^2.0.1": + version: 2.0.1 + resolution: "@smithy/chunked-blob-reader-native@npm:2.0.1" + dependencies: + "@smithy/util-base64": ^2.0.1 + tslib: ^2.5.0 + checksum: db13a380a51ace30c8ed5947ea1b9fa65f5f5d0dbb722b4abc4d19e4a1215979174f35365c692f9fe7d3c116eaaf90dd9fb58e1dcff4fe943fc76d86c7f1798d + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader@npm:^2.0.0": + version: 2.0.0 + resolution: "@smithy/chunked-blob-reader@npm:2.0.0" + dependencies: + tslib: ^2.5.0 + checksum: a47e5298f0b28e25eaa5825ea9737718f0e2b7cf0f03a49cca186eb5544dd20ac91a2d92069f9805e40e5f3ab34d32f8091853518672fdbca009411179dbeb2a + languageName: node + linkType: hard + +"@smithy/config-resolver@npm:^2.0.23": + version: 2.0.23 + resolution: "@smithy/config-resolver@npm:2.0.23" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + "@smithy/util-config-provider": ^2.1.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 16f6c9a492aca44acc3f2dbb9c92e9212daad741143b444333926befb373ebe355edb62e74cf4af6b54cea54e4b54614a985c5dcb51e127be02e59e35c8d8815 + languageName: node + linkType: hard + +"@smithy/core@npm:^1.2.2": + version: 1.2.2 + resolution: "@smithy/core@npm:1.2.2" + dependencies: + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-retry": ^2.0.26 + "@smithy/middleware-serde": ^2.0.16 + "@smithy/protocol-http": ^3.0.12 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 5688b08bf935f429ada15c1238b0e6046069418cfb1810981e04d4dc00a9552189cfe566e23f8a51579894e2de44dc3d8548aca4ff6a3e16f385a478e3fb2b18 + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^2.0.0, @smithy/credential-provider-imds@npm:^2.1.5": + version: 2.1.5 + resolution: "@smithy/credential-provider-imds@npm:2.1.5" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/property-provider": ^2.0.17 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + tslib: ^2.5.0 + checksum: 1df3be00235960bc3d6a1703c4d3446d2338ee3684e0f17d2cbefc115ca38e6c1015d0e17116551e59e5adfd02a5923a8dddb83a956e197fd5aa4308ab20acdd + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/eventstream-codec@npm:2.0.16" + dependencies: + "@aws-crypto/crc32": 3.0.0 + "@smithy/types": ^2.8.0 + "@smithy/util-hex-encoding": ^2.0.0 + tslib: ^2.5.0 + checksum: 247b7ab35a49f27527124b5927bc04f7f9974e87a8533fe9b2909ab80fe71cebcd61f21835b16a3f4cbd1550f39d2290e90d5b0fa2a9a05eac11b0ec1c21e2b5 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/eventstream-serde-browser@npm:2.0.16" + dependencies: + "@smithy/eventstream-serde-universal": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: d5517ccc486361f0a5f2b3fc1a68a8667c00fa29e1152390f1ca1fccc4c5c2d4bd7621a2bee642ae2375873bd51a2c0e4830540b074c7e754c260ff8ed898060 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/eventstream-serde-config-resolver@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 90f0d30c1a0c46261102c6be6884c70cccc6bbdc44267877884fc96c23f1ee1068e8dea5c8862ecab6bda7d1a889a9d886328b0f25551971ee87a9201409f405 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/eventstream-serde-node@npm:2.0.16" + dependencies: + "@smithy/eventstream-serde-universal": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 00f5eb3d4e66e0ad3986cd7e90cbf8f0bd965349073eda7fedd058fe6a638e34a107906308fa891fcf56cec45d0c369f07a7eb167dd055214f389c9f463c7747 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/eventstream-serde-universal@npm:2.0.16" + dependencies: + "@smithy/eventstream-codec": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 89bac869391816b77746801b4acea4b4184d922c6bbfe6c24b5ac640c6769b7a2634193be940e9d651d4be9ceb88d28a40dc25a4b61b81461055b99c8856e336 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^2.3.2": + version: 2.3.2 + resolution: "@smithy/fetch-http-handler@npm:2.3.2" + dependencies: + "@smithy/protocol-http": ^3.0.12 + "@smithy/querystring-builder": ^2.0.16 + "@smithy/types": ^2.8.0 + "@smithy/util-base64": ^2.0.1 + tslib: ^2.5.0 + checksum: 883fcfae5ffcc616229dc982a48bf6c44984f652f2ef4ca9d233bfb3c4726fbb54e6a39d78fc410fda718c22a0a0e72a5207a4a4e202755c1ccd8760a0d1d821 + languageName: node + linkType: hard + +"@smithy/hash-blob-browser@npm:^2.0.17": + version: 2.0.17 + resolution: "@smithy/hash-blob-browser@npm:2.0.17" + dependencies: + "@smithy/chunked-blob-reader": ^2.0.0 + "@smithy/chunked-blob-reader-native": ^2.0.1 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 8f44c049fa5e246528660396d15bdec7c40d845fddb92158b36268e3d04b4867108762dbac40d4eccfde9a26d20d313688aa825ec27db324d5dd927801cea38e + languageName: node + linkType: hard + +"@smithy/hash-node@npm:^2.0.18": + version: 2.0.18 + resolution: "@smithy/hash-node@npm:2.0.18" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-buffer-from": ^2.0.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 1f40ae7b38808b1836c8e166f5182fcbc09423a833728943ab1edbc019dbd83323c9a08a29a2d73cd4ed5b3483e87158f8d4cc8ee5c6c88909c1dcde9a1b9826 + languageName: node + linkType: hard + +"@smithy/hash-stream-node@npm:^2.0.18": + version: 2.0.18 + resolution: "@smithy/hash-stream-node@npm:2.0.18" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 7d2308b69110710cb24b21b9c6c9f6517fe9a0297043e17f3cccebfd124056a19e2f9b36a070de6e45301ffc934e1340eddbb4f9db33a4136231ce21fa9ceec7 + languageName: node + linkType: hard + +"@smithy/invalid-dependency@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/invalid-dependency@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 1ddc4dfb3740fb6229d20d3074e0c59a6ebafd1f3b31f01eb630fc5ee360e60f495773cae6fc5d357873b6b34ab2d89d58b0887fea4e57f1e1f26d02ab234e5c + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.0.0": + version: 2.0.0 + resolution: "@smithy/is-array-buffer@npm:2.0.0" + dependencies: + tslib: ^2.5.0 + checksum: 6d101cf509a7818667f42d297894f88f86ef41d3cc9d02eae38bbe5e69b16edf83b8e67eb691964d859a16a4e39db1aad323d83f6ae55ae4512a14ff6406c02d + languageName: node + linkType: hard + +"@smithy/md5-js@npm:^2.0.18": + version: 2.0.18 + resolution: "@smithy/md5-js@npm:2.0.18" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 7cc58fa76c86cfe6d2e6ce8a0c70ee62594269c7d13eb3a4161d0aec8a6c6fa42393e0f02674089d0037414223daf61d31e99c3e9da0c00b5e9681861934ff1b + languageName: node + linkType: hard + +"@smithy/middleware-content-length@npm:^2.0.18": + version: 2.0.18 + resolution: "@smithy/middleware-content-length@npm:2.0.18" + dependencies: + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: bbbd3e69e065c1677150a61af2303c3fda35c7fce527fd594f63be00421daecb7fedfd135945b3ef8104cd5305367f7d8e235e704d6743c5fa5d6c0178a35de3 + languageName: node + linkType: hard + +"@smithy/middleware-endpoint@npm:^2.3.0": + version: 2.3.0 + resolution: "@smithy/middleware-endpoint@npm:2.3.0" + dependencies: + "@smithy/middleware-serde": ^2.0.16 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/shared-ini-file-loader": ^2.2.8 + "@smithy/types": ^2.8.0 + "@smithy/url-parser": ^2.0.16 + "@smithy/util-middleware": ^2.0.9 + tslib: ^2.5.0 + checksum: 07512e57190dd9a063359934d6c688bfdc3849657a3d7b91a4194d4b19ca94ad67a5344f0418462147b105ce6d7d8adc895a1ac9d6aefc80937643cdea70e14e + languageName: node + linkType: hard + +"@smithy/middleware-retry@npm:^2.0.26": + version: 2.0.26 + resolution: "@smithy/middleware-retry@npm:2.0.26" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/protocol-http": ^3.0.12 + "@smithy/service-error-classification": ^2.0.9 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + "@smithy/util-middleware": ^2.0.9 + "@smithy/util-retry": ^2.0.9 + tslib: ^2.5.0 + uuid: ^8.3.2 + checksum: e33d87b539776398e1b5af99e0a0659534194f691c16aaafc22c8ef0ee6fcc2568e3e8bbcb79ad9f114c164f956530b96393f3b25ee15773a23c3f8a5df00e62 + languageName: node + linkType: hard + +"@smithy/middleware-serde@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/middleware-serde@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 64cad569c02bfb53fda13189cb24db72e35e25de058a6d4b51d9e9c89bc97f7aba7373787fb48ad32226b3d3d012d1554378c7c22a3f162f61e8e3fc1d069f5e + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^2.0.10": + version: 2.0.10 + resolution: "@smithy/middleware-stack@npm:2.0.10" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f2e491a10bf20d0605dfa0fd6e629b0fdcc821a863fb5b1f9ab7c02f2a3180869334da15739c4ad66fe8022c3f5ffb6868dec51023bff9b07977989db8164ebb + languageName: node + linkType: hard + +"@smithy/node-config-provider@npm:^2.1.9": + version: 2.1.9 + resolution: "@smithy/node-config-provider@npm:2.1.9" + dependencies: + "@smithy/property-provider": ^2.0.17 + "@smithy/shared-ini-file-loader": ^2.2.8 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 993c87c85b88671e8dab3d9443f7f832b585e34c5578f655168162e968b14f4f7f5dd04515364a9c7155aedd6dd175f7fb6a14c2318c81b01dd3245086b8e5f1 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^2.2.2": + version: 2.2.2 + resolution: "@smithy/node-http-handler@npm:2.2.2" + dependencies: + "@smithy/abort-controller": ^2.0.16 + "@smithy/protocol-http": ^3.0.12 + "@smithy/querystring-builder": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 891532bfb6c1d3f3aed710bf8ed922706270effc8d8d00d4133e6271f58525ed9ccf7d03e16c0baf7a4e8b2768d5e38b92beca23f9b6aac8e02e8bc7c6769a81 + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^2.0.0, @smithy/property-provider@npm:^2.0.17": + version: 2.0.17 + resolution: "@smithy/property-provider@npm:2.0.17" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: ecf2c909fd365cfe59bece32538131ffb2bcdc55a2a287722b1eefcac4c83de7f7f7dc92e4829cdbb74cc3c15f6fad8617eb97ec97ed1fc7ca9180ba7f758229 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^3.0.12": + version: 3.0.12 + resolution: "@smithy/protocol-http@npm:3.0.12" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 38899820a59ebcdf4784b16d6a02fa630441a76857f204e479bd8c59ec7c578e5107e995fc0b1b9dee444b9610bd9bdf00ccf7e1c806ff463c7e9402660748e1 + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/querystring-builder@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + "@smithy/util-uri-escape": ^2.0.0 + tslib: ^2.5.0 + checksum: d88983a2088dd2d00dced8e122ee20c278edf00f80ff79485187efb178d3c1a00e679f5059c605d914d646dac37e35fd458bbc20a56da0e2ac6e168dc2a8f91b + languageName: node + linkType: hard + +"@smithy/querystring-parser@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/querystring-parser@npm:2.0.16" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: f00fcfa102838a32afa43b3e4e9dd706f1d79e09778767f089f97ee47809d47e9dd6681618dac0903913aa7ae64479ee7be9269c5e7e7e0b29527ea63cde3e26 + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/service-error-classification@npm:2.0.9" + dependencies: + "@smithy/types": ^2.8.0 + checksum: 76f4fcae188e6d318d09e9974d6b563cb1329d66788d6ada882974cf3c59046b174164b35d2a9239a8cc8747a07a81831e44b4032752ad220819cd8495962c90 + languageName: node + linkType: hard + +"@smithy/shared-ini-file-loader@npm:^2.0.6, @smithy/shared-ini-file-loader@npm:^2.2.8": + version: 2.2.8 + resolution: "@smithy/shared-ini-file-loader@npm:2.2.8" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 768b57b0f783e7ce9df08e23de0cbfb85e686dcd7f0014cf78747696247941ded20a79df84ab30fe96a927ab51c937264b7e70d90a94d4ac32a44972569cb4f7 + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^2.0.0": + version: 2.0.19 + resolution: "@smithy/signature-v4@npm:2.0.19" + dependencies: + "@smithy/eventstream-codec": ^2.0.16 + "@smithy/is-array-buffer": ^2.0.0 + "@smithy/types": ^2.8.0 + "@smithy/util-hex-encoding": ^2.0.0 + "@smithy/util-middleware": ^2.0.9 + "@smithy/util-uri-escape": ^2.0.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 699855d6e0386767e956d65d43286fb2e75ee90ff592ec0176d1a10cd619aa53e55d7e3f1ef644e177d45f548b7ace5ce1eb53ffeba39f757718be1837323c21 + languageName: node + linkType: hard + +"@smithy/smithy-client@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/smithy-client@npm:2.2.1" + dependencies: + "@smithy/middleware-endpoint": ^2.3.0 + "@smithy/middleware-stack": ^2.0.10 + "@smithy/protocol-http": ^3.0.12 + "@smithy/types": ^2.8.0 + "@smithy/util-stream": ^2.0.24 + tslib: ^2.5.0 + checksum: dda90afce6f3260217967c184065a3b07be699102a36e64357124394c7a075496e40d18e7b0d23f1204748777fcc08b7d51d8f0170e877a32dd306a4ff757748 + languageName: node + linkType: hard + +"@smithy/types@npm:^2.8.0": + version: 2.8.0 + resolution: "@smithy/types@npm:2.8.0" + dependencies: + tslib: ^2.5.0 + checksum: c0c85b1422f982635c8b5c6477f5d2b28a5afeaf21f6e877dc1f96e401673632b5abaf3b49f800f1859119c498151e5a59e0361c8f56945f79642c486ac68af8 + languageName: node + linkType: hard + +"@smithy/url-parser@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/url-parser@npm:2.0.16" + dependencies: + "@smithy/querystring-parser": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 05d9cca95307f3acd53e3a31af96b83e2351e3f64b68672afdda32b5aa6d902c05f2567b2a683ed32132c152e0b8d38200c803f63f9e8c983b976ccf999fcdc4 + languageName: node + linkType: hard + +"@smithy/util-base64@npm:^2.0.1": + version: 2.0.1 + resolution: "@smithy/util-base64@npm:2.0.1" + dependencies: + "@smithy/util-buffer-from": ^2.0.0 + tslib: ^2.5.0 + checksum: 6320916b50a0f4048462564cbc413e619ee02747e188463721670ce554d0b1652517068a1aa066209101a2185b4f3d13afd0c173aac99c461ca685a1fa15f934 + languageName: node + linkType: hard + +"@smithy/util-body-length-browser@npm:^2.0.1": + version: 2.0.1 + resolution: "@smithy/util-body-length-browser@npm:2.0.1" + dependencies: + tslib: ^2.5.0 + checksum: 1d342acdba493047400a1aae9922e7274a2d4ba68f2980290ac4d44bd1a33a2a0a9d75b99c773924a7381d88c7b8cc612947e3adb442f7f67ac2edd4a4d3cf58 + languageName: node + linkType: hard + +"@smithy/util-body-length-node@npm:^2.1.0": + version: 2.1.0 + resolution: "@smithy/util-body-length-node@npm:2.1.0" + dependencies: + tslib: ^2.5.0 + checksum: e4635251898f12e1825f2848e0b7cc9d01ec6635b3f1f71b790734bb702b88e795f6c539d42d95472dad00e50e9ff13fcf396791092b131e5834069cb8f52ed0 + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.0.0": + version: 2.0.0 + resolution: "@smithy/util-buffer-from@npm:2.0.0" + dependencies: + "@smithy/is-array-buffer": ^2.0.0 + tslib: ^2.5.0 + checksum: d33cbf3e488d23390c88705ddae71b08de7a87b6453e38b508cd37a22a02e8b5be9f0cd46c1347b496c3977a815a7399b18840544ecdc4cce8cf3dcd0f5bb009 + languageName: node + linkType: hard + +"@smithy/util-config-provider@npm:^2.1.0": + version: 2.1.0 + resolution: "@smithy/util-config-provider@npm:2.1.0" + dependencies: + tslib: ^2.5.0 + checksum: bd8b677fdf1891e5ec97f6fe0ab3e798ed005fd56c3868d6f529f523fbf077999f6af04295142be7f6d87551920ae0a455a6c74f3e4de972e8cd2070b569a5b1 + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-browser@npm:^2.0.24": + version: 2.0.24 + resolution: "@smithy/util-defaults-mode-browser@npm:2.0.24" + dependencies: + "@smithy/property-provider": ^2.0.17 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + bowser: ^2.11.0 + tslib: ^2.5.0 + checksum: 711f08ac4762b70ce91fd29592228d9c2a48b2a10ea04796a4340d9eae08981d82bea26730ee740dd77381cba59a6e449556417dbbb1fa8bf089d2c649a62af4 + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-node@npm:^2.0.32": + version: 2.0.32 + resolution: "@smithy/util-defaults-mode-node@npm:2.0.32" + dependencies: + "@smithy/config-resolver": ^2.0.23 + "@smithy/credential-provider-imds": ^2.1.5 + "@smithy/node-config-provider": ^2.1.9 + "@smithy/property-provider": ^2.0.17 + "@smithy/smithy-client": ^2.2.1 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 3bccae4e22307a25c0f5c0718dec6d0a1349586a64948e6211f2ba05bd02e226db87949e653ac34333f0646cc169b4c330fdaf102b594ea95c191acba5a2cbef + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^1.0.8": + version: 1.0.8 + resolution: "@smithy/util-endpoints@npm:1.0.8" + dependencies: + "@smithy/node-config-provider": ^2.1.9 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 8133e253f390ea1456e2f13f1853f258827497042109f2933f1c7fc47307f865e490ba7fafc22f6abacf609e10e17b67f2d62c0c48f8d88f3b9e94de4e88ff62 + languageName: node + linkType: hard + +"@smithy/util-hex-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "@smithy/util-hex-encoding@npm:2.0.0" + dependencies: + tslib: ^2.5.0 + checksum: 884373e089d909e3c9805bdb78f367d1f3612e4e1e6d8f0263cc82a8b9689eddc0bc80b8b58aa711bd5b48d9cb124f9996906c172e951c9dac78984459e831cf + languageName: node + linkType: hard + +"@smithy/util-middleware@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/util-middleware@npm:2.0.9" + dependencies: + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 0c34552d6845ef215441602a16ac6e02e2a5a8ab70e1b2ed0dc37a7acbf5de6c7f2de9ba09f303c2bfbfa77a0a4869f6660874c9f6daeed757392fb42466e01a + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^2.0.9": + version: 2.0.9 + resolution: "@smithy/util-retry@npm:2.0.9" + dependencies: + "@smithy/service-error-classification": ^2.0.9 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 40385b48c846e2c8d79531789d6bbbd3c7342c607b7227a8c5e873b481e9425705927b26c65e7b71b20ff851e9b54d036b279a485c2a13a828359b7ef6d36fa4 + languageName: node + linkType: hard + +"@smithy/util-stream@npm:^2.0.24": + version: 2.0.24 + resolution: "@smithy/util-stream@npm:2.0.24" + dependencies: + "@smithy/fetch-http-handler": ^2.3.2 + "@smithy/node-http-handler": ^2.2.2 + "@smithy/types": ^2.8.0 + "@smithy/util-base64": ^2.0.1 + "@smithy/util-buffer-from": ^2.0.0 + "@smithy/util-hex-encoding": ^2.0.0 + "@smithy/util-utf8": ^2.0.2 + tslib: ^2.5.0 + checksum: 097e6be8f59d166a5611f09a7823b55a1cf42726ac7c48ac93d8a3d5f1f5423ee6e74fda1081f49e03802f2f788646d5db50ae74798e9644e4db642452dfa101 + languageName: node + linkType: hard + +"@smithy/util-uri-escape@npm:^2.0.0": + version: 2.0.0 + resolution: "@smithy/util-uri-escape@npm:2.0.0" + dependencies: + tslib: ^2.5.0 + checksum: d201cee524ece997c406902463b5ea0b72599994f7b3ac1d923d5645497e9ef93126d146016f13dd4afafe33b9a3e92faf4e023cf0af510b270c1b9ce3d78da8 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.2": + version: 2.0.2 + resolution: "@smithy/util-utf8@npm:2.0.2" + dependencies: + "@smithy/util-buffer-from": ^2.0.0 + tslib: ^2.5.0 + checksum: e38fd6324ca2858f76fb6fce427c03faec599213acf95a5b18eb77b72cdf9327bd688e5a260dbccc0f512ea5426422ed200122a9542c00b14a6d9becc3f84c79 + languageName: node + linkType: hard + +"@smithy/util-waiter@npm:^2.0.16": + version: 2.0.16 + resolution: "@smithy/util-waiter@npm:2.0.16" + dependencies: + "@smithy/abort-controller": ^2.0.16 + "@smithy/types": ^2.8.0 + tslib: ^2.5.0 + checksum: 11164f94742987f19337e6f26335f1b39e17cf9eb4e7f3e1f772c3168df2b9a4ccb2f92a42666593646ba9ff1efa3391b3c697b944e4c282007c28f35b5369e3 + languageName: node + linkType: hard + +"@supabase/auth-helpers-remix@npm:^0.2.2": + version: 0.2.6 + resolution: "@supabase/auth-helpers-remix@npm:0.2.6" + dependencies: + "@supabase/auth-helpers-shared": 0.6.3 + peerDependencies: + "@supabase/supabase-js": ^2.19.0 + checksum: fc5c9ea3f4b07f724f5b1c5eb5ef94df6e1b7db2da5e262c40ce8052b579540af475064f97a9be1d8e8aafcb52868ebdccc61354775ad770ee041c7997a499d0 + languageName: node + linkType: hard + +"@supabase/auth-helpers-shared@npm:0.6.3": + version: 0.6.3 + resolution: "@supabase/auth-helpers-shared@npm:0.6.3" + dependencies: + jose: ^4.14.4 + peerDependencies: + "@supabase/supabase-js": ^2.19.0 + checksum: 6eb9a00c3b535ba0f278005b94a682e1b2b2c29dc86cc3abc7dd0f0545eb4b0d8606285644f624d11228e8536fa7a0c67be72cd6922f96ee9555c5737916b74e + languageName: node + linkType: hard + +"@supabase/functions-js@npm:^2.1.5": + version: 2.1.5 + resolution: "@supabase/functions-js@npm:2.1.5" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: f2ab8636af8d982270b61631a5120369ca10db101b4298da71be892e5d91a8ddaddcf7f51079ad0fe24731a15892b21bd7dbe41b997da9d4b90e4326d09632c8 + languageName: node + linkType: hard + +"@supabase/gotrue-js@npm:^2.60.0": + version: 2.62.0 + resolution: "@supabase/gotrue-js@npm:2.62.0" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: 7df7078c7eb2b99658cc924fa41ae95305ba9e29fd8a56541adbe6901133a370ecda1d40553e27bcd8e782fb8f4a1c13b79ff4422af51bdcb5d614a10779340b + languageName: node + linkType: hard + +"@supabase/node-fetch@npm:^2.6.14": + version: 2.6.15 + resolution: "@supabase/node-fetch@npm:2.6.15" + dependencies: + whatwg-url: ^5.0.0 + checksum: 9673b49236a56df49eb7ea5cb789cf4e8b1393069b84b4964ac052995e318a34872f428726d128f232139e17c3375a531e45e99edd3e96a25cce60d914b53879 + languageName: node + linkType: hard + +"@supabase/postgrest-js@npm:^1.9.0": + version: 1.9.2 + resolution: "@supabase/postgrest-js@npm:1.9.2" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: 9aefbdfc1c0d8a00b932b0939dbcbb5ec392b1324ad1b63b5e0486c6f9882a9c2292c80d3f803a0338938097372f08b3bcbdc3c4699d5bef13791ddc35d53b86 + languageName: node + linkType: hard + +"@supabase/realtime-js@npm:^2.9.3": + version: 2.9.3 + resolution: "@supabase/realtime-js@npm:2.9.3" + dependencies: + "@supabase/node-fetch": ^2.6.14 + "@types/phoenix": ^1.5.4 + "@types/ws": ^8.5.10 + ws: ^8.14.2 + checksum: 180a5084b94a4e324fc04041182bf8819c3c2545a731c276a56f9647f78078180b0460b68a0d6c568d29b2fa4aace0545bb71dcb89b547ec85781032dff74e71 + languageName: node + linkType: hard + +"@supabase/storage-js@npm:^2.5.4": + version: 2.5.5 + resolution: "@supabase/storage-js@npm:2.5.5" + dependencies: + "@supabase/node-fetch": ^2.6.14 + checksum: 4470499113c15e1124d99048eef0097c7ba431d728e351519ee26948775171d6c6bb41156f8ffb3860009b82b93809af01c9d075ece6000f783f59ce9fd00ee8 + languageName: node + linkType: hard + +"@supabase/supabase-js@npm:^2.33.2": + version: 2.39.3 + resolution: "@supabase/supabase-js@npm:2.39.3" + dependencies: + "@supabase/functions-js": ^2.1.5 + "@supabase/gotrue-js": ^2.60.0 + "@supabase/node-fetch": ^2.6.14 + "@supabase/postgrest-js": ^1.9.0 + "@supabase/realtime-js": ^2.9.3 + "@supabase/storage-js": ^2.5.4 + checksum: df8a5fe7a06cd26069a0add024af46021b04bf3f5101259932103f7d76b45eee3b4d7f3164335844ceebb9cef83601ded9ba95dfc7da7ce9bbf2708702f90deb + languageName: node + linkType: hard + +"@surma/rollup-plugin-off-main-thread@npm:^2.2.3": + version: 2.2.3 + resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3" + dependencies: + ejs: ^3.1.6 + json5: ^2.2.0 + magic-string: ^0.25.0 + string.prototype.matchall: ^4.0.6 + checksum: 2c021349442e2e2cec96bb50fd82ec8bf8514d909bc73594f6cfc89b3b68f2feed909a8161d7d307d9455585c97e6b66853ce334db432626c7596836d4549c0c + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-darwin-arm64@npm:1.3.102" @@ -4851,6 +7104,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-arm64@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-darwin-arm64@npm:1.3.103" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-darwin-x64@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-darwin-x64@npm:1.3.102" @@ -4858,6 +7118,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-x64@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-darwin-x64@npm:1.3.103" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@swc/core-linux-arm-gnueabihf@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.102" @@ -4865,6 +7132,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm-gnueabihf@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.3.103" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@swc/core-linux-arm64-gnu@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-linux-arm64-gnu@npm:1.3.102" @@ -4872,6 +7146,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-gnu@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-linux-arm64-gnu@npm:1.3.103" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-arm64-musl@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-linux-arm64-musl@npm:1.3.102" @@ -4879,6 +7160,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-musl@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-linux-arm64-musl@npm:1.3.103" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@swc/core-linux-x64-gnu@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-linux-x64-gnu@npm:1.3.102" @@ -4886,6 +7174,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-gnu@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-linux-x64-gnu@npm:1.3.103" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-x64-musl@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-linux-x64-musl@npm:1.3.102" @@ -4893,6 +7188,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-musl@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-linux-x64-musl@npm:1.3.103" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@swc/core-win32-arm64-msvc@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-win32-arm64-msvc@npm:1.3.102" @@ -4900,6 +7202,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-arm64-msvc@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-win32-arm64-msvc@npm:1.3.103" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-win32-ia32-msvc@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-win32-ia32-msvc@npm:1.3.102" @@ -4907,6 +7216,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-ia32-msvc@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-win32-ia32-msvc@npm:1.3.103" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@swc/core-win32-x64-msvc@npm:1.3.102": version: 1.3.102 resolution: "@swc/core-win32-x64-msvc@npm:1.3.102" @@ -4914,6 +7230,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-x64-msvc@npm:1.3.103": + version: 1.3.103 + resolution: "@swc/core-win32-x64-msvc@npm:1.3.103" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@swc/core@npm:^1.3.55": version: 1.3.102 resolution: "@swc/core@npm:1.3.102" @@ -4960,6 +7283,52 @@ __metadata: languageName: node linkType: hard +"@swc/core@npm:^1.3.96": + version: 1.3.103 + resolution: "@swc/core@npm:1.3.103" + dependencies: + "@swc/core-darwin-arm64": 1.3.103 + "@swc/core-darwin-x64": 1.3.103 + "@swc/core-linux-arm-gnueabihf": 1.3.103 + "@swc/core-linux-arm64-gnu": 1.3.103 + "@swc/core-linux-arm64-musl": 1.3.103 + "@swc/core-linux-x64-gnu": 1.3.103 + "@swc/core-linux-x64-musl": 1.3.103 + "@swc/core-win32-arm64-msvc": 1.3.103 + "@swc/core-win32-ia32-msvc": 1.3.103 + "@swc/core-win32-x64-msvc": 1.3.103 + "@swc/counter": ^0.1.1 + "@swc/types": ^0.1.5 + peerDependencies: + "@swc/helpers": ^0.5.0 + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: cff453fd8f186bcd12c8a2f727dcc578a75382b7ca851054021f625ab940ad5463073d90c8dd13a0f4e1577defa4de039d59da280dce88a6b582d15f46edeed8 + languageName: node + linkType: hard + "@swc/counter@npm:^0.1.1": version: 0.1.2 resolution: "@swc/counter@npm:0.1.2" @@ -5061,6 +7430,19 @@ __metadata: languageName: unknown linkType: soft +"@tldraw/bookmark-extractor@workspace:apps/dotcom-bookmark-extractor": + version: 0.0.0-use.local + resolution: "@tldraw/bookmark-extractor@workspace:apps/dotcom-bookmark-extractor" + dependencies: + "@types/cors": ^2.8.15 + cors: ^2.8.5 + grabity: ^1.0.5 + lazyrepo: 0.0.0-alpha.27 + tslib: ^2.6.2 + typescript: ^5.0.2 + languageName: unknown + linkType: soft + "@tldraw/docs@workspace:apps/docs": version: 0.0.0-use.local resolution: "@tldraw/docs@workspace:apps/docs" @@ -5107,6 +7489,30 @@ __metadata: languageName: unknown linkType: soft +"@tldraw/dotcom-worker@workspace:apps/dotcom-worker": + version: 0.0.0-use.local + resolution: "@tldraw/dotcom-worker@workspace:apps/dotcom-worker" + dependencies: + "@cloudflare/workers-types": ^4.20230821.0 + "@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:*" + concurrently: ^8.2.1 + esbuild: ^0.18.4 + itty-router: ^4.0.13 + lazyrepo: 0.0.0-alpha.27 + nanoid: 4.0.2 + picocolors: ^1.0.0 + strip-ansi: ^7.1.0 + toucan-js: ^2.7.0 + typescript: ^5.0.2 + wrangler: 3.16.0 + languageName: unknown + linkType: soft + "@tldraw/editor@workspace:*, @tldraw/editor@workspace:packages/editor": version: 0.0.0-use.local resolution: "@tldraw/editor@workspace:packages/editor" @@ -5153,6 +7559,7 @@ __metadata: dependencies: "@microsoft/api-extractor": ^7.35.4 "@next/eslint-plugin-next": ^13.3.0 + "@sentry/cli": ^2.25.0 "@swc/core": ^1.3.55 "@swc/jest": ^0.2.26 "@types/glob": ^8.1.0 @@ -5163,6 +7570,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^5.57.0 "@typescript-eslint/parser": ^5.57.0 auto: ^10.46.0 + cross-env: ^7.0.3 eslint: ^8.37.0 eslint-config-prettier: ^8.8.0 eslint-plugin-deprecation: ^2.0.0 @@ -5192,9 +7600,13 @@ __metadata: version: 0.0.0-use.local resolution: "@tldraw/scripts@workspace:scripts" dependencies: + "@actions/github": ^6.0.0 "@auto-it/core": ^10.45.0 + "@aws-sdk/client-s3": ^3.440.0 + "@aws-sdk/lib-storage": ^3.440.0 "@types/is-ci": ^3.0.0 "@types/node": ^18.7.3 + "@types/tar": ^6.1.7 "@typescript-eslint/utils": ^5.59.0 ast-types: ^0.14.2 cross-fetch: ^3.1.5 @@ -5210,6 +7622,7 @@ __metadata: rimraf: ^4.4.0 semver: ^7.3.8 svgo: ^3.0.2 + tar: ^6.2.0 typescript: ^5.2.2 languageName: unknown linkType: soft @@ -5291,6 +7704,25 @@ __metadata: languageName: unknown linkType: soft +"@tldraw/tlsync@workspace:*, @tldraw/tlsync@workspace:packages/tlsync": + version: 0.0.0-use.local + resolution: "@tldraw/tlsync@workspace:packages/tlsync" + dependencies: + "@tldraw/state": "workspace:*" + "@tldraw/store": "workspace:*" + "@tldraw/tldraw": "workspace:*" + "@tldraw/tlschema": "workspace:*" + "@tldraw/utils": "workspace:*" + lodash.isequal: ^4.5.0 + nanoevents: ^7.0.1 + nanoid: 4.0.2 + typescript: ^5.0.2 + uuid-by-string: ^4.0.0 + uuid-readable: ^0.0.2 + ws: ^8.16.0 + languageName: unknown + linkType: soft + "@tldraw/utils@workspace:*, @tldraw/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@tldraw/utils@workspace:packages/utils" @@ -5526,6 +7958,13 @@ __metadata: languageName: node linkType: hard +"@types/cookie@npm:0.5.0": + version: 0.5.0 + resolution: "@types/cookie@npm:0.5.0" + checksum: c0ea731cfe2f08dbc8851fa27212e5b34dadb871c14892d309b0970a158d36bf3d20324847263e0698ce6b1c3a5f151bd4fe45c0f9fc3243d1c8115e41d0e1ce + languageName: node + linkType: hard + "@types/cookie@npm:^0.4.0": version: 0.4.1 resolution: "@types/cookie@npm:0.4.1" @@ -5540,6 +7979,15 @@ __metadata: languageName: node linkType: hard +"@types/cors@npm:^2.8.15": + version: 2.8.17 + resolution: "@types/cors@npm:2.8.17" + dependencies: + "@types/node": "*" + checksum: 469bd85e29a35977099a3745c78e489916011169a664e97c4c3d6538143b0a16e4cc72b05b407dc008df3892ed7bf595f9b7c0f1f4680e169565ee9d64966bde + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -5567,13 +8015,20 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*, @types/estree@npm:^1.0.0": +"@types/estree@npm:*, @types/estree@npm:1.0.5, @types/estree@npm:^1.0.0": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" checksum: dd8b5bed28e6213b7acd0fb665a84e693554d850b0df423ac8076cc3ad5823a6bc26b0251d080bdc545af83179ede51dd3f6fa78cad2c46ed1f29624ddf3e41a languageName: node linkType: hard +"@types/estree@npm:0.0.39": + version: 0.0.39 + resolution: "@types/estree@npm:0.0.39" + checksum: 412fb5b9868f2c418126451821833414189b75cc6bf84361156feed733e3d92ec220b9d74a89e52722e03d5e241b2932732711b7497374a404fad49087adc248 + languageName: node + linkType: hard + "@types/fs-extra@npm:^11.0.1": version: 11.0.4 resolution: "@types/fs-extra@npm:11.0.4" @@ -5751,7 +8206,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^9.0.0": +"@types/jsonwebtoken@npm:^9.0.0, @types/jsonwebtoken@npm:^9.0.1": version: 9.0.5 resolution: "@types/jsonwebtoken@npm:9.0.5" dependencies: @@ -5866,6 +8321,15 @@ __metadata: languageName: node linkType: hard +"@types/node-forge@npm:^1.3.0": + version: 1.3.11 + resolution: "@types/node-forge@npm:1.3.11" + dependencies: + "@types/node": "*" + checksum: 1e86bd55b92a492eaafd75f6d01f31e7d86a5cdadd0c6bcdc0b1df4103b7f99bb75b832efd5217c7ddda5c781095dc086a868e20b9de00f5a427ddad4c296cd5 + languageName: node + linkType: hard + "@types/node@npm:*": version: 20.11.0 resolution: "@types/node@npm:20.11.0" @@ -5905,6 +8369,13 @@ __metadata: languageName: node linkType: hard +"@types/phoenix@npm:^1.5.4": + version: 1.6.4 + resolution: "@types/phoenix@npm:1.6.4" + checksum: 0f13849602db6d9a2a4b9d96386c45471acedf2bc3d6bf6b3289876fa73f0fe0e84c8466bd55c4f2763d33e142e5311c220820fed9ac2a21d9126f1f70a7338f + languageName: node + linkType: hard + "@types/prettier@npm:^2.1.5": version: 2.7.3 resolution: "@types/prettier@npm:2.7.3" @@ -5919,6 +8390,15 @@ __metadata: languageName: node linkType: hard +"@types/qrcode@npm:^1.5.0": + version: 1.5.5 + resolution: "@types/qrcode@npm:1.5.5" + dependencies: + "@types/node": "*" + checksum: d92c1d3e77406bf13a03ec521b2ffb1ac99b2e6ea3a17cad670f2610f62e1293554c57e4074bb2fd4e9369f475f863b69e0ae8c543cb049c4a3c1b0c2d92522a + languageName: node + linkType: hard + "@types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.2.18": version: 18.2.18 resolution: "@types/react-dom@npm:18.2.18" @@ -5969,6 +8449,26 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^18.2.33": + version: 18.2.48 + resolution: "@types/react@npm:18.2.48" + dependencies: + "@types/prop-types": "*" + "@types/scheduler": "*" + csstype: ^3.0.2 + checksum: c9ca43ed2995389b7e09492c24e6f911a8439bb8276dd17cc66a2fbebbf0b42daf7b2ad177043256533607c2ca644d7d928fdfce37a67af1f8646d2bac988900 + languageName: node + linkType: hard + +"@types/resolve@npm:1.17.1": + version: 1.17.1 + resolution: "@types/resolve@npm:1.17.1" + dependencies: + "@types/node": "*" + checksum: dc6a6df507656004e242dcb02c784479deca516d5f4b58a1707e708022b269ae147e1da0521f3e8ad0d63638869d87e0adc023f0bd5454aa6f72ac66c7525cf5 + languageName: node + linkType: hard + "@types/responselike@npm:^1.0.0": version: 1.0.3 resolution: "@types/responselike@npm:1.0.3" @@ -6022,6 +8522,16 @@ __metadata: languageName: node linkType: hard +"@types/tar@npm:^6.1.7": + version: 6.1.10 + resolution: "@types/tar@npm:6.1.10" + dependencies: + "@types/node": "*" + minipass: ^4.0.0 + checksum: 82d46f1246e830b98833ed94fbdfbcb3ffb8a5d7173609622d56c9326124523a3cc541607876c63a6ab9117eb59fb37ef8a6284b194164ab1d1d8c03a8ba59c6 + languageName: node + linkType: hard + "@types/testing-library__jest-dom@npm:^5.9.1": version: 5.14.9 resolution: "@types/testing-library__jest-dom@npm:5.14.9" @@ -6038,6 +8548,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:^2.0.2": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.2 resolution: "@types/unist@npm:3.0.2" @@ -6066,7 +8583,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.9": +"@types/ws@npm:^8.5.10, @types/ws@npm:^8.5.3, @types/ws@npm:^8.5.9": version: 8.5.10 resolution: "@types/ws@npm:8.5.10" dependencies: @@ -6124,7 +8641,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^5.10.2, @typescript-eslint/parser@npm:^5.42.0, @typescript-eslint/parser@npm:^5.57.0": +"@typescript-eslint/parser@npm:^5.10.2, @typescript-eslint/parser@npm:^5.21.0, @typescript-eslint/parser@npm:^5.42.0, @typescript-eslint/parser@npm:^5.57.0": version: 5.62.0 resolution: "@typescript-eslint/parser@npm:5.62.0" dependencies: @@ -6566,6 +9083,17 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react-swc@npm:^3.5.0": + version: 3.5.0 + resolution: "@vitejs/plugin-react-swc@npm:3.5.0" + dependencies: + "@swc/core": ^1.3.96 + peerDependencies: + vite: ^4 || ^5 + checksum: 3ee2e194aa3f582db912c44ac6e2625bfba1cb1252c3d4b8b60d480d36702a3c0ca86e5848f829e43266f0b6eccdb3c66b60b34094fb61139e4dcabd9afc34b6 + languageName: node + linkType: hard + "@vitejs/plugin-react@npm:^4.2.0": version: 4.2.1 resolution: "@vitejs/plugin-react@npm:4.2.1" @@ -6595,6 +9123,13 @@ __metadata: languageName: node linkType: hard +"abab@npm:^1.0.3": + version: 1.0.4 + resolution: "abab@npm:1.0.4" + checksum: 6551e127ec54095f18d5f0942cc75bc61c021ba152a2b235d1214cbda7816fe97202fc2bf0365d3e347c76e7dceb3394d767986db8986272306b3c62b0f895ab + languageName: node + linkType: hard + "abab@npm:^2.0.5, abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -6635,6 +9170,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^4.0.0": + version: 4.3.4 + resolution: "acorn-globals@npm:4.3.4" + dependencies: + acorn: ^6.0.1 + acorn-walk: ^6.0.1 + checksum: c31bfde102d8a104835e9591c31dd037ec771449f9c86a6b1d2ac3c7c336694f828cfabba7687525b094f896a854affbf1afe6e1b12c0d998be6bab5d49c9663 + languageName: node + linkType: hard + "acorn-globals@npm:^6.0.0": version: 6.0.0 resolution: "acorn-globals@npm:6.0.0" @@ -6664,6 +9209,13 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:^6.0.1": + version: 6.2.0 + resolution: "acorn-walk@npm:6.2.0" + checksum: ea241a5d96338f1e8030aafae72a91ff0ec4360e2775e44a2fdb2eb618b07fc309e000a5126056631ac7f00fe8bd9bbd23fcb6d018eee4ba11086eb36c1b2e61 + languageName: node + linkType: hard + "acorn-walk@npm:^7.1.1": version: 7.2.0 resolution: "acorn-walk@npm:7.2.0" @@ -6678,6 +9230,24 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^5.1.2": + version: 5.7.4 + resolution: "acorn@npm:5.7.4" + bin: + acorn: bin/acorn + checksum: f51392a4d25c7705fadb890f784c59cde4ac1c5452ccd569fa59bd2191b7951b4a6398348ab7ea08a54f0bc0a56c13776710f4e1bae9de441e4d33e2015ad1e0 + languageName: node + linkType: hard + +"acorn@npm:^6.0.1": + version: 6.4.2 + resolution: "acorn@npm:6.4.2" + bin: + acorn: bin/acorn + checksum: 44b07053729db7f44d28343eed32247ed56dc4a6ec6dff2b743141ecd6b861406bbc1c20bf9d4f143ea7dd08add5dc8c290582756539bc03a8db605050ce2fb4 + languageName: node + linkType: hard + "acorn@npm:^7.1.1": version: 7.4.1 resolution: "acorn@npm:7.4.1" @@ -6687,7 +9257,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.0, acorn@npm:^8.1.0, acorn@npm:^8.11.3, acorn@npm:^8.4.1, acorn@npm:^8.5.0, acorn@npm:^8.6.0, acorn@npm:^8.7.0, acorn@npm:^8.8.1, acorn@npm:^8.9.0": +"acorn@npm:^8.0.0, acorn@npm:^8.1.0, acorn@npm:^8.11.3, acorn@npm:^8.4.1, acorn@npm:^8.5.0, acorn@npm:^8.6.0, acorn@npm:^8.7.0, acorn@npm:^8.8.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0": version: 8.11.3 resolution: "acorn@npm:8.11.3" bin: @@ -6757,6 +9327,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.6.0": + version: 8.12.0 + resolution: "ajv@npm:8.12.0" + dependencies: + fast-deep-equal: ^3.1.1 + json-schema-traverse: ^1.0.0 + require-from-string: ^2.0.2 + uri-js: ^4.2.2 + checksum: 4dc13714e316e67537c8b31bc063f99a1d9d9a497eb4bbd55191ac0dcd5e4985bbb71570352ad6f1e76684fb6d790928f96ba3b2d4fd6e10024be9612fe3f001 + languageName: node + linkType: hard + "ansi-colors@npm:4.1.1": version: 4.1.1 resolution: "ansi-colors@npm:4.1.1" @@ -6988,6 +9570,13 @@ __metadata: languageName: node linkType: hard +"array-equal@npm:^1.0.0": + version: 1.0.2 + resolution: "array-equal@npm:1.0.2" + checksum: 5c37df0cad330516d1255663dfa4fa761fb0ea63878f535aa70dfefe5499853a8b372faf0a27b91781ca1230f4b4333bbeb751e9b1748527d96df2bee30032ea + languageName: node + linkType: hard + "array-flatten@npm:1.1.1": version: 1.1.1 resolution: "array-flatten@npm:1.1.1" @@ -7096,6 +9685,15 @@ __metadata: languageName: node linkType: hard +"as-table@npm:^1.0.36": + version: 1.0.55 + resolution: "as-table@npm:1.0.55" + dependencies: + printable-characters: ^1.0.42 + checksum: 341c99d9e99a702c315b3f0744d49b4764b26ef7ddd32bafb9e1706626560c0e599100521fc1b17f640e804bd0503ce70b2ba519c023da6edf06bdd9086dccd9 + languageName: node + linkType: hard + "asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" @@ -7200,6 +9798,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.3": + version: 3.2.5 + resolution: "async@npm:3.2.5" + checksum: 5ec77f1312301dee02d62140a6b1f7ee0edd2a0f983b6fd2b0849b969f245225b990b47b8243e7b9ad16451a53e7f68e753700385b706198ced888beedba3af4 + languageName: node + linkType: hard + "asynciterator.prototype@npm:^1.0.0": version: 1.0.0 resolution: "asynciterator.prototype@npm:1.0.0" @@ -7466,7 +10071,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": +"base64-js@npm:^1.0.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -7555,6 +10160,13 @@ __metadata: languageName: node linkType: hard +"blake3-wasm@npm:^2.1.5": + version: 2.1.5 + resolution: "blake3-wasm@npm:2.1.5" + checksum: 5088e929c722b52b9c28701c1760ab850a963692056a417b894c943030e3267f12138ae6409e79069b8d7d0401a411426147e8d812b65a49e303fa432af18871 + languageName: node + linkType: hard + "bluebird@npm:^2.3.5, bluebird@npm:^2.6.2, bluebird@npm:^2.8.1, bluebird@npm:^2.8.2": version: 2.11.0 resolution: "bluebird@npm:2.11.0" @@ -7596,6 +10208,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.11.0 + resolution: "bowser@npm:2.11.0" + checksum: 29c3f01f22e703fa6644fc3b684307442df4240b6e10f6cfe1b61c6ca5721073189ca97cdeedb376081148c8518e33b1d818a57f781d70b0b70e1f31fb48814f + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -7657,6 +10276,13 @@ __metadata: languageName: node linkType: hard +"browser-fs-access@npm:^0.33.0": + version: 0.33.1 + resolution: "browser-fs-access@npm:0.33.1" + checksum: 2fb595c2d28e2da3a5772c0c4da01e8bd4ea2d812693a5ccc8675b59c7658ac82d3e16780e4b7746ca45350b0e873f09597256a524b9e9e80fe67c91bcddffa0 + languageName: node + linkType: hard + "browser-process-hrtime@npm:^1.0.0": version: 1.0.0 resolution: "browser-process-hrtime@npm:1.0.0" @@ -7731,6 +10357,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:5.6.0": + version: 5.6.0 + resolution: "buffer@npm:5.6.0" + dependencies: + base64-js: ^1.0.2 + ieee754: ^1.1.4 + checksum: d659494c5032dd39d03d2912e64179cc44c6340e7e9d1f68d3840e7ab4559989fbce92b4950174593c38d05268224235ba404f0878775cab2a616b6dcad9c23e + languageName: node + linkType: hard + "buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" @@ -7741,6 +10377,13 @@ __metadata: languageName: node linkType: hard +"builtin-modules@npm:^3.1.0": + version: 3.3.0 + resolution: "builtin-modules@npm:3.3.0" + checksum: db021755d7ed8be048f25668fe2117620861ef6703ea2c65ed2779c9e3636d5c3b82325bd912244293959ff3ae303afa3471f6a15bf5060c103e4cc3a839749d + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -7908,6 +10551,16 @@ __metadata: languageName: node linkType: hard +"capnp-ts@npm:^0.7.0": + version: 0.7.0 + resolution: "capnp-ts@npm:0.7.0" + dependencies: + debug: ^4.3.1 + tslib: ^2.2.0 + checksum: 9ab495a887c5d5fd56afa3cc930733cd8c6c0743c52c2e79c46675eb5c7753e5578f71348628a4b3d9f03e5269bc71f811e58af18d0b0557607c4ee56189cfbb + languageName: node + linkType: hard + "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -7963,7 +10616,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": +"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -8085,7 +10738,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:3.5.3, chokidar@npm:^3.5.1, chokidar@npm:^3.5.2": +"chokidar@npm:3.5.3, chokidar@npm:^3.5.1, chokidar@npm:^3.5.2, chokidar@npm:^3.5.3": version: 3.5.3 resolution: "chokidar@npm:3.5.3" dependencies: @@ -8215,6 +10868,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.0 + wrap-ansi: ^6.2.0 + checksum: 4fcfd26d292c9f00238117f39fc797608292ae36bac2168cfee4c85923817d0607fe21b3329a8621e01aedf512c99b7eaa60e363a671ffd378df6649fb48ae42 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -8410,6 +11074,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^2.20.0": + version: 2.20.3 + resolution: "commander@npm:2.20.3" + checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e + languageName: node + linkType: hard + "commander@npm:^6.1.0": version: 6.2.1 resolution: "commander@npm:6.2.1" @@ -8431,6 +11102,13 @@ __metadata: languageName: node linkType: hard +"common-tags@npm:^1.8.0": + version: 1.8.2 + resolution: "common-tags@npm:1.8.2" + checksum: 767a6255a84bbc47df49a60ab583053bb29a7d9687066a18500a516188a062c4e4cd52de341f22de0b07062e699b1b8fe3cfa1cb55b241cb9301aeb4f45b4dff + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -8468,7 +11146,7 @@ __metadata: languageName: node linkType: hard -"concurrently@npm:^8.2.2": +"concurrently@npm:^8.2.1, concurrently@npm:^8.2.2": version: 8.2.2 resolution: "concurrently@npm:8.2.2" dependencies: @@ -8521,6 +11199,13 @@ __metadata: languageName: node linkType: hard +"content-type-parser@npm:^1.0.1": + version: 1.0.2 + resolution: "content-type-parser@npm:1.0.2" + checksum: a9afe2c02059b2b44257f073856c5a44b178fcb67b8a4d1b03046711ff7bb13bfa0c7629e70905c226faf8c0b0e13ade824717a4c974c7c6cdb0ae13774752df + languageName: node + linkType: hard + "content-type@npm:~1.0.4": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -8556,7 +11241,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.5.0": +"cookie@npm:0.5.0, cookie@npm:^0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 @@ -8600,6 +11285,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.5 + resolution: "cors@npm:2.8.5" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: ced838404ccd184f61ab4fdc5847035b681c90db7ac17e428f3d81d69e2989d2b680cc254da0e2554f5ed4f8a341820a1ce3d1c16b499f6e2f47a1b9b07b5006 + languageName: node + linkType: hard + "cosmiconfig@npm:7.0.0": version: 7.0.0 resolution: "cosmiconfig@npm:7.0.0" @@ -8629,6 +11324,18 @@ __metadata: languageName: node linkType: hard +"cross-env@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-env@npm:7.0.3" + dependencies: + cross-spawn: ^7.0.1 + bin: + cross-env: src/bin/cross-env.js + cross-env-shell: src/bin/cross-env-shell.js + checksum: 26f2f3ea2ab32617f57effb70d329c2070d2f5630adc800985d8b30b56e8bf7f5f439dd3a0358b79cee6f930afc23cf8e23515f17ccfb30092c6b62c6b630a79 + languageName: node + linkType: hard + "cross-fetch@npm:^3.1.5": version: 3.1.8 resolution: "cross-fetch@npm:3.1.8" @@ -8638,7 +11345,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -8656,6 +11363,13 @@ __metadata: languageName: node linkType: hard +"crypto-random-string@npm:^2.0.0": + version: 2.0.0 + resolution: "crypto-random-string@npm:2.0.0" + checksum: 0283879f55e7c16fdceacc181f87a0a65c53bc16ffe1d58b9d19a6277adcd71900d02bb2c4843dd55e78c51e30e89b0fec618a7f170ebcc95b33182c28f05fd6 + languageName: node + linkType: hard + "css-select@npm:^5.1.0": version: 5.1.0 resolution: "css-select@npm:5.1.0" @@ -8728,6 +11442,13 @@ __metadata: languageName: node linkType: hard +"cssom@npm:0.3.x, cssom@npm:>= 0.3.2 < 0.4.0, cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 24beb3087c76c0d52dd458be9ee1fbc80ac771478a9baef35dd258cdeb527c68eb43204dd439692bb2b1ae5272fa5f2946d10946edab0d04f1078f85e06bc7f6 + languageName: node + linkType: hard + "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -8735,10 +11456,12 @@ __metadata: languageName: node linkType: hard -"cssom@npm:~0.3.6": - version: 0.3.8 - resolution: "cssom@npm:0.3.8" - checksum: 24beb3087c76c0d52dd458be9ee1fbc80ac771478a9baef35dd258cdeb527c68eb43204dd439692bb2b1ae5272fa5f2946d10946edab0d04f1078f85e06bc7f6 +"cssstyle@npm:>= 0.2.37 < 0.3.0": + version: 0.2.37 + resolution: "cssstyle@npm:0.2.37" + dependencies: + cssom: 0.3.x + checksum: cc36921c7dbfc59b12ca3ab2dfc09cb71d437e721487b670fe1b513d4ddee97719ae4d76cf5c32ef7d6cf0188159a6657328e233fda668f4c52f61bb33c75f29 languageName: node linkType: hard @@ -8781,6 +11504,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^2.0.0": + version: 2.0.2 + resolution: "data-uri-to-buffer@npm:2.0.2" + checksum: 152bec5e77513ee253a7c686700a1723246f582dad8b614e8eaaaba7fa45a15c8671ae4b8f4843f4f3a002dae1d3e7a20f852f7d7bdc8b4c15cfe7adfdfb07f8 + languageName: node + linkType: hard + "data-urls@npm:^3.0.1, data-urls@npm:^3.0.2": version: 3.0.2 resolution: "data-urls@npm:3.0.2" @@ -9143,6 +11873,13 @@ __metadata: languageName: node linkType: hard +"dijkstrajs@npm:^1.0.1": + version: 1.0.3 + resolution: "dijkstrajs@npm:1.0.3" + checksum: 82ff2c6633f235dd5e6bed04ec62cdfb1f327b4d7534557bd52f18991313f864ee50654543072fff4384a92b643ada4d5452f006b7098dbdfad6c8744a8c9e08 + languageName: node + linkType: hard + "dir-glob@npm:^2.0.0": version: 2.2.2 resolution: "dir-glob@npm:2.2.2" @@ -9204,7 +11941,7 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^1.0.1": +"domexception@npm:^1.0.0, domexception@npm:^1.0.1": version: 1.0.1 resolution: "domexception@npm:1.0.1" dependencies: @@ -9256,6 +11993,52 @@ __metadata: languageName: node linkType: hard +"dotcom-asset-upload@workspace:apps/dotcom-asset-upload": + version: 0.0.0-use.local + resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload" + dependencies: + "@cloudflare/workers-types": ^4.20230821.0 + "@types/ws": ^8.5.3 + itty-cors: ^0.3.4 + itty-router: ^2.6.6 + lazyrepo: 0.0.0-alpha.27 + wrangler: 3.16.0 + languageName: unknown + linkType: soft + +"dotcom@workspace:apps/dotcom": + version: 0.0.0-use.local + resolution: "dotcom@workspace:apps/dotcom" + dependencies: + "@radix-ui/react-popover": 1.0.6-rc.5 + "@sentry/cli": ^2.25.0 + "@sentry/integrations": ^7.34.0 + "@sentry/react": ^7.77.0 + "@tldraw/assets": "workspace:*" + "@tldraw/tldraw": "workspace:*" + "@tldraw/tlsync": "workspace:*" + "@types/qrcode": ^1.5.0 + "@types/react": ^18.2.33 + "@typescript-eslint/utils": ^5.59.0 + "@vercel/analytics": ^1.0.1 + "@vitejs/plugin-react-swc": ^3.5.0 + browser-fs-access: ^0.33.0 + dotenv: ^16.3.1 + fast-glob: ^3.3.1 + idb: ^7.1.1 + lazyrepo: 0.0.0-alpha.27 + 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 + vite: ^5.0.0 + vite-plugin-pwa: ^0.17.0 + ws: ^8.13.0 + languageName: unknown + linkType: soft + "dotenv@npm:^16.0.0, dotenv@npm:^16.0.3, dotenv@npm:^16.3.1": version: 16.3.1 resolution: "dotenv@npm:16.3.1" @@ -9350,6 +12133,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.6": + version: 3.1.9 + resolution: "ejs@npm:3.1.9" + dependencies: + jake: ^10.8.5 + bin: + ejs: bin/cli.js + checksum: af6f10eb815885ff8a8cfacc42c6b6cf87daf97a4884f87a30e0c3271fedd85d76a3a297d9c33a70e735b97ee632887f85e32854b9cdd3a2d97edf931519a35f + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.601": version: 1.4.630 resolution: "electron-to-chromium@npm:1.4.630" @@ -9399,6 +12193,13 @@ __metadata: languageName: node linkType: hard +"encode-utf8@npm:^1.0.3": + version: 1.0.3 + resolution: "encode-utf8@npm:1.0.3" + checksum: 550224bf2a104b1d355458c8a82e9b4ea07f9fc78387bc3a49c151b940ad26473de8dc9e121eefc4e84561cb0b46de1e4cd2bc766f72ee145e9ea9541482817f + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -9521,6 +12322,15 @@ __metadata: languageName: node linkType: hard +"error-stack-parser@npm:^2.0.6": + version: 2.1.4 + resolution: "error-stack-parser@npm:2.1.4" + dependencies: + stackframe: ^1.3.4 + checksum: 3b916d2d14c6682f287c8bfa28e14672f47eafe832701080e420e7cdbaebb2c50293868256a95706ac2330fe078cf5664713158b49bc30d7a5f2ac229ded0e18 + languageName: node + linkType: hard + "errors@npm:^0.2.0": version: 0.2.0 resolution: "errors@npm:0.2.0" @@ -9933,6 +12743,83 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:0.17.19, esbuild@npm:^0.17.15": + version: 0.17.19 + resolution: "esbuild@npm:0.17.19" + dependencies: + "@esbuild/android-arm": 0.17.19 + "@esbuild/android-arm64": 0.17.19 + "@esbuild/android-x64": 0.17.19 + "@esbuild/darwin-arm64": 0.17.19 + "@esbuild/darwin-x64": 0.17.19 + "@esbuild/freebsd-arm64": 0.17.19 + "@esbuild/freebsd-x64": 0.17.19 + "@esbuild/linux-arm": 0.17.19 + "@esbuild/linux-arm64": 0.17.19 + "@esbuild/linux-ia32": 0.17.19 + "@esbuild/linux-loong64": 0.17.19 + "@esbuild/linux-mips64el": 0.17.19 + "@esbuild/linux-ppc64": 0.17.19 + "@esbuild/linux-riscv64": 0.17.19 + "@esbuild/linux-s390x": 0.17.19 + "@esbuild/linux-x64": 0.17.19 + "@esbuild/netbsd-x64": 0.17.19 + "@esbuild/openbsd-x64": 0.17.19 + "@esbuild/sunos-x64": 0.17.19 + "@esbuild/win32-arm64": 0.17.19 + "@esbuild/win32-ia32": 0.17.19 + "@esbuild/win32-x64": 0.17.19 + dependenciesMeta: + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: ac11b1a5a6008e4e37ccffbd6c2c054746fc58d0ed4a2f9ee643bd030cfcea9a33a235087bc777def8420f2eaafb3486e76adb7bdb7241a9143b43a69a10afd8 + languageName: node + linkType: hard + "esbuild@npm:0.17.6": version: 0.17.6 resolution: "esbuild@npm:0.17.6" @@ -10010,83 +12897,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.17.15": - version: 0.17.19 - resolution: "esbuild@npm:0.17.19" - dependencies: - "@esbuild/android-arm": 0.17.19 - "@esbuild/android-arm64": 0.17.19 - "@esbuild/android-x64": 0.17.19 - "@esbuild/darwin-arm64": 0.17.19 - "@esbuild/darwin-x64": 0.17.19 - "@esbuild/freebsd-arm64": 0.17.19 - "@esbuild/freebsd-x64": 0.17.19 - "@esbuild/linux-arm": 0.17.19 - "@esbuild/linux-arm64": 0.17.19 - "@esbuild/linux-ia32": 0.17.19 - "@esbuild/linux-loong64": 0.17.19 - "@esbuild/linux-mips64el": 0.17.19 - "@esbuild/linux-ppc64": 0.17.19 - "@esbuild/linux-riscv64": 0.17.19 - "@esbuild/linux-s390x": 0.17.19 - "@esbuild/linux-x64": 0.17.19 - "@esbuild/netbsd-x64": 0.17.19 - "@esbuild/openbsd-x64": 0.17.19 - "@esbuild/sunos-x64": 0.17.19 - "@esbuild/win32-arm64": 0.17.19 - "@esbuild/win32-ia32": 0.17.19 - "@esbuild/win32-x64": 0.17.19 - dependenciesMeta: - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: ac11b1a5a6008e4e37ccffbd6c2c054746fc58d0ed4a2f9ee643bd030cfcea9a33a235087bc777def8420f2eaafb3486e76adb7bdb7241a9143b43a69a10afd8 - languageName: node - linkType: hard - "esbuild@npm:^0.18.10, esbuild@npm:^0.18.4, esbuild@npm:~0.18.20": version: 0.18.20 resolution: "esbuild@npm:0.18.20" @@ -10164,7 +12974,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:~0.19.10": +"esbuild@npm:^0.19.3, esbuild@npm:~0.19.10": version: 0.19.11 resolution: "esbuild@npm:0.19.11" dependencies: @@ -10286,7 +13096,7 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^1.8.1": +"escodegen@npm:^1.8.1, escodegen@npm:^1.9.0": version: 1.14.3 resolution: "escodegen@npm:1.14.3" dependencies: @@ -10323,6 +13133,29 @@ __metadata: languageName: node linkType: hard +"eslint-config-next@npm:12.2.5": + version: 12.2.5 + resolution: "eslint-config-next@npm:12.2.5" + dependencies: + "@next/eslint-plugin-next": 12.2.5 + "@rushstack/eslint-patch": ^1.1.3 + "@typescript-eslint/parser": ^5.21.0 + eslint-import-resolver-node: ^0.3.6 + eslint-import-resolver-typescript: ^2.7.1 + eslint-plugin-import: ^2.26.0 + eslint-plugin-jsx-a11y: ^6.5.1 + eslint-plugin-react: ^7.29.4 + eslint-plugin-react-hooks: ^4.5.0 + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: ">=3.3.1" + peerDependenciesMeta: + typescript: + optional: true + checksum: 21f14cda6c28670e09267c9fc28fe17f9480487c85a322928507bbf61f072c62401bb7d8e79992b176d6a0d64a86419cbd69e475098b083aa5089bb6b572b8e0 + languageName: node + linkType: hard + "eslint-config-next@npm:13.2.4": version: 13.2.4 resolution: "eslint-config-next@npm:13.2.4" @@ -10368,6 +13201,22 @@ __metadata: languageName: node linkType: hard +"eslint-import-resolver-typescript@npm:^2.7.1": + version: 2.7.1 + resolution: "eslint-import-resolver-typescript@npm:2.7.1" + dependencies: + debug: ^4.3.4 + glob: ^7.2.0 + is-glob: ^4.0.3 + resolve: ^1.22.0 + tsconfig-paths: ^3.14.1 + peerDependencies: + eslint: "*" + eslint-plugin-import: "*" + checksum: 1d81b657b1f73bf95b8f0b745c0305574b91630c1db340318f3ca8918e206fce20a933b95e7c419338cc4452cb80bb2b2d92acaf01b6aa315c78a332d832545c + languageName: node + linkType: hard + "eslint-import-resolver-typescript@npm:^3.5.2": version: 3.6.1 resolution: "eslint-import-resolver-typescript@npm:3.6.1" @@ -10512,7 +13361,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react@npm:^7.31.7, eslint-plugin-react@npm:^7.32.2": +"eslint-plugin-react@npm:^7.29.4, eslint-plugin-react@npm:^7.31.7, eslint-plugin-react@npm:^7.32.2": version: 7.33.2 resolution: "eslint-plugin-react@npm:7.33.2" dependencies: @@ -10794,6 +13643,13 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^1.0.1": + version: 1.0.1 + resolution: "estree-walker@npm:1.0.1" + checksum: 7e70da539691f6db03a08e7ce94f394ce2eef4180e136d251af299d41f92fb2d28ebcd9a6e393e3728d7970aeb5358705ddf7209d52fbcb2dd4693f95dcf925f + languageName: node + linkType: hard + "estree-walker@npm:^3.0.0": version: 3.0.3 resolution: "estree-walker@npm:3.0.3" @@ -10848,6 +13704,13 @@ __metadata: languageName: node linkType: hard +"events@npm:3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780 + languageName: node + linkType: hard + "examples.tldraw.com@workspace:apps/examples": version: 0.0.0-use.local resolution: "examples.tldraw.com@workspace:apps/examples" @@ -10906,7 +13769,7 @@ __metadata: languageName: node linkType: hard -"exit-hook@npm:2.2.1": +"exit-hook@npm:2.2.1, exit-hook@npm:^2.2.1": version: 2.2.1 resolution: "exit-hook@npm:2.2.1" checksum: 1aa8359b6c5590a012d6cadf9cd337d227291bfcaa8970dc585d73dffef0582af34ed8ac56f6164f8979979fb417cff1eb49f03cdfd782f9332a30c773f0ada0 @@ -11076,7 +13939,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.0.3, fast-glob@npm:^3.1.1, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.1": +"fast-glob@npm:^3.0.3, fast-glob@npm:^3.1.1, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.1, fast-glob@npm:^3.3.2": version: 3.3.2 resolution: "fast-glob@npm:3.3.2" dependencies: @@ -11110,6 +13973,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:4.2.5": + version: 4.2.5 + resolution: "fast-xml-parser@npm:4.2.5" + dependencies: + strnum: ^1.0.5 + bin: + fxparser: src/cli/cli.js + checksum: d32b22005504eeb207249bf40dc82d0994b5bb9ca9dcc731d335a1f425e47fe085b3cace3cf9d32172dd1a5544193c49e8615ca95b4bf95a4a4920a226b06d80 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.16.0 resolution: "fastq@npm:1.16.0" @@ -11187,6 +14061,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: ^5.0.1 + checksum: a303573b0821e17f2d5e9783688ab6fbfce5d52aaac842790ae85e704a6f5e4e3538660a63183d6453834dedf1e0f19a9dadcebfa3e926c72397694ea11f5160 + languageName: node + linkType: hard + "fill-range@npm:^7.0.1": version: 7.0.1 resolution: "fill-range@npm:7.0.1" @@ -11475,7 +14358,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^9.0.0": +"fs-extra@npm:^9.0.0, fs-extra@npm:^9.0.1": version: 9.1.0 resolution: "fs-extra@npm:9.1.0" dependencies: @@ -11689,6 +14572,13 @@ __metadata: languageName: node linkType: hard +"get-own-enumerable-property-symbols@npm:^3.0.0": + version: 3.0.2 + resolution: "get-own-enumerable-property-symbols@npm:3.0.2" + checksum: 8f0331f14159f939830884799f937343c8c0a2c330506094bc12cbee3665d88337fe97a4ea35c002cc2bdba0f5d9975ad7ec3abb925015cdf2a93e76d4759ede + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -11703,6 +14593,16 @@ __metadata: languageName: node linkType: hard +"get-source@npm:^2.0.12": + version: 2.0.12 + resolution: "get-source@npm:2.0.12" + dependencies: + data-uri-to-buffer: ^2.0.0 + source-map: ^0.6.1 + checksum: c73368fee709594ba38682ec1a96872aac6f7d766396019611d3d2358b68186a7847765a773ea0db088c42781126cc6bc09e4b87f263951c74dae5dcea50ad42 + languageName: node + linkType: hard + "get-stream@npm:^5.1.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -11874,7 +14774,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.0.6, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.0.6, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -12017,6 +14917,15 @@ __metadata: languageName: node linkType: hard +"grabity@npm:^1.0.5": + version: 1.0.5 + resolution: "grabity@npm:1.0.5" + dependencies: + jsdom: 11.3.0 + checksum: 94d56728b6b12db1640f9131360701f04a0ddb78081532f2b043f07eb5e6a10bc3bb250d5296d54f7ef66a975413fccfb34a56c36cde591bb4e2a1eed0a2342b + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -12394,6 +15303,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.2": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: ^16.7.0 + checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 + languageName: node + linkType: hard + "hosted-git-info@npm:^4.0.2": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" @@ -12410,6 +15328,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^1.0.1": + version: 1.0.2 + resolution: "html-encoding-sniffer@npm:1.0.2" + dependencies: + whatwg-encoding: ^1.0.1 + checksum: b874df6750451b7642fbe8e998c6bdd2911b0f42ad2927814b717bf1f4b082b0904b6178a1bfbc40117bf5799777993b0825e7713ca0fca49844e5aec03aa0e2 + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^3.0.0": version: 3.0.0 resolution: "html-encoding-sniffer@npm:3.0.0" @@ -12575,6 +15502,27 @@ __metadata: languageName: node linkType: hard +"huppy@workspace:apps/huppy": + version: 0.0.0-use.local + resolution: "huppy@workspace:apps/huppy" + dependencies: + "@octokit/core": ^5.0.1 + "@octokit/plugin-retry": ^6.0.1 + "@octokit/webhooks-types": ^6.11.0 + "@tldraw/utils": "workspace:*" + "@tldraw/validate": "workspace:*" + "@types/jsonwebtoken": ^9.0.1 + eslint-config-next: 12.2.5 + json5: ^2.2.3 + jsonwebtoken: ^9.0.0 + lazyrepo: 0.0.0-alpha.27 + next: ^13.2.3 + octokit: ^3.1.1 + react: ^18.2.0 + react-dom: ^18.2.0 + languageName: unknown + linkType: soft + "husky@npm:^8.0.0": version: 8.0.3 resolution: "husky@npm:8.0.3" @@ -12611,14 +15559,14 @@ __metadata: languageName: node linkType: hard -"idb@npm:^7.1.1": +"idb@npm:^7.0.1, idb@npm:^7.1.1": version: 7.1.1 resolution: "idb@npm:7.1.1" checksum: 1973c28d53c784b177bdef9f527ec89ec239ec7cf5fcbd987dae75a16c03f5b7dfcc8c6d3285716fd0309dd57739805390bd9f98ce23b1b7d8849a3b52de8d56 languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e @@ -12639,6 +15587,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62 + languageName: node + linkType: hard + "import-cwd@npm:^3.0.0": version: 3.0.0 resolution: "import-cwd@npm:3.0.0" @@ -12717,7 +15672,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -13086,6 +16041,13 @@ __metadata: languageName: node linkType: hard +"is-module@npm:^1.0.0": + version: 1.0.0 + resolution: "is-module@npm:1.0.0" + checksum: 8cd5390730c7976fb4e8546dd0b38865ee6f7bacfa08dfbb2cc07219606755f0b01709d9361e01f13009bbbd8099fa2927a8ed665118a6105d66e40f1b838c3f + languageName: node + linkType: hard + "is-nan@npm:^1.3.2": version: 1.3.2 resolution: "is-nan@npm:1.3.2" @@ -13119,6 +16081,13 @@ __metadata: languageName: node linkType: hard +"is-obj@npm:^1.0.1": + version: 1.0.1 + resolution: "is-obj@npm:1.0.1" + checksum: 3ccf0efdea12951e0b9c784e2b00e77e87b2f8bd30b42a498548a8afcc11b3287342a2030c308e473e93a7a19c9ea7854c99a8832a476591c727df2a9c79796c + languageName: node + linkType: hard + "is-object@npm:^1.0.1": version: 1.0.2 resolution: "is-object@npm:1.0.2" @@ -13187,6 +16156,13 @@ __metadata: languageName: node linkType: hard +"is-regexp@npm:^1.0.0": + version: 1.0.0 + resolution: "is-regexp@npm:1.0.0" + checksum: be692828e24cba479ec33644326fa98959ec68ba77965e0291088c1a741feaea4919d79f8031708f85fd25e39de002b4520622b55460660b9c369e6f7187faef + languageName: node + linkType: hard + "is-set@npm:^2.0.1, is-set@npm:^2.0.2": version: 2.0.2 resolution: "is-set@npm:2.0.2" @@ -13414,6 +16390,27 @@ __metadata: languageName: node linkType: hard +"itty-cors@npm:^0.3.4": + version: 0.3.6 + resolution: "itty-cors@npm:0.3.6" + checksum: 8979093d2790dc9abbcfe858fea4b41f6075545a8ba2c39798e0469be880a52ce6c5cedb102c77d12f4148f0249828113d79cc03d88754c2739c31d0b4da2d0c + languageName: node + linkType: hard + +"itty-router@npm:^2.6.6": + version: 2.6.6 + resolution: "itty-router@npm:2.6.6" + checksum: 2117cba792e7a131f12af8e4f18ed7fddf643d632a440f235e4a3478eac4e4158272a859d160969e0d76e96f5a8659bd090f714afabdb95cffb7964c29d4a89f + languageName: node + linkType: hard + +"itty-router@npm:^4.0.13": + version: 4.0.27 + resolution: "itty-router@npm:4.0.27" + checksum: 323e159cca2a321c53ddd1ebc61572e777fe88665d9ab2eb090ae0a78e2e3885f9bab4008c646859cdbd049ddbf886f7c218fc24ea13c7caeb119833226945b6 + languageName: node + linkType: hard + "jackspeak@npm:^2.3.5": version: 2.3.6 resolution: "jackspeak@npm:2.3.6" @@ -13427,6 +16424,20 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.8.7 + resolution: "jake@npm:10.8.7" + dependencies: + async: ^3.2.3 + chalk: ^4.0.2 + filelist: ^1.0.4 + minimatch: ^3.1.2 + bin: + jake: bin/cli.js + checksum: a23fd2273fb13f0d0d845502d02c791fd55ef5c6a2d207df72f72d8e1eac6d2b8ffa6caf660bc8006b3242e0daaa88a3ecc600194d72b5c6016ad56e9cd43553 + languageName: node + linkType: hard + "java-properties@npm:^1.0.0": version: 1.0.2 resolution: "java-properties@npm:1.0.2" @@ -13970,6 +16981,17 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:^26.2.1": + version: 26.6.2 + resolution: "jest-worker@npm:26.6.2" + dependencies: + "@types/node": "*" + merge-stream: ^2.0.0 + supports-color: ^7.0.0 + checksum: f9afa3b88e3f12027901e4964ba3ff048285b5783b5225cab28fac25b4058cea8ad54001e9a1577ee2bed125fac3ccf5c80dc507b120300cc1bbcb368796533e + languageName: node + linkType: hard + "jest-worker@npm:^28.1.3": version: 28.1.3 resolution: "jest-worker@npm:28.1.3" @@ -14007,6 +17029,20 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.14.4": + version: 4.15.4 + resolution: "jose@npm:4.15.4" + checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b + languageName: node + linkType: hard + +"js-md5@npm:^0.7.3": + version: 0.7.3 + resolution: "js-md5@npm:0.7.3" + checksum: 1ed9f7f23a2ad224fc159ba7e074617d20e6b501ea96319091ae4c7cfe722cc472c4e05366fd25708311fb66dc2deb1b82e51c730f9ccd910e64b67ba36d3a75 + languageName: node + linkType: hard + "js-sdsl@npm:^4.1.4": version: 4.4.2 resolution: "js-sdsl@npm:4.4.2" @@ -14014,6 +17050,13 @@ __metadata: languageName: node linkType: hard +"js-sha1@npm:^0.6.0": + version: 0.6.0 + resolution: "js-sha1@npm:0.6.0" + checksum: f12adcdac8da2b42eb40b9f83003bd085f647a8ca4364ac736462714e1b8681aab97efda09737889e001a7df569c8b35f28f35c638a3af93ef586281f92b2ca5 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -14051,6 +17094,36 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:11.3.0": + version: 11.3.0 + resolution: "jsdom@npm:11.3.0" + dependencies: + abab: ^1.0.3 + acorn: ^5.1.2 + acorn-globals: ^4.0.0 + array-equal: ^1.0.0 + content-type-parser: ^1.0.1 + cssom: ">= 0.3.2 < 0.4.0" + cssstyle: ">= 0.2.37 < 0.3.0" + domexception: ^1.0.0 + escodegen: ^1.9.0 + html-encoding-sniffer: ^1.0.1 + nwmatcher: ^1.4.1 + parse5: ^3.0.2 + pn: ^1.0.0 + request: ^2.83.0 + request-promise-native: ^1.0.3 + sax: ^1.2.1 + symbol-tree: ^3.2.1 + tough-cookie: ^2.3.3 + webidl-conversions: ^4.0.2 + whatwg-encoding: ^1.0.1 + whatwg-url: ^6.3.0 + xml-name-validator: ^2.0.1 + checksum: 97bc35095d2794ea91980c4dc5c004a851149355333303f4fcfcbc9ae1f618f407dc631be1539ce517cbcb6b4c92af0b39224d078ae4505971d726f043697741 + languageName: node + linkType: hard + "jsdom@npm:^19.0.0": version: 19.0.0 resolution: "jsdom@npm:19.0.0" @@ -14212,7 +17285,7 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:0.4.0": +"json-schema@npm:0.4.0, json-schema@npm:^0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 @@ -14256,7 +17329,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.1.2, json5@npm:^2.2.2, json5@npm:^2.2.3": +"json5@npm:^2.1.2, json5@npm:^2.2.0, json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -14304,7 +17377,14 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.2": +"jsonpointer@npm:^5.0.0": + version: 5.0.1 + resolution: "jsonpointer@npm:5.0.1" + checksum: 0b40f712900ad0c846681ea2db23b6684b9d5eedf55807b4708c656f5894b63507d0e28ae10aa1bddbea551241035afe62b6df0800fc94c2e2806a7f3adecd7c + languageName: node + linkType: hard + +"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": version: 9.0.2 resolution: "jsonwebtoken@npm:9.0.2" dependencies: @@ -14486,6 +17566,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:3.1.1": + version: 3.1.1 + resolution: "lie@npm:3.1.1" + dependencies: + immediate: ~3.0.5 + checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 + languageName: node + linkType: hard + "lilconfig@npm:3.0.0, lilconfig@npm:^3.0.0": version: 3.0.0 resolution: "lilconfig@npm:3.0.0" @@ -14590,6 +17679,15 @@ __metadata: languageName: node linkType: hard +"localforage@npm:^1.8.1": + version: 1.10.0 + resolution: "localforage@npm:1.10.0" + dependencies: + lie: 3.1.1 + checksum: f2978b434dafff9bcb0d9498de57d97eba165402419939c944412e179cab1854782830b5ec196212560b22712d1dd03918939f59cf1d4fc1d756fca7950086cf + languageName: node + linkType: hard + "locate-path@npm:^2.0.0": version: 2.0.0 resolution: "locate-path@npm:2.0.0" @@ -14726,6 +17824,13 @@ __metadata: languageName: node linkType: hard +"lodash.sortby@npm:^4.7.0": + version: 4.7.0 + resolution: "lodash.sortby@npm:4.7.0" + checksum: db170c9396d29d11fe9a9f25668c4993e0c1331bcb941ddbd48fb76f492e732add7f2a47cfdf8e9d740fa59ac41bbfaf931d268bc72aab3ab49e9f89354d718c + languageName: node + linkType: hard + "lodash.throttle@npm:^4.1.1": version: 4.1.1 resolution: "lodash.throttle@npm:4.1.1" @@ -14740,7 +17845,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.7.0, lodash@npm:~4.17.15": +"lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.7.0, lodash@npm:~4.17.15": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -14857,7 +17962,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.25.3": +"magic-string@npm:^0.25.0, magic-string@npm:^0.25.3, magic-string@npm:^0.25.7": version: 0.25.9 resolution: "magic-string@npm:0.25.9" dependencies: @@ -15976,6 +19081,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^3.0.0": + version: 3.0.0 + resolution: "mime@npm:3.0.0" + bin: + mime: cli.js + checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -16011,6 +19125,28 @@ __metadata: languageName: node linkType: hard +"miniflare@npm:3.20231030.0": + version: 3.20231030.0 + resolution: "miniflare@npm:3.20231030.0" + dependencies: + acorn: ^8.8.0 + acorn-walk: ^8.2.0 + capnp-ts: ^0.7.0 + exit-hook: ^2.2.1 + glob-to-regexp: ^0.4.1 + source-map-support: 0.5.21 + stoppable: ^1.1.0 + undici: ^5.22.1 + workerd: 1.20231030.0 + ws: ^8.11.0 + youch: ^3.2.2 + zod: ^3.20.6 + bin: + miniflare: bootstrap.js + checksum: 506a143f86571cc5f870985ca76afa5b7f5b479c801a4ad04d562da47b1dc5edb556b7ae93e490c5d7a7b45ffee5e5dba28be699fb9ad497c394a6da779bb6c4 + languageName: node + linkType: hard + "minimatch@npm:4.2.1": version: 4.2.1 resolution: "minimatch@npm:4.2.1" @@ -16147,7 +19283,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": +"minipass@npm:^4.0.0, minipass@npm:^4.2.4": version: 4.2.8 resolution: "minipass@npm:4.2.8" checksum: 7f4914d5295a9a30807cae5227a37a926e6d910c03f315930fde52332cf0575dfbc20295318f91f0baf0e6bb11a6f668e30cde8027dea7a11b9d159867a3c830 @@ -16292,6 +19428,15 @@ __metadata: languageName: node linkType: hard +"mustache@npm:^4.2.0": + version: 4.2.0 + resolution: "mustache@npm:4.2.0" + bin: + mustache: bin/mustache + checksum: 928fcb63e3aa44a562bfe9b59ba202cccbe40a46da50be6f0dd831b495be1dd7e38ca4657f0ecab2c1a89dc7bccba0885eab7ee7c1b215830da765758c7e0506 + languageName: node + linkType: hard + "mute-stream@npm:0.0.8, mute-stream@npm:~0.0.4": version: 0.0.8 resolution: "mute-stream@npm:0.0.8" @@ -16299,6 +19444,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^7.0.1": + version: 7.0.1 + resolution: "nanoevents@npm:7.0.1" + checksum: 5c0704cfeb7af9052a70b1ce53e556e3743cefbbd09758121e0062e4fac2cf70ebea1e6b1414f18a81975ee1bdf4af05e13c82e1b015975493ef8ef34737787d + languageName: node + linkType: hard + "nanoid@npm:3.3.1": version: 3.3.1 resolution: "nanoid@npm:3.3.1" @@ -16317,7 +19469,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": +"nanoid@npm:^3.3.3, nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" bin: @@ -16394,6 +19546,61 @@ __metadata: languageName: node linkType: hard +"next@npm:^13.2.3": + version: 13.5.6 + resolution: "next@npm:13.5.6" + dependencies: + "@next/env": 13.5.6 + "@next/swc-darwin-arm64": 13.5.6 + "@next/swc-darwin-x64": 13.5.6 + "@next/swc-linux-arm64-gnu": 13.5.6 + "@next/swc-linux-arm64-musl": 13.5.6 + "@next/swc-linux-x64-gnu": 13.5.6 + "@next/swc-linux-x64-musl": 13.5.6 + "@next/swc-win32-arm64-msvc": 13.5.6 + "@next/swc-win32-ia32-msvc": 13.5.6 + "@next/swc-win32-x64-msvc": 13.5.6 + "@swc/helpers": 0.5.2 + busboy: 1.6.0 + caniuse-lite: ^1.0.30001406 + postcss: 8.4.31 + styled-jsx: 5.1.1 + watchpack: 2.4.0 + peerDependencies: + "@opentelemetry/api": ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-ia32-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: c869b0014ae921ada3bf22301985027ec320aebcd6aa9c16e8afbded68bb8def5874cca034c680e8c351a79578f1e514971d02777f6f0a5a1d7290f25970ac0d + languageName: node + linkType: hard + "next@npm:^14.0.4": version: 14.0.4 resolution: "next@npm:14.0.4" @@ -16521,6 +19728,13 @@ __metadata: languageName: node linkType: hard +"node-forge@npm:^1": + version: 1.3.1 + resolution: "node-forge@npm:1.3.1" + checksum: 08fb072d3d670599c89a1704b3e9c649ff1b998256737f0e06fbd1a5bf41cae4457ccaee32d95052d80bbafd9ffe01284e078c8071f0267dc9744e51c5ed42a9 + languageName: node + linkType: hard + "node-gyp-build@npm:^4.2.2": version: 4.8.0 resolution: "node-gyp-build@npm:4.8.0" @@ -16700,6 +19914,13 @@ __metadata: languageName: node linkType: hard +"nwmatcher@npm:^1.4.1": + version: 1.4.4 + resolution: "nwmatcher@npm:1.4.4" + checksum: ab086276db7e93756a4e9704dab29bac06e3a58f8b7074735afbd3df1d428c802a71310e362ea596e9654bd344670070075e496e559ba2534d4c1a35f0be04c3 + languageName: node + linkType: hard + "nwsapi@npm:^2.2.0, nwsapi@npm:^2.2.2": version: 2.2.7 resolution: "nwsapi@npm:2.2.7" @@ -16721,7 +19942,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -17364,7 +20585,7 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:6.2.1": +"path-to-regexp@npm:6.2.1, path-to-regexp@npm:^6.2.0": version: 6.2.1 resolution: "path-to-regexp@npm:6.2.1" checksum: f0227af8284ea13300f4293ba111e3635142f976d4197f14d5ad1f124aebd9118783dd2e5f1fe16f7273743cc3dbeddfb7493f237bb27c10fdae07020cc9b698 @@ -17528,6 +20749,20 @@ __metadata: languageName: node linkType: hard +"pn@npm:^1.0.0": + version: 1.1.0 + resolution: "pn@npm:1.1.0" + checksum: e4654186dc92a187c8c7fe4ccda902f4d39dd9c10f98d1c5a08ce5fad5507ef1e33ddb091240c3950bee81bd201b4c55098604c433a33b5e8bdd97f38b732fa0 + languageName: node + linkType: hard + +"pngjs@npm:^5.0.0": + version: 5.0.0 + resolution: "pngjs@npm:5.0.0" + checksum: 04e912cc45fb9601564e2284efaf0c5d20d131d9b596244f8a6789fc6cdb6b18d2975a6bbf7a001858d7e159d5c5c5dd7b11592e97629b7137f7f5cef05904c8 + languageName: node + linkType: hard + "postcss-discard-duplicates@npm:^5.1.0": version: 5.1.0 resolution: "postcss-discard-duplicates@npm:5.1.0" @@ -17645,7 +20880,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.19, postcss@npm:^8.4.27, postcss@npm:^8.4.4": +"postcss@npm:^8.4.19, postcss@npm:^8.4.27, postcss@npm:^8.4.32, postcss@npm:^8.4.4": version: 8.4.33 resolution: "postcss@npm:8.4.33" dependencies: @@ -17734,13 +20969,20 @@ __metadata: languageName: node linkType: hard -"pretty-bytes@npm:5.6.0": +"pretty-bytes@npm:5.6.0, pretty-bytes@npm:^5.3.0": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" checksum: 9c082500d1e93434b5b291bd651662936b8bd6204ec9fa17d563116a192d6d86b98f6d328526b4e8d783c07d5499e2614a807520249692da9ec81564b2f439cd languageName: node linkType: hard +"pretty-bytes@npm:^6.1.1": + version: 6.1.1 + resolution: "pretty-bytes@npm:6.1.1" + checksum: 43d29d909d2d88072da2c3d72f8fd0f2d2523c516bfa640aff6e31f596ea1004b6601f4cabc50d14b2cf10e82635ebe5b7d9378f3d5bae1c0067131829421b8a + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -17784,6 +21026,13 @@ __metadata: languageName: node linkType: hard +"printable-characters@npm:^1.0.42": + version: 1.0.42 + resolution: "printable-characters@npm:1.0.42" + checksum: 2724aa02919d7085933af0f8f904bd0de67a6b53834f2e5b98fc7aa3650e20755c805e8c85bcf96c09f678cb16a58b55640dd3a2da843195fce06b1ccb0c8ce4 + languageName: node + linkType: hard + "proc-log@npm:^3.0.0": version: 3.0.0 resolution: "proc-log@npm:3.0.0" @@ -17805,6 +21054,13 @@ __metadata: languageName: node linkType: hard +"progress@npm:^2.0.3": + version: 2.0.3 + resolution: "progress@npm:2.0.3" + checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7 + languageName: node + linkType: hard + "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -17972,6 +21228,20 @@ __metadata: languageName: node linkType: hard +"qrcode@npm:^1.5.1": + version: 1.5.3 + resolution: "qrcode@npm:1.5.3" + dependencies: + dijkstrajs: ^1.0.1 + encode-utf8: ^1.0.3 + pngjs: ^5.0.0 + yargs: ^15.3.1 + bin: + qrcode: bin/qrcode + checksum: 9a8a20a0a9cb1d15de8e7b3ffa214e8b6d2a8b07655f25bd1b1d77f4681488f84d7bae569870c0652872d829d5f8ac4922c27a6bd14c13f0e197bf07b28dead7 + languageName: node + linkType: hard + "qs@npm:6.11.0": version: 6.11.0 resolution: "qs@npm:6.11.0" @@ -18093,6 +21363,29 @@ __metadata: languageName: node linkType: hard +"react-fast-compare@npm:^3.2.0": + version: 3.2.2 + resolution: "react-fast-compare@npm:3.2.2" + checksum: 2071415b4f76a3e6b55c84611c4d24dcb12ffc85811a2840b5a3f1ff2d1a99be1020d9437ee7c6e024c9f4cbb84ceb35e48cf84f28fcb00265ad2dfdd3947704 + languageName: node + linkType: hard + +"react-helmet-async@npm:^1.3.0": + version: 1.3.0 + resolution: "react-helmet-async@npm:1.3.0" + dependencies: + "@babel/runtime": ^7.12.5 + invariant: ^2.2.4 + prop-types: ^15.7.2 + react-fast-compare: ^3.2.0 + shallowequal: ^1.1.0 + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 + checksum: 7ca7e47f8af14ea186688b512a87ab912bf6041312b297f92516341b140b3f0f8aedf5a44d226d99e69ed067b0cc106e38aeb9c9b738ffcc63d10721c844db90 + languageName: node + linkType: hard + "react-hotkeys-hook@npm:^4.4.1": version: 4.4.4 resolution: "react-hotkeys-hook@npm:4.4.4" @@ -18110,7 +21403,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -18166,7 +21459,7 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:^6.9.0": +"react-router-dom@npm:^6.17.0, react-router-dom@npm:^6.9.0": version: 6.21.2 resolution: "react-router-dom@npm:6.21.2" dependencies: @@ -18277,7 +21570,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -18654,7 +21947,31 @@ __metadata: languageName: node linkType: hard -"request@npm:^2.88.0": +"request-promise-core@npm:1.1.4": + version: 1.1.4 + resolution: "request-promise-core@npm:1.1.4" + dependencies: + lodash: ^4.17.19 + peerDependencies: + request: ^2.34 + checksum: c798bafd552961e36fbf5023b1d081e81c3995ab390f1bc8ef38a711ba3fe4312eb94dbd61887073d7356c3499b9380947d7f62faa805797c0dc50f039425699 + languageName: node + linkType: hard + +"request-promise-native@npm:^1.0.3": + version: 1.0.9 + resolution: "request-promise-native@npm:1.0.9" + dependencies: + request-promise-core: 1.1.4 + stealthy-require: ^1.1.1 + tough-cookie: ^2.3.3 + peerDependencies: + request: ^2.34 + checksum: 3e2c694eefac88cb20beef8911ad57a275ab3ccbae0c4ca6c679fffb09d5fd502458aab08791f0814ca914b157adab2d4e472597c97a73be702918e41725ed69 + languageName: node + linkType: hard + +"request@npm:^2.83.0, request@npm:^2.88.0": version: 2.88.2 resolution: "request@npm:2.88.2" dependencies: @@ -18779,7 +22096,14 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.0.0, resolve@npm:^1.14.2, resolve@npm:^1.20.0, resolve@npm:^1.22.4, resolve@npm:~1.22.1": +"resolve.exports@npm:^2.0.2": + version: 2.0.2 + resolution: "resolve.exports@npm:2.0.2" + checksum: 1c7778ca1b86a94f8ab4055d196c7d87d1874b96df4d7c3e67bbf793140f0717fd506dcafd62785b079cd6086b9264424ad634fb904409764c3509c3df1653f2 + languageName: node + linkType: hard + +"resolve@npm:^1.0.0, resolve@npm:^1.14.2, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.0, resolve@npm:^1.22.4, resolve@npm:~1.22.1": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -18824,7 +22148,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.0.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.4#~builtin, resolve@patch:resolve@~1.22.1#~builtin": +"resolve@patch:resolve@^1.0.0#~builtin, resolve@patch:resolve@^1.14.2#~builtin, resolve@patch:resolve@^1.19.0#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.0#~builtin, resolve@patch:resolve@^1.22.4#~builtin, resolve@patch:resolve@~1.22.1#~builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -19001,6 +22325,20 @@ __metadata: languageName: node linkType: hard +"rollup-plugin-terser@npm:^7.0.0": + version: 7.0.2 + resolution: "rollup-plugin-terser@npm:7.0.2" + dependencies: + "@babel/code-frame": ^7.10.4 + jest-worker: ^26.2.1 + serialize-javascript: ^4.0.0 + terser: ^5.0.0 + peerDependencies: + rollup: ^2.0.0 + checksum: af84bb7a7a894cd00852b6486528dfb8653cf94df4c126f95f389a346f401d054b08c46bee519a2ab6a22b33804d1d6ac6d8c90b1b2bf8fffb097eed73fc3c72 + languageName: node + linkType: hard + "rollup-pluginutils@npm:^2.8.1": version: 2.8.2 resolution: "rollup-pluginutils@npm:2.8.2" @@ -19010,6 +22348,20 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^2.43.1": + version: 2.79.1 + resolution: "rollup@npm:2.79.1" + dependencies: + fsevents: ~2.3.2 + dependenciesMeta: + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 6a2bf167b3587d4df709b37d149ad0300692cc5deb510f89ac7bdc77c8738c9546ae3de9322b0968e1ed2b0e984571f5f55aae28fa7de4cfcb1bc5402a4e2be6 + languageName: node + linkType: hard + "rollup@npm:^3.27.1": version: 3.29.4 resolution: "rollup@npm:3.29.4" @@ -19024,6 +22376,60 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.2.0": + version: 4.9.5 + resolution: "rollup@npm:4.9.5" + dependencies: + "@rollup/rollup-android-arm-eabi": 4.9.5 + "@rollup/rollup-android-arm64": 4.9.5 + "@rollup/rollup-darwin-arm64": 4.9.5 + "@rollup/rollup-darwin-x64": 4.9.5 + "@rollup/rollup-linux-arm-gnueabihf": 4.9.5 + "@rollup/rollup-linux-arm64-gnu": 4.9.5 + "@rollup/rollup-linux-arm64-musl": 4.9.5 + "@rollup/rollup-linux-riscv64-gnu": 4.9.5 + "@rollup/rollup-linux-x64-gnu": 4.9.5 + "@rollup/rollup-linux-x64-musl": 4.9.5 + "@rollup/rollup-win32-arm64-msvc": 4.9.5 + "@rollup/rollup-win32-ia32-msvc": 4.9.5 + "@rollup/rollup-win32-x64-msvc": 4.9.5 + "@types/estree": 1.0.5 + fsevents: ~2.3.2 + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: a6bb721f2251a2299e99be2eb58b0949571545809b75571c42baa50e749437aa9ef40f0660644d992e2387ba7f0775271ab9388fe4fbb02c6c3fc5db6a8b9711 + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -19111,7 +22517,7 @@ __metadata: languageName: node linkType: hard -"sax@npm:>=0.6.0": +"sax@npm:>=0.6.0, sax@npm:^1.2.1": version: 1.3.0 resolution: "sax@npm:1.3.0" checksum: 238ab3a9ba8c8f8aaf1c5ea9120386391f6ee0af52f1a6a40bbb6df78241dd05d782f2359d614ac6aae08c4c4125208b456548a6cf68625aa4fe178486e63ecd @@ -19155,6 +22561,16 @@ __metadata: languageName: node linkType: hard +"selfsigned@npm:^2.0.1": + version: 2.4.1 + resolution: "selfsigned@npm:2.4.1" + dependencies: + "@types/node-forge": ^1.3.0 + node-forge: ^1 + checksum: 38b91c56f1d7949c0b77f9bbe4545b19518475cae15e7d7f0043f87b1626710b011ce89879a88969651f650a19d213bb15b7d5b4c2877df9eeeff7ba8f8b9bfa + languageName: node + linkType: hard + "semver@npm:5.5.x": version: 5.5.1 resolution: "semver@npm:5.5.1" @@ -19243,6 +22659,15 @@ __metadata: languageName: node linkType: hard +"serialize-javascript@npm:^4.0.0": + version: 4.0.0 + resolution: "serialize-javascript@npm:4.0.0" + dependencies: + randombytes: ^2.1.0 + checksum: 3273b3394b951671fcf388726e9577021870dfbf85e742a1183fb2e91273e6101bdccea81ff230724f6659a7ee4cef924b0ff9baca32b79d9384ec37caf07302 + languageName: node + linkType: hard + "serve-static@npm:1.15.0": version: 1.15.0 resolution: "serve-static@npm:1.15.0" @@ -19307,6 +22732,13 @@ __metadata: languageName: node linkType: hard +"shallowequal@npm:^1.1.0": + version: 1.1.0 + resolution: "shallowequal@npm:1.1.0" + checksum: f4c1de0837f106d2dbbfd5d0720a5d059d1c66b42b580965c8f06bb1db684be8783538b684092648c981294bf817869f743a066538771dbecb293df78f765e00 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -19539,7 +22971,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.12, source-map-support@npm:^0.5.17, source-map-support@npm:^0.5.21": +"source-map-support@npm:0.5.21, source-map-support@npm:^0.5.12, source-map-support@npm:^0.5.17, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -19549,7 +22981,14 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": +"source-map@npm:0.5.6": + version: 0.5.6 + resolution: "source-map@npm:0.5.6" + checksum: 390b3f5165c9631a74fb6fb55ba61e62a7f9b7d4026ae0e2bfc2899c241d71c1bccb8731c496dc7f7cb79a5f523406eb03d8c5bebe8448ee3fc38168e2d209c8 + languageName: node + linkType: hard + +"source-map@npm:0.6.1, source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2 @@ -19563,6 +23002,15 @@ __metadata: languageName: node linkType: hard +"source-map@npm:^0.8.0-beta.0": + version: 0.8.0-beta.0 + resolution: "source-map@npm:0.8.0-beta.0" + dependencies: + whatwg-url: ^7.0.0 + checksum: e94169be6461ab0ac0913313ad1719a14c60d402bd22b0ad96f4a6cffd79130d91ab5df0a5336a326b04d2df131c1409f563c9dc0d21a6ca6239a44b6c8dbd92 + languageName: node + linkType: hard + "sourcemap-codec@npm:^1.4.8": version: 1.4.8 resolution: "sourcemap-codec@npm:1.4.8" @@ -19674,6 +23122,15 @@ __metadata: languageName: node linkType: hard +"stack-generator@npm:^2.0.5": + version: 2.0.10 + resolution: "stack-generator@npm:2.0.10" + dependencies: + stackframe: ^1.3.4 + checksum: 4fc3978a934424218a0aa9f398034e1f78153d5ff4f4ff9c62478c672debb47dd58de05b09fc3900530cbb526d72c93a6e6c9353bacc698e3b1c00ca3dda0c47 + languageName: node + linkType: hard + "stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" @@ -19683,6 +23140,44 @@ __metadata: languageName: node linkType: hard +"stackframe@npm:^1.3.4": + version: 1.3.4 + resolution: "stackframe@npm:1.3.4" + checksum: bae1596873595c4610993fa84f86a3387d67586401c1816ea048c0196800c0646c4d2da98c2ee80557fd9eff05877efe33b91ba6cd052658ed96ddc85d19067d + languageName: node + linkType: hard + +"stacktrace-gps@npm:^3.0.4": + version: 3.1.2 + resolution: "stacktrace-gps@npm:3.1.2" + dependencies: + source-map: 0.5.6 + stackframe: ^1.3.4 + checksum: 85daa232d138239b6ae0f4bcdd87d15d302a045d93625db17614030945b5314e204b5fbcf9bee5b6f4f9e6af5fca05f65c27fe910894b861ef6853b99470aa1c + languageName: node + linkType: hard + +"stacktrace-js@npm:2.0.2": + version: 2.0.2 + resolution: "stacktrace-js@npm:2.0.2" + dependencies: + error-stack-parser: ^2.0.6 + stack-generator: ^2.0.5 + stacktrace-gps: ^3.0.4 + checksum: 081e786d56188ac04ac6604c09cd863b3ca2b4300ec061366cf68c3e4ad9edaa34fb40deea03cc23a05f442aa341e9171f47313f19bd588f9bec6c505a396286 + languageName: node + linkType: hard + +"stacktracey@npm:^2.1.8": + version: 2.1.8 + resolution: "stacktracey@npm:2.1.8" + dependencies: + as-table: ^1.0.36 + get-source: ^2.0.12 + checksum: abd8316b4e120884108f5a47b2f61abdcaeaa118afd95f3c48317cb057fff43d697450ba00de3f9fe7fee61ee72644ccda4db990a8e4553706644f7c17522eab + languageName: node + linkType: hard + "statuses@npm:2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" @@ -19690,6 +23185,13 @@ __metadata: languageName: node linkType: hard +"stealthy-require@npm:^1.1.1": + version: 1.1.1 + resolution: "stealthy-require@npm:1.1.1" + checksum: 6805b857a9f3a6a1079fc6652278038b81011f2a5b22cbd559f71a6c02087e6f1df941eb10163e3fdc5391ab5807aa46758d4258547c1f5ede31e6d9bfda8dd3 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0": version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" @@ -19699,6 +23201,23 @@ __metadata: languageName: node linkType: hard +"stoppable@npm:^1.1.0": + version: 1.1.0 + resolution: "stoppable@npm:1.1.0" + checksum: 63104fcbdece130bc4906fd982061e763d2ef48065ed1ab29895e5ad00552c625f8a4c50c9cd2e3bfa805c8a2c3bfdda0f07c5ae39694bd2d5cb0bee1618d1e9 + languageName: node + linkType: hard + +"stream-browserify@npm:3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: ~2.0.4 + readable-stream: ^3.5.0 + checksum: 4c47ef64d6f03815a9ca3874e2319805e8e8a85f3550776c47ce523b6f4c6cd57f40e46ec6a9ab8ad260fde61863c2718f250d3bedb3fe9052444eb9abfd9921 + languageName: node + linkType: hard + "stream-combiner@npm:^0.2.1": version: 0.2.2 resolution: "stream-combiner@npm:0.2.2" @@ -19885,6 +23404,17 @@ __metadata: languageName: node linkType: hard +"stringify-object@npm:^3.3.0": + version: 3.3.0 + resolution: "stringify-object@npm:3.3.0" + dependencies: + get-own-enumerable-property-symbols: ^3.0.0 + is-obj: ^1.0.1 + is-regexp: ^1.0.0 + checksum: 6827a3f35975cfa8572e8cd3ed4f7b262def260af18655c6fde549334acdac49ddba69f3c861ea5a6e9c5a4990fe4ae870b9c0e6c31019430504c94a83b7a154 + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -19953,6 +23483,13 @@ __metadata: languageName: node linkType: hard +"strip-comments@npm:^2.0.1": + version: 2.0.1 + resolution: "strip-comments@npm:2.0.1" + checksum: 36cd122e1c27b5be69df87e1687770a62fe183bdee9f3ff5cf85d30bbc98280afc012581f2fd50c7ad077c90f656f272560c9d2e520d28604b8b7ea3bc87d6f9 + languageName: node + linkType: hard + "strip-final-newline@npm:^2.0.0": version: 2.0.0 resolution: "strip-final-newline@npm:2.0.0" @@ -19990,6 +23527,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^1.0.5": + version: 1.0.5 + resolution: "strnum@npm:1.0.5" + checksum: 651b2031db5da1bf4a77fdd2f116a8ac8055157c5420f5569f64879133825915ad461513e7202a16d7fec63c54fd822410d0962f8ca12385c4334891b9ae6dd2 + languageName: node + linkType: hard + "style-to-object@npm:^0.4.1": version: 0.4.4 resolution: "style-to-object@npm:0.4.4" @@ -20092,7 +23636,7 @@ __metadata: languageName: node linkType: hard -"symbol-tree@npm:^3.2.4": +"symbol-tree@npm:^3.2.1, symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4" checksum: 6e8fc7e1486b8b54bea91199d9535bb72f10842e40c79e882fc94fb7b14b89866adf2fd79efa5ebb5b658bc07fb459ccce5ac0e99ef3d72f474e74aaf284029d @@ -20143,7 +23687,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2": +"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.0": version: 6.2.0 resolution: "tar@npm:6.2.0" dependencies: @@ -20157,6 +23701,25 @@ __metadata: languageName: node linkType: hard +"temp-dir@npm:^2.0.0": + version: 2.0.0 + resolution: "temp-dir@npm:2.0.0" + checksum: cc4f0404bf8d6ae1a166e0e64f3f409b423f4d1274d8c02814a59a5529f07db6cd070a749664141b992b2c1af337fa9bb451a460a43bb9bcddc49f235d3115aa + languageName: node + linkType: hard + +"tempy@npm:^0.6.0": + version: 0.6.0 + resolution: "tempy@npm:0.6.0" + dependencies: + is-stream: ^2.0.0 + temp-dir: ^2.0.0 + type-fest: ^0.16.0 + unique-string: ^2.0.0 + checksum: dd09c8b6615e4b785ea878e9a18b17ac0bfe5dccf5a0e205ebd274bb356356aff3f5c90a6c917077d51c75efb7648b113a78b0492e2ffc81a7c9912eb872ac52 + languageName: node + linkType: hard + "terminal-link@npm:^2.0.0, terminal-link@npm:^2.1.1": version: 2.1.1 resolution: "terminal-link@npm:2.1.1" @@ -20167,6 +23730,20 @@ __metadata: languageName: node linkType: hard +"terser@npm:^5.0.0": + version: 5.26.0 + resolution: "terser@npm:5.26.0" + dependencies: + "@jridgewell/source-map": ^0.3.3 + acorn: ^8.8.2 + commander: ^2.20.0 + source-map-support: ~0.5.20 + bin: + terser: bin/terser + checksum: 02a9bb896f04df828025af8f0eced36c315d25d310b6c2418e7dad2bed19ddeb34a9cea9b34e7c24789830fa51e1b6a9be26679980987a9c817a7e6d9cd4154b + languageName: node + linkType: hard + "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -20329,7 +23906,22 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^2.3.1, tough-cookie@npm:~2.5.0": +"toucan-js@npm:^2.7.0": + version: 2.7.0 + resolution: "toucan-js@npm:2.7.0" + dependencies: + "@sentry/core": 6.19.6 + "@sentry/hub": 6.19.6 + "@sentry/types": 6.19.6 + "@sentry/utils": 6.19.6 + "@types/cookie": 0.5.0 + cookie: 0.5.0 + stacktrace-js: 2.0.2 + checksum: 2ad055f9c09949605e0df2f301cd2d39581c1ffb3e37ccc5e387e4e2c14430d8d2efdcb0d871262b5eaf60183ae453f6abe86d733ab169677b681c2192dc0ee6 + languageName: node + linkType: hard + +"tough-cookie@npm:^2.3.1, tough-cookie@npm:^2.3.3, tough-cookie@npm:~2.5.0": version: 2.5.0 resolution: "tough-cookie@npm:2.5.0" dependencies: @@ -20351,6 +23943,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^1.0.1": + version: 1.0.1 + resolution: "tr46@npm:1.0.1" + dependencies: + punycode: ^2.1.0 + checksum: 96d4ed46bc161db75dbf9247a236ea0bfcaf5758baae6749e92afab0bc5a09cb59af21788ede7e55080f2bf02dce3e4a8f2a484cc45164e29f4b5e68f7cbcc1a + languageName: node + linkType: hard + "tr46@npm:^2.1.0": version: 2.1.0 resolution: "tr46@npm:2.1.0" @@ -20572,7 +24173,7 @@ __metadata: languageName: node linkType: hard -"tsconfig-paths@npm:^3.15.0": +"tsconfig-paths@npm:^3.14.1, tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" dependencies: @@ -20621,14 +24222,14 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1, tslib@npm:^1.9.0": +"tslib@npm:^1.11.1, tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd languageName: node linkType: hard -"tslib@npm:^2, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.1, tslib@npm:^2.6.2": +"tslib@npm:^2, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.1, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad @@ -20688,7 +24289,7 @@ __metadata: languageName: node linkType: hard -"tunnel@npm:0.0.6": +"tunnel@npm:0.0.6, tunnel@npm:^0.0.6": version: 0.0.6 resolution: "tunnel@npm:0.0.6" checksum: c362948df9ad34b649b5585e54ce2838fa583aa3037091aaed66793c65b423a264e5229f0d7e9a95513a795ac2bd4cb72cda7e89a74313f182c1e9ae0b0994fa @@ -20736,6 +24337,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.16.0": + version: 0.16.0 + resolution: "type-fest@npm:0.16.0" + checksum: 1a4102c06dc109db00418c753062e206cab65befd469d000ece4452ee649bf2a9cf57686d96fb42326bc9d918d9a194d4452897b486dcc41989e5c99e4e87094 + languageName: node + linkType: hard + "type-fest@npm:^0.20.2": version: 0.20.2 resolution: "type-fest@npm:0.20.2" @@ -20849,7 +24457,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.2.2": +"typescript@npm:^5.0.2, typescript@npm:^5.2.2": version: 5.3.3 resolution: "typescript@npm:5.3.3" bin: @@ -20879,7 +24487,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@^5.2.2#~builtin": +"typescript@patch:typescript@^5.0.2#~builtin, typescript@patch:typescript@^5.2.2#~builtin": version: 5.3.3 resolution: "typescript@patch:typescript@npm%3A5.3.3#~builtin::version=5.3.3&hash=85af82" bin: @@ -20971,6 +24579,15 @@ __metadata: languageName: node linkType: hard +"undici@npm:^5.22.1, undici@npm:^5.25.4": + version: 5.28.2 + resolution: "undici@npm:5.28.2" + dependencies: + "@fastify/busboy": ^2.0.0 + checksum: f9e9335803f962fff07c3c11c6d50bbc76248bacf97035047155adb29c3622a65bd6bff23a22218189740133149d22e63b68131d8c40e78ac6cb4b6d686a6dfa + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" @@ -21068,6 +24685,15 @@ __metadata: languageName: node linkType: hard +"unique-string@npm:^2.0.0": + version: 2.0.0 + resolution: "unique-string@npm:2.0.0" + dependencies: + crypto-random-string: ^2.0.0 + checksum: ef68f639136bcfe040cf7e3cd7a8dff076a665288122855148a6f7134092e6ed33bf83a7f3a9185e46c98dddc445a0da6ac25612afa1a7c38b8b654d6c02498e + languageName: node + linkType: hard + "unist-builder@npm:^3.0.0": version: 3.0.1 resolution: "unist-builder@npm:3.0.1" @@ -21254,6 +24880,13 @@ __metadata: languageName: node linkType: hard +"upath@npm:^1.2.0": + version: 1.2.0 + resolution: "upath@npm:1.2.0" + checksum: 4c05c094797cb733193a0784774dbea5b1889d502fc9f0572164177e185e4a59ba7099bf0b0adf945b232e2ac60363f9bf18aac9b2206fb99cbef971a8455445 + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.0.13": version: 1.0.13 resolution: "update-browserslist-db@npm:1.0.13" @@ -21329,6 +24962,18 @@ __metadata: languageName: node linkType: hard +"use-isomorphic-layout-effect@npm:^1.1.1": + version: 1.1.2 + resolution: "use-isomorphic-layout-effect@npm:1.1.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: a6532f7fc9ae222c3725ff0308aaf1f1ddbd3c00d685ef9eee6714fd0684de5cb9741b432fbf51e61a784e2955424864f7ea9f99734a02f237b17ad3e18ea5cb + languageName: node + linkType: hard + "use-sidecar@npm:^1.1.2": version: 1.1.2 resolution: "use-sidecar@npm:1.1.2" @@ -21391,6 +25036,25 @@ __metadata: languageName: node linkType: hard +"uuid-by-string@npm:^4.0.0": + version: 4.0.0 + resolution: "uuid-by-string@npm:4.0.0" + dependencies: + js-md5: ^0.7.3 + js-sha1: ^0.6.0 + checksum: eff002658f86e8b9eef6346aff91ae26ba4c8cd1192d42f96b08490af2856a70010e1a1e4553f80bbb89eee0b2e4016917e195197a0c698da85e55f1fbbe8bef + languageName: node + linkType: hard + +"uuid-readable@npm:^0.0.2": + version: 0.0.2 + resolution: "uuid-readable@npm:0.0.2" + dependencies: + uuid: ^8.3.0 + checksum: 402a1df6e41ed502c8c85836d2d71843501c1a13d629b3c1c4b3808857dafb3ea91131adfae7b4d558e4a77d09e02360ea92c7fa4b57c38bca694b76a8aeb12f + languageName: node + linkType: hard + "uuid@npm:^2.0.1": version: 2.0.3 resolution: "uuid@npm:2.0.3" @@ -21407,6 +25071,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^8.3.0, uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df + languageName: node + linkType: hard + "uuid@npm:^9.0.0": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -21455,7 +25128,7 @@ __metadata: languageName: node linkType: hard -"vary@npm:~1.1.2": +"vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b @@ -21624,6 +25297,23 @@ __metadata: languageName: node linkType: hard +"vite-plugin-pwa@npm:^0.17.0": + version: 0.17.4 + resolution: "vite-plugin-pwa@npm:0.17.4" + dependencies: + debug: ^4.3.4 + fast-glob: ^3.3.2 + pretty-bytes: ^6.1.1 + workbox-build: ^7.0.0 + workbox-window: ^7.0.0 + peerDependencies: + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 + workbox-build: ^7.0.0 + workbox-window: ^7.0.0 + checksum: aaa0247a435d0621b0c3078938156fde1a1abbd2cb0608968d79beec85f5348e50a2cc8e1bd75bee85f7b5385e20eaf388d4a710466252050f1d660b64245bec + languageName: node + linkType: hard + "vite@npm:^3.0.0 || ^4.0.0, vite@npm:^4.1.4, vite@npm:^4.3.4": version: 4.5.1 resolution: "vite@npm:4.5.1" @@ -21664,6 +25354,46 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.0.0": + version: 5.0.11 + resolution: "vite@npm:5.0.11" + dependencies: + esbuild: ^0.19.3 + fsevents: ~2.3.3 + postcss: ^8.4.32 + rollup: ^4.2.0 + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 262e41f25ce0cc5fc3c2065b1796f64ec115d3ac2d9625dbfb36d6628ba10e63684ef5515bb2ff1aa8e34c6f89e9c10e8211cb88f6c7f0da6869362851345437 + languageName: node + linkType: hard + "vm2@npm:^3.9.17": version: 3.9.19 resolution: "vm2@npm:3.9.19" @@ -21830,6 +25560,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^1.0.1": + version: 1.0.5 + resolution: "whatwg-encoding@npm:1.0.5" + dependencies: + iconv-lite: 0.4.24 + checksum: 5be4efe111dce29ddee3448d3915477fcc3b28f991d9cf1300b4e50d6d189010d47bca2f51140a844cf9b726e8f066f4aee72a04d687bfe4f2ee2767b2f5b1e6 + languageName: node + linkType: hard + "whatwg-encoding@npm:^2.0.0": version: 2.0.0 resolution: "whatwg-encoding@npm:2.0.0" @@ -21876,6 +25615,28 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^6.3.0": + version: 6.5.0 + resolution: "whatwg-url@npm:6.5.0" + dependencies: + lodash.sortby: ^4.7.0 + tr46: ^1.0.1 + webidl-conversions: ^4.0.2 + checksum: a10bd5e29f4382cd19789c2a7bbce25416e606b6fefc241c7fe34a2449de5bc5709c165bd13634eda433942d917ca7386a52841780b82dc37afa8141c31a8ebd + languageName: node + linkType: hard + +"whatwg-url@npm:^7.0.0": + version: 7.1.0 + resolution: "whatwg-url@npm:7.1.0" + dependencies: + lodash.sortby: ^4.7.0 + tr46: ^1.0.1 + webidl-conversions: ^4.0.2 + checksum: fecb07c87290b47d2ec2fb6d6ca26daad3c9e211e0e531dd7566e7ff95b5b3525a57d4f32640ad4adf057717e0c215731db842ad761e61d947e81010e05cf5fd + languageName: node + linkType: hard + "whatwg-url@npm:^8.4.0": version: 8.7.0 resolution: "whatwg-url@npm:8.7.0" @@ -22009,6 +25770,222 @@ __metadata: languageName: node linkType: hard +"workbox-background-sync@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-background-sync@npm:7.0.0" + dependencies: + idb: ^7.0.1 + workbox-core: 7.0.0 + checksum: 79b64416563761d36b91342d6ce2618d1c984bebcd511ce56b80098127e42c676d4831dd566a0a80a6bb52a618ad815b277ce6b310e4a5c5043e7394829d30c6 + languageName: node + linkType: hard + +"workbox-broadcast-update@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-broadcast-update@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: eee5c09fd78b3439348c7c92013f63700f14004d46161f19b0daf0d01303c6785f0953b746258cfb2627932108631370c8fa52ec5b526177cd528ae02530370e + languageName: node + linkType: hard + +"workbox-build@npm:^7.0.0": + version: 7.0.0 + resolution: "workbox-build@npm:7.0.0" + dependencies: + "@apideck/better-ajv-errors": ^0.3.1 + "@babel/core": ^7.11.1 + "@babel/preset-env": ^7.11.0 + "@babel/runtime": ^7.11.2 + "@rollup/plugin-babel": ^5.2.0 + "@rollup/plugin-node-resolve": ^11.2.1 + "@rollup/plugin-replace": ^2.4.1 + "@surma/rollup-plugin-off-main-thread": ^2.2.3 + ajv: ^8.6.0 + common-tags: ^1.8.0 + fast-json-stable-stringify: ^2.1.0 + fs-extra: ^9.0.1 + glob: ^7.1.6 + lodash: ^4.17.20 + pretty-bytes: ^5.3.0 + rollup: ^2.43.1 + rollup-plugin-terser: ^7.0.0 + source-map: ^0.8.0-beta.0 + stringify-object: ^3.3.0 + strip-comments: ^2.0.1 + tempy: ^0.6.0 + upath: ^1.2.0 + workbox-background-sync: 7.0.0 + workbox-broadcast-update: 7.0.0 + workbox-cacheable-response: 7.0.0 + workbox-core: 7.0.0 + workbox-expiration: 7.0.0 + workbox-google-analytics: 7.0.0 + workbox-navigation-preload: 7.0.0 + workbox-precaching: 7.0.0 + workbox-range-requests: 7.0.0 + workbox-recipes: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + workbox-streams: 7.0.0 + workbox-sw: 7.0.0 + workbox-window: 7.0.0 + checksum: f230463833a8b6d1beadbfb4db5526d1b6b047ffa23abcd2afdc306510e1f3f942a74d1c59c76ee371a326bb2fe616ced05d0c53aefee5902c68a3f31faa27dc + languageName: node + linkType: hard + +"workbox-cacheable-response@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-cacheable-response@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: c9d834b25564ee01dd4df17b1f27e61160a3b610f40c0e297a9973712878fe617e168e3b1541c7b70b0de3828cb4b62de3088424b4a2872ed5a106e7e777772f + languageName: node + linkType: hard + +"workbox-core@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-core@npm:7.0.0" + checksum: ca64872f9ce59ee1f3f32a5ecbde36377081a221930c6f925e2c0d7fe39d3fdc309166c430d56d972eba4f7c40d2e7e91a0020699a0745790fbef578ff8f34f6 + languageName: node + linkType: hard + +"workbox-expiration@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-expiration@npm:7.0.0" + dependencies: + idb: ^7.0.1 + workbox-core: 7.0.0 + checksum: 3d7cce573111bfb32f35d97ea95d5016ac42bdc0f3ab5096e5c0fd799dd466ccc3cbfdbdeab4e7158923ae3e406f2002add01e5c9369f9c3e2623e41bc04b324 + languageName: node + linkType: hard + +"workbox-google-analytics@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-google-analytics@npm:7.0.0" + dependencies: + workbox-background-sync: 7.0.0 + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + checksum: defb12c3f4cf924aef8c647724c32d1100042447aed20128702815eba0f6d55ba6dde6557036dc13d68c0ab0570188757136bd453823fe25f2fa541cb18b8e0c + languageName: node + linkType: hard + +"workbox-navigation-preload@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-navigation-preload@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: 329018003ce44812d37f1e168960abe34c7ac4b8cd1c8f86da172e73919fb51ba94a63db3b4024614066bf1ea38e1a89839eafd46eed9a13015dd4cf6fcd056c + languageName: node + linkType: hard + +"workbox-precaching@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-precaching@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + checksum: 311b1c4a162e976e0a41e36e6a96eb64fea381eda538d8a9ae962d4f39c5ba420617753aac44e19105de19aef5242c9c68a09226d144ca3cf62738fc9f491f5d + languageName: node + linkType: hard + +"workbox-range-requests@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-range-requests@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: 04f6d7921a8a4a024b0bf0049a592ebedcdd285a52d1b8714e0a53efc936339dac39c3a5b5b6db9a3356b9f3ed1876024403260ec426cf9dc65e3b7ba5464914 + languageName: node + linkType: hard + +"workbox-recipes@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-recipes@npm:7.0.0" + dependencies: + workbox-cacheable-response: 7.0.0 + workbox-core: 7.0.0 + workbox-expiration: 7.0.0 + workbox-precaching: 7.0.0 + workbox-routing: 7.0.0 + workbox-strategies: 7.0.0 + checksum: 253d50a315855917ca6683d6a3e910ac3c6f8915a8bcc80a7f15f277db7f48dc288c0ec2d9cdc64390bdd50446e66910246f384ce19f46688db97c715b323123 + languageName: node + linkType: hard + +"workbox-routing@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-routing@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: 9ea5b00fde5d90819e29ebf6d4aec3b84abec97854eb333c71b83548f1ba12b7f92d764a159f23cfa9e8164940e7b7136536fc0477784560cf2108d8dfe7f83b + languageName: node + linkType: hard + +"workbox-strategies@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-strategies@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + checksum: 4f20604e762fb43b32a16d60e014d14c0933300083c109a95251c06c65c25c9d78ab16bbe638b64435911d4a01ae5f7c28c7e78d611a122ee6453be2c42a87dc + languageName: node + linkType: hard + +"workbox-streams@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-streams@npm:7.0.0" + dependencies: + workbox-core: 7.0.0 + workbox-routing: 7.0.0 + checksum: e2975eb773bcf765c9cc8166936a9a2aaec2609fcddc178cbf6b2da54a113c4e2e62cbd257104861ea21b80c2a051936d62249f06d2414072405147f5181c0ef + languageName: node + linkType: hard + +"workbox-sw@npm:7.0.0": + version: 7.0.0 + resolution: "workbox-sw@npm:7.0.0" + checksum: f2673bc3f73ef5a54349eb7c4c63aefb7dfe6b6492947851ffa44079efdbfff07a26e68a0f7ea3801e03ab3fdc29acdc36cd315b9fbdb8a60963c7cb95f2de43 + languageName: node + linkType: hard + +"workbox-window@npm:7.0.0, workbox-window@npm:^7.0.0": + version: 7.0.0 + resolution: "workbox-window@npm:7.0.0" + dependencies: + "@types/trusted-types": ^2.0.2 + workbox-core: 7.0.0 + checksum: 486ceaf2c04953cd73fe04760929a9c42818b57fffbbaca3fc9065cfd6bf3f5a571d2ea78db177e548a98041c8752faa360dda8eaf0f10b8638ef3eb1b696b13 + languageName: node + linkType: hard + +"workerd@npm:1.20231030.0": + version: 1.20231030.0 + resolution: "workerd@npm:1.20231030.0" + dependencies: + "@cloudflare/workerd-darwin-64": 1.20231030.0 + "@cloudflare/workerd-darwin-arm64": 1.20231030.0 + "@cloudflare/workerd-linux-64": 1.20231030.0 + "@cloudflare/workerd-linux-arm64": 1.20231030.0 + "@cloudflare/workerd-windows-64": 1.20231030.0 + dependenciesMeta: + "@cloudflare/workerd-darwin-64": + optional: true + "@cloudflare/workerd-darwin-arm64": + optional: true + "@cloudflare/workerd-linux-64": + optional: true + "@cloudflare/workerd-linux-arm64": + optional: true + "@cloudflare/workerd-windows-64": + optional: true + bin: + workerd: bin/workerd + checksum: a0c7af094c05260ed07b1217fd8542789d39fef3e9b6ba4f97cc1902136c1d5a76f4870e2d04789570925761fc8ec65e7ddfbac53f35ec8c5f939e08d128f55b + languageName: node + linkType: hard + "workerpool@npm:6.2.0": version: 6.2.0 resolution: "workerpool@npm:6.2.0" @@ -22016,6 +25993,35 @@ __metadata: languageName: node linkType: hard +"wrangler@npm:3.16.0": + version: 3.16.0 + resolution: "wrangler@npm:3.16.0" + dependencies: + "@cloudflare/kv-asset-handler": ^0.2.0 + "@esbuild-plugins/node-globals-polyfill": ^0.2.3 + "@esbuild-plugins/node-modules-polyfill": ^0.2.2 + blake3-wasm: ^2.1.5 + chokidar: ^3.5.3 + esbuild: 0.17.19 + fsevents: ~2.3.2 + miniflare: 3.20231030.0 + nanoid: ^3.3.3 + path-to-regexp: ^6.2.0 + resolve.exports: ^2.0.2 + selfsigned: ^2.0.1 + source-map: 0.6.1 + source-map-support: 0.5.21 + xxhash-wasm: ^1.0.1 + dependenciesMeta: + fsevents: + optional: true + bin: + wrangler: bin/wrangler.js + wrangler2: bin/wrangler.js + checksum: 2b892b56ab18f4f0e2826c517e3785b959a851ba505edcc7ebe5d535460a52a277b078394e4be99e416aed14a29819ba6039a226ffecef2b0b2b8ce666707be3 + languageName: node + linkType: hard + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" @@ -22038,7 +26044,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^6.0.1": +"wrap-ansi@npm:^6.0.1, wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" dependencies: @@ -22103,7 +26109,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.14.2, ws@npm:^8.2.3": +"ws@npm:^8.11.0, ws@npm:^8.13.0, ws@npm:^8.14.2, ws@npm:^8.16.0, ws@npm:^8.2.3": version: 8.16.0 resolution: "ws@npm:8.16.0" peerDependencies: @@ -22151,6 +26157,13 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^2.0.1": + version: 2.0.1 + resolution: "xml-name-validator@npm:2.0.1" + checksum: 648e8950d5abca736d2e77f016bdec06b6a27d8b7c2616590f7e726267c9315611bb2d909d7fd34d55bd88ac6ec0f3b5bfb1c1d4510f3fb19a7397eee6c7e66a + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0" @@ -22203,6 +26216,13 @@ __metadata: languageName: node linkType: hard +"xxhash-wasm@npm:^1.0.1": + version: 1.0.2 + resolution: "xxhash-wasm@npm:1.0.2" + checksum: 11fec6e6196e37ad96cc958b7a4477dc30caf5b4da889a02a84f6f663ab8cd3c9be6ae405e66f0af0404301f27c39375191c5254f0409a793020e2093afd1409 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3" @@ -22276,6 +26296,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: ^5.0.0 + decamelize: ^1.2.0 + checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9 + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -22335,6 +26365,25 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^15.3.1": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: ^6.0.0 + decamelize: ^1.2.0 + find-up: ^4.1.0 + get-caller-file: ^2.0.1 + require-directory: ^2.1.1 + require-main-filename: ^2.0.0 + set-blocking: ^2.0.0 + string-width: ^4.2.0 + which-module: ^2.0.0 + y18n: ^4.0.0 + yargs-parser: ^18.1.2 + checksum: 40b974f508d8aed28598087720e086ecd32a5fd3e945e95ea4457da04ee9bdb8bdd17fd91acff36dc5b7f0595a735929c514c40c402416bbb87c03f6fb782373 + languageName: node + linkType: hard + "yargs@npm:^17.3.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" @@ -22383,6 +26432,17 @@ __metadata: languageName: node linkType: hard +"youch@npm:^3.2.2": + version: 3.3.3 + resolution: "youch@npm:3.3.3" + dependencies: + cookie: ^0.5.0 + mustache: ^4.2.0 + stacktracey: ^2.1.8 + checksum: 2b099d8f7b7579ef9d226023037d06c3c03ed6c663e03966d034505e11c026b7ea1359052d5402ded439a9e762cf72449b0db4170e6982ce8be24bbbda4e6b95 + languageName: node + linkType: hard + "z-schema@npm:~5.0.2": version: 5.0.5 resolution: "z-schema@npm:5.0.5" @@ -22409,7 +26469,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.21.4": +"zod@npm:^3.20.6, zod@npm:^3.21.4": version: 3.22.4 resolution: "zod@npm:3.22.4" checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f