diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
index b20dbb4e6..4746320dc 100644
--- a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
+++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts
@@ -4,7 +4,9 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { ROOM_OPEN_MODE, type RoomOpenMode } from '@tldraw/dotcom-shared'
import {
+ DBLoadResultType,
RoomSnapshot,
+ TLCloseEventCode,
TLServer,
TLServerEvent,
TLSyncRoom,
@@ -241,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!,
@@ -268,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/src/components/ErrorPage/ErrorPage.tsx b/apps/dotcom/src/components/ErrorPage/ErrorPage.tsx
index 9a275d8dd..4e61e33d8 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,
@@ -19,9 +20,7 @@ export function ErrorPage({
{messages.para1}
{messages.para2 &&
{messages.para2}
}
-
- Take me home.
-
+ {isInIframe() ? 'Open tldraw.' : 'Back to tldraw.'}
)
diff --git a/apps/dotcom/src/components/StoreErrorScreen.tsx b/apps/dotcom/src/components/StoreErrorScreen.tsx
index 62c2177b2..6694bdbf1 100644
--- a/apps/dotcom/src/components/StoreErrorScreen.tsx
+++ b/apps/dotcom/src/components/StoreErrorScreen.tsx
@@ -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 (
-
- {message}
-
- )
+ return
}
diff --git a/apps/dotcom/src/hooks/useRemoteSyncClient.ts b/apps/dotcom/src/hooks/useRemoteSyncClient.ts
index 42bd6aab4..d01df1fc8 100644
--- a/apps/dotcom/src/hooks/useRemoteSyncClient.ts
+++ b/apps/dotcom/src/hooks/useRemoteSyncClient.ts
@@ -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({
diff --git a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts
index 92e10fd0b..edd133970 100644
--- a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts
+++ b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.test.ts
@@ -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')
})
})
diff --git a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts
index cd7de836d..bd7d33c9f 100644
--- a/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts
+++ b/apps/dotcom/src/utils/remote-sync/ClientWebSocketAdapter.ts
@@ -1,5 +1,6 @@
import {
chunk,
+ TLCloseEventCode,
TLPersistentClientSocket,
TLPersistentClientSocketStatus,
TLSocketClientSentEvent,
@@ -68,15 +69,20 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket cb(newStatus))
+ this.statusListeners.forEach((cb) => cb(newStatus, closeCode))
}
this._reconnectManager.disconnected()
@@ -120,10 +126,10 @@ export class ClientWebSocketAdapter implements TLPersistentClientSocket {
+ 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 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)
diff --git a/apps/dotcom/styles/core.css b/apps/dotcom/styles/core.css
index 94f907e35..2ceb7f2a6 100644
--- a/apps/dotcom/styles/core.css
+++ b/apps/dotcom/styles/core.css
@@ -183,6 +183,7 @@ a {
font-weight: 500;
color: var(--text-color-2);
padding: 12px 4px;
+ text-decoration: underline;
}
/* ------------------ Board history ----------------- */
diff --git a/packages/tlsync/src/index.ts b/packages/tlsync/src/index.ts
index fd95e08d4..accdc1109 100644
--- a/packages/tlsync/src/index.ts
+++ b/packages/tlsync/src/index.ts
@@ -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,
diff --git a/packages/tlsync/src/lib/TLServer.ts b/packages/tlsync/src/lib/TLServer.ts
index ecafaab18..18cf37488 100644
--- a/packages/tlsync/src/lib/TLServer.ts
+++ b/packages/tlsync/src/lib/TLServer.ts
@@ -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 => {
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'
}
/**
diff --git a/packages/tlsync/src/lib/TLSyncClient.ts b/packages/tlsync/src/lib/TLSyncClient.ts
index 46b0b99e6..d045d07ad 100644
--- a/packages/tlsync/src/lib/TLSyncClient.ts
+++ b/packages/tlsync/src/lib/TLSyncClient.ts
@@ -24,6 +24,17 @@ import './requestAnimationFrame.polyfill'
type SubscribingFn = (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 = Store
})
)
}
+
// 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') {
diff --git a/packages/tlsync/src/lib/protocol.ts b/packages/tlsync/src/lib/protocol.ts
index 9bc21d364..588eefa22 100644
--- a/packages/tlsync/src/lib/protocol.ts
+++ b/packages/tlsync/src/lib/protocol.ts
@@ -10,6 +10,7 @@ export const TLIncompatibilityReason = {
ServerTooOld: 'serverTooOld',
InvalidRecord: 'invalidRecord',
InvalidOperation: 'invalidOperation',
+ RoomNotFound: 'roomNotFound',
} as const
/** @public */
diff --git a/packages/tlsync/src/test/TLServer.test.ts b/packages/tlsync/src/test/TLServer.test.ts
index ae5ece04c..523cae129 100644
--- a/packages/tlsync/src/test/TLServer.test.ts
+++ b/packages/tlsync/src/test/TLServer.test.ts
@@ -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 }),
+ PageRecordType.create({ index: ZERO_INDEX_KEY, name: 'page 2' }),
+]
+const makeSnapshot = (records: TLRecord[], others: Partial = {}) => ({
+ 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 {
- return { type: 'room_not_found' }
+ return { type: 'room_found', snapshot: makeSnapshot(records) }
}
override async persistToDatabase?(_roomId: string): Promise {
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 => {
+ 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 => {
+ return { type: 'room_not_found' }
+ }
+
+ const connectionResult = await openConnection()
+
+ expect(connectionResult).toBe('room_not_found')
+ })
})