diff --git a/apps/dotcom-worker/package.json b/apps/dotcom-worker/package.json
index 9e71e78d4..b137d976f 100644
--- a/apps/dotcom-worker/package.json
+++ b/apps/dotcom-worker/package.json
@@ -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:*",
diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
index ed6a37d8e..4746320dc 100644
--- a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
+++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
@@ -2,8 +2,11 @@
///
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 })
}
diff --git a/apps/dotcom-worker/src/lib/routes/createRoom.ts b/apps/dotcom-worker/src/lib/routes/createRoom.ts
index c6a450ce4..43899c56e 100644
--- a/apps/dotcom-worker/src/lib/routes/createRoom.ts
+++ b/apps/dotcom-worker/src/lib/routes/createRoom.ts
@@ -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
-}
+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 {
// 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
- parent_slug?: string | string[] | undefined
-}
-
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise {
const data = (await request.json()) as CreateSnapshotRequestBody
diff --git a/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts b/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts
new file mode 100644
index 000000000..e1eb4f5c5
--- /dev/null
+++ b/apps/dotcom-worker/src/lib/routes/getReadonlySlug.ts
@@ -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 {
+ 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)
+ )
+}
diff --git a/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts
index 99569fe99..b90b87113 100644
--- a/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts
+++ b/apps/dotcom-worker/src/lib/routes/joinExistingRoom.ts
@@ -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 {
- const roomId = request.params.roomId
+export async function joinExistingRoom(
+ request: IRequest,
+ env: Environment,
+ roomOpenMode: RoomOpenMode
+): Promise {
+ const roomId = await getSlug(env, request.params.roomId, roomOpenMode)
if (!roomId) return fourOhFour()
if (isRoomIdTooLong(roomId)) return roomIdIsTooLong()
diff --git a/apps/dotcom-worker/src/lib/types.ts b/apps/dotcom-worker/src/lib/types.ts
index 3505ad5c6..9fdbd8ef0 100644
--- a/apps/dotcom-worker/src/lib/types.ts
+++ b/apps/dotcom-worker/src/lib/types.ts
@@ -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
diff --git a/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts b/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts
new file mode 100644
index 000000000..2e1626605
--- /dev/null
+++ b/apps/dotcom-worker/src/lib/utils/roomOpenMode.ts
@@ -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)
+ }
+}
diff --git a/apps/dotcom-worker/src/lib/worker.ts b/apps/dotcom-worker/src/lib/worker.ts
index 9c415186c..d2100e10a 100644
--- a/apps/dotcom-worker/src/lib/worker.ts
+++ b/apps/dotcom-worker/src/lib/worker.ts
@@ -1,11 +1,13 @@
///
///
+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
diff --git a/apps/dotcom-worker/tsconfig.json b/apps/dotcom-worker/tsconfig.json
index b4d7ff869..e7a2d4918 100644
--- a/apps/dotcom-worker/tsconfig.json
+++ b/apps/dotcom-worker/tsconfig.json
@@ -7,6 +7,9 @@
"emitDeclarationOnly": false
},
"references": [
+ {
+ "path": "../../packages/dotcom-shared"
+ },
{
"path": "../../packages/store"
},
diff --git a/apps/dotcom-worker/wrangler.toml b/apps/dotcom-worker/wrangler.toml
index 98368c816..6d6163a63 100644
--- a/apps/dotcom-worker/wrangler.toml
+++ b/apps/dotcom-worker/wrangler.toml
@@ -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"
diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json
index bf5700284..c2cd34985 100644
--- a/apps/dotcom/package.json
+++ b/apps/dotcom/package.json
@@ -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",
diff --git a/apps/dotcom/scripts/build.ts b/apps/dotcom/scripts/build.ts
index e9e734921..69373d5f8 100644
--- a/apps/dotcom/scripts/build.ts
+++ b/apps/dotcom/scripts/build.ts
@@ -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
diff --git a/apps/dotcom/src/__snapshots__/routes.test.tsx.snap b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap
index db16700e1..4d6d55f9b 100644
--- a/apps/dotcom/src/__snapshots__/routes.test.tsx.snap
+++ b/apps/dotcom/src/__snapshots__/routes.test.tsx.snap
@@ -26,6 +26,10 @@ exports[`the_routes 1`] = `
"reactRouterPattern": "/r/:roomId",
"vercelRouterPattern": "^/r/[^/]*/?$",
},
+ {
+ "reactRouterPattern": "/ro/:roomId",
+ "vercelRouterPattern": "^/ro/[^/]*/?$",
+ },
{
"reactRouterPattern": "/s/:roomId",
"vercelRouterPattern": "^/s/[^/]*/?$",
diff --git a/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx
index 9a275d8dd..ba5aebb13 100644
--- a/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx
+++ b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx
@@ -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 (
@@ -19,8 +20,8 @@ export function ErrorPage({
{messages.para1}
{messages.para2 &&
{messages.para2}
}
-
- Take me home.
+
+ {inIframe ? 'Open tldraw.' : 'Back to tldraw.'}
diff --git a/apps/dotcom/src/components/IFrameProtector.tsx b/apps/dotcom/src/components/IFrameProtector.tsx
index b4fd9f06d..95309432f 100644
--- a/apps/dotcom/src/components/IFrameProtector.tsx
+++ b/apps/dotcom/src/components/IFrameProtector.tsx
@@ -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 Loading in an iframe...
+ return Loading in an iframe…
}
- if (embeddedState === 'iframe-not-allowed') {
+ if (embeddedState === EMBEDDED_STATE.IFRAME_NOT_ALLOWED) {
// We're in an iframe and its not one of ours
return (