[bemo bae] Spike on tlsync public API improvement (#4002)

This PR replaces the extendable TLServer class with an instantiatable
wrapper for the TLSyncRoom called TLSocketRoom.

The goal is to provide an API where you pretty much just

1. create a room from some (optional) snapshot
2. pass websockets into it when they connect

And then lifecycle stuff and persistence stuff is left to the consumer,
since that all seems to be much more context dependent.

One thing remaining here is to work on observability. We had a slightly
messy situation regarding logging and error handling and analytics and I
want to clean that all up.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [x] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [x] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
4.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-06-26 15:07:36 +01:00 committed by GitHub
parent fe44631d8f
commit 4a3d9d407d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 643 additions and 778 deletions

View file

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

View file

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

View file

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

View file

@ -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<TLSocketRoom<TLRecord, { storeId: string }>> | 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<TLRecord, { storeId: string }>({
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<DBLoadResult> {
async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
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() {

View file

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

View file

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

View file

@ -0,0 +1,31 @@
// Save the room to supabase
export function createPersistQueue(persist: () => Promise<void>, timeout: number) {
let persistAgain = false
let queue: null | Promise<void> = 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
}
}
}

View file

@ -1,5 +1,5 @@
main = "src/lib/worker.ts"
compatibility_date = "2023-10-16"
compatibility_date = "2024-06-19"
[dev]
port = 8787

View file

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

View file

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

View file

@ -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<R extends UnknownRecord> =
export type RoomSession<R extends UnknownRecord, Meta> =
| {
state: typeof RoomSessionState.AwaitingConnectMessage
sessionKey: string
presenceId: string
socket: TLRoomSocket<R>
sessionStartTime: number
meta: Meta
}
| {
state: typeof RoomSessionState.AwaitingRemoval
@ -28,6 +29,7 @@ export type RoomSession<R extends UnknownRecord> =
presenceId: string
socket: TLRoomSocket<R>
cancellationTime: number
meta: Meta
}
| {
state: typeof RoomSessionState.Connected
@ -38,4 +40,5 @@ export type RoomSession<R extends UnknownRecord> =
lastInteractionTime: number
debounceTimer: ReturnType<typeof setTimeout> | null
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
meta: Meta
}

View file

@ -3,14 +3,14 @@ import ws from 'ws'
import { TLRoomSocket } from './TLSyncRoom'
import { TLSocketServerSentEvent } from './protocol'
interface ServerSocketAdapterOptions {
interface ServerSocketAdapterOptions<R extends UnknownRecord> {
readonly ws: WebSocket | ws.WebSocket
readonly logSendMessage: (type: string, size: number) => void
readonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent<R>, stringified: string) => void
}
/** @public */
export class ServerSocketAdapter<R extends UnknownRecord> implements TLRoomSocket<R> {
constructor(public readonly opts: ServerSocketAdapterOptions) {}
constructor(public readonly opts: ServerSocketAdapterOptions<R>) {}
// 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<R extends UnknownRecord> implements TLRoomSocke
// see TLRoomSocket for details on why this accepts a union and not just arrays
sendMessage(msg: TLSocketServerSentEvent<R>) {
const message = JSON.stringify(msg)
this.opts.logSendMessage(msg.type, message.length)
this.opts.onBeforeSendMessage?.(msg, message)
this.opts.ws.send(message)
}
close() {

View file

@ -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<DBLoadResultType> => {
const clientId = nanoid()
const [roomState, roomOpenKind] = await this.getInitialRoomState(persistenceKey)
if (roomOpenKind === 'room_not_found' || !roomState) {
return 'room_not_found'
}
roomState.room.handleNewSession(
sessionKey,
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<DBLoadResult>
/**
* Persist data to a database. (Optional)
*
* @param roomId - The id of the room to load.
* @public
*/
abstract persistToDatabase?(roomId: string): Promise<void>
/**
* 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
}

View file

@ -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<R extends UnknownRecord, SessionMeta> {
private room: TLSyncRoom<R, SessionMeta>
private readonly sessions = new Map<
string,
{ assembler: JsonChunkAssembler; socket: WebSocket; unlisten: () => void }
>()
readonly log: TLSyncLog
constructor(
public readonly opts: {
initialSnapshot?: RoomSnapshot
schema?: StoreSchema<R>
// 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<R, SessionMeta>,
args: { sessionKey: string; numSessionsRemaining: number; meta: SessionMeta }
) => void
// a callback that is called whenever a message is sent
onBeforeSendMessage?: (args: {
sessionId: string
message: TLSocketServerSentEvent<R>
stringified: string
}) => void
onDataChange?: () => void
}
) {
const initialClock = opts.initialSnapshot?.clock ?? 0
this.room = new TLSyncRoom<R, SessionMeta>(
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<R>)
)
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<R, SessionMeta>(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()
}
}

View file

@ -133,9 +133,9 @@ export interface RoomSnapshot {
*
* @public
*/
export class TLSyncRoom<R extends UnknownRecord> {
export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
// A table of connected clients
readonly sessions = new Map<string, RoomSession<R>>()
readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
pruneSessions = () => {
for (const client of this.sessions.values()) {
@ -180,7 +180,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
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<R extends UnknownRecord> {
// 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<R extends UnknownRecord> {
this.state.set({ documents, tombstones })
this.pruneTombstones()
this.documentClock = this.clock
}
private pruneTombstones = () => {
@ -484,7 +486,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
})
}
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<R extends UnknownRecord> {
presenceId: session.presenceId,
socket: session.socket,
cancellationTime: Date.now(),
meta: session.meta,
})
}
@ -560,7 +563,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
* @param sessionKey - The session of the client that connected to the room.
* @param socket - Their socket.
*/
handleNewSession = (sessionKey: string, socket: TLRoomSocket<R>) => {
handleNewSession = (sessionKey: string, socket: TLRoomSocket<R>, meta: SessionMeta) => {
const existing = this.sessions.get(sessionKey)
this.sessions.set(sessionKey, {
state: RoomSessionState.AwaitingConnectMessage,
@ -568,6 +571,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
socket,
presenceId: existing?.presenceId ?? this.presenceType.createId(),
sessionStartTime: Date.now(),
meta,
})
return this
}
@ -647,7 +651,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
}
/** If the client is out of date, or we are out of date, we need to let them know */
private rejectSession(session: RoomSession<R>, reason: TLIncompatibilityReason) {
private rejectSession(session: RoomSession<R, SessionMeta>, reason: TLIncompatibilityReason) {
try {
if (session.socket.isOpen) {
session.socket.sendMessage({
@ -663,7 +667,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
}
private handleConnectRequest(
session: RoomSession<R>,
session: RoomSession<R, SessionMeta>,
message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
) {
// if the protocol versions don't match, disconnect the client
@ -708,6 +712,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
lastInteractionTime: Date.now(),
debounceTimer: null,
outstandingDataMessages: [],
meta: session.meta,
})
this.sendMessage(session.sessionKey, msg)
}
@ -797,7 +802,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
}
private handlePushRequest(
session: RoomSession<R>,
session: RoomSession<R, SessionMeta>,
message: Extract<TLSocketClientSentEvent<R>, { type: 'push' }>
) {
// We must be connected to handle push requests
@ -1058,6 +1063,10 @@ export class TLSyncRoom<R extends UnknownRecord> {
})
}
if (docChanges.diff) {
this.documentClock = this.clock
}
return
})
}

View file

@ -1,12 +1,4 @@
import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
/** @public */
export interface RoomState {
// the slug of the room
persistenceKey: string
// the room
room: TLSyncRoom<any>
}
import { RoomSnapshot } from './TLSyncRoom'
/** @public */
export interface PersistedRoomSnapshotForSupabase {

View file

@ -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<TLDocument> }),
PageRecordType.create({ index: ZERO_INDEX_KEY, name: 'page 2' }),
]
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}) => ({
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
clock: 0,
...others,
})
class TLServerTestImpl extends TLServer {
wsServer = new ws.Server({ port: PORT })
async close() {
await new Promise((resolve) => {
this.wsServer.close((err) => {
if (err) {
console.error(err)
}
resolve(err)
})
})
}
async createSocketPair() {
const connectionPromise = new Promise<WebSocket>((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<DBLoadResult> {
return { type: 'room_found', snapshot: makeSnapshot(records) }
}
override async persistToDatabase?(_roomId: string): Promise<void> {
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> = T extends Promise<infer U> ? U : T
const schema = createTLSchema().serialize()
let server: TLServerTestImpl
let sockets: UnpackPromise<ReturnType<typeof server.createSocketPair>>
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<DBLoadResult> => {
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<TLRecord> = {
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<DBLoadResult> => {
return { type: 'room_not_found' }
}
const connectionResult = await openConnection()
expect(connectionResult).toBe('room_not_found')
})
})

View file

@ -64,14 +64,14 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
describe('TLSyncRoom', () => {
it('can be constructed with a schema alone', () => {
const room = new TLSyncRoom<any>(schema)
const room = new TLSyncRoom<any, undefined>(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<TLRecord>(schema, makeSnapshot(records))
const room = new TLSyncRoom<TLRecord, undefined>(schema, makeSnapshot(records))
expect(
room

View file

@ -3,13 +3,13 @@ import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
import { TestSocketPair } from './TestSocketPair'
export class TestServer<R extends UnknownRecord, P = unknown> {
room: TLSyncRoom<R>
room: TLSyncRoom<R, undefined>
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
this.room = new TLSyncRoom<R>(schema, snapshot)
this.room = new TLSyncRoom<R, undefined>(schema, snapshot)
}
connect(socketPair: TestSocketPair<R>): void {
this.room.handleNewSession(socketPair.id, socketPair.roomSocket)
this.room.handleNewSession(socketPair.id, socketPair.roomSocket, undefined)
socketPair.clientSocket.connectionStatus = 'online'
socketPair.didReceiveFromClient = (msg) => {

View file

@ -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<RV2>()
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<RV2>()
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',

View file

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

179
yarn.lock
View file

@ -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<compat/resolve>, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin<compat/resolve>":
"resolve@patch:resolve@npm%3A^1.0.0#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin<compat/resolve>, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin<compat/resolve>":
version: 1.22.8
resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin<compat/resolve>::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