[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:
parent
fe44631d8f
commit
4a3d9d407d
22 changed files with 643 additions and 778 deletions
2
.github/workflows/checks.yml
vendored
2
.github/workflows/checks.yml
vendored
|
@ -49,6 +49,8 @@ jobs:
|
||||||
|
|
||||||
- name: Check PR template
|
- name: Check PR template
|
||||||
run: yarn update-pr-template --check
|
run: yarn update-pr-template --check
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|
|
@ -20,10 +20,10 @@
|
||||||
"itty-router": "^4.0.13"
|
"itty-router": "^4.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20230821.0",
|
"@cloudflare/workers-types": "^4.20240620.0",
|
||||||
"@types/ws": "^8.5.9",
|
"@types/ws": "^8.5.9",
|
||||||
"lazyrepo": "0.0.0-alpha.27",
|
"lazyrepo": "0.0.0-alpha.27",
|
||||||
"wrangler": "3.19.0"
|
"wrangler": "3.61.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "config/jest/node",
|
"preset": "config/jest/node",
|
||||||
|
|
|
@ -37,12 +37,12 @@
|
||||||
"toucan-js": "^2.7.0"
|
"toucan-js": "^2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20230821.0",
|
"@cloudflare/workers-types": "^4.20240620.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"lazyrepo": "0.0.0-alpha.27",
|
"lazyrepo": "0.0.0-alpha.27",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"wrangler": "3.19.0"
|
"wrangler": "3.61.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "config/jest/node",
|
"preset": "config/jest/node",
|
||||||
|
|
|
@ -9,16 +9,12 @@ import {
|
||||||
ROOM_PREFIX,
|
ROOM_PREFIX,
|
||||||
type RoomOpenMode,
|
type RoomOpenMode,
|
||||||
} from '@tldraw/dotcom-shared'
|
} from '@tldraw/dotcom-shared'
|
||||||
|
import { TLRecord } from '@tldraw/tlschema'
|
||||||
import {
|
import {
|
||||||
DBLoadResultType,
|
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
||||||
TLServer,
|
TLSocketRoom,
|
||||||
TLServerEvent,
|
|
||||||
TLSyncRoom,
|
|
||||||
type DBLoadResult,
|
|
||||||
type PersistedRoomSnapshotForSupabase,
|
type PersistedRoomSnapshotForSupabase,
|
||||||
type RoomState,
|
|
||||||
} from '@tldraw/tlsync'
|
} from '@tldraw/tlsync'
|
||||||
import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils'
|
import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils'
|
||||||
import { IRequest, Router } from 'itty-router'
|
import { IRequest, Router } from 'itty-router'
|
||||||
|
@ -26,7 +22,8 @@ import Toucan from 'toucan-js'
|
||||||
import { AlarmScheduler } from './AlarmScheduler'
|
import { AlarmScheduler } from './AlarmScheduler'
|
||||||
import { PERSIST_INTERVAL_MS } from './config'
|
import { PERSIST_INTERVAL_MS } from './config'
|
||||||
import { getR2KeyForRoom } from './r2'
|
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 { createSupabaseClient } from './utils/createSupabaseClient'
|
||||||
import { getSlug } from './utils/roomOpenMode'
|
import { getSlug } from './utils/roomOpenMode'
|
||||||
import { throttle } from './utils/throttle'
|
import { throttle } from './utils/throttle'
|
||||||
|
@ -40,12 +37,84 @@ interface DocumentInfo {
|
||||||
slug: string
|
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
|
// A unique identifier for this instance of the Durable Object
|
||||||
id: DurableObjectId
|
id: DurableObjectId
|
||||||
|
|
||||||
// For TLSyncRoom
|
// 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
|
// For storage
|
||||||
storage: DurableObjectStorage
|
storage: DurableObjectStorage
|
||||||
|
@ -68,13 +137,11 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
_documentInfo: DocumentInfo | null = null
|
_documentInfo: DocumentInfo | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private controller: DurableObjectState,
|
private state: DurableObjectState,
|
||||||
private env: Environment
|
private env: Environment
|
||||||
) {
|
) {
|
||||||
super()
|
this.id = state.id
|
||||||
|
this.storage = state.storage
|
||||||
this.id = controller.id
|
|
||||||
this.storage = controller.storage
|
|
||||||
this.sentryDSN = env.SENTRY_DSN
|
this.sentryDSN = env.SENTRY_DSN
|
||||||
this.measure = env.MEASURE
|
this.measure = env.MEASURE
|
||||||
this.supabaseClient = createSupabaseClient(env)
|
this.supabaseClient = createSupabaseClient(env)
|
||||||
|
@ -85,7 +152,7 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
versionCache: env.ROOMS_HISTORY_EPHEMERAL,
|
versionCache: env.ROOMS_HISTORY_EPHEMERAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.blockConcurrencyWhile(async () => {
|
state.blockConcurrencyWhile(async () => {
|
||||||
const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null
|
const existingDocumentInfo = (await this.storage.get('documentInfo')) as DocumentInfo | null
|
||||||
if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) {
|
if (existingDocumentInfo?.version !== CURRENT_DOCUMENT_INFO_VERSION) {
|
||||||
this._documentInfo = null
|
this._documentInfo = null
|
||||||
|
@ -122,9 +189,7 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
storage: () => this.storage,
|
storage: () => this.storage,
|
||||||
alarms: {
|
alarms: {
|
||||||
persist: async () => {
|
persist: async () => {
|
||||||
const room = this.getRoomForPersistenceKey(this.documentInfo.slug)
|
this.persistToDatabase()
|
||||||
if (!room) return
|
|
||||||
this.persistToDatabase(room.persistenceKey)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -176,53 +241,31 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isRestoring = false
|
||||||
async onRestore(req: IRequest) {
|
async onRestore(req: IRequest) {
|
||||||
const roomId = this.documentInfo.slug
|
this._isRestoring = true
|
||||||
const roomKey = getR2KeyForRoom(roomId)
|
try {
|
||||||
const timestamp = ((await req.json()) as any).timestamp
|
const roomId = this.documentInfo.slug
|
||||||
if (!timestamp) {
|
const roomKey = getR2KeyForRoom(roomId)
|
||||||
return new Response('Missing timestamp', { status: 400 })
|
const timestamp = ((await req.json()) as any).timestamp
|
||||||
}
|
if (!timestamp) {
|
||||||
const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`)
|
return new Response('Missing timestamp', { status: 400 })
|
||||||
if (!data) {
|
}
|
||||||
return new Response('Version not found', { status: 400 })
|
const data = await this.r2.versionCache.get(`${roomKey}/${timestamp}`)
|
||||||
}
|
if (!data) {
|
||||||
const dataText = await data.text()
|
return new Response('Version not found', { status: 400 })
|
||||||
await this.r2.rooms.put(roomKey, dataText)
|
}
|
||||||
const roomState = this.getRoomForPersistenceKey(roomId)
|
const dataText = await data.text()
|
||||||
if (!roomState) {
|
await this.r2.rooms.put(roomKey, dataText)
|
||||||
// nothing else to do because the room is not currently in use
|
const room = await this.getRoom()
|
||||||
|
|
||||||
|
const snapshot: RoomSnapshot = JSON.parse(dataText)
|
||||||
|
room.loadSnapshot(snapshot)
|
||||||
|
|
||||||
return new Response()
|
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) {
|
async onRequest(req: IRequest) {
|
||||||
|
@ -234,58 +277,51 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
// handle legacy param names
|
// handle legacy param names
|
||||||
sessionKey ??= params.instanceId
|
sessionKey ??= params.instanceId
|
||||||
storeId ??= params.localClientId
|
storeId ??= params.localClientId
|
||||||
|
const isNewSession = !this._room
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the websocket pair for the client
|
// Create the websocket pair for the client
|
||||||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
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.accept()
|
||||||
serverWebSocket.addEventListener(
|
|
||||||
'message',
|
|
||||||
throttle(() => {
|
|
||||||
this.schedulePersist()
|
|
||||||
}, 2000)
|
|
||||||
)
|
|
||||||
serverWebSocket.addEventListener('close', () => {
|
|
||||||
this.schedulePersist()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (connectionResult === 'room_not_found') {
|
try {
|
||||||
// If the room is not found, we need to accept and then immediately close the connection
|
const room = await this.getRoom()
|
||||||
// with our custom close code.
|
// Don't connect if we're already at max connections
|
||||||
serverWebSocket.close(TLCloseEventCode.NOT_FOUND, 'Room not found')
|
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(
|
private writeEvent(
|
||||||
name: string,
|
name: string,
|
||||||
{ blobs, indexes, doubles }: { blobs?: string[]; indexes?: [string]; doubles?: number[] }
|
{ blobs, indexes, doubles }: { blobs?: string[]; indexes?: [string]; doubles?: number[] }
|
||||||
|
@ -307,7 +343,7 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
case 'client': {
|
case 'client': {
|
||||||
// we would add user/connection ids here if we could
|
// we would add user/connection ids here if we could
|
||||||
this.writeEvent(event.name, {
|
this.writeEvent(event.name, {
|
||||||
blobs: [event.roomId, event.clientId, event.instanceId],
|
blobs: [event.roomId, 'unused', event.instanceId],
|
||||||
indexes: [event.localClientId],
|
indexes: [event.localClientId],
|
||||||
})
|
})
|
||||||
break
|
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).
|
// 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 {
|
try {
|
||||||
const key = getR2KeyForRoom(persistenceKey)
|
const key = getR2KeyForRoom(persistenceKey)
|
||||||
// when loading, prefer to fetch documents from the bucket
|
// when loading, prefer to fetch documents from the bucket
|
||||||
|
@ -376,48 +399,31 @@ export class TLDrawDurableObject extends TLServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_isPersisting = false
|
|
||||||
_lastPersistedClock: number | null = null
|
_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
|
// Save the room to supabase
|
||||||
async persistToDatabase(persistenceKey: string) {
|
async persistToDatabase() {
|
||||||
if (this._isPersisting) {
|
await this._persistQueue()
|
||||||
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 schedulePersist() {
|
async schedulePersist() {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
// https://developers.cloudflare.com/analytics/analytics-engine/
|
// https://developers.cloudflare.com/analytics/analytics-engine/
|
||||||
|
|
||||||
|
import { RoomSnapshot } from '@tldraw/tlsync'
|
||||||
|
|
||||||
// This type isn't available in @cloudflare/workers-types yet
|
// This type isn't available in @cloudflare/workers-types yet
|
||||||
export interface Analytics {
|
export interface Analytics {
|
||||||
writeDataPoint(data: {
|
writeDataPoint(data: {
|
||||||
|
@ -35,3 +37,41 @@ export interface Environment {
|
||||||
IS_LOCAL: string | undefined
|
IS_LOCAL: string | undefined
|
||||||
WORKER_NAME: 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
|
||||||
|
}
|
||||||
|
|
49
apps/dotcom-worker/src/lib/utils/createPersistQueue.test.ts
Normal file
49
apps/dotcom-worker/src/lib/utils/createPersistQueue.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
31
apps/dotcom-worker/src/lib/utils/createPersistQueue.ts
Normal file
31
apps/dotcom-worker/src/lib/utils/createPersistQueue.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
main = "src/lib/worker.ts"
|
main = "src/lib/worker.ts"
|
||||||
compatibility_date = "2023-10-16"
|
compatibility_date = "2024-06-19"
|
||||||
|
|
||||||
[dev]
|
[dev]
|
||||||
port = 8787
|
port = 8787
|
||||||
|
|
|
@ -12,10 +12,10 @@
|
||||||
"@tldraw/utils": "workspace:*"
|
"@tldraw/utils": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20230821.0",
|
"@cloudflare/workers-types": "^4.20240620.0",
|
||||||
"@types/node": "~20.11",
|
"@types/node": "~20.11",
|
||||||
"discord-api-types": "^0.37.67",
|
"discord-api-types": "^0.37.67",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"wrangler": "3.19.0"
|
"wrangler": "3.61.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
export {
|
export { TLSocketRoom } from './lib/TLSocketRoom'
|
||||||
TLServer,
|
|
||||||
type DBLoadResult,
|
|
||||||
type DBLoadResultType,
|
|
||||||
type TLServerEvent,
|
|
||||||
} from './lib/TLServer'
|
|
||||||
export {
|
export {
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
||||||
TLSyncClient,
|
TLSyncClient,
|
||||||
|
@ -37,4 +32,4 @@ export {
|
||||||
type TLSocketServerSentEvent,
|
type TLSocketServerSentEvent,
|
||||||
} from './lib/protocol'
|
} from './lib/protocol'
|
||||||
export { schema } from './lib/schema'
|
export { schema } from './lib/schema'
|
||||||
export type { PersistedRoomSnapshotForSupabase, RoomState as RoomState } from './lib/server-types'
|
export type { PersistedRoomSnapshotForSupabase } from './lib/server-types'
|
||||||
|
|
|
@ -14,13 +14,14 @@ export const SESSION_START_WAIT_TIME = 10000
|
||||||
export const SESSION_REMOVAL_WAIT_TIME = 10000
|
export const SESSION_REMOVAL_WAIT_TIME = 10000
|
||||||
export const SESSION_IDLE_TIMEOUT = 20000
|
export const SESSION_IDLE_TIMEOUT = 20000
|
||||||
|
|
||||||
export type RoomSession<R extends UnknownRecord> =
|
export type RoomSession<R extends UnknownRecord, Meta> =
|
||||||
| {
|
| {
|
||||||
state: typeof RoomSessionState.AwaitingConnectMessage
|
state: typeof RoomSessionState.AwaitingConnectMessage
|
||||||
sessionKey: string
|
sessionKey: string
|
||||||
presenceId: string
|
presenceId: string
|
||||||
socket: TLRoomSocket<R>
|
socket: TLRoomSocket<R>
|
||||||
sessionStartTime: number
|
sessionStartTime: number
|
||||||
|
meta: Meta
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
state: typeof RoomSessionState.AwaitingRemoval
|
state: typeof RoomSessionState.AwaitingRemoval
|
||||||
|
@ -28,6 +29,7 @@ export type RoomSession<R extends UnknownRecord> =
|
||||||
presenceId: string
|
presenceId: string
|
||||||
socket: TLRoomSocket<R>
|
socket: TLRoomSocket<R>
|
||||||
cancellationTime: number
|
cancellationTime: number
|
||||||
|
meta: Meta
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
state: typeof RoomSessionState.Connected
|
state: typeof RoomSessionState.Connected
|
||||||
|
@ -38,4 +40,5 @@ export type RoomSession<R extends UnknownRecord> =
|
||||||
lastInteractionTime: number
|
lastInteractionTime: number
|
||||||
debounceTimer: ReturnType<typeof setTimeout> | null
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
||||||
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
|
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
|
||||||
|
meta: Meta
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,14 @@ import ws from 'ws'
|
||||||
import { TLRoomSocket } from './TLSyncRoom'
|
import { TLRoomSocket } from './TLSyncRoom'
|
||||||
import { TLSocketServerSentEvent } from './protocol'
|
import { TLSocketServerSentEvent } from './protocol'
|
||||||
|
|
||||||
interface ServerSocketAdapterOptions {
|
interface ServerSocketAdapterOptions<R extends UnknownRecord> {
|
||||||
readonly ws: WebSocket | ws.WebSocket
|
readonly ws: WebSocket | ws.WebSocket
|
||||||
readonly logSendMessage: (type: string, size: number) => void
|
readonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent<R>, stringified: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export class ServerSocketAdapter<R extends UnknownRecord> implements TLRoomSocket<R> {
|
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
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
get isOpen(): boolean {
|
get isOpen(): boolean {
|
||||||
return this.opts.ws.readyState === 1 // ready state open
|
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
|
// see TLRoomSocket for details on why this accepts a union and not just arrays
|
||||||
sendMessage(msg: TLSocketServerSentEvent<R>) {
|
sendMessage(msg: TLSocketServerSentEvent<R>) {
|
||||||
const message = JSON.stringify(msg)
|
const message = JSON.stringify(msg)
|
||||||
this.opts.logSendMessage(msg.type, message.length)
|
this.opts.onBeforeSendMessage?.(msg, message)
|
||||||
this.opts.ws.send(message)
|
this.opts.ws.send(message)
|
||||||
}
|
}
|
||||||
close() {
|
close() {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
187
packages/tlsync/src/lib/TLSocketRoom.ts
Normal file
187
packages/tlsync/src/lib/TLSocketRoom.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -133,9 +133,9 @@ export interface RoomSnapshot {
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export class TLSyncRoom<R extends UnknownRecord> {
|
export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
||||||
// A table of connected clients
|
// A table of connected clients
|
||||||
readonly sessions = new Map<string, RoomSession<R>>()
|
readonly sessions = new Map<string, RoomSession<R, SessionMeta>>()
|
||||||
|
|
||||||
pruneSessions = () => {
|
pruneSessions = () => {
|
||||||
for (const client of this.sessions.values()) {
|
for (const client of this.sessions.values()) {
|
||||||
|
@ -180,7 +180,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
|
|
||||||
readonly events = createNanoEvents<{
|
readonly events = createNanoEvents<{
|
||||||
room_became_empty: () => void
|
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).
|
// 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
|
// initial lastServerClock value get the full state
|
||||||
// in this case clients will start with 0, and the server will start with 1
|
// in this case clients will start with 0, and the server will start with 1
|
||||||
clock = 1
|
clock = 1
|
||||||
|
documentClock = 1
|
||||||
tombstoneHistoryStartsAtClock = this.clock
|
tombstoneHistoryStartsAtClock = this.clock
|
||||||
// map from record id to clock upon deletion
|
// map from record id to clock upon deletion
|
||||||
|
|
||||||
|
@ -324,6 +325,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
this.state.set({ documents, tombstones })
|
this.state.set({ documents, tombstones })
|
||||||
|
|
||||||
this.pruneTombstones()
|
this.pruneTombstones()
|
||||||
|
this.documentClock = this.clock
|
||||||
}
|
}
|
||||||
|
|
||||||
private pruneTombstones = () => {
|
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) {
|
if (this.sessions.size === 0) {
|
||||||
this.events.emit('room_became_empty')
|
this.events.emit('room_became_empty')
|
||||||
}
|
}
|
||||||
|
@ -507,6 +509,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
presenceId: session.presenceId,
|
presenceId: session.presenceId,
|
||||||
socket: session.socket,
|
socket: session.socket,
|
||||||
cancellationTime: Date.now(),
|
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 sessionKey - The session of the client that connected to the room.
|
||||||
* @param socket - Their socket.
|
* @param socket - Their socket.
|
||||||
*/
|
*/
|
||||||
handleNewSession = (sessionKey: string, socket: TLRoomSocket<R>) => {
|
handleNewSession = (sessionKey: string, socket: TLRoomSocket<R>, meta: SessionMeta) => {
|
||||||
const existing = this.sessions.get(sessionKey)
|
const existing = this.sessions.get(sessionKey)
|
||||||
this.sessions.set(sessionKey, {
|
this.sessions.set(sessionKey, {
|
||||||
state: RoomSessionState.AwaitingConnectMessage,
|
state: RoomSessionState.AwaitingConnectMessage,
|
||||||
|
@ -568,6 +571,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
socket,
|
socket,
|
||||||
presenceId: existing?.presenceId ?? this.presenceType.createId(),
|
presenceId: existing?.presenceId ?? this.presenceType.createId(),
|
||||||
sessionStartTime: Date.now(),
|
sessionStartTime: Date.now(),
|
||||||
|
meta,
|
||||||
})
|
})
|
||||||
return this
|
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 */
|
/** 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 {
|
try {
|
||||||
if (session.socket.isOpen) {
|
if (session.socket.isOpen) {
|
||||||
session.socket.sendMessage({
|
session.socket.sendMessage({
|
||||||
|
@ -663,7 +667,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectRequest(
|
private handleConnectRequest(
|
||||||
session: RoomSession<R>,
|
session: RoomSession<R, SessionMeta>,
|
||||||
message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
|
message: Extract<TLSocketClientSentEvent<R>, { type: 'connect' }>
|
||||||
) {
|
) {
|
||||||
// if the protocol versions don't match, disconnect the client
|
// if the protocol versions don't match, disconnect the client
|
||||||
|
@ -708,6 +712,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
lastInteractionTime: Date.now(),
|
lastInteractionTime: Date.now(),
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
outstandingDataMessages: [],
|
outstandingDataMessages: [],
|
||||||
|
meta: session.meta,
|
||||||
})
|
})
|
||||||
this.sendMessage(session.sessionKey, msg)
|
this.sendMessage(session.sessionKey, msg)
|
||||||
}
|
}
|
||||||
|
@ -797,7 +802,7 @@ export class TLSyncRoom<R extends UnknownRecord> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePushRequest(
|
private handlePushRequest(
|
||||||
session: RoomSession<R>,
|
session: RoomSession<R, SessionMeta>,
|
||||||
message: Extract<TLSocketClientSentEvent<R>, { type: 'push' }>
|
message: Extract<TLSocketClientSentEvent<R>, { type: 'push' }>
|
||||||
) {
|
) {
|
||||||
// We must be connected to handle push requests
|
// 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
|
return
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,4 @@
|
||||||
import { RoomSnapshot, TLSyncRoom } from './TLSyncRoom'
|
import { RoomSnapshot } from './TLSyncRoom'
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export interface RoomState {
|
|
||||||
// the slug of the room
|
|
||||||
persistenceKey: string
|
|
||||||
// the room
|
|
||||||
room: TLSyncRoom<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface PersistedRoomSnapshotForSupabase {
|
export interface PersistedRoomSnapshotForSupabase {
|
||||||
|
|
|
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -64,14 +64,14 @@ const oldArrow: TLBaseShape<'arrow', Omit<TLArrowShapeProps, 'labelColor'>> = {
|
||||||
|
|
||||||
describe('TLSyncRoom', () => {
|
describe('TLSyncRoom', () => {
|
||||||
it('can be constructed with a schema alone', () => {
|
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
|
// we populate the store with a default document if none is given
|
||||||
expect(room.getSnapshot().documents.length).toBeGreaterThan(0)
|
expect(room.getSnapshot().documents.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can be constructed with a snapshot', () => {
|
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(
|
expect(
|
||||||
room
|
room
|
||||||
|
|
|
@ -3,13 +3,13 @@ import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
|
||||||
import { TestSocketPair } from './TestSocketPair'
|
import { TestSocketPair } from './TestSocketPair'
|
||||||
|
|
||||||
export class TestServer<R extends UnknownRecord, P = unknown> {
|
export class TestServer<R extends UnknownRecord, P = unknown> {
|
||||||
room: TLSyncRoom<R>
|
room: TLSyncRoom<R, undefined>
|
||||||
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot) {
|
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 {
|
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.clientSocket.connectionStatus = 'online'
|
||||||
socketPair.didReceiveFromClient = (msg) => {
|
socketPair.didReceiveFromClient = (msg) => {
|
||||||
|
|
|
@ -335,7 +335,7 @@ test('clients will receive updates from a snapshot migration upon connection', (
|
||||||
|
|
||||||
const id = 'test_upgrade_brand_new'
|
const id = 'test_upgrade_brand_new'
|
||||||
const newClientSocket = mockSocket()
|
const newClientSocket = mockSocket()
|
||||||
newServer.room.handleNewSession(id, newClientSocket)
|
newServer.room.handleNewSession(id, newClientSocket, undefined)
|
||||||
newServer.room.handleMessage(id, {
|
newServer.room.handleMessage(id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -359,7 +359,7 @@ test('out-of-date clients will receive incompatibility errors', () => {
|
||||||
const id = 'test_upgrade_v2'
|
const id = 'test_upgrade_v2'
|
||||||
const socket = mockSocket()
|
const socket = mockSocket()
|
||||||
|
|
||||||
v3server.room.handleNewSession(id, socket)
|
v3server.room.handleNewSession(id, socket, undefined)
|
||||||
v3server.room.handleMessage(id, {
|
v3server.room.handleMessage(id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -383,7 +383,7 @@ test('clients using an out-of-date protocol will receive compatibility errors',
|
||||||
const id = 'test_upgrade_v3'
|
const id = 'test_upgrade_v3'
|
||||||
const socket = mockSocket()
|
const socket = mockSocket()
|
||||||
|
|
||||||
v2server.room.handleNewSession(id, socket)
|
v2server.room.handleNewSession(id, socket, undefined)
|
||||||
v2server.room.handleMessage(id, {
|
v2server.room.handleMessage(id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -412,7 +412,7 @@ test('v5 special case should allow connections', () => {
|
||||||
const id = 'test_upgrade_v3'
|
const id = 'test_upgrade_v3'
|
||||||
const socket = mockSocket()
|
const socket = mockSocket()
|
||||||
|
|
||||||
v2server.room.handleNewSession(id, socket)
|
v2server.room.handleNewSession(id, socket, undefined)
|
||||||
v2server.room.handleMessage(id, {
|
v2server.room.handleMessage(id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -443,7 +443,7 @@ test('clients using a too-new protocol will receive compatibility errors', () =>
|
||||||
const id = 'test_upgrade_v3'
|
const id = 'test_upgrade_v3'
|
||||||
const socket = mockSocket()
|
const socket = mockSocket()
|
||||||
|
|
||||||
v2server.room.handleNewSession(id, socket)
|
v2server.room.handleNewSession(id, socket, undefined)
|
||||||
v2server.room.handleMessage(id, {
|
v2server.room.handleMessage(id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -485,7 +485,7 @@ test('when the client is too new it cannot connect', () => {
|
||||||
const v2_id = 'test_upgrade_v2'
|
const v2_id = 'test_upgrade_v2'
|
||||||
const v2_socket = mockSocket<RV2>()
|
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, {
|
v1Server.room.handleMessage(v2_id as any, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -545,7 +545,7 @@ describe('when the client is too old', () => {
|
||||||
|
|
||||||
const v1SendMessage = v1Socket.sendMessage as jest.Mock
|
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, {
|
v2Server.room.handleMessage(v1Id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -554,7 +554,7 @@ describe('when the client is too old', () => {
|
||||||
schema: schemaV1.serialize(),
|
schema: schemaV1.serialize(),
|
||||||
})
|
})
|
||||||
|
|
||||||
v2Server.room.handleNewSession(v2Id, v2Socket)
|
v2Server.room.handleNewSession(v2Id, v2Socket, undefined)
|
||||||
v2Server.room.handleMessage(v2Id, {
|
v2Server.room.handleMessage(v2Id, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -692,7 +692,7 @@ describe('when the client is the same version', () => {
|
||||||
const bId = 'v2ClientB'
|
const bId = 'v2ClientB'
|
||||||
const bSocket = mockSocket<RV2>()
|
const bSocket = mockSocket<RV2>()
|
||||||
|
|
||||||
v2Server.room.handleNewSession(aId, aSocket)
|
v2Server.room.handleNewSession(aId, aSocket, undefined)
|
||||||
v2Server.room.handleMessage(aId, {
|
v2Server.room.handleMessage(aId, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
@ -701,7 +701,7 @@ describe('when the client is the same version', () => {
|
||||||
schema: JSON.parse(JSON.stringify(schemaV2.serialize())),
|
schema: JSON.parse(JSON.stringify(schemaV2.serialize())),
|
||||||
})
|
})
|
||||||
|
|
||||||
v2Server.room.handleNewSession(bId, bSocket)
|
v2Server.room.handleNewSession(bId, bSocket, undefined)
|
||||||
v2Server.room.handleMessage(bId, {
|
v2Server.room.handleMessage(bId, {
|
||||||
type: 'connect',
|
type: 'connect',
|
||||||
connectRequestId: 'test',
|
connectRequestId: 'test',
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { formatLabelOptionsForPRTemplate, getLabelNames } from './lib/labels'
|
||||||
|
|
||||||
const prTemplatePath = join(REPO_ROOT, '.github', 'pull_request_template.md')
|
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) {
|
async function updatePRTemplate(check: boolean) {
|
||||||
if (!existsSync(prTemplatePath)) {
|
if (!existsSync(prTemplatePath)) {
|
||||||
|
|
179
yarn.lock
179
yarn.lock
|
@ -1283,54 +1283,54 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/kv-asset-handler@npm:^0.2.0":
|
"@cloudflare/kv-asset-handler@npm:0.3.3":
|
||||||
version: 0.2.0
|
version: 0.3.3
|
||||||
resolution: "@cloudflare/kv-asset-handler@npm:0.2.0"
|
resolution: "@cloudflare/kv-asset-handler@npm:0.3.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
mime: "npm:^3.0.0"
|
mime: "npm:^3.0.0"
|
||||||
checksum: 56affbe5afdcfcf0860e7b9c826b3156210f1286791e702320b0ee378e540ed3e2d7ecdd55928e404475d4469433a47ca255889374b88992b481499a6d30b84b
|
checksum: 020ef0a6f7f70f8cdcecd5d8c6cd3938a80205c5e4561255d8f6d6ad6be4bf231961a62c6a8d0cf94dab1cea249cc81064b8670fdbac2eb13a006b1f34a759c4
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workerd-darwin-64@npm:1.20231030.0":
|
"@cloudflare/workerd-darwin-64@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "@cloudflare/workerd-darwin-64@npm:1.20231030.0"
|
resolution: "@cloudflare/workerd-darwin-64@npm:1.20240610.1"
|
||||||
conditions: os=darwin & cpu=x64
|
conditions: os=darwin & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workerd-darwin-arm64@npm:1.20231030.0":
|
"@cloudflare/workerd-darwin-arm64@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20231030.0"
|
resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20240610.1"
|
||||||
conditions: os=darwin & cpu=arm64
|
conditions: os=darwin & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workerd-linux-64@npm:1.20231030.0":
|
"@cloudflare/workerd-linux-64@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "@cloudflare/workerd-linux-64@npm:1.20231030.0"
|
resolution: "@cloudflare/workerd-linux-64@npm:1.20240610.1"
|
||||||
conditions: os=linux & cpu=x64
|
conditions: os=linux & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workerd-linux-arm64@npm:1.20231030.0":
|
"@cloudflare/workerd-linux-arm64@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "@cloudflare/workerd-linux-arm64@npm:1.20231030.0"
|
resolution: "@cloudflare/workerd-linux-arm64@npm:1.20240610.1"
|
||||||
conditions: os=linux & cpu=arm64
|
conditions: os=linux & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workerd-windows-64@npm:1.20231030.0":
|
"@cloudflare/workerd-windows-64@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "@cloudflare/workerd-windows-64@npm:1.20231030.0"
|
resolution: "@cloudflare/workerd-windows-64@npm:1.20240610.1"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@cloudflare/workers-types@npm:^4.20230821.0":
|
"@cloudflare/workers-types@npm:^4.20240620.0":
|
||||||
version: 4.20231218.0
|
version: 4.20240620.0
|
||||||
resolution: "@cloudflare/workers-types@npm:4.20231218.0"
|
resolution: "@cloudflare/workers-types@npm:4.20240620.0"
|
||||||
checksum: f6b84026ec22b4011661287be2fddb3f07157fb8566423081919b7ada73d46a726ca761e5609a68b5ac708113b25888f78d54700e6bd920f8045d98ae284eabd
|
checksum: 8292d02668b46777d43bdb94316a41ac4c78afa2687083d8c6749d187c3ded79b52a683265e38876fc358b4fb327fe59d5f728cf8ea898d06f8f3546e871d9c6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -1505,7 +1505,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 0.8.1
|
||||||
resolution: "@cspotcode/source-map-support@npm:0.8.1"
|
resolution: "@cspotcode/source-map-support@npm:0.8.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -6092,7 +6092,7 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@tldraw/dotcom-worker@workspace:apps/dotcom-worker"
|
resolution: "@tldraw/dotcom-worker@workspace:apps/dotcom-worker"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cloudflare/workers-types": "npm:^4.20230821.0"
|
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
||||||
"@supabase/auth-helpers-remix": "npm:^0.2.2"
|
"@supabase/auth-helpers-remix": "npm:^0.2.2"
|
||||||
"@supabase/supabase-js": "npm:^2.33.2"
|
"@supabase/supabase-js": "npm:^2.33.2"
|
||||||
"@tldraw/dotcom-shared": "workspace:*"
|
"@tldraw/dotcom-shared": "workspace:*"
|
||||||
|
@ -6111,7 +6111,7 @@ __metadata:
|
||||||
strip-ansi: "npm:^7.1.0"
|
strip-ansi: "npm:^7.1.0"
|
||||||
toucan-js: "npm:^2.7.0"
|
toucan-js: "npm:^2.7.0"
|
||||||
typescript: "npm:^5.3.3"
|
typescript: "npm:^5.3.3"
|
||||||
wrangler: "npm:3.19.0"
|
wrangler: "npm:3.61.0"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
@ -9585,6 +9585,13 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
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":
|
"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
resolution: "console-control-strings@npm:1.1.0"
|
resolution: "console-control-strings@npm:1.1.0"
|
||||||
|
@ -10066,6 +10073,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"delayed-stream@npm:~1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "delayed-stream@npm:1.0.0"
|
resolution: "delayed-stream@npm:1.0.0"
|
||||||
|
@ -10307,12 +10321,12 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload"
|
resolution: "dotcom-asset-upload@workspace:apps/dotcom-asset-upload"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cloudflare/workers-types": "npm:^4.20230821.0"
|
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
||||||
"@types/ws": "npm:^8.5.9"
|
"@types/ws": "npm:^8.5.9"
|
||||||
itty-cors: "npm:^0.3.4"
|
itty-cors: "npm:^0.3.4"
|
||||||
itty-router: "npm:^4.0.13"
|
itty-router: "npm:^4.0.13"
|
||||||
lazyrepo: "npm:0.0.0-alpha.27"
|
lazyrepo: "npm:0.0.0-alpha.27"
|
||||||
wrangler: "npm:3.19.0"
|
wrangler: "npm:3.61.0"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
@ -13326,12 +13340,12 @@ __metadata:
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "health-worker@workspace:apps/health-worker"
|
resolution: "health-worker@workspace:apps/health-worker"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cloudflare/workers-types": "npm:^4.20230821.0"
|
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
||||||
"@tldraw/utils": "workspace:*"
|
"@tldraw/utils": "workspace:*"
|
||||||
"@types/node": "npm:~20.11"
|
"@types/node": "npm:~20.11"
|
||||||
discord-api-types: "npm:^0.37.67"
|
discord-api-types: "npm:^0.37.67"
|
||||||
typescript: "npm:^5.3.3"
|
typescript: "npm:^5.3.3"
|
||||||
wrangler: "npm:3.19.0"
|
wrangler: "npm:3.61.0"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
@ -17123,25 +17137,25 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"miniflare@npm:3.20231030.3":
|
"miniflare@npm:3.20240610.1":
|
||||||
version: 3.20231030.3
|
version: 3.20240610.1
|
||||||
resolution: "miniflare@npm:3.20231030.3"
|
resolution: "miniflare@npm:3.20240610.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@cspotcode/source-map-support": "npm:0.8.1"
|
||||||
acorn: "npm:^8.8.0"
|
acorn: "npm:^8.8.0"
|
||||||
acorn-walk: "npm:^8.2.0"
|
acorn-walk: "npm:^8.2.0"
|
||||||
capnp-ts: "npm:^0.7.0"
|
capnp-ts: "npm:^0.7.0"
|
||||||
exit-hook: "npm:^2.2.1"
|
exit-hook: "npm:^2.2.1"
|
||||||
glob-to-regexp: "npm:^0.4.1"
|
glob-to-regexp: "npm:^0.4.1"
|
||||||
source-map-support: "npm:0.5.21"
|
|
||||||
stoppable: "npm:^1.1.0"
|
stoppable: "npm:^1.1.0"
|
||||||
undici: "npm:^5.22.1"
|
undici: "npm:^5.28.4"
|
||||||
workerd: "npm:1.20231030.0"
|
workerd: "npm:1.20240610.1"
|
||||||
ws: "npm:^8.11.0"
|
ws: "npm:^8.14.2"
|
||||||
youch: "npm:^3.2.2"
|
youch: "npm:^3.2.2"
|
||||||
zod: "npm:^3.20.6"
|
zod: "npm:^3.22.3"
|
||||||
bin:
|
bin:
|
||||||
miniflare: bootstrap.js
|
miniflare: bootstrap.js
|
||||||
checksum: 988bec15ebef26770c5e6f3b8d1012748fcac404cb1040a2a806a097918c6cb3b45170238abca8b48060dfef74c433f66d50c422dac2995f7ad6000d6fa48e92
|
checksum: ebd4d979294af03838c86275503f9f9c6d55fd4f17aafbd07421252660521ae48229358fa9ea06a1d24c052ce0de471c75213dbf6ad19e78a8b641b092881b5c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -17705,6 +17719,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"node-fetch@npm:2.6.7":
|
||||||
version: 2.6.7
|
version: 2.6.7
|
||||||
resolution: "node-fetch@npm:2.6.7"
|
resolution: "node-fetch@npm:2.6.7"
|
||||||
|
@ -18657,6 +18678,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"pdf-lib@npm:^1.17.1":
|
||||||
version: 1.17.1
|
version: 1.17.1
|
||||||
resolution: "pdf-lib@npm:1.17.1"
|
resolution: "pdf-lib@npm:1.17.1"
|
||||||
|
@ -19783,7 +19811,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.22.8
|
||||||
resolution: "resolve@npm:1.22.8"
|
resolution: "resolve@npm:1.22.8"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -19828,7 +19856,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.22.8
|
||||||
resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin<compat/resolve>::version=1.22.8&hash=c3c19d"
|
resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin<compat/resolve>::version=1.22.8&hash=c3c19d"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -20545,7 +20573,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 0.5.21
|
||||||
resolution: "source-map-support@npm:0.5.21"
|
resolution: "source-map-support@npm:0.5.21"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -20562,7 +20590,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 0.6.1
|
||||||
resolution: "source-map@npm:0.6.1"
|
resolution: "source-map@npm:0.6.1"
|
||||||
checksum: 59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff
|
checksum: 59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff
|
||||||
|
@ -22069,6 +22097,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"uid-promise@npm:1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "uid-promise@npm:1.0.0"
|
resolution: "uid-promise@npm:1.0.0"
|
||||||
|
@ -22111,7 +22146,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 5.28.4
|
||||||
resolution: "undici@npm:5.28.4"
|
resolution: "undici@npm:5.28.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -22120,6 +22155,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"unified@npm:^10.0.0":
|
||||||
version: 10.1.2
|
version: 10.1.2
|
||||||
resolution: "unified@npm:10.1.2"
|
resolution: "unified@npm:10.1.2"
|
||||||
|
@ -23112,15 +23161,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"workerd@npm:1.20231030.0":
|
"workerd@npm:1.20240610.1":
|
||||||
version: 1.20231030.0
|
version: 1.20240610.1
|
||||||
resolution: "workerd@npm:1.20231030.0"
|
resolution: "workerd@npm:1.20240610.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cloudflare/workerd-darwin-64": "npm:1.20231030.0"
|
"@cloudflare/workerd-darwin-64": "npm:1.20240610.1"
|
||||||
"@cloudflare/workerd-darwin-arm64": "npm:1.20231030.0"
|
"@cloudflare/workerd-darwin-arm64": "npm:1.20240610.1"
|
||||||
"@cloudflare/workerd-linux-64": "npm:1.20231030.0"
|
"@cloudflare/workerd-linux-64": "npm:1.20240610.1"
|
||||||
"@cloudflare/workerd-linux-arm64": "npm:1.20231030.0"
|
"@cloudflare/workerd-linux-arm64": "npm:1.20240610.1"
|
||||||
"@cloudflare/workerd-windows-64": "npm:1.20231030.0"
|
"@cloudflare/workerd-windows-64": "npm:1.20240610.1"
|
||||||
dependenciesMeta:
|
dependenciesMeta:
|
||||||
"@cloudflare/workerd-darwin-64":
|
"@cloudflare/workerd-darwin-64":
|
||||||
optional: true
|
optional: true
|
||||||
|
@ -23134,7 +23183,7 @@ __metadata:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
workerd: bin/workerd
|
workerd: bin/workerd
|
||||||
checksum: 7fbb12c1e9a1c6394dbe03005777e337bbf486a5e529065d9a2f2260978b239225a7d8a374cbe9e38cb4a91ec8fd6ebbbde95c2ae67773d78480fdbfdd4122ca
|
checksum: c37cd30c25fbdc7f97a296cf4b1a8ad2dbb65621a9c4b4bb24cb8fc00d9674c6610c5d2e5dc87c6cda83aa7b3b23a63d84b924ec1d9a3881622a9284590723f6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -23145,32 +23194,38 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"wrangler@npm:3.19.0":
|
"wrangler@npm:3.61.0":
|
||||||
version: 3.19.0
|
version: 3.61.0
|
||||||
resolution: "wrangler@npm:3.19.0"
|
resolution: "wrangler@npm:3.61.0"
|
||||||
dependencies:
|
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-globals-polyfill": "npm:^0.2.3"
|
||||||
"@esbuild-plugins/node-modules-polyfill": "npm:^0.2.2"
|
"@esbuild-plugins/node-modules-polyfill": "npm:^0.2.2"
|
||||||
blake3-wasm: "npm:^2.1.5"
|
blake3-wasm: "npm:^2.1.5"
|
||||||
chokidar: "npm:^3.5.3"
|
chokidar: "npm:^3.5.3"
|
||||||
esbuild: "npm:0.17.19"
|
esbuild: "npm:0.17.19"
|
||||||
fsevents: "npm:~2.3.2"
|
fsevents: "npm:~2.3.2"
|
||||||
miniflare: "npm:3.20231030.3"
|
miniflare: "npm:3.20240610.1"
|
||||||
nanoid: "npm:^3.3.3"
|
nanoid: "npm:^3.3.3"
|
||||||
path-to-regexp: "npm:^6.2.0"
|
path-to-regexp: "npm:^6.2.0"
|
||||||
|
resolve: "npm:^1.22.8"
|
||||||
resolve.exports: "npm:^2.0.2"
|
resolve.exports: "npm:^2.0.2"
|
||||||
selfsigned: "npm:^2.0.1"
|
selfsigned: "npm:^2.0.1"
|
||||||
source-map: "npm:0.6.1"
|
source-map: "npm:^0.6.1"
|
||||||
source-map-support: "npm:0.5.21"
|
unenv: "npm:unenv-nightly@1.10.0-1717606461.a117952"
|
||||||
xxhash-wasm: "npm:^1.0.1"
|
xxhash-wasm: "npm:^1.0.1"
|
||||||
|
peerDependencies:
|
||||||
|
"@cloudflare/workers-types": ^4.20240605.0
|
||||||
dependenciesMeta:
|
dependenciesMeta:
|
||||||
fsevents:
|
fsevents:
|
||||||
optional: true
|
optional: true
|
||||||
|
peerDependenciesMeta:
|
||||||
|
"@cloudflare/workers-types":
|
||||||
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
wrangler: bin/wrangler.js
|
wrangler: bin/wrangler.js
|
||||||
wrangler2: bin/wrangler.js
|
wrangler2: bin/wrangler.js
|
||||||
checksum: 18ff7dfce24c34c077e77162f978a74021324a7f21653352436956915e7ceaa45220823bf5982bb11bb78233f5ea99c61b88c923f9021ef866709cbf75e12a52
|
checksum: 91594c182fbfa65ffb1d3946829c3a51258d2ce6d7c83f0447f043e3bdb237096c4c69eda5e028f1e9742d8c0ddeae01c87512f438ab3635c5db9e923da3412b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
@ -23589,7 +23644,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 3.23.8
|
||||||
resolution: "zod@npm:3.23.8"
|
resolution: "zod@npm:3.23.8"
|
||||||
checksum: 846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1
|
checksum: 846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1
|
||||||
|
|
Loading…
Reference in a new issue