diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d5cdc5bcf..6dd9ec193 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,6 +49,8 @@ jobs: - name: Check PR template run: yarn update-pr-template --check + env: + GH_TOKEN: ${{ github.token }} - name: Lint run: yarn lint diff --git a/apps/dotcom-asset-upload/package.json b/apps/dotcom-asset-upload/package.json index 7eb0f5579..1265c3f59 100644 --- a/apps/dotcom-asset-upload/package.json +++ b/apps/dotcom-asset-upload/package.json @@ -20,10 +20,10 @@ "itty-router": "^4.0.13" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20230821.0", + "@cloudflare/workers-types": "^4.20240620.0", "@types/ws": "^8.5.9", "lazyrepo": "0.0.0-alpha.27", - "wrangler": "3.19.0" + "wrangler": "3.61.0" }, "jest": { "preset": "config/jest/node", diff --git a/apps/dotcom-worker/package.json b/apps/dotcom-worker/package.json index b137d976f..726294bad 100644 --- a/apps/dotcom-worker/package.json +++ b/apps/dotcom-worker/package.json @@ -37,12 +37,12 @@ "toucan-js": "^2.7.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20230821.0", + "@cloudflare/workers-types": "^4.20240620.0", "concurrently": "^8.2.2", "lazyrepo": "0.0.0-alpha.27", "picocolors": "^1.0.0", "typescript": "^5.3.3", - "wrangler": "3.19.0" + "wrangler": "3.61.0" }, "jest": { "preset": "config/jest/node", diff --git a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts index ee2b4de5e..13cce46ac 100644 --- a/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts +++ b/apps/dotcom-worker/src/lib/TLDrawDurableObject.ts @@ -9,16 +9,12 @@ import { ROOM_PREFIX, type RoomOpenMode, } from '@tldraw/dotcom-shared' +import { TLRecord } from '@tldraw/tlschema' import { - DBLoadResultType, RoomSnapshot, TLCloseEventCode, - TLServer, - TLServerEvent, - TLSyncRoom, - type DBLoadResult, + TLSocketRoom, type PersistedRoomSnapshotForSupabase, - type RoomState, } from '@tldraw/tlsync' import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils' import { IRequest, Router } from 'itty-router' @@ -26,7 +22,8 @@ import Toucan from 'toucan-js' import { AlarmScheduler } from './AlarmScheduler' import { PERSIST_INTERVAL_MS } from './config' import { getR2KeyForRoom } from './r2' -import { Analytics, Environment } from './types' +import { Analytics, DBLoadResult, Environment, TLServerEvent } from './types' +import { createPersistQueue } from './utils/createPersistQueue' import { createSupabaseClient } from './utils/createSupabaseClient' import { getSlug } from './utils/roomOpenMode' import { throttle } from './utils/throttle' @@ -40,12 +37,84 @@ interface DocumentInfo { slug: string } -export class TLDrawDurableObject extends TLServer { +const ROOM_NOT_FOUND = Symbol('room_not_found') + +export class TLDrawDurableObject { // A unique identifier for this instance of the Durable Object id: DurableObjectId // For TLSyncRoom - _roomState: RoomState | undefined + _room: Promise> | null = null + + getRoom() { + if (!this._documentInfo) { + throw new Error('documentInfo must be present when accessing room') + } + const slug = this._documentInfo.slug + if (!this._room) { + this._room = this.loadFromDatabase(slug).then((result) => { + switch (result.type) { + case 'room_found': { + const room = new TLSocketRoom({ + initialSnapshot: result.snapshot, + onSessionRemoved: async (room, args) => { + this.logEvent({ + type: 'client', + roomId: slug, + name: 'leave', + instanceId: args.sessionKey, + localClientId: args.meta.storeId, + }) + + if (args.numSessionsRemaining > 0) return + if (!this._room) return + this.logEvent({ + type: 'client', + roomId: slug, + name: 'last_out', + instanceId: args.sessionKey, + localClientId: args.meta.storeId, + }) + try { + await this.persistToDatabase() + } catch (err) { + // already logged + } + // make sure nobody joined the room while we were persisting + if (room.getNumActiveSessions() > 0) return + this._room = null + this.logEvent({ type: 'room', roomId: slug, name: 'room_empty' }) + room.close() + }, + onDataChange: () => { + this.triggerPersistSchedule() + }, + onBeforeSendMessage: ({ message, stringified }) => { + this.logEvent({ + type: 'send_message', + roomId: slug, + messageType: message.type, + messageLength: stringified.length, + }) + }, + }) + this.logEvent({ type: 'room', roomId: slug, name: 'room_start' }) + return room + } + case 'room_not_found': { + throw ROOM_NOT_FOUND + } + case 'error': { + throw result.error + } + default: { + exhaustiveSwitchError(result) + } + } + }) + } + return this._room + } // For storage storage: DurableObjectStorage @@ -68,13 +137,11 @@ export class TLDrawDurableObject extends TLServer { _documentInfo: DocumentInfo | null = null constructor( - private controller: DurableObjectState, + private state: DurableObjectState, private env: Environment ) { - super() - - this.id = controller.id - this.storage = controller.storage + this.id = state.id + this.storage = state.storage this.sentryDSN = env.SENTRY_DSN this.measure = env.MEASURE this.supabaseClient = createSupabaseClient(env) @@ -85,7 +152,7 @@ export class TLDrawDurableObject extends TLServer { versionCache: env.ROOMS_HISTORY_EPHEMERAL, } - controller.blockConcurrencyWhile(async () => { + state.blockConcurrencyWhile(async () => { const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) { this._documentInfo = null @@ -122,9 +189,7 @@ export class TLDrawDurableObject extends TLServer { storage: () => this.storage, alarms: { persist: async () => { - const room = this.getRoomForPersistenceKey(this.documentInfo.slug) - if (!room) return - this.persistToDatabase(room.persistenceKey) + this.persistToDatabase() }, }, }) @@ -176,53 +241,31 @@ export class TLDrawDurableObject extends TLServer { } } + _isRestoring = false async onRestore(req: IRequest) { - const roomId = this.documentInfo.slug - const roomKey = getR2KeyForRoom(roomId) - const timestamp = ((await req.json()) as any).timestamp - if (!timestamp) { - return new Response('Missing timestamp', { status: 400 }) - } - const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`) - if (!data) { - return new Response('Version not found', { status: 400 }) - } - const dataText = await data.text() - await this.r2.rooms.put(roomKey, dataText) - const roomState = this.getRoomForPersistenceKey(roomId) - if (!roomState) { - // nothing else to do because the room is not currently in use + this._isRestoring = true + try { + const roomId = this.documentInfo.slug + const roomKey = getR2KeyForRoom(roomId) + const timestamp = ((await req.json()) as any).timestamp + if (!timestamp) { + return new Response('Missing timestamp', { status: 400 }) + } + const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`) + if (!data) { + return new Response('Version not found', { status: 400 }) + } + const dataText = await data.text() + await this.r2.rooms.put(roomKey, dataText) + const room = await this.getRoom() + + const snapshot: RoomSnapshot = JSON.parse(dataText) + room.loadSnapshot(snapshot) + return new Response() + } finally { + this._isRestoring = false } - const snapshot: RoomSnapshot = JSON.parse(dataText) - const oldRoom = roomState.room - const oldIds = oldRoom.getSnapshot().documents.map((d) => d.state.id) - const newIds = new Set(snapshot.documents.map((d) => d.state.id)) - const removedIds = oldIds.filter((id) => !newIds.has(id)) - - const tombstones = { ...snapshot.tombstones } - removedIds.forEach((id) => { - tombstones[id] = oldRoom.clock + 1 - }) - newIds.forEach((id) => { - delete tombstones[id] - }) - - const newRoom = new TLSyncRoom(roomState.room.schema, { - clock: oldRoom.clock + 1, - documents: snapshot.documents.map((d) => ({ - lastChangedClock: oldRoom.clock + 1, - state: d.state, - })), - schema: snapshot.schema, - tombstones, - }) - - // replace room with new one and kick out all the clients - this.setRoomState(this.documentInfo.slug, { ...roomState, room: newRoom }) - oldRoom.close() - - return new Response() } async onRequest(req: IRequest) { @@ -234,58 +277,51 @@ export class TLDrawDurableObject extends TLServer { // handle legacy param names sessionKey ??= params.instanceId storeId ??= params.localClientId - - // Don't connect if we're already at max connections - const roomState = this.getRoomForPersistenceKey(this.documentInfo.slug) - if (roomState !== undefined) { - if (roomState.room.sessions.size >= MAX_CONNECTIONS) { - return new Response('Room is full', { - status: 403, - }) - } - } + const isNewSession = !this._room // Create the websocket pair for the client 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 - connectionResult = await this.controller.blockConcurrencyWhile(() => - this.handleConnection({ - socket: serverWebSocket as any, - persistenceKey: this.documentInfo.slug!, - sessionKey, - storeId, - }) - ) - } catch (e: any) { - console.error(e) - return new Response(e.message, { status: 500 }) - } - - // Accept the websocket connection serverWebSocket.accept() - serverWebSocket.addEventListener( - 'message', - throttle(() => { - this.schedulePersist() - }, 2000) - ) - serverWebSocket.addEventListener('close', () => { - 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') + try { + const room = await this.getRoom() + // Don't connect if we're already at max connections + if (room.getNumActiveSessions() >= MAX_CONNECTIONS) { + return new Response('Room is full', { status: 403 }) + } + + // all good + room.handleSocketConnect(sessionKey, serverWebSocket, { storeId }) + if (isNewSession) { + this.logEvent({ + type: 'client', + roomId: this.documentInfo.slug, + name: 'room_reopen', + instanceId: sessionKey, + localClientId: storeId, + }) + } + this.logEvent({ + type: 'client', + roomId: this.documentInfo.slug, + name: 'enter', + instanceId: sessionKey, + localClientId: storeId, + }) + return new Response(null, { status: 101, webSocket: clientWebSocket }) + } catch (e) { + if (e === ROOM_NOT_FOUND) { + serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found') + return new Response(null, { status: 101, webSocket: clientWebSocket }) + } + throw e } - - return new Response(null, { status: 101, webSocket: clientWebSocket }) } + triggerPersistSchedule = throttle(() => { + this.schedulePersist() + }, 2000) + private writeEvent( name: string, { blobs, indexes, doubles }: { blobs?: string[]; indexes?: [string]; doubles?: number[] } @@ -307,7 +343,7 @@ export class TLDrawDurableObject extends TLServer { case 'client': { // we would add user/connection ids here if we could this.writeEvent(event.name, { - blobs: [event.roomId, event.clientId, event.instanceId], + blobs: [event.roomId, 'unused', event.instanceId], indexes: [event.localClientId], }) break @@ -325,21 +361,8 @@ export class TLDrawDurableObject extends TLServer { } } - getRoomForPersistenceKey(_persistenceKey: string): RoomState | undefined { - return this._roomState // only one room per worker - } - - setRoomState(_persistenceKey: string, roomState: RoomState): void { - this.deleteRoomState() - this._roomState = roomState - } - - deleteRoomState(): void { - this._roomState = undefined - } - // Load the room's drawing data. First we check the R2 bucket, then we fallback to supabase (legacy). - override async loadFromDatabase(persistenceKey: string): Promise { + async loadFromDatabase(persistenceKey: string): Promise { try { const key = getR2KeyForRoom(persistenceKey) // when loading, prefer to fetch documents from the bucket @@ -376,48 +399,31 @@ export class TLDrawDurableObject extends TLServer { } } - _isPersisting = false _lastPersistedClock: number | null = null + _persistQueue = createPersistQueue(async () => { + // check whether the worker was woken up to persist after having gone to sleep + if (!this._room) return + const slug = this.documentInfo.slug + const room = await this.getRoom() + const clock = room.getCurrentDocumentClock() + if (this._lastPersistedClock === clock) return + if (this._isRestoring) return + + const snapshot = JSON.stringify(room.getCurrentSnapshot()) + + const key = getR2KeyForRoom(slug) + await Promise.all([ + this.r2.rooms.put(key, snapshot), + this.r2.versionCache.put(key + `/` + new Date().toISOString(), snapshot), + ]) + this._lastPersistedClock = clock + // use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop + // just in case there's any possibility of setting up a neverending queue + }, PERSIST_INTERVAL_MS / 2) // Save the room to supabase - async persistToDatabase(persistenceKey: string) { - if (this._isPersisting) { - setTimeout(() => { - this.schedulePersist() - }, 5000) - return - } - - try { - this._isPersisting = true - - const roomState = this.getRoomForPersistenceKey(persistenceKey) - if (!roomState) { - // room was closed - return - } - - const { room } = roomState - const { clock } = room - if (this._lastPersistedClock === clock) return - - try { - const snapshot = JSON.stringify(room.getSnapshot()) - - const key = getR2KeyForRoom(persistenceKey) - await Promise.all([ - this.r2.rooms.put(key, snapshot), - this.r2.versionCache.put(key + `/` + new Date().toISOString(), snapshot), - ]) - this._lastPersistedClock = clock - } catch (error) { - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'failed_persist_to_db' }) - console.error('failed to persist document', persistenceKey, error) - throw error - } - } finally { - this._isPersisting = false - } + async persistToDatabase() { + await this._persistQueue() } async schedulePersist() { diff --git a/apps/dotcom-worker/src/lib/types.ts b/apps/dotcom-worker/src/lib/types.ts index e3824730d..17c99715a 100644 --- a/apps/dotcom-worker/src/lib/types.ts +++ b/apps/dotcom-worker/src/lib/types.ts @@ -1,5 +1,7 @@ // https://developers.cloudflare.com/analytics/analytics-engine/ +import { RoomSnapshot } from '@tldraw/tlsync' + // This type isn't available in @cloudflare/workers-types yet export interface Analytics { writeDataPoint(data: { @@ -35,3 +37,41 @@ export interface Environment { IS_LOCAL: string | undefined WORKER_NAME: string | undefined } + +export type DBLoadResult = + | { + type: 'error' + error?: Error | undefined + } + | { + type: 'room_found' + snapshot: RoomSnapshot + } + | { + type: 'room_not_found' + } + +export type TLServerEvent = + | { + type: 'client' + name: 'room_create' | 'room_reopen' | 'enter' | 'leave' | 'last_out' + roomId: string + instanceId: string + localClientId: string + } + | { + type: 'room' + name: + | 'failed_load_from_db' + | 'failed_persist_to_db' + | 'room_empty' + | 'fail_persist' + | 'room_start' + roomId: string + } + | { + type: 'send_message' + roomId: string + messageType: string + messageLength: number + } diff --git a/apps/dotcom-worker/src/lib/utils/createPersistQueue.test.ts b/apps/dotcom-worker/src/lib/utils/createPersistQueue.test.ts new file mode 100644 index 000000000..2c7eed236 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/createPersistQueue.test.ts @@ -0,0 +1,49 @@ +import { promiseWithResolve } from '@tldraw/utils' +import { createPersistQueue } from './createPersistQueue' + +const tick = () => Promise.resolve() + +describe('createPersistQueue', () => { + it('creates a function that runs some async function when invoked', async () => { + let numExecutions = 0 + const persist = createPersistQueue(async () => { + numExecutions++ + }, 10) + + expect(numExecutions).toBe(0) + await tick() + // nothing happens until we call the function + expect(numExecutions).toBe(0) + + await persist() + expect(numExecutions).toBe(1) + + await tick() + expect(numExecutions).toBe(1) + }) + + it('will queue up a second invocation if invoked while executing', async () => { + let numExecutions = 0 + const promise = promiseWithResolve() + const persist = createPersistQueue(async () => { + await promise + numExecutions++ + }, 10) + + const persistPromiseA = persist() + await tick() + expect(numExecutions).toBe(0) + persist() + persist() + const persistPromiseB = persist() + await tick() + expect(numExecutions).toBe(0) + + // nothing happens until we resolve the promise + promise.resolve(undefined) + await persistPromiseA + expect(numExecutions).toBe(2) + await persistPromiseB + expect(numExecutions).toBe(2) + }) +}) diff --git a/apps/dotcom-worker/src/lib/utils/createPersistQueue.ts b/apps/dotcom-worker/src/lib/utils/createPersistQueue.ts new file mode 100644 index 000000000..9c2fb8137 --- /dev/null +++ b/apps/dotcom-worker/src/lib/utils/createPersistQueue.ts @@ -0,0 +1,31 @@ +// Save the room to supabase +export function createPersistQueue(persist: () => Promise, timeout: number) { + let persistAgain = false + let queue: null | Promise = null + // check whether the worker was woken up to persist after having gone to sleep + return async () => { + if (queue) { + persistAgain = true + return await queue + } + + try { + queue = Promise.resolve( + (async () => { + do { + if (persistAgain) { + if (timeout > 0) { + await new Promise((resolve) => setTimeout(resolve, timeout)) + } + persistAgain = false + } + await persist() + } while (persistAgain) + })() + ) + await queue + } finally { + queue = null + } + } +} diff --git a/apps/dotcom-worker/wrangler.toml b/apps/dotcom-worker/wrangler.toml index b5d904b61..d82806060 100644 --- a/apps/dotcom-worker/wrangler.toml +++ b/apps/dotcom-worker/wrangler.toml @@ -1,5 +1,5 @@ main = "src/lib/worker.ts" -compatibility_date = "2023-10-16" +compatibility_date = "2024-06-19" [dev] port = 8787 diff --git a/apps/health-worker/package.json b/apps/health-worker/package.json index ed01a8a6a..bf7b2aea7 100644 --- a/apps/health-worker/package.json +++ b/apps/health-worker/package.json @@ -12,10 +12,10 @@ "@tldraw/utils": "workspace:*" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20230821.0", + "@cloudflare/workers-types": "^4.20240620.0", "@types/node": "~20.11", "discord-api-types": "^0.37.67", "typescript": "^5.3.3", - "wrangler": "3.19.0" + "wrangler": "3.61.0" } } diff --git a/packages/tlsync/src/index.ts b/packages/tlsync/src/index.ts index 578ad6112..4a107d405 100644 --- a/packages/tlsync/src/index.ts +++ b/packages/tlsync/src/index.ts @@ -1,9 +1,4 @@ -export { - TLServer, - type DBLoadResult, - type DBLoadResultType, - type TLServerEvent, -} from './lib/TLServer' +export { TLSocketRoom } from './lib/TLSocketRoom' export { TLCloseEventCode, TLSyncClient, @@ -37,4 +32,4 @@ export { type TLSocketServerSentEvent, } from './lib/protocol' export { schema } from './lib/schema' -export type { PersistedRoomSnapshotForSupabase, RoomState as RoomState } from './lib/server-types' +export type { PersistedRoomSnapshotForSupabase } from './lib/server-types' diff --git a/packages/tlsync/src/lib/RoomSession.ts b/packages/tlsync/src/lib/RoomSession.ts index 69c25be0a..77e2b5366 100644 --- a/packages/tlsync/src/lib/RoomSession.ts +++ b/packages/tlsync/src/lib/RoomSession.ts @@ -14,13 +14,14 @@ export const SESSION_START_WAIT_TIME = 10000 export const SESSION_REMOVAL_WAIT_TIME = 10000 export const SESSION_IDLE_TIMEOUT = 20000 -export type RoomSession = +export type RoomSession = | { state: typeof RoomSessionState.AwaitingConnectMessage sessionKey: string presenceId: string socket: TLRoomSocket sessionStartTime: number + meta: Meta } | { state: typeof RoomSessionState.AwaitingRemoval @@ -28,6 +29,7 @@ export type RoomSession = presenceId: string socket: TLRoomSocket cancellationTime: number + meta: Meta } | { state: typeof RoomSessionState.Connected @@ -38,4 +40,5 @@ export type RoomSession = lastInteractionTime: number debounceTimer: ReturnType | null outstandingDataMessages: TLSocketServerSentDataEvent[] + meta: Meta } diff --git a/packages/tlsync/src/lib/ServerSocketAdapter.ts b/packages/tlsync/src/lib/ServerSocketAdapter.ts index a393a4f87..136b601c7 100644 --- a/packages/tlsync/src/lib/ServerSocketAdapter.ts +++ b/packages/tlsync/src/lib/ServerSocketAdapter.ts @@ -3,14 +3,14 @@ import ws from 'ws' import { TLRoomSocket } from './TLSyncRoom' import { TLSocketServerSentEvent } from './protocol' -interface ServerSocketAdapterOptions { +interface ServerSocketAdapterOptions { readonly ws: WebSocket | ws.WebSocket - readonly logSendMessage: (type: string, size: number) => void + readonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent, stringified: string) => void } /** @public */ export class ServerSocketAdapter implements TLRoomSocket { - constructor(public readonly opts: ServerSocketAdapterOptions) {} + constructor(public readonly opts: ServerSocketAdapterOptions) {} // eslint-disable-next-line no-restricted-syntax get isOpen(): boolean { return this.opts.ws.readyState === 1 // ready state open @@ -18,7 +18,7 @@ export class ServerSocketAdapter implements TLRoomSocke // see TLRoomSocket for details on why this accepts a union and not just arrays sendMessage(msg: TLSocketServerSentEvent) { const message = JSON.stringify(msg) - this.opts.logSendMessage(msg.type, message.length) + this.opts.onBeforeSendMessage?.(msg, message) this.opts.ws.send(message) } close() { diff --git a/packages/tlsync/src/lib/TLServer.ts b/packages/tlsync/src/lib/TLServer.ts deleted file mode 100644 index 18cf37488..000000000 --- a/packages/tlsync/src/lib/TLServer.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { nanoid } from 'nanoid' -import * as WebSocket from 'ws' -import { ServerSocketAdapter } from './ServerSocketAdapter' -import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom' -import { JsonChunkAssembler } from './chunk' -import { schema } from './schema' -import { RoomState } from './server-types' - -type LoadKind = 'reopen' | 'open' | 'room_not_found' -export type DBLoadResult = - | { - type: 'error' - error?: Error | undefined - } - | { - type: 'room_found' - snapshot: RoomSnapshot - } - | { - type: 'room_not_found' - } -export type DBLoadResultType = DBLoadResult['type'] - -export type TLServerEvent = - | { - type: 'client' - name: 'room_create' | 'room_reopen' | 'enter' | 'leave' | 'last_out' - roomId: string - clientId: string - instanceId: string - localClientId: string - } - | { - type: 'room' - name: - | 'failed_load_from_db' - | 'failed_persist_to_db' - | 'room_empty' - | 'fail_persist' - | 'room_start' - roomId: string - } - | { - type: 'send_message' - roomId: string - messageType: string - messageLength: number - } - -/** - * This class manages rooms for a websocket server. - * - * @public - */ -export abstract class TLServer { - schema = schema - - async getInitialRoomState(persistenceKey: string): Promise<[RoomState | undefined, LoadKind]> { - let roomState = this.getRoomForPersistenceKey(persistenceKey) - - let roomOpenKind: LoadKind = 'open' - - // If no room exists for the id, create one - if (roomState === undefined) { - // Try to load a room from persistence - if (this.loadFromDatabase) { - const data = await this.loadFromDatabase(persistenceKey) - if (data.type === 'error') { - throw data.error - } - - if (data.type === 'room_found') { - roomOpenKind = 'reopen' - - roomState = { - persistenceKey, - room: new TLSyncRoom(this.schema, data.snapshot), - } - } - } - - // If we still don't have a room, throw an error. - if (roomState === undefined) { - // 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 - - roomState.room.events.on('room_became_empty', async () => { - // Record that the room is now empty - const roomState = this.getRoomForPersistenceKey(persistenceKey) - if (!roomState || roomState.room !== thisRoom) { - // room was already closed - return - } - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_empty' }) - this.deleteRoomState(persistenceKey) - roomState.room.close() - - try { - await this.persistToDatabase?.(persistenceKey) - } catch (err) { - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'fail_persist' }) - console.error('failed to save to storage', err) - } - }) - - // persist on an interval... - this.setRoomState(persistenceKey, roomState) - - // If we created a new room, then persist to the database again; - // we may have run migrations or cleanup, so let's make sure that - // the new data is put back into the database. - this.persistToDatabase?.(persistenceKey) - } - - return [roomState, roomOpenKind] - } - - /** - * When a connection comes in, set up the client and event listeners for the client's room. The - * roomId is the websocket's protocol. - * - * @param ws - The client's websocket connection. - * @public - */ - handleConnection = async ({ - socket, - persistenceKey, - sessionKey, - storeId, - }: { - socket: WebSocket.WebSocket - 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, - new ServerSocketAdapter({ - ws: socket, - logSendMessage: (messageType, messageLength) => - this.logEvent({ - type: 'send_message', - roomId: persistenceKey, - messageType, - messageLength, - }), - }) - ) - - if (roomOpenKind === 'reopen') { - // Record that the room is now active - this.logEvent({ type: 'room', roomId: persistenceKey, name: 'room_start' }) - - // Record what kind of room start event this is (why don't we extend the previous event? or even remove it?) - this.logEvent({ - type: 'client', - roomId: persistenceKey, - name: 'room_reopen', - clientId, - instanceId: sessionKey, - localClientId: storeId, - }) - } - - // Record that the user entered the room - this.logEvent({ - type: 'client', - roomId: persistenceKey, - name: 'enter', - clientId, - instanceId: sessionKey, - localClientId: storeId, - }) - - // Handle a 'message' event from the server. - const assembler = new JsonChunkAssembler() - const handleMessageFromClient = (event: WebSocket.MessageEvent) => { - try { - if (typeof event.data === 'string') { - const res = assembler.handleMessage(event.data) - if (res?.data) { - roomState.room.handleMessage(sessionKey, res.data as any) - } - if (res?.error) { - console.warn('Error assembling message', res.error) - } - } else { - console.warn('Unknown message type', typeof event.data) - } - } catch (e) { - console.error(e) - socket.send(JSON.stringify({ type: 'error', error: e })) - socket.close(400) - } - } - - const handleCloseOrErrorFromClient = () => { - // Remove the client from the room and delete associated user data. - roomState?.room.handleClose(sessionKey) - } - - const unsub = roomState.room.events.on('session_removed', async (ev) => { - // Record who the last person to leave the room was - if (sessionKey !== ev.sessionKey) return - unsub() - this.logEvent({ - type: 'client', - roomId: persistenceKey, - name: 'leave', - clientId, - instanceId: sessionKey, - localClientId: storeId, - }) - this.logEvent({ - type: 'client', - roomId: persistenceKey, - name: 'last_out', - clientId, - instanceId: sessionKey, - localClientId: storeId, - }) - - socket.removeEventListener('message', handleMessageFromClient) - socket.removeEventListener('close', handleCloseOrErrorFromClient) - socket.removeEventListener('error', handleCloseOrErrorFromClient) - }) - - socket.addEventListener('message', handleMessageFromClient) - socket.addEventListener('close', handleCloseOrErrorFromClient) - socket.addEventListener('error', handleCloseOrErrorFromClient) - - return 'room_found' - } - - /** - * Load data from a database. (Optional) - * - * @param roomId - The id of the room to load. - * @public - */ - abstract loadFromDatabase?(roomId: string): Promise - - /** - * Persist data to a database. (Optional) - * - * @param roomId - The id of the room to load. - * @public - */ - abstract persistToDatabase?(roomId: string): Promise - - /** - * Log an event. (Optional) - * - * @param event - The event to log. - * @public - */ - abstract logEvent(event: TLServerEvent): void - - /** - * Get a room by its id. - * - * @param persistenceKey - The id of the room to get. - * @public - */ - abstract getRoomForPersistenceKey(persistenceKey: string): RoomState | undefined - - /** - * Set a room to an id. - * - * @param persistenceKey - The id of the room to set. - * @param roomState - The room to set. - * @public - */ - abstract setRoomState(persistenceKey: string, roomState: RoomState): void - - /** - * Delete a room by its id. - * - * @param persistenceKey - The id of the room to delete. - * @public - */ - abstract deleteRoomState(persistenceKey: string): void -} diff --git a/packages/tlsync/src/lib/TLSocketRoom.ts b/packages/tlsync/src/lib/TLSocketRoom.ts new file mode 100644 index 000000000..ea42ee844 --- /dev/null +++ b/packages/tlsync/src/lib/TLSocketRoom.ts @@ -0,0 +1,187 @@ +import { StoreSchema, UnknownRecord, createTLSchema } from 'tldraw' +import { ServerSocketAdapter } from './ServerSocketAdapter' +import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom' +import { JsonChunkAssembler } from './chunk' +import { TLSocketServerSentEvent } from './protocol' + +// TODO: structured logging support +interface TLSyncLog { + info?: (...args: any[]) => void + warn?: (...args: any[]) => void + error?: (...args: any[]) => void +} + +export class TLSocketRoom { + private room: TLSyncRoom + private readonly sessions = new Map< + string, + { assembler: JsonChunkAssembler; socket: WebSocket; unlisten: () => void } + >() + readonly log: TLSyncLog + constructor( + public readonly opts: { + initialSnapshot?: RoomSnapshot + schema?: StoreSchema + // how long to wait for a client to communicate before disconnecting them + clientTimeout?: number + log?: TLSyncLog + // a callback that is called when a client is disconnected + onSessionRemoved?: ( + room: TLSocketRoom, + args: { sessionKey: string; numSessionsRemaining: number; meta: SessionMeta } + ) => void + // a callback that is called whenever a message is sent + onBeforeSendMessage?: (args: { + sessionId: string + message: TLSocketServerSentEvent + stringified: string + }) => void + onDataChange?: () => void + } + ) { + const initialClock = opts.initialSnapshot?.clock ?? 0 + this.room = new TLSyncRoom( + opts.schema ?? (createTLSchema() as any), + opts.initialSnapshot + ) + if (this.room.clock !== initialClock) { + this.opts?.onDataChange?.() + } + this.room.events.on('session_removed', (args) => { + this.sessions.delete(args.sessionKey) + if (this.opts.onSessionRemoved) { + this.opts.onSessionRemoved(this, { + sessionKey: args.sessionKey, + numSessionsRemaining: this.room.sessions.size, + meta: args.meta, + }) + } + }) + this.log = opts.log ?? console + } + getNumActiveSessions() { + return this.room.sessions.size + } + + handleSocketConnect(sessionId: string, socket: WebSocket, meta: SessionMeta) { + const handleSocketMessage = (event: MessageEvent) => + this.handleSocketMessage(sessionId, event.data) + const handleSocketError = this.handleSocketError.bind(this, sessionId) + const handleSocketClose = this.handleSocketClose.bind(this, sessionId) + + this.sessions.set(sessionId, { + assembler: new JsonChunkAssembler(), + socket, + unlisten: () => { + socket.removeEventListener('message', handleSocketMessage) + socket.removeEventListener('close', handleSocketClose) + socket.removeEventListener('error', handleSocketError) + }, + }) + + this.room.handleNewSession( + sessionId, + new ServerSocketAdapter({ + ws: socket, + onBeforeSendMessage: this.opts.onBeforeSendMessage + ? (message, stringified) => + this.opts.onBeforeSendMessage!({ + sessionId, + message, + stringified, + }) + : undefined, + }), + meta + ) + + socket.addEventListener('message', handleSocketMessage) + socket.addEventListener('close', handleSocketClose) + socket.addEventListener('error', handleSocketError) + } + + handleSocketMessage(sessionId: string, message: string | ArrayBuffer) { + const documentClockAtStart = this.room.documentClock + const assembler = this.sessions.get(sessionId)?.assembler + if (!assembler) { + this.log.warn?.('Received message from unknown session', sessionId) + return + } + + try { + const messageString = + typeof message === 'string' ? message : new TextDecoder().decode(message) + const res = assembler.handleMessage(messageString) + if (res?.data) { + this.room.handleMessage(sessionId, res.data as any) + } + if (res?.error) { + this.log.warn?.('Error assembling message', res.error) + } + } catch (e) { + this.log.error?.(e) + const socket = this.sessions.get(sessionId)?.socket + if (socket) { + socket.send( + JSON.stringify({ + type: 'error', + error: typeof e?.toString === 'function' ? e.toString() : e, + } satisfies TLSocketServerSentEvent) + ) + socket.close() + } + } finally { + if (this.room.documentClock !== documentClockAtStart) { + this.opts.onDataChange?.() + } + } + } + + handleSocketError(sessionId: string) { + this.room.handleClose(sessionId) + } + + handleSocketClose(sessionId: string) { + this.room.handleClose(sessionId) + } + + getCurrentDocumentClock() { + return this.room.documentClock + } + getCurrentSnapshot() { + return this.room.getSnapshot() + } + + loadSnapshot(snapshot: RoomSnapshot) { + const oldRoom = this.room + const oldIds = oldRoom.getSnapshot().documents.map((d) => d.state.id) + const newIds = new Set(snapshot.documents.map((d) => d.state.id)) + const removedIds = oldIds.filter((id) => !newIds.has(id)) + + const tombstones = { ...snapshot.tombstones } + removedIds.forEach((id) => { + tombstones[id] = oldRoom.clock + 1 + }) + newIds.forEach((id) => { + delete tombstones[id] + }) + + const newRoom = new TLSyncRoom(oldRoom.schema, { + clock: oldRoom.clock + 1, + documents: snapshot.documents.map((d) => ({ + lastChangedClock: oldRoom.clock + 1, + state: d.state, + })), + schema: snapshot.schema, + tombstones, + }) + + // replace room with new one and kick out all the clients + this.room = newRoom + oldRoom.close() + } + + close() { + this.room.close() + } +} diff --git a/packages/tlsync/src/lib/TLSyncRoom.ts b/packages/tlsync/src/lib/TLSyncRoom.ts index a1b895426..38af31b50 100644 --- a/packages/tlsync/src/lib/TLSyncRoom.ts +++ b/packages/tlsync/src/lib/TLSyncRoom.ts @@ -133,9 +133,9 @@ export interface RoomSnapshot { * * @public */ -export class TLSyncRoom { +export class TLSyncRoom { // A table of connected clients - readonly sessions = new Map>() + readonly sessions = new Map>() pruneSessions = () => { for (const client of this.sessions.values()) { @@ -180,7 +180,7 @@ export class TLSyncRoom { readonly events = createNanoEvents<{ room_became_empty: () => void - session_removed: (args: { sessionKey: string }) => void + session_removed: (args: { sessionKey: string; meta: SessionMeta }) => void }>() // Values associated with each uid (must be serializable). @@ -196,6 +196,7 @@ export class TLSyncRoom { // initial lastServerClock value get the full state // in this case clients will start with 0, and the server will start with 1 clock = 1 + documentClock = 1 tombstoneHistoryStartsAtClock = this.clock // map from record id to clock upon deletion @@ -324,6 +325,7 @@ export class TLSyncRoom { this.state.set({ documents, tombstones }) this.pruneTombstones() + this.documentClock = this.clock } private pruneTombstones = () => { @@ -484,7 +486,7 @@ export class TLSyncRoom { }) } - this.events.emit('session_removed', { sessionKey }) + this.events.emit('session_removed', { sessionKey, meta: session.meta }) if (this.sessions.size === 0) { this.events.emit('room_became_empty') } @@ -507,6 +509,7 @@ export class TLSyncRoom { presenceId: session.presenceId, socket: session.socket, cancellationTime: Date.now(), + meta: session.meta, }) } @@ -560,7 +563,7 @@ export class TLSyncRoom { * @param sessionKey - The session of the client that connected to the room. * @param socket - Their socket. */ - handleNewSession = (sessionKey: string, socket: TLRoomSocket) => { + handleNewSession = (sessionKey: string, socket: TLRoomSocket, meta: SessionMeta) => { const existing = this.sessions.get(sessionKey) this.sessions.set(sessionKey, { state: RoomSessionState.AwaitingConnectMessage, @@ -568,6 +571,7 @@ export class TLSyncRoom { socket, presenceId: existing?.presenceId ?? this.presenceType.createId(), sessionStartTime: Date.now(), + meta, }) return this } @@ -647,7 +651,7 @@ export class TLSyncRoom { } /** If the client is out of date, or we are out of date, we need to let them know */ - private rejectSession(session: RoomSession, reason: TLIncompatibilityReason) { + private rejectSession(session: RoomSession, reason: TLIncompatibilityReason) { try { if (session.socket.isOpen) { session.socket.sendMessage({ @@ -663,7 +667,7 @@ export class TLSyncRoom { } private handleConnectRequest( - session: RoomSession, + session: RoomSession, message: Extract, { type: 'connect' }> ) { // if the protocol versions don't match, disconnect the client @@ -708,6 +712,7 @@ export class TLSyncRoom { lastInteractionTime: Date.now(), debounceTimer: null, outstandingDataMessages: [], + meta: session.meta, }) this.sendMessage(session.sessionKey, msg) } @@ -797,7 +802,7 @@ export class TLSyncRoom { } private handlePushRequest( - session: RoomSession, + session: RoomSession, message: Extract, { type: 'push' }> ) { // We must be connected to handle push requests @@ -1058,6 +1063,10 @@ export class TLSyncRoom { }) } + if (docChanges.diff) { + this.documentClock = this.clock + } + return }) } diff --git a/packages/tlsync/src/lib/server-types.ts b/packages/tlsync/src/lib/server-types.ts index 76b24cace..911b76399 100644 --- a/packages/tlsync/src/lib/server-types.ts +++ b/packages/tlsync/src/lib/server-types.ts @@ -1,12 +1,4 @@ -import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom' - -/** @public */ -export interface RoomState { - // the slug of the room - persistenceKey: string - // the room - room: TLSyncRoom -} +import { RoomSnapshot } from './TLSyncRoom' /** @public */ export interface PersistedRoomSnapshotForSupabase { diff --git a/packages/tlsync/src/test/TLServer.test.ts b/packages/tlsync/src/test/TLServer.test.ts deleted file mode 100644 index 153320632..000000000 --- a/packages/tlsync/src/test/TLServer.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - DocumentRecordType, - PageRecordType, - RecordId, - TLDocument, - TLRecord, - ZERO_INDEX_KEY, - createTLSchema, -} 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 { TLSocketClientSentEvent, getTlsyncProtocolVersion } from '../lib/protocol' -import { RoomState } from '../lib/server-types' - -// Because we are using jsdom in this package, jest tries to load the 'browser' version of the ws library -// which doesn't do anything except throw an error. So we need to sneakily load the node version of ws. -const wsPath = require.resolve('ws').replace('/browser.js', '/index.js') -// eslint-disable-next-line @typescript-eslint/no-var-requires -const ws = require(wsPath) as typeof import('ws') - -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() { - await new Promise((resolve) => { - this.wsServer.close((err) => { - if (err) { - console.error(err) - } - resolve(err) - }) - }) - } - async createSocketPair() { - const connectionPromise = new Promise((resolve) => { - this.wsServer.on('connection', resolve) - }) - - const client = new ws.WebSocket('ws://localhost:' + PORT) - disposables.push(() => { - client.close() - }) - const openPromise = new Promise((resolve) => { - client.on('open', resolve) - }) - - const server = await connectionPromise - disposables.push(() => { - server.close() - }) - await openPromise - - return { - client, - server, - } - } - override async loadFromDatabase?(_roomId: string): Promise { - return { type: 'room_found', snapshot: makeSnapshot(records) } - } - override async persistToDatabase?(_roomId: string): Promise { - return - } - override logEvent(_event: any): void { - return - } - roomState: RoomState | undefined = undefined - override getRoomForPersistenceKey(_persistenceKey: string): RoomState | undefined { - return this.roomState - } - override setRoomState(_persistenceKey: string, roomState: RoomState): void { - this.roomState = roomState - } - override deleteRoomState(_persistenceKey: string): void { - this.roomState = undefined - } -} -type UnpackPromise = T extends Promise ? U : T - -const schema = createTLSchema().serialize() - -let server: TLServerTestImpl -let sockets: UnpackPromise> -beforeEach(async () => { - server = new TLServerTestImpl() - 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 () => { - const result = await server.handleConnection({ - persistenceKey: 'test-persistence-key', - sessionKey: 'test-session-key', - socket: sockets.server, - storeId: 'test-store-id', - }) - - return result -} - -afterEach(async () => { - disposables.forEach((d) => d()) - disposables.length = 0 - await server.close() -}) - -describe('TLServer', () => { - it('accepts new connections', async () => { - await openConnection() - - expect(server.roomState).not.toBeUndefined() - expect(server.roomState?.persistenceKey).toBe('test-persistence-key') - expect(server.roomState?.room.sessions.size).toBe(1) - expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( - RoomSessionState.AwaitingConnectMessage - ) - }) - - it('allows requests to be chunked', async () => { - await openConnection() - - const connectMsg: TLSocketClientSentEvent = { - type: 'connect', - lastServerClock: 0, - connectRequestId: 'test-connect-request-id', - protocolVersion: getTlsyncProtocolVersion(), - schema, - } - - const chunks = chunk(JSON.stringify(connectMsg), 200) - expect(chunks.length).toBeGreaterThan(1) - - const onClientMessage = jest.fn() - const receivedPromise = new Promise((resolve) => { - onClientMessage.mockImplementationOnce(resolve) - }) - - sockets.client.on('message', onClientMessage) - - expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( - RoomSessionState.AwaitingConnectMessage - ) - - for (const chunk of chunks) { - sockets.client.send(chunk) - } - - await receivedPromise - - expect(server.roomState?.room.sessions.get('test-session-key')?.state).toBe( - RoomSessionState.Connected - ) - - expect(onClientMessage).toHaveBeenCalledTimes(1) - expect(JSON.parse(onClientMessage.mock.calls[0][0])).toMatchObject({ - connectRequestId: 'test-connect-request-id', - hydrationType: 'wipe_all', - diff: { - 'document:document': [ - RecordOpType.Put, - { - /* ... */ - }, - ], - }, - }) - }) - - 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') - }) -}) diff --git a/packages/tlsync/src/test/TLSyncRoom.test.ts b/packages/tlsync/src/test/TLSyncRoom.test.ts index ccd70fe1c..2d7acb877 100644 --- a/packages/tlsync/src/test/TLSyncRoom.test.ts +++ b/packages/tlsync/src/test/TLSyncRoom.test.ts @@ -64,14 +64,14 @@ const oldArrow: TLBaseShape<'arrow', Omit> = { describe('TLSyncRoom', () => { it('can be constructed with a schema alone', () => { - const room = new TLSyncRoom(schema) + const room = new TLSyncRoom(schema) // we populate the store with a default document if none is given expect(room.getSnapshot().documents.length).toBeGreaterThan(0) }) it('can be constructed with a snapshot', () => { - const room = new TLSyncRoom(schema, makeSnapshot(records)) + const room = new TLSyncRoom(schema, makeSnapshot(records)) expect( room diff --git a/packages/tlsync/src/test/TestServer.ts b/packages/tlsync/src/test/TestServer.ts index 256894cf9..aa0795559 100644 --- a/packages/tlsync/src/test/TestServer.ts +++ b/packages/tlsync/src/test/TestServer.ts @@ -3,13 +3,13 @@ import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom' import { TestSocketPair } from './TestSocketPair' export class TestServer { - room: TLSyncRoom + room: TLSyncRoom constructor(schema: StoreSchema, snapshot?: RoomSnapshot) { - this.room = new TLSyncRoom(schema, snapshot) + this.room = new TLSyncRoom(schema, snapshot) } connect(socketPair: TestSocketPair): void { - this.room.handleNewSession(socketPair.id, socketPair.roomSocket) + this.room.handleNewSession(socketPair.id, socketPair.roomSocket, undefined) socketPair.clientSocket.connectionStatus = 'online' socketPair.didReceiveFromClient = (msg) => { diff --git a/packages/tlsync/src/test/upgradeDowngrade.test.ts b/packages/tlsync/src/test/upgradeDowngrade.test.ts index 89f4577da..961258266 100644 --- a/packages/tlsync/src/test/upgradeDowngrade.test.ts +++ b/packages/tlsync/src/test/upgradeDowngrade.test.ts @@ -335,7 +335,7 @@ test('clients will receive updates from a snapshot migration upon connection', ( const id = 'test_upgrade_brand_new' const newClientSocket = mockSocket() - newServer.room.handleNewSession(id, newClientSocket) + newServer.room.handleNewSession(id, newClientSocket, undefined) newServer.room.handleMessage(id, { type: 'connect', connectRequestId: 'test', @@ -359,7 +359,7 @@ test('out-of-date clients will receive incompatibility errors', () => { const id = 'test_upgrade_v2' const socket = mockSocket() - v3server.room.handleNewSession(id, socket) + v3server.room.handleNewSession(id, socket, undefined) v3server.room.handleMessage(id, { type: 'connect', connectRequestId: 'test', @@ -383,7 +383,7 @@ test('clients using an out-of-date protocol will receive compatibility errors', const id = 'test_upgrade_v3' const socket = mockSocket() - v2server.room.handleNewSession(id, socket) + v2server.room.handleNewSession(id, socket, undefined) v2server.room.handleMessage(id, { type: 'connect', connectRequestId: 'test', @@ -412,7 +412,7 @@ test('v5 special case should allow connections', () => { const id = 'test_upgrade_v3' const socket = mockSocket() - v2server.room.handleNewSession(id, socket) + v2server.room.handleNewSession(id, socket, undefined) v2server.room.handleMessage(id, { type: 'connect', connectRequestId: 'test', @@ -443,7 +443,7 @@ test('clients using a too-new protocol will receive compatibility errors', () => const id = 'test_upgrade_v3' const socket = mockSocket() - v2server.room.handleNewSession(id, socket) + v2server.room.handleNewSession(id, socket, undefined) v2server.room.handleMessage(id, { type: 'connect', connectRequestId: 'test', @@ -485,7 +485,7 @@ test('when the client is too new it cannot connect', () => { const v2_id = 'test_upgrade_v2' const v2_socket = mockSocket() - v1Server.room.handleNewSession(v2_id, v2_socket as any) + v1Server.room.handleNewSession(v2_id, v2_socket as any, undefined) v1Server.room.handleMessage(v2_id as any, { type: 'connect', connectRequestId: 'test', @@ -545,7 +545,7 @@ describe('when the client is too old', () => { const v1SendMessage = v1Socket.sendMessage as jest.Mock - v2Server.room.handleNewSession(v1Id, v1Socket as any) + v2Server.room.handleNewSession(v1Id, v1Socket as any, undefined) v2Server.room.handleMessage(v1Id, { type: 'connect', connectRequestId: 'test', @@ -554,7 +554,7 @@ describe('when the client is too old', () => { schema: schemaV1.serialize(), }) - v2Server.room.handleNewSession(v2Id, v2Socket) + v2Server.room.handleNewSession(v2Id, v2Socket, undefined) v2Server.room.handleMessage(v2Id, { type: 'connect', connectRequestId: 'test', @@ -692,7 +692,7 @@ describe('when the client is the same version', () => { const bId = 'v2ClientB' const bSocket = mockSocket() - v2Server.room.handleNewSession(aId, aSocket) + v2Server.room.handleNewSession(aId, aSocket, undefined) v2Server.room.handleMessage(aId, { type: 'connect', connectRequestId: 'test', @@ -701,7 +701,7 @@ describe('when the client is the same version', () => { schema: JSON.parse(JSON.stringify(schemaV2.serialize())), }) - v2Server.room.handleNewSession(bId, bSocket) + v2Server.room.handleNewSession(bId, bSocket, undefined) v2Server.room.handleMessage(bId, { type: 'connect', connectRequestId: 'test', diff --git a/scripts/update-pr-template.ts b/scripts/update-pr-template.ts index 5360c000b..76ed09ec5 100644 --- a/scripts/update-pr-template.ts +++ b/scripts/update-pr-template.ts @@ -6,7 +6,7 @@ import { formatLabelOptionsForPRTemplate, getLabelNames } from './lib/labels' const prTemplatePath = join(REPO_ROOT, '.github', 'pull_request_template.md') -const octo = new Octokit({}) +const octo = process.env.GH_TOKEN ? new Octokit({ auth: process.env.GH_TOKEN }) : new Octokit() async function updatePRTemplate(check: boolean) { if (!existsSync(prTemplatePath)) { diff --git a/yarn.lock b/yarn.lock index 1266555ee..6603188cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1283,54 +1283,54 @@ __metadata: languageName: node linkType: hard -"@cloudflare/kv-asset-handler@npm:^0.2.0": - version: 0.2.0 - resolution: "@cloudflare/kv-asset-handler@npm:0.2.0" +"@cloudflare/kv-asset-handler@npm:0.3.3": + version: 0.3.3 + resolution: "@cloudflare/kv-asset-handler@npm:0.3.3" dependencies: mime: "npm:^3.0.0" - checksum: 56affbe5afdcfcf0860e7b9c826b3156210f1286791e702320b0ee378e540ed3e2d7ecdd55928e404475d4469433a47ca255889374b88992b481499a6d30b84b + checksum: 020ef0a6f7f70f8cdcecd5d8c6cd3938a80205c5e4561255d8f6d6ad6be4bf231961a62c6a8d0cf94dab1cea249cc81064b8670fdbac2eb13a006b1f34a759c4 languageName: node linkType: hard -"@cloudflare/workerd-darwin-64@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "@cloudflare/workerd-darwin-64@npm:1.20231030.0" +"@cloudflare/workerd-darwin-64@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "@cloudflare/workerd-darwin-64@npm:1.20240610.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@cloudflare/workerd-darwin-arm64@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20231030.0" +"@cloudflare/workerd-darwin-arm64@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20240610.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@cloudflare/workerd-linux-64@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "@cloudflare/workerd-linux-64@npm:1.20231030.0" +"@cloudflare/workerd-linux-64@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "@cloudflare/workerd-linux-64@npm:1.20240610.1" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@cloudflare/workerd-linux-arm64@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "@cloudflare/workerd-linux-arm64@npm:1.20231030.0" +"@cloudflare/workerd-linux-arm64@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "@cloudflare/workerd-linux-arm64@npm:1.20240610.1" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@cloudflare/workerd-windows-64@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "@cloudflare/workerd-windows-64@npm:1.20231030.0" +"@cloudflare/workerd-windows-64@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "@cloudflare/workerd-windows-64@npm:1.20240610.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@cloudflare/workers-types@npm:^4.20230821.0": - version: 4.20231218.0 - resolution: "@cloudflare/workers-types@npm:4.20231218.0" - checksum: f6b84026ec22b4011661287be2fddb3f07157fb8566423081919b7ada73d46a726ca761e5609a68b5ac708113b25888f78d54700e6bd920f8045d98ae284eabd +"@cloudflare/workers-types@npm:^4.20240620.0": + version: 4.20240620.0 + resolution: "@cloudflare/workers-types@npm:4.20240620.0" + checksum: 8292d02668b46777d43bdb94316a41ac4c78afa2687083d8c6749d187c3ded79b52a683265e38876fc358b4fb327fe59d5f728cf8ea898d06f8f3546e871d9c6 languageName: node linkType: hard @@ -1505,7 +1505,7 @@ __metadata: languageName: node linkType: hard -"@cspotcode/source-map-support@npm:^0.8.0": +"@cspotcode/source-map-support@npm:0.8.1, @cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" dependencies: @@ -6092,7 +6092,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tldraw/dotcom-worker@workspace:apps/dotcom-worker" dependencies: - "@cloudflare/workers-types": "npm:^4.20230821.0" + "@cloudflare/workers-types": "npm:^4.20240620.0" "@supabase/auth-helpers-remix": "npm:^0.2.2" "@supabase/supabase-js": "npm:^2.33.2" "@tldraw/dotcom-shared": "workspace:*" @@ -6111,7 +6111,7 @@ __metadata: strip-ansi: "npm:^7.1.0" toucan-js: "npm:^2.7.0" typescript: "npm:^5.3.3" - wrangler: "npm:3.19.0" + wrangler: "npm:3.61.0" languageName: unknown linkType: soft @@ -9585,6 +9585,13 @@ __metadata: languageName: unknown linkType: soft +"consola@npm:^3.2.3": + version: 3.2.3 + resolution: "consola@npm:3.2.3" + checksum: 02972dcb048c337357a3628438e5976b8e45bcec22fdcfbe9cd17622992953c4d695d5152f141464a02deac769b1d23028e8ac87f56483838df7a6bbf8e0f5a2 + languageName: node + linkType: hard + "console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" @@ -10066,6 +10073,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.4": + version: 6.1.4 + resolution: "defu@npm:6.1.4" + checksum: aeffdb47300f45b4fdef1c5bd3880ac18ea7a1fd5b8a8faf8df29350ff03bf16dd34f9800205cab513d476e4c0a3783aa0cff0a433aff0ac84a67ddc4c8a2d64 + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -10307,12 +10321,12 @@ __metadata: version: 0.0.0-use.local resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload" dependencies: - "@cloudflare/workers-types": "npm:^4.20230821.0" + "@cloudflare/workers-types": "npm:^4.20240620.0" "@types/ws": "npm:^8.5.9" itty-cors: "npm:^0.3.4" itty-router: "npm:^4.0.13" lazyrepo: "npm:0.0.0-alpha.27" - wrangler: "npm:3.19.0" + wrangler: "npm:3.61.0" languageName: unknown linkType: soft @@ -13326,12 +13340,12 @@ __metadata: version: 0.0.0-use.local resolution: "health-worker@workspace:apps/health-worker" dependencies: - "@cloudflare/workers-types": "npm:^4.20230821.0" + "@cloudflare/workers-types": "npm:^4.20240620.0" "@tldraw/utils": "workspace:*" "@types/node": "npm:~20.11" discord-api-types: "npm:^0.37.67" typescript: "npm:^5.3.3" - wrangler: "npm:3.19.0" + wrangler: "npm:3.61.0" languageName: unknown linkType: soft @@ -17123,25 +17137,25 @@ __metadata: languageName: node linkType: hard -"miniflare@npm:3.20231030.3": - version: 3.20231030.3 - resolution: "miniflare@npm:3.20231030.3" +"miniflare@npm:3.20240610.1": + version: 3.20240610.1 + resolution: "miniflare@npm:3.20240610.1" dependencies: + "@cspotcode/source-map-support": "npm:0.8.1" acorn: "npm:^8.8.0" acorn-walk: "npm:^8.2.0" capnp-ts: "npm:^0.7.0" exit-hook: "npm:^2.2.1" glob-to-regexp: "npm:^0.4.1" - source-map-support: "npm:0.5.21" stoppable: "npm:^1.1.0" - undici: "npm:^5.22.1" - workerd: "npm:1.20231030.0" - ws: "npm:^8.11.0" + undici: "npm:^5.28.4" + workerd: "npm:1.20240610.1" + ws: "npm:^8.14.2" youch: "npm:^3.2.2" - zod: "npm:^3.20.6" + zod: "npm:^3.22.3" bin: miniflare: bootstrap.js - checksum: 988bec15ebef26770c5e6f3b8d1012748fcac404cb1040a2a806a097918c6cb3b45170238abca8b48060dfef74c433f66d50c422dac2995f7ad6000d6fa48e92 + checksum: ebd4d979294af03838c86275503f9f9c6d55fd4f17aafbd07421252660521ae48229358fa9ea06a1d24c052ce0de471c75213dbf6ad19e78a8b641b092881b5c languageName: node linkType: hard @@ -17705,6 +17719,13 @@ __metadata: languageName: node linkType: hard +"node-fetch-native@npm:^1.6.4": + version: 1.6.4 + resolution: "node-fetch-native@npm:1.6.4" + checksum: 39c4c6d0c2a4bed1444943e1647ad0d79eb6638cf159bc37dffeafd22cffcf6a998e006aa1f3dd1d9d2258db7d78dee96b44bee4ba0bbaf0440ed348794f2543 + languageName: node + linkType: hard + "node-fetch@npm:2.6.7": version: 2.6.7 resolution: "node-fetch@npm:2.6.7" @@ -18657,6 +18678,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: f201d796351bf7433d147b92c20eb154a4e0ea83512017bf4ec4e492a5d6e738fb45798be4259a61aa81270179fce11026f6ff0d3fa04173041de044defe9d80 + languageName: node + linkType: hard + "pdf-lib@npm:^1.17.1": version: 1.17.1 resolution: "pdf-lib@npm:1.17.1" @@ -19783,7 +19811,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.0.0, resolve@npm:^1.20.0, resolve@npm:^1.22.4, resolve@npm:~1.22.1": +"resolve@npm:^1.0.0, resolve@npm:^1.20.0, resolve@npm:^1.22.4, resolve@npm:^1.22.8, resolve@npm:~1.22.1": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -19828,7 +19856,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.0.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin": +"resolve@patch:resolve@npm%3A^1.0.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -20545,7 +20573,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:0.5.21, source-map-support@npm:^0.5.12, source-map-support@npm:^0.5.17, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.12, source-map-support@npm:^0.5.17, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -20562,7 +20590,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:0.6.1, source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff @@ -22069,6 +22097,13 @@ __metadata: languageName: node linkType: hard +"ufo@npm:^1.5.3": + version: 1.5.3 + resolution: "ufo@npm:1.5.3" + checksum: 2b30dddd873c643efecdb58cfe457183cd4d95937ccdacca6942c697b87a2c578232c25a5149fda85436696bf0fdbc213bf2b220874712bc3e58c0fb00a2c950 + languageName: node + linkType: hard + "uid-promise@npm:1.0.0": version: 1.0.0 resolution: "uid-promise@npm:1.0.0" @@ -22111,7 +22146,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:^5.22.1, undici@npm:^5.25.4": +"undici@npm:^5.25.4, undici@npm:^5.28.4": version: 5.28.4 resolution: "undici@npm:5.28.4" dependencies: @@ -22120,6 +22155,20 @@ __metadata: languageName: node linkType: hard +"unenv@npm:unenv-nightly@1.10.0-1717606461.a117952": + version: 1.10.0-1717606461.a117952 + resolution: "unenv-nightly@npm:1.10.0-1717606461.a117952" + dependencies: + consola: "npm:^3.2.3" + defu: "npm:^6.1.4" + mime: "npm:^3.0.0" + node-fetch-native: "npm:^1.6.4" + pathe: "npm:^1.1.2" + ufo: "npm:^1.5.3" + checksum: 6799faa50bc396828b3968a583d9e414771113a0bbfbce33e86c6e9ead4835825d97c9ea54681eec6d68d89ef1f88b05b8c41960538d10d0b614696b7e6be377 + languageName: node + linkType: hard + "unified@npm:^10.0.0": version: 10.1.2 resolution: "unified@npm:10.1.2" @@ -23112,15 +23161,15 @@ __metadata: languageName: node linkType: hard -"workerd@npm:1.20231030.0": - version: 1.20231030.0 - resolution: "workerd@npm:1.20231030.0" +"workerd@npm:1.20240610.1": + version: 1.20240610.1 + resolution: "workerd@npm:1.20240610.1" dependencies: - "@cloudflare/workerd-darwin-64": "npm:1.20231030.0" - "@cloudflare/workerd-darwin-arm64": "npm:1.20231030.0" - "@cloudflare/workerd-linux-64": "npm:1.20231030.0" - "@cloudflare/workerd-linux-arm64": "npm:1.20231030.0" - "@cloudflare/workerd-windows-64": "npm:1.20231030.0" + "@cloudflare/workerd-darwin-64": "npm:1.20240610.1" + "@cloudflare/workerd-darwin-arm64": "npm:1.20240610.1" + "@cloudflare/workerd-linux-64": "npm:1.20240610.1" + "@cloudflare/workerd-linux-arm64": "npm:1.20240610.1" + "@cloudflare/workerd-windows-64": "npm:1.20240610.1" dependenciesMeta: "@cloudflare/workerd-darwin-64": optional: true @@ -23134,7 +23183,7 @@ __metadata: optional: true bin: workerd: bin/workerd - checksum: 7fbb12c1e9a1c6394dbe03005777e337bbf486a5e529065d9a2f2260978b239225a7d8a374cbe9e38cb4a91ec8fd6ebbbde95c2ae67773d78480fdbfdd4122ca + checksum: c37cd30c25fbdc7f97a296cf4b1a8ad2dbb65621a9c4b4bb24cb8fc00d9674c6610c5d2e5dc87c6cda83aa7b3b23a63d84b924ec1d9a3881622a9284590723f6 languageName: node linkType: hard @@ -23145,32 +23194,38 @@ __metadata: languageName: node linkType: hard -"wrangler@npm:3.19.0": - version: 3.19.0 - resolution: "wrangler@npm:3.19.0" +"wrangler@npm:3.61.0": + version: 3.61.0 + resolution: "wrangler@npm:3.61.0" dependencies: - "@cloudflare/kv-asset-handler": "npm:^0.2.0" + "@cloudflare/kv-asset-handler": "npm:0.3.3" "@esbuild-plugins/node-globals-polyfill": "npm:^0.2.3" "@esbuild-plugins/node-modules-polyfill": "npm:^0.2.2" blake3-wasm: "npm:^2.1.5" chokidar: "npm:^3.5.3" esbuild: "npm:0.17.19" fsevents: "npm:~2.3.2" - miniflare: "npm:3.20231030.3" + miniflare: "npm:3.20240610.1" nanoid: "npm:^3.3.3" path-to-regexp: "npm:^6.2.0" + resolve: "npm:^1.22.8" resolve.exports: "npm:^2.0.2" selfsigned: "npm:^2.0.1" - source-map: "npm:0.6.1" - source-map-support: "npm:0.5.21" + source-map: "npm:^0.6.1" + unenv: "npm:unenv-nightly@1.10.0-1717606461.a117952" xxhash-wasm: "npm:^1.0.1" + peerDependencies: + "@cloudflare/workers-types": ^4.20240605.0 dependenciesMeta: fsevents: optional: true + peerDependenciesMeta: + "@cloudflare/workers-types": + optional: true bin: wrangler: bin/wrangler.js wrangler2: bin/wrangler.js - checksum: 18ff7dfce24c34c077e77162f978a74021324a7f21653352436956915e7ceaa45220823bf5982bb11bb78233f5ea99c61b88c923f9021ef866709cbf75e12a52 + checksum: 91594c182fbfa65ffb1d3946829c3a51258d2ce6d7c83f0447f043e3bdb237096c4c69eda5e028f1e9742d8c0ddeae01c87512f438ab3635c5db9e923da3412b languageName: node linkType: hard @@ -23589,7 +23644,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.20.6, zod@npm:^3.21.4": +"zod@npm:^3.21.4, zod@npm:^3.22.3": version: 3.23.8 resolution: "zod@npm:3.23.8" checksum: 846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1