Readonly / room creation omnibus (#3192)
Reworks how the readonly urls work. Till now we just used a simple function that would scramble the slugs. Now we use a proper key value mapping between regular and readonly slugs: - We use two KV stores. One is for going from a slug to a readonly slug and the other one for going the other way around. They are populated at the same time. - We separate preview KV stores (dev, preview, staging) from production one. I've already created these on Cloudflare. [My understanding is ](https://developers.cloudflare.com/kv/reference/data-security/#encryption-at-rest)that ids [can be public](https://community.cloudflare.com/t/is-it-safe-to-keep-kv-ids-in-a-public-git-repo/517387/4) since we can only access KV from our worker. Happy to move them to env variables though. - [x] Disable creating new rooms when tldraw is embedded inside iframes on other websites (we check the referrer and if it's not the same as the iframe's origin we don't allow it) - [x] Fork a project when inside an iframe now opens the forked project on tldraw.com and not inside iframe. - [x] We allow embeding of iframes, but we now track the where they are used via the referrer. We send this to Vercel analytics. - [x] Improved UX of the share menu to make it less confusing. Toggle is gone. - [x] `/new` and `/r` routes not redirect to `/`. - [x] This introduces a new `/ro` route for readonly rooms. Legacy rooms still live on `/v`. - [x] Brought back `dotcom-shared` project to share code between BE and FE. Mostly types. - [x] Prevent creating of rooms by entering `/r/non-existing-slug`. - [x] Handle getting a readonly slug for old rooms. Added a comment about it [here](https://github.com/tldraw/tldraw/pull/3192/files#diff-c0954b3dc71bb7097c39656441175f3238ed60cf5cee64077c06e21da82182cbR17-R18). - [x] We no longer expose editor on the window object for readonly rooms. Prevents the users disabling readonly rooms manually. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [ ] `sdk` — Changes the tldraw SDK - [x] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Make sure old readonly rooms still work. 2. Creating a readonly link from an existing room should still use `/v` path. 3. Newly created rooms should use `/ro` path for readonly rooms. Make sure these work as well. 4. `/r` room was disabled and redirects to `/` 5. `/new` should still work when not inside iframes. - [x] Unit Tests - [ ] End to end tests ### Release Notes 1. This adds new functionality for readonly rooms: - We have a new route `/ro` for newly created readonly rooms. These rooms no longer use the scrambling logic to create readonly slugs. Instead we now use KV storage from cloudflare to track the mapping for slugs -> readonly slug and readonly slug -> slug. - The old route `/v` is preserved, so that the old room still work as they did before. - For old rooms we will keep on generating the old readonly slugs, but for new rooms we'll start using the new logic. 2. We no longer prevent embedding of tldraw inside iframes. 3. We do prevent generating new rooms from inside the iframes though. `/r`, `/new`, `/r/non-existing-id` should not allow creation of new rooms inside iframes. Only `/new` still works when not inside iframes. 4. Forking a project from inside an iframe now opens it on tldraw.com 5. Slight copy change on the sharing menu. We no longer have a toggle between readonly and non-readonly links. 6. `editor` and `app` are no longer exposed on the window object for readonly rooms. Prevents users from using the `updateInstanceState` to escape readonly rooms. --------- Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
This commit is contained in:
parent
4c5abe888c
commit
15dd56a75e
54 changed files with 30236 additions and 227 deletions
|
@ -23,6 +23,7 @@
|
|||
"dependencies": {
|
||||
"@supabase/auth-helpers-remix": "^0.2.2",
|
||||
"@supabase/supabase-js": "^2.33.2",
|
||||
"@tldraw/dotcom-shared": "workspace:*",
|
||||
"@tldraw/store": "workspace:*",
|
||||
"@tldraw/tlschema": "workspace:*",
|
||||
"@tldraw/tlsync": "workspace:*",
|
||||
|
|
|
@ -2,8 +2,11 @@
|
|||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ROOM_OPEN_MODE, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import {
|
||||
DBLoadResultType,
|
||||
RoomSnapshot,
|
||||
TLCloseEventCode,
|
||||
TLServer,
|
||||
TLServerEvent,
|
||||
TLSyncRoom,
|
||||
|
@ -19,6 +22,7 @@ import { PERSIST_INTERVAL_MS } from './config'
|
|||
import { getR2KeyForRoom } from './r2'
|
||||
import { Analytics, Environment } from './types'
|
||||
import { createSupabaseClient } from './utils/createSupabaseClient'
|
||||
import { getSlug } from './utils/roomOpenMode'
|
||||
import { throttle } from './utils/throttle'
|
||||
|
||||
const MAX_CONNECTIONS = 50
|
||||
|
@ -88,12 +92,22 @@ export class TLDrawDurableObject extends TLServer {
|
|||
readonly router = Router()
|
||||
.get(
|
||||
'/r/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.get(
|
||||
'/v/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.get(
|
||||
'/ro/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.post(
|
||||
'/r/:roomId/restore',
|
||||
(req) => this.extractDocumentInfoFromRequest(req),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
|
||||
(req) => this.onRestore(req)
|
||||
)
|
||||
.all('*', () => new Response('Not found', { status: 404 }))
|
||||
|
@ -113,8 +127,11 @@ export class TLDrawDurableObject extends TLServer {
|
|||
get documentInfo() {
|
||||
return assertExists(this._documentInfo, 'documentInfo must be present')
|
||||
}
|
||||
extractDocumentInfoFromRequest = async (req: IRequest) => {
|
||||
const slug = assertExists(req.params.roomId, 'roomId must be present')
|
||||
extractDocumentInfoFromRequest = async (req: IRequest, roomOpenMode: RoomOpenMode) => {
|
||||
const slug = assertExists(
|
||||
await getSlug(this.env, req.params.roomId, roomOpenMode),
|
||||
'roomId must be present'
|
||||
)
|
||||
if (this._documentInfo) {
|
||||
assert(this._documentInfo.slug === slug, 'slug must match')
|
||||
} else {
|
||||
|
@ -226,9 +243,10 @@ export class TLDrawDurableObject extends TLServer {
|
|||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||
|
||||
// Handle the connection (see TLServer)
|
||||
let connectionResult: DBLoadResultType
|
||||
try {
|
||||
// block concurrency while initializing the room if that needs to happen
|
||||
await this.controller.blockConcurrencyWhile(() =>
|
||||
connectionResult = await this.controller.blockConcurrencyWhile(() =>
|
||||
this.handleConnection({
|
||||
socket: serverWebSocket as any,
|
||||
persistenceKey: this.documentInfo.slug!,
|
||||
|
@ -253,6 +271,12 @@ export class TLDrawDurableObject extends TLServer {
|
|||
this.schedulePersist()
|
||||
})
|
||||
|
||||
if (connectionResult === 'room_not_found') {
|
||||
// If the room is not found, we need to accept and then immediately close the connection
|
||||
// with our custom close code.
|
||||
serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found')
|
||||
}
|
||||
|
||||
return new Response(null, { status: 101, webSocket: clientWebSocket })
|
||||
}
|
||||
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot, schema } from '@tldraw/tlsync'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type SnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
import { isAllowedOrigin } from '../worker'
|
||||
|
||||
// Sets up a new room based on a provided snapshot, e.g. when a user clicks the "Share" buttons or the "Fork project" buttons.
|
||||
export async function createRoom(request: IRequest, env: Environment): Promise<Response> {
|
||||
// The data sent from the client will include the data for the new room
|
||||
const data = (await request.json()) as SnapshotRequestBody
|
||||
const data = (await request.json()) as CreateRoomRequestBody
|
||||
if (!isAllowedOrigin(data.origin)) {
|
||||
return Response.json({ error: true, message: 'Not allowed' }, { status: 406 })
|
||||
}
|
||||
|
||||
// There's a chance the data will be invalid, so we check it first
|
||||
const snapshotResult = validateSnapshot(data)
|
||||
const snapshotResult = validateSnapshot(data.snapshot)
|
||||
if (!snapshotResult.ok) {
|
||||
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
|
||||
}
|
||||
|
@ -40,6 +38,11 @@ export async function createRoom(request: IRequest, env: Environment): Promise<R
|
|||
// Bang that snapshot into the database
|
||||
await env.ROOMS.put(getR2KeyForRoom(slug), JSON.stringify(snapshot))
|
||||
|
||||
// Create a readonly slug and store it
|
||||
const readonlySlug = nanoid()
|
||||
await env.SLUG_TO_READONLY_SLUG.put(slug, readonlySlug)
|
||||
await env.READONLY_SLUG_TO_SLUG.put(readonlySlug, slug)
|
||||
|
||||
// Send back the slug so that the client can redirect to the new room
|
||||
return new Response(JSON.stringify({ error: false, slug }))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Environment } from '../types'
|
||||
|
@ -7,12 +6,6 @@ import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseCl
|
|||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
||||
const data = (await request.json()) as CreateSnapshotRequestBody
|
||||
|
||||
|
|
30
apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts
Normal file
30
apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { lns } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
|
||||
// Return a URL to a readonly version of the room
|
||||
export async function getReadonlySlug(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
if (!roomId) {
|
||||
return new Response('Bad request', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId)
|
||||
let isLegacy = false
|
||||
|
||||
if (!slug) {
|
||||
// For all newly created rooms we add the readonly slug to the KV store.
|
||||
// If it does not exist there it means we are trying to get a slug for an old room.
|
||||
slug = lns(roomId)
|
||||
isLegacy = true
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
slug,
|
||||
isLegacy,
|
||||
} satisfies GetReadonlySlugResponseBody)
|
||||
)
|
||||
}
|
|
@ -1,11 +1,16 @@
|
|||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { Environment } from '../types'
|
||||
import { fourOhFour } from '../utils/fourOhFour'
|
||||
import { isRoomIdTooLong, roomIdIsTooLong } from '../utils/roomIdIsTooLong'
|
||||
import { getSlug } from '../utils/roomOpenMode'
|
||||
|
||||
// This is the entry point for joining an existing room
|
||||
export async function joinExistingRoom(request: IRequest, env: Environment): Promise<Response> {
|
||||
const roomId = request.params.roomId
|
||||
export async function joinExistingRoom(
|
||||
request: IRequest,
|
||||
env: Environment,
|
||||
roomOpenMode: RoomOpenMode
|
||||
): Promise<Response> {
|
||||
const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
|
||||
if (!roomId) return fourOhFour()
|
||||
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
|
||||
|
||||
|
|
|
@ -17,6 +17,9 @@ export interface Environment {
|
|||
ROOMS: R2Bucket
|
||||
ROOMS_HISTORY_EPHEMERAL: R2Bucket
|
||||
|
||||
SLUG_TO_READONLY_SLUG: KVNamespace
|
||||
READONLY_SLUG_TO_SLUG: KVNamespace
|
||||
|
||||
// env vars
|
||||
SUPABASE_URL: string | undefined
|
||||
SUPABASE_KEY: string | undefined
|
||||
|
|
17
apps/dotcom-worker/src/lib/utils/roomOpenMode.ts
Normal file
17
apps/dotcom-worker/src/lib/utils/roomOpenMode.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { ROOM_OPEN_MODE, RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { exhaustiveSwitchError, lns } from '@tldraw/utils'
|
||||
import { Environment } from '../types'
|
||||
|
||||
export async function getSlug(env: Environment, slug: string | null, roomOpenMode: RoomOpenMode) {
|
||||
if (!slug) return null
|
||||
switch (roomOpenMode) {
|
||||
case ROOM_OPEN_MODE.READ_WRITE:
|
||||
return slug
|
||||
case ROOM_OPEN_MODE.READ_ONLY:
|
||||
return await env.READONLY_SLUG_TO_SLUG.get(slug)
|
||||
case ROOM_OPEN_MODE.READ_ONLY_LEGACY:
|
||||
return lns(slug)
|
||||
default:
|
||||
exhaustiveSwitchError(roomOpenMode)
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
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 { getReadonlySlug } from './routes/getReadonlySlug'
|
||||
import { getRoomHistory } from './routes/getRoomHistory'
|
||||
import { getRoomHistorySnapshot } from './routes/getRoomHistorySnapshot'
|
||||
import { getRoomSnapshot } from './routes/getRoomSnapshot'
|
||||
|
@ -24,9 +26,12 @@ const router = Router()
|
|||
.post('/new-room', createRoom)
|
||||
.post('/snapshots', createRoomSnapshot)
|
||||
.get('/snapshot/:roomId', getRoomSnapshot)
|
||||
.get('/r/:roomId', joinExistingRoom)
|
||||
.get('/r/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_WRITE))
|
||||
.get('/v/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY_LEGACY))
|
||||
.get('/ro/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY))
|
||||
.get('/r/:roomId/history', getRoomHistory)
|
||||
.get('/r/:roomId/history/:timestamp', getRoomHistorySnapshot)
|
||||
.get('/readonly-slug/:roomId', getReadonlySlug)
|
||||
.post('/r/:roomId/restore', forwardRoomRequest)
|
||||
.all('*', fourOhFour)
|
||||
|
||||
|
@ -70,7 +75,7 @@ const Worker = {
|
|||
},
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin: string) {
|
||||
export 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
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
"emitDeclarationOnly": false
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/dotcom-shared"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/store"
|
||||
},
|
||||
|
|
|
@ -114,3 +114,36 @@ bucket_name = "rooms-history-ephemeral-preview"
|
|||
[[env.production.r2_buckets]]
|
||||
binding = "ROOMS_HISTORY_EPHEMERAL"
|
||||
bucket_name = "rooms-history-ephemeral"
|
||||
|
||||
#################### Key value storage ####################
|
||||
[[env.dev.kv_namespaces]]
|
||||
binding = "SLUG_TO_READONLY_SLUG"
|
||||
id = "847a6bded62045c6808dda6a275ef96c"
|
||||
|
||||
[[env.dev.kv_namespaces]]
|
||||
binding = "READONLY_SLUG_TO_SLUG"
|
||||
id = "0a83acab40374ccd918cc9d755741714"
|
||||
|
||||
[[env.preview.kv_namespaces]]
|
||||
binding = "SLUG_TO_READONLY_SLUG"
|
||||
id = "847a6bded62045c6808dda6a275ef96c"
|
||||
|
||||
[[env.preview.kv_namespaces]]
|
||||
binding = "READONLY_SLUG_TO_SLUG"
|
||||
id = "0a83acab40374ccd918cc9d755741714"
|
||||
|
||||
[[env.staging.kv_namespaces]]
|
||||
binding = "SLUG_TO_READONLY_SLUG"
|
||||
id = "847a6bded62045c6808dda6a275ef96c"
|
||||
|
||||
[[env.staging.kv_namespaces]]
|
||||
binding = "READONLY_SLUG_TO_SLUG"
|
||||
id = "0a83acab40374ccd918cc9d755741714"
|
||||
|
||||
[[env.production.kv_namespaces]]
|
||||
binding = "SLUG_TO_READONLY_SLUG"
|
||||
id = "2fb5fc7f7ca54a5a9dfae1b07a30a778"
|
||||
|
||||
[[env.production.kv_namespaces]]
|
||||
binding = "READONLY_SLUG_TO_SLUG"
|
||||
id = "96be6637b281412ab35b2544539d78e8"
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
"@sentry/integrations": "^7.34.0",
|
||||
"@sentry/react": "^7.77.0",
|
||||
"@tldraw/assets": "workspace:*",
|
||||
"@tldraw/dotcom-shared": "workspace:*",
|
||||
"@tldraw/tlsync": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
|
|
|
@ -8,6 +8,7 @@ import json5 from 'json5'
|
|||
import { nicelog } from '../../../scripts/lib/nicelog'
|
||||
|
||||
import { T } from '@tldraw/validate'
|
||||
import { getMultiplayerServerURL } from '../vite.config'
|
||||
|
||||
// We load the list of routes that should be forwarded to our SPA's index.html here.
|
||||
// It uses a jest snapshot file because deriving the set of routes from our
|
||||
|
@ -56,9 +57,7 @@ async function build() {
|
|||
// rewrite api calls to the multiplayer server
|
||||
{
|
||||
src: '^/api(/(.*))?$',
|
||||
dest: `${
|
||||
process.env.MULTIPLAYER_SERVER?.replace(/^ws/, 'http') ?? 'http://127.0.0.1:8787'
|
||||
}$1`,
|
||||
dest: `${getMultiplayerServerURL()}$1`,
|
||||
check: true,
|
||||
},
|
||||
// cache static assets immutably
|
||||
|
|
|
@ -26,6 +26,10 @@ exports[`the_routes 1`] = `
|
|||
"reactRouterPattern": "/r/:roomId",
|
||||
"vercelRouterPattern": "^/r/[^/]*/?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/ro/:roomId",
|
||||
"vercelRouterPattern": "^/ro/[^/]*/?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/s/:roomId",
|
||||
"vercelRouterPattern": "^/s/[^/]*/?$",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { isInIframe } from '../../utils/iFrame'
|
||||
|
||||
export function ErrorPage({
|
||||
icon,
|
||||
|
@ -6,8 +7,8 @@ export function ErrorPage({
|
|||
}: {
|
||||
icon?: boolean
|
||||
messages: { header: string; para1: string; para2?: string }
|
||||
redirectTo?: string
|
||||
}) {
|
||||
const inIframe = isInIframe()
|
||||
return (
|
||||
<div className="error-page">
|
||||
<div className="error-page__container">
|
||||
|
@ -19,8 +20,8 @@ export function ErrorPage({
|
|||
<p>{messages.para1}</p>
|
||||
{messages.para2 && <p>{messages.para2}</p>}
|
||||
</div>
|
||||
<Link to={'/'}>
|
||||
<a>Take me home.</a>
|
||||
<Link to={'/'} target={inIframe ? '_blank' : '_self'}>
|
||||
{inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,12 +2,13 @@ import { ReactNode, useEffect, useState } from 'react'
|
|||
import { LoadingScreen } from 'tldraw'
|
||||
import { version } from '../../version'
|
||||
import { useUrl } from '../hooks/useUrl'
|
||||
import { getParentOrigin, isInIframe } from '../utils/iFrame'
|
||||
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
|
||||
|
||||
/*
|
||||
If we're in an iframe, we need to figure out whether we're on a whitelisted host (e.g. tldraw itself)
|
||||
or a not-allowed host (e.g. someone else's website). Some websites embed tldraw in iframes and this is kinda
|
||||
risky for us and for them, too—and hey, if we decide to offer a hosted thing, then that's another stor
|
||||
risky for us and for them, too—and hey, if we decide to offer a hosted thing, then that's another story.
|
||||
|
||||
Figuring this out is a little tricky because the same code here is going to run on:
|
||||
- the website as a top window (tldraw-top)
|
||||
|
@ -26,33 +27,45 @@ and we should show an annoying messsage.
|
|||
If we're not in an iframe, we don't need to do anything.
|
||||
*/
|
||||
|
||||
export const ROOM_CONTEXT = {
|
||||
PUBLIC_MULTIPLAYER: 'public-multiplayer',
|
||||
PUBLIC_READONLY: 'public-readonly',
|
||||
PUBLIC_SNAPSHOT: 'public-snapshot',
|
||||
HISTORY_SNAPSHOT: 'history-snapshot',
|
||||
HISTORY: 'history',
|
||||
LOCAL: 'local',
|
||||
} as const
|
||||
type $ROOM_CONTEXT = (typeof ROOM_CONTEXT)[keyof typeof ROOM_CONTEXT]
|
||||
|
||||
const EMBEDDED_STATE = {
|
||||
IFRAME_UNKNOWN: 'iframe-unknown',
|
||||
IFRAME_NOT_ALLOWED: 'iframe-not-allowed',
|
||||
NOT_IFRAME: 'not-iframe',
|
||||
IFRAME_OK: 'iframe-ok',
|
||||
} as const
|
||||
type $EMBEDDED_STATE = (typeof EMBEDDED_STATE)[keyof typeof EMBEDDED_STATE]
|
||||
|
||||
// Which routes do we allow to be embedded in tldraw.com itself?
|
||||
const WHITELIST_CONTEXT = ['public-multiplayer', 'public-readonly', 'public-snapshot']
|
||||
const WHITELIST_CONTEXT: $ROOM_CONTEXT[] = [
|
||||
ROOM_CONTEXT.PUBLIC_MULTIPLAYER,
|
||||
ROOM_CONTEXT.PUBLIC_READONLY,
|
||||
ROOM_CONTEXT.PUBLIC_SNAPSHOT,
|
||||
]
|
||||
const EXPECTED_QUESTION = 'are we cool?'
|
||||
const EXPECTED_RESPONSE = 'yes' + version
|
||||
|
||||
const isInIframe = () => {
|
||||
return typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
|
||||
}
|
||||
|
||||
export function IFrameProtector({
|
||||
slug,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
slug: string
|
||||
context:
|
||||
| 'public-multiplayer'
|
||||
| 'public-readonly'
|
||||
| 'public-snapshot'
|
||||
| 'history-snapshot'
|
||||
| 'history'
|
||||
| 'local'
|
||||
context: $ROOM_CONTEXT
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [embeddedState, setEmbeddedState] = useState<
|
||||
'iframe-unknown' | 'iframe-not-allowed' | 'not-iframe' | 'iframe-ok'
|
||||
>(isInIframe() ? 'iframe-unknown' : 'not-iframe')
|
||||
const [embeddedState, setEmbeddedState] = useState<$EMBEDDED_STATE>(
|
||||
isInIframe() ? EMBEDDED_STATE.IFRAME_UNKNOWN : EMBEDDED_STATE.NOT_IFRAME
|
||||
)
|
||||
|
||||
const url = useUrl()
|
||||
|
||||
|
@ -76,24 +89,28 @@ export function IFrameProtector({
|
|||
|
||||
if (event.data === EXPECTED_RESPONSE) {
|
||||
// todo: check the origin?
|
||||
setEmbeddedState('iframe-ok')
|
||||
setEmbeddedState(EMBEDDED_STATE.IFRAME_OK)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessageEvent, false)
|
||||
|
||||
if (embeddedState === 'iframe-unknown') {
|
||||
if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) {
|
||||
// We iframe embeddings on multiplayer or readonly
|
||||
if (WHITELIST_CONTEXT.includes(context)) {
|
||||
window.parent.postMessage(EXPECTED_QUESTION, '*') // todo: send to a specific origin?
|
||||
timeout = setTimeout(() => {
|
||||
setEmbeddedState('iframe-not-allowed')
|
||||
trackAnalyticsEvent('connect_to_room_in_iframe', { slug, context })
|
||||
setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED)
|
||||
trackAnalyticsEvent('connect_to_room_in_iframe', {
|
||||
slug,
|
||||
context,
|
||||
origin: getParentOrigin(),
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
// We don't allow iframe embeddings on other routes
|
||||
setEmbeddedState('iframe-not-allowed')
|
||||
setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,12 +120,12 @@ export function IFrameProtector({
|
|||
}
|
||||
}, [embeddedState, slug, context])
|
||||
|
||||
if (embeddedState === 'iframe-unknown') {
|
||||
if (embeddedState === EMBEDDED_STATE.IFRAME_UNKNOWN) {
|
||||
// We're in an iframe, but we don't know if it's a tldraw iframe
|
||||
return <LoadingScreen>Loading in an iframe...</LoadingScreen>
|
||||
return <LoadingScreen>Loading in an iframe…</LoadingScreen>
|
||||
}
|
||||
|
||||
if (embeddedState === 'iframe-not-allowed') {
|
||||
if (embeddedState === EMBEDDED_STATE.IFRAME_NOT_ALLOWED) {
|
||||
// We're in an iframe and its not one of ours
|
||||
return (
|
||||
<div className="tldraw__editor tl-container">
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import {
|
||||
DefaultContextMenu,
|
||||
|
@ -18,7 +19,6 @@ import {
|
|||
TldrawUiMenuItem,
|
||||
ViewSubmenu,
|
||||
atom,
|
||||
lns,
|
||||
useActions,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
|
@ -104,19 +104,17 @@ const components: TLComponents = {
|
|||
}
|
||||
|
||||
export function MultiplayerEditor({
|
||||
isReadOnly,
|
||||
roomOpenMode,
|
||||
roomSlug,
|
||||
}: {
|
||||
isReadOnly: boolean
|
||||
roomOpenMode: RoomOpenMode
|
||||
roomSlug: string
|
||||
}) {
|
||||
const handleUiEvent = useHandleUiEvents()
|
||||
|
||||
const roomId = isReadOnly ? lns(roomSlug) : roomSlug
|
||||
|
||||
const storeWithStatus = useRemoteSyncClient({
|
||||
uri: `${MULTIPLAYER_SERVER}/r/${roomId}`,
|
||||
roomId,
|
||||
uri: `${MULTIPLAYER_SERVER}/${RoomOpenModeToPath[roomOpenMode]}/${roomSlug}`,
|
||||
roomId: roomSlug,
|
||||
})
|
||||
|
||||
const isOffline =
|
||||
|
@ -128,16 +126,22 @@ export function MultiplayerEditor({
|
|||
const sharingUiOverrides = useSharing()
|
||||
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
|
||||
const cursorChatOverrides = useCursorChat()
|
||||
const isReadonly =
|
||||
roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY
|
||||
|
||||
const handleMount = useCallback(
|
||||
(editor: Editor) => {
|
||||
;(window as any).app = editor
|
||||
;(window as any).editor = editor
|
||||
editor.updateInstanceState({ isReadonly: isReadOnly })
|
||||
if (!isReadonly) {
|
||||
;(window as any).app = editor
|
||||
;(window as any).editor = editor
|
||||
}
|
||||
editor.updateInstanceState({
|
||||
isReadonly,
|
||||
})
|
||||
editor.registerExternalAssetHandler('file', createAssetFromFile)
|
||||
editor.registerExternalAssetHandler('url', createAssetFromUrl)
|
||||
},
|
||||
[isReadOnly]
|
||||
[isReadonly]
|
||||
)
|
||||
|
||||
if (storeWithStatus.error) {
|
||||
|
@ -151,7 +155,7 @@ export function MultiplayerEditor({
|
|||
assetUrls={assetUrls}
|
||||
onMount={handleMount}
|
||||
overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]}
|
||||
initialState={isReadOnly ? 'hand' : 'select'}
|
||||
initialState={isReadonly ? 'hand' : 'select'}
|
||||
onUiEvent={handleUiEvent}
|
||||
components={components}
|
||||
autoFocus
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import * as Popover from '@radix-ui/react-popover'
|
||||
import {
|
||||
GetReadonlySlugResponseBody,
|
||||
ROOM_OPEN_MODE,
|
||||
RoomOpenModeToPath,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
TldrawUiMenuContextProvider,
|
||||
|
@ -15,29 +20,71 @@ import { createQRCodeImageDataString } from '../utils/qrcode'
|
|||
import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing'
|
||||
import { ShareButton } from './ShareButton'
|
||||
|
||||
const SHARE_CURRENT_STATE = {
|
||||
OFFLINE: 'offline',
|
||||
SHARED_READ_WRITE: 'shared-read-write',
|
||||
SHARED_READ_ONLY: 'shared-read-only',
|
||||
} as const
|
||||
type ShareCurrentState = (typeof SHARE_CURRENT_STATE)[keyof typeof SHARE_CURRENT_STATE]
|
||||
|
||||
type ShareState = {
|
||||
state: 'offline' | 'shared' | 'readonly'
|
||||
state: ShareCurrentState
|
||||
qrCodeDataUrl: string
|
||||
url: string
|
||||
readonlyUrl: string
|
||||
readonlyUrl: string | null
|
||||
readonlyQrCodeDataUrl: string
|
||||
}
|
||||
|
||||
function isSharedReadonlyUrl(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY]}/`) ||
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY_LEGACY]}/`)
|
||||
)
|
||||
}
|
||||
|
||||
function isSharedReadWriteUrl(pathname: string) {
|
||||
return pathname.startsWith('/r/')
|
||||
}
|
||||
|
||||
function getFreshShareState(): ShareState {
|
||||
const isShared = window.location.href.includes('/r/')
|
||||
const isReadOnly = window.location.href.includes('/v/')
|
||||
const isSharedReadWrite = isSharedReadWriteUrl(window.location.pathname)
|
||||
const isSharedReadOnly = isSharedReadonlyUrl(window.location.pathname)
|
||||
|
||||
return {
|
||||
state: isShared ? 'shared' : isReadOnly ? 'readonly' : 'offline',
|
||||
state: isSharedReadWrite
|
||||
? SHARE_CURRENT_STATE.SHARED_READ_WRITE
|
||||
: isSharedReadOnly
|
||||
? SHARE_CURRENT_STATE.SHARED_READ_ONLY
|
||||
: SHARE_CURRENT_STATE.OFFLINE,
|
||||
url: window.location.href,
|
||||
readonlyUrl: window.location.href.includes('/r/')
|
||||
? getShareUrl(window.location.href, true)
|
||||
: window.location.href,
|
||||
readonlyUrl: isSharedReadOnly ? window.location.href : null,
|
||||
qrCodeDataUrl: '',
|
||||
readonlyQrCodeDataUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function getReadonlyUrl() {
|
||||
const pathname = window.location.pathname
|
||||
const isReadOnly = isSharedReadonlyUrl(pathname)
|
||||
if (isReadOnly) return window.location.href
|
||||
|
||||
const segments = pathname.split('/')
|
||||
|
||||
const roomId = segments[2]
|
||||
const result = await fetch(`/api/readonly-slug/${roomId}`)
|
||||
if (!result.ok) return
|
||||
|
||||
const data = (await result.json()) as GetReadonlySlugResponseBody
|
||||
if (!data.slug) return
|
||||
|
||||
segments[1] =
|
||||
RoomOpenModeToPath[data.isLegacy ? ROOM_OPEN_MODE.READ_ONLY_LEGACY : ROOM_OPEN_MODE.READ_ONLY]
|
||||
segments[2] = data.slug
|
||||
const newPathname = segments.join('/')
|
||||
|
||||
return `${window.location.origin}${newPathname}${window.location.search}`
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const ShareMenu = React.memo(function ShareMenu() {
|
||||
const msg = useTranslation()
|
||||
|
@ -50,25 +97,24 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)
|
||||
const [isReadOnlyLink, setIsReadOnlyLink] = useState(shareState.state === 'readonly')
|
||||
const isReadOnlyLink = shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY
|
||||
const currentShareLinkUrl = isReadOnlyLink ? shareState.readonlyUrl : shareState.url
|
||||
const currentQrCodeUrl = isReadOnlyLink
|
||||
? shareState.readonlyQrCodeDataUrl
|
||||
: shareState.qrCodeDataUrl
|
||||
const [didCopy, setDidCopy] = useState(false)
|
||||
const [didCopyReadonlyLink, setDidCopyReadonlyLink] = useState(false)
|
||||
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shareState.state === 'offline') {
|
||||
if (shareState.state === SHARE_CURRENT_STATE.OFFLINE) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const shareUrl = getShareUrl(window.location.href, false)
|
||||
const readonlyShareUrl = getShareUrl(window.location.href, true)
|
||||
|
||||
if (!shareState.qrCodeDataUrl && shareState.state === 'shared') {
|
||||
if (!shareState.qrCodeDataUrl && shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE) {
|
||||
// Fetch the QR code data URL
|
||||
createQRCodeImageDataString(shareUrl).then((dataUrl) => {
|
||||
if (!cancelled) {
|
||||
|
@ -77,14 +123,16 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
})
|
||||
}
|
||||
|
||||
if (!shareState.readonlyQrCodeDataUrl) {
|
||||
// fetch the readonly QR code data URL
|
||||
createQRCodeImageDataString(readonlyShareUrl).then((dataUrl) => {
|
||||
if (!cancelled) {
|
||||
setShareState((s) => ({ ...s, readonlyShareUrl, readonlyQrCodeDataUrl: dataUrl }))
|
||||
}
|
||||
})
|
||||
}
|
||||
getReadonlyUrl().then((readonlyUrl) => {
|
||||
if (readonlyUrl && !shareState.readonlyQrCodeDataUrl) {
|
||||
// fetch the readonly QR code data URL
|
||||
createQRCodeImageDataString(readonlyUrl).then((dataUrl) => {
|
||||
if (!cancelled) {
|
||||
setShareState((s) => ({ ...s, readonlyUrl, readonlyQrCodeDataUrl: dataUrl }))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const url = window.location.href
|
||||
|
@ -115,7 +163,8 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
alignOffset={4}
|
||||
>
|
||||
<TldrawUiMenuContextProvider type="panel" sourceId="share-menu">
|
||||
{shareState.state === 'shared' || shareState.state === 'readonly' ? (
|
||||
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE ||
|
||||
shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY ? (
|
||||
<>
|
||||
<button
|
||||
className="tlui-share-zone__qr-code"
|
||||
|
@ -124,41 +173,42 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!currentShareLinkUrl) return
|
||||
setDidCopy(true)
|
||||
setTimeout(() => setDidCopy(false), 1000)
|
||||
navigator.clipboard.writeText(currentShareLinkUrl)
|
||||
}}
|
||||
/>
|
||||
|
||||
<TldrawUiMenuGroup id="copy">
|
||||
<TldrawUiMenuItem
|
||||
id="copy-to-clipboard"
|
||||
readonlyOk
|
||||
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
|
||||
label={
|
||||
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
|
||||
}
|
||||
onSelect={() => {
|
||||
setDidCopy(true)
|
||||
setTimeout(() => setDidCopy(false), 750)
|
||||
navigator.clipboard.writeText(currentShareLinkUrl)
|
||||
}}
|
||||
/>
|
||||
{shareState.state === 'shared' && (
|
||||
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE && (
|
||||
<TldrawUiMenuItem
|
||||
id="toggle-read-only"
|
||||
label="share-menu.readonly-link"
|
||||
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
|
||||
onSelect={async () => {
|
||||
setIsReadOnlyLink(() => !isReadOnlyLink)
|
||||
id="copy-to-clipboard"
|
||||
readonlyOk
|
||||
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
|
||||
label="share-menu.copy-link"
|
||||
onSelect={() => {
|
||||
if (!shareState.url) return
|
||||
setDidCopy(true)
|
||||
setTimeout(() => setDidCopy(false), 750)
|
||||
navigator.clipboard.writeText(shareState.url)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TldrawUiMenuItem
|
||||
id="copy-readonly-to-clipboard"
|
||||
readonlyOk
|
||||
icon={didCopyReadonlyLink ? 'clipboard-copied' : 'clipboard-copy'}
|
||||
label="share-menu.copy-readonly-link"
|
||||
onSelect={() => {
|
||||
if (!shareState.readonlyUrl) return
|
||||
setDidCopyReadonlyLink(true)
|
||||
setTimeout(() => setDidCopyReadonlyLink(false), 750)
|
||||
navigator.clipboard.writeText(shareState.readonlyUrl)
|
||||
}}
|
||||
/>
|
||||
<p className="tlui-menu__group tlui-share-zone__details">
|
||||
{msg(
|
||||
isReadOnlyLink
|
||||
? 'share-menu.copy-readonly-link-note'
|
||||
: 'share-menu.copy-link-note'
|
||||
)}
|
||||
{msg('share-menu.copy-readonly-link-note')}
|
||||
</p>
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
|
@ -185,6 +235,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
<TldrawUiMenuGroup id="share">
|
||||
<TldrawUiMenuItem
|
||||
id="share-project"
|
||||
readonlyOk
|
||||
label="share-menu.share-project"
|
||||
icon="share-1"
|
||||
onSelect={async () => {
|
||||
|
@ -197,7 +248,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
/>
|
||||
<p className="tlui-menu__group tlui-share-zone__details">
|
||||
{msg(
|
||||
shareState.state === 'offline'
|
||||
shareState.state === SHARE_CURRENT_STATE.OFFLINE
|
||||
? 'share-menu.offline-note'
|
||||
: isReadOnlyLink
|
||||
? 'share-menu.copy-readonly-link-note'
|
||||
|
@ -208,6 +259,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
<TldrawUiMenuGroup id="copy-snapshot-link">
|
||||
<TldrawUiMenuItem
|
||||
id="copy-snapshot-link"
|
||||
readonlyOk
|
||||
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
|
||||
label={unwrapLabel(shareSnapshot.label)}
|
||||
onSelect={async () => {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { TLIncompatibilityReason } from '@tldraw/tlsync'
|
||||
import { ErrorScreen, exhaustiveSwitchError } from 'tldraw'
|
||||
import { exhaustiveSwitchError } from 'tldraw'
|
||||
import { RemoteSyncError } from '../utils/remote-sync/remote-sync'
|
||||
import { ErrorPage } from './ErrorPage/ErrorPage'
|
||||
|
||||
export function StoreErrorScreen({ error }: { error: Error }) {
|
||||
let message = 'Could not connect to server.'
|
||||
let header = 'Could not connect to server.'
|
||||
let message = ''
|
||||
|
||||
if (error instanceof RemoteSyncError) {
|
||||
switch (error.reason) {
|
||||
|
@ -26,14 +28,15 @@ export function StoreErrorScreen({ error }: { error: Error }) {
|
|||
'Your changes were rejected by the server. Please reload the page. If the problem persists contact the system administrator.'
|
||||
break
|
||||
}
|
||||
case TLIncompatibilityReason.RoomNotFound: {
|
||||
header = 'Room not found'
|
||||
message = 'The room you are trying to connect to does not exist.'
|
||||
break
|
||||
}
|
||||
default:
|
||||
exhaustiveSwitchError(error.reason)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tldraw__editor tl-container">
|
||||
<ErrorScreen>{message}</ErrorScreen>
|
||||
</div>
|
||||
)
|
||||
return <ErrorPage icon messages={{ header, para1: message }} />
|
||||
}
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { TLSyncClient, schema } from '@tldraw/tlsync'
|
||||
import {
|
||||
TLCloseEventCode,
|
||||
TLIncompatibilityReason,
|
||||
TLPersistentClientSocketStatus,
|
||||
TLSyncClient,
|
||||
schema,
|
||||
} from '@tldraw/tlsync'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
TAB_ID,
|
||||
|
@ -55,6 +61,16 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
|||
return withParams.toString()
|
||||
})
|
||||
|
||||
socket.onStatusChange((val: TLPersistentClientSocketStatus, closeCode?: number) => {
|
||||
if (val === 'error' && closeCode === TLCloseEventCode.NOT_FOUND) {
|
||||
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
|
||||
setState({ error: new RemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
|
||||
client.close()
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
let didCancel = false
|
||||
|
||||
const client = new TLSyncClient({
|
||||
|
|
|
@ -2,7 +2,7 @@ import { RoomSnapshot } from '@tldraw/tlsync'
|
|||
import '../../styles/globals.css'
|
||||
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'
|
||||
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { defineLoader } from '../utils/defineLoader'
|
||||
|
||||
const { loader, useData } = defineLoader(async (args) => {
|
||||
|
@ -32,13 +32,12 @@ export function Component() {
|
|||
header: 'Page not found',
|
||||
para1: 'The page you are looking does not exist or has been moved.',
|
||||
}}
|
||||
redirectTo="/"
|
||||
/>
|
||||
)
|
||||
|
||||
const { data, roomId, timestamp } = result
|
||||
return (
|
||||
<IFrameProtector slug={roomId} context="history-snapshot">
|
||||
<IFrameProtector slug={roomId} context={ROOM_CONTEXT.HISTORY_SNAPSHOT}>
|
||||
<BoardHistorySnapshot data={data} roomId={roomId} timestamp={timestamp} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BoardHistoryLog } from '../components/BoardHistoryLog/BoardHistoryLog'
|
||||
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { defineLoader } from '../utils/defineLoader'
|
||||
|
||||
const { loader, useData } = defineLoader(async (args) => {
|
||||
|
@ -29,11 +29,10 @@ export function Component() {
|
|||
header: 'Page not found',
|
||||
para1: 'The page you are looking does not exist or has been moved.',
|
||||
}}
|
||||
redirectTo="/"
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<IFrameProtector slug={data.boardId} context="history">
|
||||
<IFrameProtector slug={data.boardId} context={ROOM_CONTEXT.HISTORY}>
|
||||
<BoardHistoryLog data={data.data} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
|
|
40
apps/dotcom/src/pages/new.tsx
Normal file
40
apps/dotcom/src/pages/new.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Snapshot } from '@tldraw/dotcom-shared'
|
||||
import { schema } from '@tldraw/tlsync'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
|
||||
import { defineLoader } from '../utils/defineLoader'
|
||||
import { isInIframe } from '../utils/iFrame'
|
||||
import { getNewRoomResponse } from '../utils/sharing'
|
||||
|
||||
const { loader, useData } = defineLoader(async (_args) => {
|
||||
if (isInIframe()) return null
|
||||
|
||||
const res = await getNewRoomResponse({
|
||||
schema: schema.serialize(),
|
||||
snapshot: {},
|
||||
} satisfies Snapshot)
|
||||
|
||||
const response = (await res.json()) as { error: boolean; slug?: string }
|
||||
if (!res.ok || response.error || !response.slug) {
|
||||
return null
|
||||
}
|
||||
return { slug: response.slug }
|
||||
})
|
||||
|
||||
export { loader }
|
||||
|
||||
export function Component() {
|
||||
const data = useData()
|
||||
if (!data)
|
||||
return (
|
||||
<ErrorPage
|
||||
icon
|
||||
messages={{
|
||||
header: 'Page not found',
|
||||
para1: 'The page you are looking does not exist or has been moved.',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
return <Navigate to={`/r/${data.slug}`} />
|
||||
}
|
|
@ -8,7 +8,6 @@ export function Component() {
|
|||
header: 'Page not found',
|
||||
para1: 'The page you are looking does not exist or has been moved.',
|
||||
}}
|
||||
redirectTo="/"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { MultiplayerEditor } from '../components/MultiplayerEditor'
|
||||
|
||||
export function Component() {
|
||||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context="public-multiplayer">
|
||||
<MultiplayerEditor isReadOnly={false} roomSlug={id} />
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_MULTIPLAYER}>
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_WRITE} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
||||
|
|
14
apps/dotcom/src/pages/public-readonly-legacy.tsx
Normal file
14
apps/dotcom/src/pages/public-readonly-legacy.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { MultiplayerEditor } from '../components/MultiplayerEditor'
|
||||
|
||||
export function Component() {
|
||||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY_LEGACY} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { MultiplayerEditor } from '../components/MultiplayerEditor'
|
||||
|
||||
export function Component() {
|
||||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context="public-readonly">
|
||||
<MultiplayerEditor isReadOnly={true} roomSlug={id} />
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { SerializedSchema, TLRecord } from 'tldraw'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { SnapshotsEditor } from '../components/SnapshotsEditor'
|
||||
import { defineLoader } from '../utils/defineLoader'
|
||||
|
||||
|
@ -23,7 +23,7 @@ export function Component() {
|
|||
if (!result) throw Error('Room not found')
|
||||
const { roomId, records, schema } = result
|
||||
return (
|
||||
<IFrameProtector slug={roomId} context="public-snapshot">
|
||||
<IFrameProtector slug={roomId} context={ROOM_CONTEXT.PUBLIC_SNAPSHOT}>
|
||||
<SnapshotsEditor records={records} schema={schema} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import '../../styles/globals.css'
|
||||
import { IFrameProtector } from '../components/IFrameProtector'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
import { LocalEditor } from '../components/LocalEditor'
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<IFrameProtector slug="home" context="local">
|
||||
<IFrameProtector slug="home" context={ROOM_CONTEXT.LOCAL}>
|
||||
<LocalEditor />
|
||||
</IFrameProtector>
|
||||
)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { captureException } from '@sentry/react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoutesFromElements, Outlet, redirect, Route, useRouteError } from 'react-router-dom'
|
||||
import { createRoutesFromElements, Navigate, Outlet, Route, useRouteError } from 'react-router-dom'
|
||||
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
|
||||
import { ErrorPage } from './components/ErrorPage/ErrorPage'
|
||||
|
||||
|
@ -30,20 +29,8 @@ export const router = createRoutesFromElements(
|
|||
>
|
||||
<Route errorElement={<DefaultErrorFallback />}>
|
||||
<Route path="/" lazy={() => import('./pages/root')} />
|
||||
<Route
|
||||
path="/r"
|
||||
loader={() => {
|
||||
const id = 'v2' + nanoid()
|
||||
return redirect(`/r/${id}`)
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path="/new"
|
||||
loader={() => {
|
||||
const id = 'v2' + nanoid()
|
||||
return redirect(`/r/${id}`)
|
||||
}}
|
||||
/>
|
||||
<Route path="/r" element={<Navigate to="/" />} />
|
||||
<Route path="/new" lazy={() => import('./pages/new')} />
|
||||
<Route path="/r/:roomId" lazy={() => import('./pages/public-multiplayer')} />
|
||||
<Route path="/r/:boardId/history" lazy={() => import('./pages/history')} />
|
||||
<Route
|
||||
|
@ -51,7 +38,8 @@ export const router = createRoutesFromElements(
|
|||
lazy={() => import('./pages/history-snapshot')}
|
||||
/>
|
||||
<Route path="/s/:roomId" lazy={() => import('./pages/public-snapshot')} />
|
||||
<Route path="/v/:roomId" lazy={() => import('./pages/public-readonly')} />
|
||||
<Route path="/v/:roomId" lazy={() => import('./pages/public-readonly-legacy')} />
|
||||
<Route path="/ro/:roomId" lazy={() => import('./pages/public-readonly')} />
|
||||
</Route>
|
||||
<Route path="*" lazy={() => import('./pages/not-found')} />
|
||||
</Route>
|
||||
|
|
16
apps/dotcom/src/utils/iFrame.ts
Normal file
16
apps/dotcom/src/utils/iFrame.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
export const isInIframe = () => {
|
||||
return typeof window !== 'undefined' && (window !== window.top || window.self !== window.parent)
|
||||
}
|
||||
|
||||
export function getParentOrigin() {
|
||||
if (isInIframe()) {
|
||||
const ancestorOrigins = window.location.ancestorOrigins
|
||||
// ancestorOrigins is not supported in Firefox
|
||||
if (ancestorOrigins && ancestorOrigins.length > 0) {
|
||||
return ancestorOrigins[0]
|
||||
} else {
|
||||
return document.referrer
|
||||
}
|
||||
}
|
||||
return document.location.origin
|
||||
}
|
|
@ -155,20 +155,33 @@ describe(ClientWebSocketAdapter, () => {
|
|||
it('signals status changes', async () => {
|
||||
const onStatusChange = jest.fn()
|
||||
adapter.onStatusChange(onStatusChange)
|
||||
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('online')
|
||||
connectedServerSocket.terminate()
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline')
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline', 1006)
|
||||
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('online')
|
||||
connectedServerSocket.terminate()
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline')
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline', 1006)
|
||||
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('online')
|
||||
adapter._ws?.onerror?.({} as any)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('error')
|
||||
expect(onStatusChange).toHaveBeenCalledWith('error', undefined)
|
||||
})
|
||||
|
||||
it('signals the correct closeCode when a room is not found', async () => {
|
||||
const onStatusChange = jest.fn()
|
||||
adapter.onStatusChange(onStatusChange)
|
||||
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
||||
|
||||
adapter._ws!.onclose?.({ code: 4099 } as any)
|
||||
|
||||
expect(onStatusChange).toHaveBeenCalledWith('error', 4099)
|
||||
})
|
||||
|
||||
it('signals status changes while restarting', async () => {
|
||||
|
@ -181,7 +194,7 @@ describe(ClientWebSocketAdapter, () => {
|
|||
|
||||
await waitFor(() => onStatusChange.mock.calls.length === 2)
|
||||
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline')
|
||||
expect(onStatusChange).toHaveBeenCalledWith('offline', undefined)
|
||||
expect(onStatusChange).toHaveBeenCalledWith('online')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
chunk,
|
||||
TLCloseEventCode,
|
||||
TLPersistentClientSocket,
|
||||
TLPersistentClientSocketStatus,
|
||||
TLSocketClientSentEvent,
|
||||
|
@ -68,15 +69,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|||
this._reconnectManager.connected()
|
||||
}
|
||||
|
||||
private _handleDisconnect(reason: 'closed' | 'error' | 'manual') {
|
||||
private _handleDisconnect(reason: 'closed' | 'error' | 'manual', closeCode?: number) {
|
||||
debug('handleDisconnect', {
|
||||
currentStatus: this.connectionStatus,
|
||||
closeCode,
|
||||
reason,
|
||||
})
|
||||
|
||||
let newStatus: 'offline' | 'error'
|
||||
switch (reason) {
|
||||
case 'closed':
|
||||
if (closeCode === TLCloseEventCode.NOT_FOUND) {
|
||||
newStatus = 'error'
|
||||
break
|
||||
}
|
||||
newStatus = 'offline'
|
||||
break
|
||||
case 'error':
|
||||
|
@ -94,7 +100,7 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|||
!(newStatus === 'error' && this.connectionStatus === 'offline')
|
||||
) {
|
||||
this._connectionStatus.set(newStatus)
|
||||
this.statusListeners.forEach((cb) => cb(newStatus))
|
||||
this.statusListeners.forEach((cb) => cb(newStatus, closeCode))
|
||||
}
|
||||
|
||||
this._reconnectManager.disconnected()
|
||||
|
@ -120,10 +126,10 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|||
)
|
||||
this._handleConnect()
|
||||
}
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
debug('ws.onclose')
|
||||
if (this._ws === ws) {
|
||||
this._handleDisconnect('closed')
|
||||
this._handleDisconnect('closed', event.code)
|
||||
} else {
|
||||
debug('ignoring onclose for an orphaned socket')
|
||||
}
|
||||
|
@ -194,8 +200,10 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLRecord
|
|||
}
|
||||
}
|
||||
|
||||
private statusListeners = new Set<(status: TLPersistentClientSocketStatus) => void>()
|
||||
onStatusChange(cb: (val: TLPersistentClientSocketStatus) => void) {
|
||||
private statusListeners = new Set<
|
||||
(status: TLPersistentClientSocketStatus, closeCode?: number) => void
|
||||
>()
|
||||
onStatusChange(cb: (val: TLPersistentClientSocketStatus, closeCode?: number) => void) {
|
||||
assert(!this.isDisposed, 'Tried to add status listener on a disposed socket')
|
||||
|
||||
this.statusListeners.add(cb)
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import {
|
||||
CreateRoomRequestBody,
|
||||
CreateSnapshotRequestBody,
|
||||
CreateSnapshotResponseBody,
|
||||
Snapshot,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
AssetRecordType,
|
||||
Editor,
|
||||
SerializedSchema,
|
||||
SerializedStore,
|
||||
TLAsset,
|
||||
TLAssetId,
|
||||
TLRecord,
|
||||
|
@ -20,6 +24,7 @@ import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'
|
|||
import { getViewportUrlQuery } from '../hooks/useUrlState'
|
||||
import { cloneAssetForShare } from './cloneAssetForShare'
|
||||
import { ASSET_UPLOADER_URL } from './config'
|
||||
import { getParentOrigin, isInIframe } from './iFrame'
|
||||
import { shouldLeaveSharedProject } from './shouldLeaveSharedProject'
|
||||
import { trackAnalyticsEvent } from './trackAnalyticsEvent'
|
||||
import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent'
|
||||
|
@ -32,27 +37,6 @@ 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<TLRecord>
|
||||
}
|
||||
|
||||
type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
type CreateSnapshotResponseBody =
|
||||
| {
|
||||
error: false
|
||||
roomId: string
|
||||
}
|
||||
| {
|
||||
error: true
|
||||
message: string
|
||||
}
|
||||
|
||||
async function getSnapshotLink(
|
||||
source: string,
|
||||
editor: Editor,
|
||||
|
@ -90,11 +74,25 @@ async function getSnapshotLink(
|
|||
})
|
||||
}
|
||||
|
||||
export async function getNewRoomResponse(snapshot: Snapshot) {
|
||||
return await fetch(SNAPSHOT_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
origin: getParentOrigin(),
|
||||
snapshot,
|
||||
} satisfies CreateRoomRequestBody),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSharing(): TLUiOverrides {
|
||||
const navigate = useNavigate()
|
||||
const id = useSearchParams()[0].get('id') ?? undefined
|
||||
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
|
||||
const handleUiEvent = useHandleUiEvents()
|
||||
const runningInIFrame = isInIframe()
|
||||
|
||||
return useMemo(
|
||||
(): TLUiOverrides => ({
|
||||
|
@ -122,17 +120,10 @@ export function useSharing(): TLUiOverrides {
|
|||
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 res = await getNewRoomResponse({
|
||||
schema: editor.store.schema.serialize(),
|
||||
snapshot: data,
|
||||
})
|
||||
|
||||
const response = (await res.json()) as { error: boolean; slug?: string }
|
||||
if (!res.ok || response.error) {
|
||||
console.error(await res.text())
|
||||
|
@ -140,8 +131,13 @@ export function useSharing(): TLUiOverrides {
|
|||
}
|
||||
|
||||
const query = getViewportUrlQuery(editor)
|
||||
|
||||
navigate(`/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`)
|
||||
const origin = window.location.origin
|
||||
const pathname = `/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`
|
||||
if (runningInIFrame) {
|
||||
window.open(`${origin}${pathname}`)
|
||||
} else {
|
||||
navigate(pathname)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addToast({
|
||||
|
@ -182,12 +178,12 @@ export function useSharing(): TLUiOverrides {
|
|||
actions[FORK_PROJECT_ACTION] = {
|
||||
...actions[SHARE_PROJECT_ACTION],
|
||||
id: FORK_PROJECT_ACTION,
|
||||
label: 'action.fork-project',
|
||||
label: runningInIFrame ? 'action.fork-project-on-tldraw' : 'action.fork-project',
|
||||
}
|
||||
return actions
|
||||
},
|
||||
}),
|
||||
[handleUiEvent, navigate, uploadFileToAsset, id]
|
||||
[handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -183,6 +183,7 @@ a {
|
|||
font-weight: 500;
|
||||
color: var(--text-color-2);
|
||||
padding: 12px 4px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ------------------ Board history ----------------- */
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
{
|
||||
"path": "../../packages/assets"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/dotcom-shared"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/tldraw"
|
||||
},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<svg width="30" height="31" viewBox="0 0 30 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 2V4H18V2H8ZM6 1.5C6 0.671573 6.67157 0 7.5 0H18.5C19.3284 0 20 0.671572 20 1.5V2H21C22.6569 2 24 3.34315 24 5V14H22V5C22 4.44772 21.5523 4 21 4H20V4.5C20 5.32843 19.3284 6 18.5 6H7.5C6.67157 6 6 5.32843 6 4.5V4H5C4.44771 4 4 4.44772 4 5V25C4 25.5523 4.44772 26 5 26H12V28H5C3.34315 28 2 26.6569 2 25V5C2 3.34314 3.34315 2 5 2H6V1.5Z" fill="black"/>
|
||||
<path d="M27.5197 17.173C28.0099 17.4936 28.1475 18.1509 27.827 18.6411L20.6149 29.6713C20.445 29.9313 20.1696 30.1037 19.8615 30.143C19.5534 30.1823 19.2436 30.0846 19.0138 29.8757L14.3472 25.6333C13.9137 25.2393 13.8818 24.5685 14.2758 24.1351C14.6698 23.7017 15.3406 23.6697 15.774 24.0638L19.5203 27.4694L26.0516 17.4803C26.3721 16.9901 27.0294 16.8525 27.5197 17.173Z" fill="black"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 893 B After Width: | Height: | Size: 893 B |
|
@ -48,6 +48,7 @@
|
|||
"action.flip-horizontal.short": "Flip H",
|
||||
"action.flip-vertical.short": "Flip V",
|
||||
"action.fork-project": "Fork this project",
|
||||
"action.fork-project-on-tldraw": "Fork project on tldraw",
|
||||
"action.group": "Group",
|
||||
"action.insert-embed": "Insert embed",
|
||||
"action.insert-media": "Upload media",
|
||||
|
@ -262,7 +263,7 @@
|
|||
"share-menu.copy-readonly-link": "Copy read-only link",
|
||||
"share-menu.offline-note": "Create a new shared project based on your current project.",
|
||||
"share-menu.copy-link-note": "Anyone with the link will be able to view and edit this project.",
|
||||
"share-menu.copy-readonly-link-note": "Anyone with the link will be able to view (but not edit) this project.",
|
||||
"share-menu.copy-readonly-link-note": "Anyone with the link will be able to access this project.",
|
||||
"share-menu.project-too-large": "Sorry, this project can't be shared because it's too large. We're working on it!",
|
||||
"share-menu.upload-failed": "Sorry, we couldn't upload your project at the moment. Please try again or let us know if the problem persists.",
|
||||
"status.offline": "Offline",
|
||||
|
|
26
packages/dotcom-shared/package.json
Normal file
26
packages/dotcom-shared/package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@tldraw/dotcom-shared",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"/* 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": [],
|
||||
"dependencies": {
|
||||
"tldraw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"scripts": {
|
||||
"test-ci": "lazy inherit",
|
||||
"test": "yarn run -T jest",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "config/jest/node",
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
}
|
3
packages/dotcom-shared/src/index.test.ts
Normal file
3
packages/dotcom-shared/src/index.test.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
it('works', () => {
|
||||
// we need a test for jest to pass.
|
||||
})
|
8
packages/dotcom-shared/src/index.ts
Normal file
8
packages/dotcom-shared/src/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from './routes'
|
||||
export type {
|
||||
CreateRoomRequestBody,
|
||||
CreateSnapshotRequestBody,
|
||||
CreateSnapshotResponseBody,
|
||||
GetReadonlySlugResponseBody,
|
||||
Snapshot,
|
||||
} from './types'
|
14
packages/dotcom-shared/src/routes.ts
Normal file
14
packages/dotcom-shared/src/routes.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/** @public */
|
||||
export const ROOM_OPEN_MODE = {
|
||||
READ_ONLY: 'readonly',
|
||||
READ_ONLY_LEGACY: 'readonly-legacy',
|
||||
READ_WRITE: 'read-write',
|
||||
} as const
|
||||
export type RoomOpenMode = (typeof ROOM_OPEN_MODE)[keyof typeof ROOM_OPEN_MODE]
|
||||
|
||||
/** @public */
|
||||
export const RoomOpenModeToPath: Record<RoomOpenMode, string> = {
|
||||
[ROOM_OPEN_MODE.READ_ONLY]: 'ro',
|
||||
[ROOM_OPEN_MODE.READ_ONLY_LEGACY]: 'v',
|
||||
[ROOM_OPEN_MODE.READ_WRITE]: 'r',
|
||||
}
|
29
packages/dotcom-shared/src/types.ts
Normal file
29
packages/dotcom-shared/src/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { SerializedSchema, SerializedStore, TLRecord } from 'tldraw'
|
||||
|
||||
export type Snapshot = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
|
||||
export type CreateRoomRequestBody = {
|
||||
origin: string
|
||||
snapshot: Snapshot
|
||||
}
|
||||
|
||||
export type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
export type CreateSnapshotResponseBody =
|
||||
| {
|
||||
error: false
|
||||
roomId: string
|
||||
}
|
||||
| {
|
||||
error: true
|
||||
message: string
|
||||
}
|
||||
|
||||
export type GetReadonlySlugResponseBody = { slug: string; isLegacy: boolean }
|
14
packages/dotcom-shared/tsconfig.json
Normal file
14
packages/dotcom-shared/tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", ".tsbuild*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../tldraw"
|
||||
}
|
||||
]
|
||||
}
|
File diff suppressed because one or more lines are too long
29539
packages/tldraw/api/api.json
Normal file
29539
packages/tldraw/api/api.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -52,6 +52,7 @@ export type TLUiTranslationKey =
|
|||
| 'action.flip-horizontal.short'
|
||||
| 'action.flip-vertical.short'
|
||||
| 'action.fork-project'
|
||||
| 'action.fork-project-on-tldraw'
|
||||
| 'action.group'
|
||||
| 'action.insert-embed'
|
||||
| 'action.insert-media'
|
||||
|
|
|
@ -52,6 +52,7 @@ export const DEFAULT_TRANSLATION = {
|
|||
'action.flip-horizontal.short': 'Flip H',
|
||||
'action.flip-vertical.short': 'Flip V',
|
||||
'action.fork-project': 'Fork this project',
|
||||
'action.fork-project-on-tldraw': 'Fork project on tldraw',
|
||||
'action.group': 'Group',
|
||||
'action.insert-embed': 'Insert embed',
|
||||
'action.insert-media': 'Upload media',
|
||||
|
@ -266,8 +267,7 @@ export const DEFAULT_TRANSLATION = {
|
|||
'share-menu.copy-readonly-link': 'Copy read-only link',
|
||||
'share-menu.offline-note': 'Create a new shared project based on your current project.',
|
||||
'share-menu.copy-link-note': 'Anyone with the link will be able to view and edit this project.',
|
||||
'share-menu.copy-readonly-link-note':
|
||||
'Anyone with the link will be able to view (but not edit) this project.',
|
||||
'share-menu.copy-readonly-link-note': 'Anyone with the link will be able to access this project.',
|
||||
'share-menu.project-too-large':
|
||||
"Sorry, this project can't be shared because it's too large. We're working on it!",
|
||||
'share-menu.upload-failed':
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
export { TLServer, type DBLoadResult, type TLServerEvent } from './lib/TLServer'
|
||||
export {
|
||||
TLServer,
|
||||
type DBLoadResult,
|
||||
type DBLoadResultType,
|
||||
type TLServerEvent,
|
||||
} from './lib/TLServer'
|
||||
export {
|
||||
TLCloseEventCode,
|
||||
TLSyncClient,
|
||||
type TLPersistentClientSocket,
|
||||
type TLPersistentClientSocketStatus,
|
||||
|
|
|
@ -6,7 +6,7 @@ import { JsonChunkAssembler } from './chunk'
|
|||
import { schema } from './schema'
|
||||
import { RoomState } from './server-types'
|
||||
|
||||
type LoadKind = 'new' | 'reopen' | 'open'
|
||||
type LoadKind = 'reopen' | 'open' | 'room_not_found'
|
||||
export type DBLoadResult =
|
||||
| {
|
||||
type: 'error'
|
||||
|
@ -19,6 +19,7 @@ export type DBLoadResult =
|
|||
| {
|
||||
type: 'room_not_found'
|
||||
}
|
||||
export type DBLoadResultType = DBLoadResult['type']
|
||||
|
||||
export type TLServerEvent =
|
||||
| {
|
||||
|
@ -54,10 +55,10 @@ export type TLServerEvent =
|
|||
export abstract class TLServer {
|
||||
schema = schema
|
||||
|
||||
async getInitialRoomState(persistenceKey: string): Promise<[RoomState, LoadKind]> {
|
||||
async getInitialRoomState(persistenceKey: string): Promise<[RoomState | undefined, LoadKind]> {
|
||||
let roomState = this.getRoomForPersistenceKey(persistenceKey)
|
||||
|
||||
let roomOpenKind = 'open' as 'open' | 'reopen' | 'new'
|
||||
let roomOpenKind: LoadKind = 'open'
|
||||
|
||||
// If no room exists for the id, create one
|
||||
if (roomState === undefined) {
|
||||
|
@ -78,14 +79,22 @@ export abstract class TLServer {
|
|||
}
|
||||
}
|
||||
|
||||
// If we still don't have a room, create a new one
|
||||
// If we still don't have a room, throw an error.
|
||||
if (roomState === undefined) {
|
||||
roomOpenKind = 'new'
|
||||
|
||||
roomState = {
|
||||
persistenceKey,
|
||||
room: new TLSyncRoom(this.schema),
|
||||
}
|
||||
// This is how it bubbles down to the client:
|
||||
// 1.) From here, we send back a `room_not_found` to TLDrawDurableObject.
|
||||
// 2.) In TLDrawDurableObject, we accept and then immediately close the client.
|
||||
// This lets us send a TLCloseEventCode.NOT_FOUND closeCode down to the client.
|
||||
// 3.) joinExistingRoom which handles the websocket upgrade is not affected.
|
||||
// Again, we accept the connection, it's just that we immediately close right after.
|
||||
// 4.) In ClientWebSocketAdapter, ws.onclose is called, and that calls _handleDisconnect.
|
||||
// 5.) _handleDisconnect sets the status to 'error' and calls the onStatusChange callback.
|
||||
// 6.) On the dotcom app in useRemoteSyncClient, we have socket.onStatusChange callback
|
||||
// where we set TLIncompatibilityReason.RoomNotFound and close the client + socket.
|
||||
// 7.) Finally on the dotcom app we use StoreErrorScreen to display an appropriate msg.
|
||||
//
|
||||
// Phew!
|
||||
return [roomState, 'room_not_found']
|
||||
}
|
||||
|
||||
const thisRoom = roomState.room
|
||||
|
@ -138,9 +147,13 @@ export abstract class TLServer {
|
|||
persistenceKey: string
|
||||
sessionKey: string
|
||||
storeId: string
|
||||
}) => {
|
||||
}): Promise<DBLoadResultType> => {
|
||||
const clientId = nanoid()
|
||||
|
||||
const [roomState, roomOpenKind] = await this.getInitialRoomState(persistenceKey)
|
||||
if (roomOpenKind === 'room_not_found' || !roomState) {
|
||||
return 'room_not_found'
|
||||
}
|
||||
|
||||
roomState.room.handleNewSession(
|
||||
sessionKey,
|
||||
|
@ -156,7 +169,7 @@ export abstract class TLServer {
|
|||
})
|
||||
)
|
||||
|
||||
if (roomOpenKind === 'new' || roomOpenKind === 'reopen') {
|
||||
if (roomOpenKind === 'reopen') {
|
||||
// Record that the room is now active
|
||||
this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_start' })
|
||||
|
||||
|
@ -164,7 +177,7 @@ export abstract class TLServer {
|
|||
this.logEvent({
|
||||
type: 'client',
|
||||
roomId: persistenceKey,
|
||||
name: roomOpenKind === 'new' ? 'room_create' : 'room_reopen',
|
||||
name: 'room_reopen',
|
||||
clientId,
|
||||
instanceId: sessionKey,
|
||||
localClientId: storeId,
|
||||
|
@ -237,6 +250,8 @@ export abstract class TLServer {
|
|||
socket.addEventListener('message', handleMessageFromClient)
|
||||
socket.addEventListener('close', handleCloseOrErrorFromClient)
|
||||
socket.addEventListener('error', handleCloseOrErrorFromClient)
|
||||
|
||||
return 'room_found'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,6 +24,17 @@ import './requestAnimationFrame.polyfill'
|
|||
|
||||
type SubscribingFn<T> = (cb: (val: T) => void) => () => void
|
||||
|
||||
/**
|
||||
* These are our private codes to be sent from server->client.
|
||||
* They are in the private range of the websocket code range.
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const TLCloseEventCode = {
|
||||
NOT_FOUND: 4099,
|
||||
} as const
|
||||
|
||||
/** @public */
|
||||
export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'
|
||||
/**
|
||||
|
@ -236,6 +247,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 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') {
|
||||
|
|
|
@ -10,6 +10,7 @@ export const TLIncompatibilityReason = {
|
|||
ServerTooOld: 'serverTooOld',
|
||||
InvalidRecord: 'invalidRecord',
|
||||
InvalidOperation: 'invalidOperation',
|
||||
RoomNotFound: 'roomNotFound',
|
||||
} as const
|
||||
|
||||
/** @public */
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { TLRecord, createTLStore, defaultShapeUtils } from 'tldraw'
|
||||
import {
|
||||
DocumentRecordType,
|
||||
PageRecordType,
|
||||
RecordId,
|
||||
TLDocument,
|
||||
TLRecord,
|
||||
ZERO_INDEX_KEY,
|
||||
createTLStore,
|
||||
defaultShapeUtils,
|
||||
} from 'tldraw'
|
||||
import { type WebSocket } from 'ws'
|
||||
import { RoomSessionState } from '../lib/RoomSession'
|
||||
import { DBLoadResult, TLServer } from '../lib/TLServer'
|
||||
import { RoomSnapshot } from '../lib/TLSyncRoom'
|
||||
import { chunk } from '../lib/chunk'
|
||||
import { RecordOpType } from '../lib/diff'
|
||||
import { TLSYNC_PROTOCOL_VERSION, TLSocketClientSentEvent } from '../lib/protocol'
|
||||
|
@ -17,6 +27,16 @@ const PORT = 23473
|
|||
|
||||
const disposables: (() => void)[] = []
|
||||
|
||||
const records = [
|
||||
DocumentRecordType.create({ id: 'document:document' as RecordId<TLDocument> }),
|
||||
PageRecordType.create({ index: ZERO_INDEX_KEY, name: 'page 2' }),
|
||||
]
|
||||
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
|
||||
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
||||
clock: 0,
|
||||
...others,
|
||||
})
|
||||
|
||||
class TLServerTestImpl extends TLServer {
|
||||
wsServer = new ws.Server({ port: PORT })
|
||||
async close() {
|
||||
|
@ -54,7 +74,7 @@ class TLServerTestImpl extends TLServer {
|
|||
}
|
||||
}
|
||||
override async loadFromDatabase?(_roomId: string): Promise<DBLoadResult> {
|
||||
return { type: 'room_not_found' }
|
||||
return { type: 'room_found', snapshot: makeSnapshot(records) }
|
||||
}
|
||||
override async persistToDatabase?(_roomId: string): Promise<void> {
|
||||
return
|
||||
|
@ -84,15 +104,20 @@ beforeEach(async () => {
|
|||
sockets = await server.createSocketPair()
|
||||
expect(sockets.client.readyState).toBe(ws.OPEN)
|
||||
expect(sockets.server.readyState).toBe(ws.OPEN)
|
||||
server.loadFromDatabase = async (_roomId: string): Promise<DBLoadResult> => {
|
||||
return { type: 'room_found', snapshot: makeSnapshot(records) }
|
||||
}
|
||||
})
|
||||
|
||||
const openConnection = async () => {
|
||||
await server.handleConnection({
|
||||
const result = await server.handleConnection({
|
||||
persistenceKey: 'test-persistence-key',
|
||||
sessionKey: 'test-session-key',
|
||||
socket: sockets.server,
|
||||
storeId: 'test-store-id',
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
|
@ -162,4 +187,14 @@ describe('TLServer', () => {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a room_not_found when room is not found', async () => {
|
||||
server.loadFromDatabase = async (_roomId: string): Promise<DBLoadResult> => {
|
||||
return { type: 'room_not_found' }
|
||||
}
|
||||
|
||||
const connectionResult = await openConnection()
|
||||
|
||||
expect(connectionResult).toBe('room_not_found')
|
||||
})
|
||||
})
|
||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -7456,6 +7456,17 @@ __metadata:
|
|||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@tldraw/dotcom-shared@workspace:*, @tldraw/dotcom-shared@workspace:packages/dotcom-shared":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@tldraw/dotcom-shared@workspace:packages/dotcom-shared"
|
||||
dependencies:
|
||||
tldraw: "workspace:*"
|
||||
peerDependencies:
|
||||
react: ^18
|
||||
react-dom: ^18
|
||||
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"
|
||||
|
@ -7463,6 +7474,7 @@ __metadata:
|
|||
"@cloudflare/workers-types": "npm:^4.20230821.0"
|
||||
"@supabase/auth-helpers-remix": "npm:^0.2.2"
|
||||
"@supabase/supabase-js": "npm:^2.33.2"
|
||||
"@tldraw/dotcom-shared": "workspace:*"
|
||||
"@tldraw/store": "workspace:*"
|
||||
"@tldraw/tlschema": "workspace:*"
|
||||
"@tldraw/tlsync": "workspace:*"
|
||||
|
@ -11914,6 +11926,7 @@ __metadata:
|
|||
"@sentry/integrations": "npm:^7.34.0"
|
||||
"@sentry/react": "npm:^7.77.0"
|
||||
"@tldraw/assets": "workspace:*"
|
||||
"@tldraw/dotcom-shared": "workspace:*"
|
||||
"@tldraw/tlsync": "workspace:*"
|
||||
"@tldraw/utils": "workspace:*"
|
||||
"@tldraw/validate": "workspace:*"
|
||||
|
|
Loading…
Reference in a new issue