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