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:
Mitja Bezenšek 2024-04-25 16:10:40 +02:00 committed by GitHub
parent 4c5abe888c
commit 15dd56a75e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 30236 additions and 227 deletions

View file

@ -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:*",

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

@ -7,6 +7,9 @@
"emitDeclarationOnly": false
},
"references": [
{
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/store"
},

View file

@ -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"

View file

@ -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",

View file

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

View file

@ -26,6 +26,10 @@ exports[`the_routes 1`] = `
"reactRouterPattern": "/r/:roomId",
"vercelRouterPattern": "^/r/[^/]*/?$",
},
{
"reactRouterPattern": "/ro/:roomId",
"vercelRouterPattern": "^/ro/[^/]*/?$",
},
{
"reactRouterPattern": "/s/:roomId",
"vercelRouterPattern": "^/s/[^/]*/?$",

View file

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

View file

@ -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, tooand hey, if we decide to offer a hosted thing, then that's another stor
risky for us and for them, tooand 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">

View file

@ -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) => {
if (!isReadonly) {
;(window as any).app = editor
;(window as any).editor = editor
editor.updateInstanceState({ isReadonly: isReadOnly })
}
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

View file

@ -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) {
getReadonlyUrl().then((readonlyUrl) => {
if (readonlyUrl && !shareState.readonlyQrCodeDataUrl) {
// fetch the readonly QR code data URL
createQRCodeImageDataString(readonlyShareUrl).then((dataUrl) => {
createQRCodeImageDataString(readonlyUrl).then((dataUrl) => {
if (!cancelled) {
setShareState((s) => ({ ...s, readonlyShareUrl, readonlyQrCodeDataUrl: dataUrl }))
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">
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE && (
<TldrawUiMenuItem
id="copy-to-clipboard"
readonlyOk
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
label={
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
}
label="share-menu.copy-link"
onSelect={() => {
if (!shareState.url) return
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
navigator.clipboard.writeText(currentShareLinkUrl)
navigator.clipboard.writeText(shareState.url)
}}
/>
{shareState.state === 'shared' && (
)}
<TldrawUiMenuItem
id="toggle-read-only"
label="share-menu.readonly-link"
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
onSelect={async () => {
setIsReadOnlyLink(() => !isReadOnlyLink)
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 () => {

View file

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

View file

@ -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({

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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({
const res = await getNewRoomResponse({
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())
@ -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]
)
}

View file

@ -183,6 +183,7 @@ a {
font-weight: 500;
color: var(--text-color-2);
padding: 12px 4px;
text-decoration: underline;
}
/* ------------------ Board history ----------------- */

View file

@ -28,6 +28,9 @@
{
"path": "../../packages/assets"
},
{
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/tldraw"
},

View file

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

View file

@ -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",

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

View file

@ -0,0 +1,3 @@
it('works', () => {
// we need a test for jest to pass.
})

View file

@ -0,0 +1,8 @@
export { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from './routes'
export type {
CreateRoomRequestBody,
CreateSnapshotRequestBody,
CreateSnapshotResponseBody,
GetReadonlySlugResponseBody,
Snapshot,
} from './types'

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

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

View 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

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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':

View file

@ -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,

View file

@ -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'
}
/**

View file

@ -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') {

View file

@ -10,6 +10,7 @@ export const TLIncompatibilityReason = {
ServerTooOld: 'serverTooOld',
InvalidRecord: 'invalidRecord',
InvalidOperation: 'invalidOperation',
RoomNotFound: 'roomNotFound',
} as const
/** @public */

View file

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

View file

@ -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:*"