put sync stuff in bemo worker (#4060)
this PR puts sync stuff in the bemo worker, and sets up a temporary dev-only page in dotcom for testing bemo stuff ### Change type - [ ] `bugfix` - [ ] `improvement` - [x] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. Create a shape... 2. - [ ] Unit tests - [ ] End to end tests ### Release notes - Fixed a bug with...
This commit is contained in:
parent
8906bd8ffa
commit
c1fe8ec99a
83 changed files with 571 additions and 120 deletions
|
@ -32,3 +32,5 @@ apps/vscode/extension/editor/tldraw-assets.json
|
||||||
apps/dotcom/public/sw.js
|
apps/dotcom/public/sw.js
|
||||||
|
|
||||||
patchedJestJsDom.js
|
patchedJestJsDom.js
|
||||||
|
|
||||||
|
**/.clasp.json
|
2
.ignore
2
.ignore
|
@ -28,3 +28,5 @@ apps/docs/utils/vector-db
|
||||||
apps/docs/content/releases/**/*
|
apps/docs/content/releases/**/*
|
||||||
apps/docs/content/reference/**/*
|
apps/docs/content/reference/**/*
|
||||||
packages/**/api
|
packages/**/api
|
||||||
|
|
||||||
|
**/.clasp.json
|
|
@ -28,3 +28,5 @@ apps/docs/content/reference/**/*
|
||||||
**/.out/*
|
**/.out/*
|
||||||
**/.temp/*
|
**/.temp/*
|
||||||
apps/dotcom/public/**/*.*
|
apps/dotcom/public/**/*.*
|
||||||
|
|
||||||
|
**/.clasp.json
|
|
@ -21,9 +21,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tldraw/dotcom-shared": "workspace:*",
|
"@tldraw/dotcom-shared": "workspace:*",
|
||||||
"@tldraw/store": "workspace:*",
|
"@tldraw/store": "workspace:*",
|
||||||
|
"@tldraw/sync": "workspace:*",
|
||||||
"@tldraw/tlschema": "workspace:*",
|
"@tldraw/tlschema": "workspace:*",
|
||||||
"@tldraw/tlsync": "workspace:*",
|
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
|
"@tldraw/validate": "workspace:*",
|
||||||
"@tldraw/worker-shared": "workspace:*",
|
"@tldraw/worker-shared": "workspace:*",
|
||||||
"itty-router": "^4.0.13",
|
"itty-router": "^4.0.13",
|
||||||
"nanoid": "4.0.2",
|
"nanoid": "4.0.2",
|
||||||
|
|
|
@ -1,9 +1,33 @@
|
||||||
import { createSentry } from '@tldraw/worker-shared'
|
import { RoomSnapshot, TLCloseEventCode, TLSocketRoom } from '@tldraw/sync'
|
||||||
|
import { TLRecord } from '@tldraw/tlschema'
|
||||||
|
import { throttle } from '@tldraw/utils'
|
||||||
|
import { T } from '@tldraw/validate'
|
||||||
|
import { createPersistQueue, createSentry, parseRequestQuery } from '@tldraw/worker-shared'
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
import { Router } from 'itty-router'
|
import { IRequest, Router } from 'itty-router'
|
||||||
import { Environment } from './types'
|
import { Environment } from './types'
|
||||||
|
|
||||||
|
const connectRequestQuery = T.object({
|
||||||
|
sessionKey: T.string,
|
||||||
|
storeId: T.string.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export class BemoDO extends DurableObject<Environment> {
|
export class BemoDO extends DurableObject<Environment> {
|
||||||
|
r2: R2Bucket
|
||||||
|
_slug: string | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public state: DurableObjectState,
|
||||||
|
env: Environment
|
||||||
|
) {
|
||||||
|
super(state, env)
|
||||||
|
this.r2 = env.BEMO_BUCKET
|
||||||
|
|
||||||
|
state.blockConcurrencyWhile(async () => {
|
||||||
|
this._slug = ((await this.state.storage.get('slug')) ?? null) as string | null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private reportError(e: unknown, request?: Request) {
|
private reportError(e: unknown, request?: Request) {
|
||||||
const sentry = createSentry(this.ctx, this.env, request)
|
const sentry = createSentry(this.ctx, this.env, request)
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
@ -12,19 +36,18 @@ export class BemoDO extends DurableObject<Environment> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly router = Router()
|
private readonly router = Router()
|
||||||
.get('/do', async () => {
|
.all('/connect/:slug', async (req) => {
|
||||||
return Response.json({ message: 'Hello from a durable object!' })
|
if (!this._slug) {
|
||||||
})
|
await this.state.blockConcurrencyWhile(async () => {
|
||||||
.get('/do/error', async () => {
|
await this.state.storage.put('slug', req.params.slug)
|
||||||
this.doAnError()
|
this._slug = req.params.slug
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.handleConnect(req)
|
||||||
})
|
})
|
||||||
.all('*', async () => new Response('Not found', { status: 404 }))
|
.all('*', async () => new Response('Not found', { status: 404 }))
|
||||||
|
|
||||||
private doAnError() {
|
override async fetch(request: Request): Promise<Response> {
|
||||||
throw new Error('this is an error from a DO')
|
|
||||||
}
|
|
||||||
|
|
||||||
override async fetch(request: Request<unknown, CfProperties<unknown>>): Promise<Response> {
|
|
||||||
try {
|
try {
|
||||||
return await this.router.handle(request)
|
return await this.router.handle(request)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -35,4 +58,148 @@ export class BemoDO extends DurableObject<Environment> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleConnect(req: IRequest) {
|
||||||
|
// extract query params from request, should include instanceId
|
||||||
|
const { sessionKey } = parseRequestQuery(req, connectRequestQuery)
|
||||||
|
|
||||||
|
// Create the websocket pair for the client
|
||||||
|
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||||
|
serverWebSocket.accept()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const room = await this.getRoom()
|
||||||
|
// Don't connect if we're already at max connections
|
||||||
|
if (room.getNumActiveSessions() >= MAX_CONNECTIONS) {
|
||||||
|
// TODO: this is not handled on the client, it just gets stuck in a loading state.
|
||||||
|
// With hibernatable sockets it should be fine to send a .close() event here.
|
||||||
|
// but we should really handle unknown errors better on the client.
|
||||||
|
return new Response('Room is full', {
|
||||||
|
status: 403,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// all good
|
||||||
|
room.handleSocketConnect(sessionKey, serverWebSocket)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For TLSyncRoom
|
||||||
|
_room: Promise<TLSocketRoom<TLRecord, void>> | null = null
|
||||||
|
|
||||||
|
getSlug() {
|
||||||
|
if (!this._slug) {
|
||||||
|
throw new Error('slug must be present')
|
||||||
|
}
|
||||||
|
return this._slug
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoom() {
|
||||||
|
const slug = this.getSlug()
|
||||||
|
if (!this._room) {
|
||||||
|
this._room = this.loadFromDatabase(slug).then((result) => {
|
||||||
|
return new TLSocketRoom<TLRecord, void>({
|
||||||
|
initialSnapshot: result.type === 'room_found' ? result.snapshot : undefined,
|
||||||
|
onSessionRemoved: async (room, args) => {
|
||||||
|
if (args.numSessionsRemaining > 0) return
|
||||||
|
if (!this._room) return
|
||||||
|
try {
|
||||||
|
await this.persistToDatabase()
|
||||||
|
} catch (err) {
|
||||||
|
// already logged
|
||||||
|
}
|
||||||
|
this._room = null
|
||||||
|
room.close()
|
||||||
|
},
|
||||||
|
onDataChange: () => {
|
||||||
|
// when we send a message, we make sure to persist the room
|
||||||
|
this.triggerPersistSchedule()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this._room
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerPersistSchedule = throttle(() => {
|
||||||
|
this.schedulePersist()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
|
||||||
|
try {
|
||||||
|
const key = getR2KeyForSlug(persistenceKey)
|
||||||
|
// when loading, prefer to fetch documents from the bucket
|
||||||
|
const roomFromBucket = await this.r2.get(key)
|
||||||
|
if (roomFromBucket) {
|
||||||
|
return { type: 'room_found', snapshot: await roomFromBucket.json() }
|
||||||
|
}
|
||||||
|
return { type: 'room_not_found' }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('failed to fetch doc', persistenceKey, error)
|
||||||
|
return { type: 'error', error: error as Error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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.getSlug()
|
||||||
|
const room = await this.getRoom()
|
||||||
|
const clock = room.getCurrentDocumentClock()
|
||||||
|
if (this._lastPersistedClock === clock) return
|
||||||
|
|
||||||
|
const snapshot = JSON.stringify(room.getCurrentSnapshot())
|
||||||
|
|
||||||
|
const key = getR2KeyForSlug(slug)
|
||||||
|
await Promise.all([this.r2.put(key, snapshot)])
|
||||||
|
this._lastPersistedClock = clock
|
||||||
|
// use a shorter timeout for this 'inner' loop than the 'outer' alarm-scheduled loop
|
||||||
|
// just in case there's any possibility of setting up a neverending queue
|
||||||
|
}, PERSIST_INTERVAL_MS / 2)
|
||||||
|
|
||||||
|
// Save the room to supabase
|
||||||
|
async persistToDatabase() {
|
||||||
|
await this._persistQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
async schedulePersist() {
|
||||||
|
const existing = await this.state.storage.getAlarm()
|
||||||
|
if (!existing) {
|
||||||
|
this.state.storage.setAlarm(PERSIST_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Will be called automatically when the alarm ticks.
|
||||||
|
override async alarm() {
|
||||||
|
this.persistToDatabase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getR2KeyForSlug(persistenceKey: string) {
|
||||||
|
return `rooms/${persistenceKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOM_NOT_FOUND = new Error('Room not found')
|
||||||
|
const MAX_CONNECTIONS = 30
|
||||||
|
const PERSIST_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
type DBLoadResult =
|
||||||
|
| {
|
||||||
|
type: 'error'
|
||||||
|
error?: Error | undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'room_found'
|
||||||
|
snapshot: RoomSnapshot
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'room_not_found'
|
||||||
|
}
|
||||||
|
|
|
@ -41,10 +41,13 @@ export default class Worker extends WorkerEntrypoint<Environment> {
|
||||||
const query = parseRequestQuery(request, urlMetadataQueryValidator)
|
const query = parseRequestQuery(request, urlMetadataQueryValidator)
|
||||||
return Response.json(await getUrlMetadata(query))
|
return Response.json(await getUrlMetadata(query))
|
||||||
})
|
})
|
||||||
.get('/do', async (request) => {
|
.get('/connect/:slug', (request) => {
|
||||||
const bemo = this.env.BEMO_DO.get(this.env.BEMO_DO.idFromName('bemo-do'))
|
const slug = request.params.slug
|
||||||
const message = await (await bemo.fetch(request)).json()
|
if (!slug) return new Response('Not found', { status: 404 })
|
||||||
return Response.json(message)
|
|
||||||
|
// Set up the durable object for this room
|
||||||
|
const id = this.env.BEMO_DO.idFromName(slug)
|
||||||
|
return this.env.BEMO_DO.get(id).fetch(request)
|
||||||
})
|
})
|
||||||
.all('*', notFound)
|
.all('*', notFound)
|
||||||
|
|
||||||
|
|
|
@ -11,19 +11,22 @@
|
||||||
"path": "../../packages/dotcom-shared"
|
"path": "../../packages/dotcom-shared"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/worker-shared"
|
"path": "../../packages/store"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/store"
|
"path": "../../packages/sync"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/tlschema"
|
"path": "../../packages/tlschema"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/tlsync"
|
"path": "../../packages/utils"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/utils"
|
"path": "../../packages/validate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/worker-shared"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ We have a [sockets example](https://github.com/tldraw/tldraw-sockets-example) th
|
||||||
|
|
||||||
### Our own sync engine
|
### Our own sync engine
|
||||||
|
|
||||||
We developed our own sync engine for use on tldraw.com based on a push/pull/rebase-style algorithm. It powers our "shared projects", such as [this one](https://tldraw.com/r). The engine's source code can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync). It was designed to be hosted on Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/).
|
We developed our own sync engine for use on tldraw.com based on a push/pull/rebase-style algorithm. It powers our "shared projects", such as [this one](https://tldraw.com/r). The engine's source code can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/sync). It was designed to be hosted on Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/).
|
||||||
|
|
||||||
We don't suggest using this code directly. However, like our other examples, it may serve as a good reference for your own sync engine.
|
We don't suggest using this code directly. However, like our other examples, it may serve as a good reference for your own sync engine.
|
||||||
|
|
||||||
|
|
|
@ -409,7 +409,7 @@ Tldraw ships with a local-only sync engine based on `IndexedDb` and `BroadcastCh
|
||||||
### Tldraw.com sync engine
|
### Tldraw.com sync engine
|
||||||
|
|
||||||
[tldraw.com/r](https://tldraw.com/r) currently uses a simple custom sync engine based on a push/pull/rebase-style algorithm.
|
[tldraw.com/r](https://tldraw.com/r) currently uses a simple custom sync engine based on a push/pull/rebase-style algorithm.
|
||||||
It can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync).
|
It can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/sync).
|
||||||
It was optimized for Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/)
|
It was optimized for Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/)
|
||||||
|
|
||||||
We don't suggest using our code directly yet, but it may serve as a good reference for your own sync engine.
|
We don't suggest using our code directly yet, but it may serve as a good reference for your own sync engine.
|
||||||
|
|
|
@ -23,8 +23,8 @@
|
||||||
"@supabase/supabase-js": "^2.33.2",
|
"@supabase/supabase-js": "^2.33.2",
|
||||||
"@tldraw/dotcom-shared": "workspace:*",
|
"@tldraw/dotcom-shared": "workspace:*",
|
||||||
"@tldraw/store": "workspace:*",
|
"@tldraw/store": "workspace:*",
|
||||||
|
"@tldraw/sync": "workspace:*",
|
||||||
"@tldraw/tlschema": "workspace:*",
|
"@tldraw/tlschema": "workspace:*",
|
||||||
"@tldraw/tlsync": "workspace:*",
|
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
"@tldraw/validate": "workspace:*",
|
"@tldraw/validate": "workspace:*",
|
||||||
"@tldraw/worker-shared": "workspace:*",
|
"@tldraw/worker-shared": "workspace:*",
|
||||||
|
|
|
@ -9,21 +9,20 @@ import {
|
||||||
ROOM_PREFIX,
|
ROOM_PREFIX,
|
||||||
type RoomOpenMode,
|
type RoomOpenMode,
|
||||||
} from '@tldraw/dotcom-shared'
|
} from '@tldraw/dotcom-shared'
|
||||||
import { TLRecord } from '@tldraw/tlschema'
|
|
||||||
import {
|
import {
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
||||||
TLSocketRoom,
|
TLSocketRoom,
|
||||||
type PersistedRoomSnapshotForSupabase,
|
type PersistedRoomSnapshotForSupabase,
|
||||||
} from '@tldraw/tlsync'
|
} from '@tldraw/sync'
|
||||||
|
import { TLRecord } from '@tldraw/tlschema'
|
||||||
import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils'
|
import { assert, assertExists, exhaustiveSwitchError } from '@tldraw/utils'
|
||||||
import { createSentry } from '@tldraw/worker-shared'
|
import { createPersistQueue, createSentry } from '@tldraw/worker-shared'
|
||||||
import { IRequest, Router } from 'itty-router'
|
import { IRequest, Router } from 'itty-router'
|
||||||
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, DBLoadResult, Environment, TLServerEvent } 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'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
|
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
|
||||||
import { RoomSnapshot, schema } from '@tldraw/tlsync'
|
import { RoomSnapshot, schema } from '@tldraw/sync'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { getR2KeyForRoom } from '../r2'
|
import { getR2KeyForRoom } from '../r2'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
|
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/sync'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { getR2KeyForSnapshot } from '../r2'
|
import { getR2KeyForSnapshot } from '../r2'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/sync'
|
||||||
import { notFound } from '@tldraw/worker-shared'
|
import { notFound } from '@tldraw/worker-shared'
|
||||||
import { IRequest } from 'itty-router'
|
import { IRequest } from 'itty-router'
|
||||||
import { getR2KeyForSnapshot } from '../r2'
|
import { getR2KeyForSnapshot } from '../r2'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// https://developers.cloudflare.com/analytics/analytics-engine/
|
// https://developers.cloudflare.com/analytics/analytics-engine/
|
||||||
|
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/sync'
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||||
|
import { schema } from '@tldraw/sync'
|
||||||
import { TLRecord } from '@tldraw/tlschema'
|
import { TLRecord } from '@tldraw/tlschema'
|
||||||
import { schema } from '@tldraw/tlsync'
|
|
||||||
import { Result, objectMapEntries } from '@tldraw/utils'
|
import { Result, objectMapEntries } from '@tldraw/utils'
|
||||||
|
|
||||||
interface SnapshotRequestBody {
|
interface SnapshotRequestBody {
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
"path": "../../packages/tlschema"
|
"path": "../../packages/tlschema"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/tlsync"
|
"path": "../../packages/sync"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/utils"
|
"path": "../../packages/utils"
|
||||||
|
|
|
@ -24,7 +24,8 @@
|
||||||
"@sentry/react": "^7.77.0",
|
"@sentry/react": "^7.77.0",
|
||||||
"@tldraw/assets": "workspace:*",
|
"@tldraw/assets": "workspace:*",
|
||||||
"@tldraw/dotcom-shared": "workspace:*",
|
"@tldraw/dotcom-shared": "workspace:*",
|
||||||
"@tldraw/tlsync": "workspace:*",
|
"@tldraw/sync": "workspace:*",
|
||||||
|
"@tldraw/sync-react": "workspace:*",
|
||||||
"@tldraw/utils": "workspace:*",
|
"@tldraw/utils": "workspace:*",
|
||||||
"@vercel/analytics": "^1.1.1",
|
"@vercel/analytics": "^1.1.1",
|
||||||
"browser-fs-access": "^0.35.0",
|
"browser-fs-access": "^0.35.0",
|
||||||
|
@ -54,7 +55,6 @@
|
||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"resolver": "<rootDir>/jestResolver.js",
|
|
||||||
"preset": "config/jest/node",
|
"preset": "config/jest/node",
|
||||||
"roots": [
|
"roots": [
|
||||||
"<rootDir>"
|
"<rootDir>"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/sync'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Tldraw, fetch } from 'tldraw'
|
import { Tldraw, fetch } from 'tldraw'
|
||||||
import '../../../styles/core.css'
|
import '../../../styles/core.css'
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||||
|
import { useRemoteSyncClient } from '@tldraw/sync-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
DefaultContextMenu,
|
DefaultContextMenu,
|
||||||
|
@ -22,7 +23,6 @@ import {
|
||||||
useActions,
|
useActions,
|
||||||
useValue,
|
useValue,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import { useRemoteSyncClient } from '../hooks/useRemoteSyncClient'
|
|
||||||
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
|
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
|
||||||
import { resolveAsset } from '../utils/assetHandler'
|
import { resolveAsset } from '../utils/assetHandler'
|
||||||
import { assetUrls } from '../utils/assetUrls'
|
import { assetUrls } from '../utils/assetUrls'
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { TLIncompatibilityReason } from '@tldraw/tlsync'
|
import { TLIncompatibilityReason, TLRemoteSyncError } from '@tldraw/sync'
|
||||||
import { exhaustiveSwitchError } from 'tldraw'
|
import { exhaustiveSwitchError } from 'tldraw'
|
||||||
import { RemoteSyncError } from '../utils/remote-sync/remote-sync'
|
|
||||||
import { ErrorPage } from './ErrorPage/ErrorPage'
|
import { ErrorPage } from './ErrorPage/ErrorPage'
|
||||||
|
|
||||||
export function StoreErrorScreen({ error }: { error: Error }) {
|
export function StoreErrorScreen({ error }: { error: Error }) {
|
||||||
let header = 'Could not connect to server.'
|
let header = 'Could not connect to server.'
|
||||||
let message = ''
|
let message = ''
|
||||||
if (error instanceof RemoteSyncError) {
|
if (error instanceof TLRemoteSyncError) {
|
||||||
switch (error.reason) {
|
switch (error.reason) {
|
||||||
case TLIncompatibilityReason.ClientTooOld: {
|
case TLIncompatibilityReason.ClientTooOld: {
|
||||||
return (
|
return (
|
||||||
|
|
98
apps/dotcom/src/components/TemporaryBemoDevEditor.tsx
Normal file
98
apps/dotcom/src/components/TemporaryBemoDevEditor.tsx
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import { useRemoteSyncClient } from '@tldraw/sync-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { DefaultContextMenu, DefaultContextMenuContent, TLComponents, Tldraw, atom } from 'tldraw'
|
||||||
|
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
|
||||||
|
import { assetUrls } from '../utils/assetUrls'
|
||||||
|
import { CursorChatMenuItem } from '../utils/context-menu/CursorChatMenuItem'
|
||||||
|
import { useCursorChat } from '../utils/useCursorChat'
|
||||||
|
import { useFileSystem } from '../utils/useFileSystem'
|
||||||
|
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
|
||||||
|
import { CursorChatBubble } from './CursorChatBubble'
|
||||||
|
import { PeopleMenu } from './PeopleMenu/PeopleMenu'
|
||||||
|
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
|
||||||
|
import { StoreErrorScreen } from './StoreErrorScreen'
|
||||||
|
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'
|
||||||
|
|
||||||
|
const shittyOfflineAtom = atom('shitty offline atom', false)
|
||||||
|
|
||||||
|
const components: TLComponents = {
|
||||||
|
ErrorFallback: ({ error }) => {
|
||||||
|
throw error
|
||||||
|
},
|
||||||
|
ContextMenu: (props) => (
|
||||||
|
<DefaultContextMenu {...props}>
|
||||||
|
<CursorChatMenuItem />
|
||||||
|
<DefaultContextMenuContent />
|
||||||
|
</DefaultContextMenu>
|
||||||
|
),
|
||||||
|
SharePanel: () => {
|
||||||
|
return (
|
||||||
|
<div className="tlui-share-zone" draggable={false}>
|
||||||
|
<PeopleMenu />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemporaryBemoDevEditor({ slug }: { slug: string }) {
|
||||||
|
const handleUiEvent = useHandleUiEvents()
|
||||||
|
|
||||||
|
const storeWithStatus = useRemoteSyncClient({
|
||||||
|
uri: `http://127.0.0.1:8989/connect/${slug}`,
|
||||||
|
roomId: slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOffline =
|
||||||
|
storeWithStatus.status === 'synced-remote' && storeWithStatus.connectionStatus === 'offline'
|
||||||
|
useEffect(() => {
|
||||||
|
shittyOfflineAtom.set(isOffline)
|
||||||
|
}, [isOffline])
|
||||||
|
|
||||||
|
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
|
||||||
|
const cursorChatOverrides = useCursorChat()
|
||||||
|
|
||||||
|
// TODO: handle assets and bookmarks
|
||||||
|
// const handleMount = useCallback(
|
||||||
|
// (editor: Editor) => {
|
||||||
|
// editor.registerExternalAssetHandler('file', createAssetFromFile)
|
||||||
|
// editor.registerExternalAssetHandler('url', createAssetFromUrl)
|
||||||
|
// },
|
||||||
|
// []
|
||||||
|
// )
|
||||||
|
|
||||||
|
if (storeWithStatus.error) {
|
||||||
|
return <StoreErrorScreen error={storeWithStatus.error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<Tldraw
|
||||||
|
store={storeWithStatus}
|
||||||
|
assetUrls={assetUrls}
|
||||||
|
overrides={[fileSystemUiOverrides, cursorChatOverrides]}
|
||||||
|
onUiEvent={handleUiEvent}
|
||||||
|
components={components}
|
||||||
|
inferDarkMode
|
||||||
|
>
|
||||||
|
<UrlStateSync />
|
||||||
|
<CursorChatBubble />
|
||||||
|
<SneakyOnDropOverride isMultiplayer />
|
||||||
|
<ThemeUpdater />
|
||||||
|
</Tldraw>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UrlStateSync() {
|
||||||
|
const syncViewport = useCallback((params: UrlStateParams) => {
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
document.title,
|
||||||
|
window.location.pathname + `?v=${params.v}&p=${params.p}`
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useUrlState(syncViewport)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { schema } from '@tldraw/tlsync'
|
import { schema } from '@tldraw/sync'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
MigrationFailureReason,
|
MigrationFailureReason,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
|
||||||
import { RoomSnapshot } from '@tldraw/tlsync'
|
import { RoomSnapshot } from '@tldraw/sync'
|
||||||
import { fetch } from 'tldraw'
|
import { fetch } from 'tldraw'
|
||||||
import '../../styles/globals.css'
|
import '../../styles/globals.css'
|
||||||
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'
|
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-shared'
|
import { ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-shared'
|
||||||
import { schema } from '@tldraw/tlsync'
|
import { schema } from '@tldraw/sync'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import '../../styles/globals.css'
|
import '../../styles/globals.css'
|
||||||
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
|
import { ErrorPage } from '../components/ErrorPage/ErrorPage'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CreateRoomRequestBody, ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-shared'
|
import { CreateRoomRequestBody, ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-shared'
|
||||||
import { schema } from '@tldraw/tlsync'
|
import { schema } from '@tldraw/sync'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { TldrawUiButton, fetch } from 'tldraw'
|
import { TldrawUiButton, fetch } from 'tldraw'
|
||||||
|
|
13
apps/dotcom/src/pages/temporary-bemo.tsx
Normal file
13
apps/dotcom/src/pages/temporary-bemo.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import '../../styles/globals.css'
|
||||||
|
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||||
|
import { TemporaryBemoDevEditor } from '../components/TemporaryBemoDevEditor'
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const id = useParams()['roomId'] as string
|
||||||
|
return (
|
||||||
|
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_MULTIPLAYER}>
|
||||||
|
<TemporaryBemoDevEditor slug={id} />
|
||||||
|
</IFrameProtector>
|
||||||
|
)
|
||||||
|
}
|
|
@ -10,6 +10,11 @@ import { Outlet, Route, createRoutesFromElements, useRouteError } from 'react-ro
|
||||||
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
|
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
|
||||||
import { ErrorPage } from './components/ErrorPage/ErrorPage'
|
import { ErrorPage } from './components/ErrorPage/ErrorPage'
|
||||||
|
|
||||||
|
const enableTemporaryLocalBemo =
|
||||||
|
window.location.hostname === 'localhost' &&
|
||||||
|
window.location.port === '3000' &&
|
||||||
|
typeof jest === 'undefined'
|
||||||
|
|
||||||
export const router = createRoutesFromElements(
|
export const router = createRoutesFromElements(
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
@ -50,6 +55,9 @@ export const router = createRoutesFromElements(
|
||||||
lazy={() => import('./pages/public-readonly-legacy')}
|
lazy={() => import('./pages/public-readonly-legacy')}
|
||||||
/>
|
/>
|
||||||
<Route path={`/${READ_ONLY_PREFIX}/:roomId`} lazy={() => import('./pages/public-readonly')} />
|
<Route path={`/${READ_ONLY_PREFIX}/:roomId`} lazy={() => import('./pages/public-readonly')} />
|
||||||
|
{enableTemporaryLocalBemo && (
|
||||||
|
<Route path={`/bemo/:roomId`} lazy={() => import('./pages/temporary-bemo')} />
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" lazy={() => import('./pages/not-found')} />
|
<Route path="*" lazy={() => import('./pages/not-found')} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { TLIncompatibilityReason } from '@tldraw/tlsync'
|
|
||||||
import { Signal, TLStoreSnapshot, TLUserPreferences } from 'tldraw'
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export class RemoteSyncError extends Error {
|
|
||||||
override name = 'RemoteSyncError'
|
|
||||||
constructor(public readonly reason: TLIncompatibilityReason) {
|
|
||||||
super(`remote sync error: ${reason}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export interface UseSyncClientConfig {
|
|
||||||
uri: string
|
|
||||||
roomId?: string
|
|
||||||
userPreferences?: Signal<TLUserPreferences>
|
|
||||||
snapshotForNewRoomRef?: { current: null | TLStoreSnapshot }
|
|
||||||
}
|
|
|
@ -32,10 +32,13 @@
|
||||||
"path": "../../packages/dotcom-shared"
|
"path": "../../packages/dotcom-shared"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/tldraw"
|
"path": "../../packages/sync"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/tlsync"
|
"path": "../../packages/sync-react"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/tldraw"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../packages/utils"
|
"path": "../../packages/utils"
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { TLStoreSnapshot } from '@tldraw/tlschema'
|
import { TLStoreSnapshot } from '@tldraw/tlschema'
|
||||||
import { areObjectsShallowEqual } from '@tldraw/utils'
|
import { areObjectsShallowEqual } from '@tldraw/utils'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { TLEditorSnapshot } from '../..'
|
import { TLEditorSnapshot, loadSnapshot } from '../config/TLEditorSnapshot'
|
||||||
import { loadSnapshot } from '../config/TLEditorSnapshot'
|
|
||||||
import { TLStoreOptions, createTLStore } from '../config/createTLStore'
|
import { TLStoreOptions, createTLStore } from '../config/createTLStore'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
|
|
3
packages/sync-react/README.md
Normal file
3
packages/sync-react/README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# @tldraw/sync-react
|
||||||
|
|
||||||
|
react bindings for tldraw sync
|
70
packages/sync-react/package.json
Normal file
70
packages/sync-react/package.json
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
{
|
||||||
|
"name": "@tldraw/sync-react",
|
||||||
|
"description": "A tiny little drawing app (multiplayer sync react bindings).",
|
||||||
|
"version": "2.0.0-alpha.11",
|
||||||
|
"private": true,
|
||||||
|
"author": {
|
||||||
|
"name": "tldraw GB Ltd.",
|
||||||
|
"email": "hello@tldraw.com"
|
||||||
|
},
|
||||||
|
"homepage": "https://tldraw.dev",
|
||||||
|
"license": "SEE LICENSE IN LICENSE.md",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tldraw/tldraw"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/tldraw/tldraw/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"tldraw",
|
||||||
|
"drawing",
|
||||||
|
"app",
|
||||||
|
"development",
|
||||||
|
"whiteboard",
|
||||||
|
"canvas",
|
||||||
|
"infinite"
|
||||||
|
],
|
||||||
|
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./.tsbuild/index.d.ts",
|
||||||
|
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||||
|
"files": [],
|
||||||
|
"scripts": {
|
||||||
|
"test-ci": "lazy inherit",
|
||||||
|
"test": "yarn run -T jest",
|
||||||
|
"test-coverage": "lazy inherit",
|
||||||
|
"lint": "yarn run -T tsx ../../scripts/lint.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"uuid-by-string": "^4.0.0",
|
||||||
|
"uuid-readable": "^0.0.2"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "config/jest/node",
|
||||||
|
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^~(.*)": "<rootDir>/src/$1"
|
||||||
|
},
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"ignore everything. swc is fast enough to transform everything"
|
||||||
|
],
|
||||||
|
"setupFiles": [
|
||||||
|
"./setupJest.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tldraw/sync": "workspace:*",
|
||||||
|
"@tldraw/utils": "workspace:*",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"nanoevents": "^7.0.1",
|
||||||
|
"nanoid": "4.0.2",
|
||||||
|
"tldraw": "workspace:*",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18"
|
||||||
|
}
|
||||||
|
}
|
0
packages/sync-react/setupJest.js
Normal file
0
packages/sync-react/setupJest.js
Normal file
3
packages/sync-react/src/index.test.ts
Normal file
3
packages/sync-react/src/index.test.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
test('make ci pass with empty test', () => {
|
||||||
|
// empty
|
||||||
|
})
|
1
packages/sync-react/src/index.ts
Normal file
1
packages/sync-react/src/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { useRemoteSyncClient, type RemoteTLStoreWithStatus } from './useRemoteSyncClient'
|
|
@ -1,16 +1,21 @@
|
||||||
import {
|
import {
|
||||||
|
ClientWebSocketAdapter,
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
||||||
TLIncompatibilityReason,
|
TLIncompatibilityReason,
|
||||||
TLPersistentClientSocketStatus,
|
TLPersistentClientSocketStatus,
|
||||||
|
TLRemoteSyncError,
|
||||||
TLSyncClient,
|
TLSyncClient,
|
||||||
schema,
|
schema,
|
||||||
} from '@tldraw/tlsync'
|
} from '@tldraw/sync'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Signal,
|
||||||
TAB_ID,
|
TAB_ID,
|
||||||
TLRecord,
|
TLRecord,
|
||||||
TLStore,
|
TLStore,
|
||||||
|
TLStoreSnapshot,
|
||||||
TLStoreWithStatus,
|
TLStoreWithStatus,
|
||||||
|
TLUserPreferences,
|
||||||
computed,
|
computed,
|
||||||
createPresenceStateDerivation,
|
createPresenceStateDerivation,
|
||||||
defaultUserPreferences,
|
defaultUserPreferences,
|
||||||
|
@ -18,9 +23,6 @@ import {
|
||||||
useTLStore,
|
useTLStore,
|
||||||
useValue,
|
useValue,
|
||||||
} from 'tldraw'
|
} from 'tldraw'
|
||||||
import { ClientWebSocketAdapter } from '../utils/remote-sync/ClientWebSocketAdapter'
|
|
||||||
import { RemoteSyncError, UseSyncClientConfig } from '../utils/remote-sync/remote-sync'
|
|
||||||
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
|
|
||||||
|
|
||||||
const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
|
const MULTIPLAYER_EVENT_NAME = 'multiplayer.client'
|
||||||
|
|
||||||
|
@ -41,6 +43,7 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
||||||
const store = useTLStore({ schema })
|
const store = useTLStore({ schema })
|
||||||
|
|
||||||
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
|
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
|
||||||
|
const track = opts.trackAnalyticsEvent
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) return
|
if (error) return
|
||||||
|
@ -67,8 +70,8 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
||||||
|
|
||||||
socket.onStatusChange((val: TLPersistentClientSocketStatus, closeCode?: number) => {
|
socket.onStatusChange((val: TLPersistentClientSocketStatus, closeCode?: number) => {
|
||||||
if (val === 'error' && closeCode === TLCloseEventCode.NOT_FOUND) {
|
if (val === 'error' && closeCode === TLCloseEventCode.NOT_FOUND) {
|
||||||
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
|
track?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
|
||||||
setState({ error: new RemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
|
setState({ error: new TLRemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
|
||||||
client.close()
|
client.close()
|
||||||
socket.close()
|
socket.close()
|
||||||
return
|
return
|
||||||
|
@ -82,17 +85,17 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
||||||
socket,
|
socket,
|
||||||
didCancel: () => didCancel,
|
didCancel: () => didCancel,
|
||||||
onLoad(client) {
|
onLoad(client) {
|
||||||
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
|
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
|
||||||
setState({ readyClient: client })
|
setState({ readyClient: client })
|
||||||
},
|
},
|
||||||
onLoadError(err) {
|
onLoadError(err) {
|
||||||
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'load-error', roomId })
|
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load-error', roomId })
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setState({ error: err })
|
setState({ error: err })
|
||||||
},
|
},
|
||||||
onSyncError(reason) {
|
onSyncError(reason) {
|
||||||
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'sync-error', roomId, reason })
|
track?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error', roomId, reason })
|
||||||
setState({ error: new RemoteSyncError(reason) })
|
setState({ error: new TLRemoteSyncError(reason) })
|
||||||
},
|
},
|
||||||
onAfterConnect() {
|
onAfterConnect() {
|
||||||
// if the server crashes and loses all data it can return an empty document
|
// if the server crashes and loses all data it can return an empty document
|
||||||
|
@ -111,7 +114,7 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
||||||
client.close()
|
client.close()
|
||||||
socket.close()
|
socket.close()
|
||||||
}
|
}
|
||||||
}, [prefs, roomId, store, uri, error])
|
}, [prefs, roomId, store, uri, error, track])
|
||||||
|
|
||||||
return useValue<RemoteTLStoreWithStatus>(
|
return useValue<RemoteTLStoreWithStatus>(
|
||||||
'remote synced store',
|
'remote synced store',
|
||||||
|
@ -129,3 +132,13 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
|
||||||
[state]
|
[state]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface UseSyncClientConfig {
|
||||||
|
uri: string
|
||||||
|
roomId?: string
|
||||||
|
userPreferences?: Signal<TLUserPreferences>
|
||||||
|
snapshotForNewRoomRef?: { current: null | TLStoreSnapshot }
|
||||||
|
/* @internal */
|
||||||
|
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
|
||||||
|
}
|
20
packages/sync-react/tsconfig.json
Normal file
20
packages/sync-react/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "../../config/tsconfig.base.json",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "docs", ".tsbuild*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./.tsbuild",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../tldraw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../utils"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
1
packages/sync/LICENSE.md
Normal file
1
packages/sync/LICENSE.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
4
packages/sync/api-extractor.json
Normal file
4
packages/sync/api-extractor.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||||
|
"extends": "../../config/api-extractor.json"
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@tldraw/tlsync",
|
"name": "@tldraw/sync",
|
||||||
"description": "A tiny little drawing app (multiplayer sync).",
|
"description": "A tiny little drawing app (multiplayer sync).",
|
||||||
"version": "2.0.0-alpha.11",
|
"version": "2.0.0-alpha.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -43,6 +43,7 @@
|
||||||
"uuid-readable": "^0.0.2"
|
"uuid-readable": "^0.0.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
"resolver": "<rootDir>/jestResolver.js",
|
||||||
"preset": "config/jest/node",
|
"preset": "config/jest/node",
|
||||||
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
|
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
|
@ -1,3 +1,5 @@
|
||||||
|
export { ClientWebSocketAdapter } from './lib/ClientWebSocketAdapter'
|
||||||
|
export { TLRemoteSyncError } from './lib/TLRemoteSyncError'
|
||||||
export { TLSocketRoom } from './lib/TLSocketRoom'
|
export { TLSocketRoom } from './lib/TLSocketRoom'
|
||||||
export {
|
export {
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
|
@ -1,8 +1,8 @@
|
||||||
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from '@tldraw/tlsync'
|
|
||||||
import { TLRecord } from 'tldraw'
|
import { TLRecord } from 'tldraw'
|
||||||
import { ClientWebSocketAdapter, INACTIVE_MIN_DELAY } from './ClientWebSocketAdapter'
|
import { ClientWebSocketAdapter, INACTIVE_MIN_DELAY } from './ClientWebSocketAdapter'
|
||||||
// NOTE: there is a hack in apps/dotcom/jestResolver.js to make this import work
|
// NOTE: there is a hack in apps/dotcom/jestResolver.js to make this import work
|
||||||
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
|
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
|
||||||
|
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from './protocol'
|
||||||
|
|
||||||
async function waitFor(predicate: () => boolean) {
|
async function waitFor(predicate: () => boolean) {
|
||||||
let safety = 0
|
let safety = 0
|
|
@ -1,13 +1,13 @@
|
||||||
|
import { atom, Atom } from '@tldraw/state'
|
||||||
|
import { TLRecord } from '@tldraw/tlschema'
|
||||||
|
import { assert } from '@tldraw/utils'
|
||||||
|
import { chunk } from './chunk'
|
||||||
|
import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'
|
||||||
import {
|
import {
|
||||||
chunk,
|
|
||||||
TLCloseEventCode,
|
TLCloseEventCode,
|
||||||
TLPersistentClientSocket,
|
TLPersistentClientSocket,
|
||||||
TLPersistentClientSocketStatus,
|
TLPersistentClientSocketStatus,
|
||||||
TLSocketClientSentEvent,
|
} from './TLSyncClient'
|
||||||
TLSocketServerSentEvent,
|
|
||||||
} from '@tldraw/tlsync'
|
|
||||||
import { assert } from '@tldraw/utils'
|
|
||||||
import { atom, Atom, TLRecord } from 'tldraw'
|
|
||||||
|
|
||||||
function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {
|
function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {
|
||||||
target.addEventListener(event, handler)
|
target.addEventListener(event, handler)
|
9
packages/sync/src/lib/TLRemoteSyncError.ts
Normal file
9
packages/sync/src/lib/TLRemoteSyncError.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { TLIncompatibilityReason } from './protocol'
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export class TLRemoteSyncError extends Error {
|
||||||
|
override name = 'RemoteSyncError'
|
||||||
|
constructor(public readonly reason: TLIncompatibilityReason) {
|
||||||
|
super(`remote sync error: ${reason}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,8 +36,8 @@ import {
|
||||||
} from '../shared/default-shape-constants'
|
} from '../shared/default-shape-constants'
|
||||||
import { getFontDefForExport } from '../shared/defaultStyleDefs'
|
import { getFontDefForExport } from '../shared/defaultStyleDefs'
|
||||||
|
|
||||||
import { useDefaultColorTheme } from '../../..'
|
|
||||||
import { startEditingShapeWithLabel } from '../../tools/SelectTool/selectHelpers'
|
import { startEditingShapeWithLabel } from '../../tools/SelectTool/selectHelpers'
|
||||||
|
import { useDefaultColorTheme } from '../shared/useDefaultColorTheme'
|
||||||
import {
|
import {
|
||||||
CLONE_HANDLE_MARGIN,
|
CLONE_HANDLE_MARGIN,
|
||||||
NOTE_CENTER_OFFSET,
|
NOTE_CENTER_OFFSET,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
export { createPersistQueue } from './createPersistQueue'
|
||||||
export { notFound } from './errors'
|
export { notFound } from './errors'
|
||||||
export { getUrlMetadata, urlMetadataQueryValidator } from './getUrlMetadata'
|
export { getUrlMetadata, urlMetadataQueryValidator } from './getUrlMetadata'
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -202,3 +202,9 @@ function retry(
|
||||||
attempt()
|
attempt()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publishProductionDocsAndExamples({
|
||||||
|
gitRef = 'HEAD',
|
||||||
|
}: { gitRef?: string } = {}) {
|
||||||
|
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { appendFileSync } from 'fs'
|
import { appendFileSync } from 'fs'
|
||||||
import { exec } from './lib/exec'
|
import { exec } from './lib/exec'
|
||||||
import { getLatestVersion, publish } from './lib/publishing'
|
import { getLatestVersion, publish, publishProductionDocsAndExamples } from './lib/publishing'
|
||||||
import { uploadStaticAssets } from './upload-static-assets'
|
import { uploadStaticAssets } from './upload-static-assets'
|
||||||
|
|
||||||
// This expects the package.json files to be in the correct state.
|
// This expects the package.json files to be in the correct state.
|
||||||
|
@ -20,6 +20,10 @@ async function main() {
|
||||||
await uploadStaticAssets(latestVersionInBranch.version)
|
await uploadStaticAssets(latestVersionInBranch.version)
|
||||||
|
|
||||||
await publish()
|
await publish()
|
||||||
|
|
||||||
|
if (isLatestVersion) {
|
||||||
|
await publishProductionDocsAndExamples()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -7,7 +7,12 @@ import { SemVer, parse } from 'semver'
|
||||||
import { exec } from './lib/exec'
|
import { exec } from './lib/exec'
|
||||||
import { generateAutoRcFile } from './lib/labels'
|
import { generateAutoRcFile } from './lib/labels'
|
||||||
import { nicelog } from './lib/nicelog'
|
import { nicelog } from './lib/nicelog'
|
||||||
import { getLatestVersion, publish, setAllVersions } from './lib/publishing'
|
import {
|
||||||
|
getLatestVersion,
|
||||||
|
publish,
|
||||||
|
publishProductionDocsAndExamples,
|
||||||
|
setAllVersions,
|
||||||
|
} from './lib/publishing'
|
||||||
import { getAllWorkspacePackages } from './lib/workspace'
|
import { getAllWorkspacePackages } from './lib/workspace'
|
||||||
import { uploadStaticAssets } from './upload-static-assets'
|
import { uploadStaticAssets } from './upload-static-assets'
|
||||||
|
|
||||||
|
@ -126,7 +131,7 @@ async function main() {
|
||||||
if (!isPrerelease) {
|
if (!isPrerelease) {
|
||||||
const { major, minor } = parse(nextVersion)!
|
const { major, minor } = parse(nextVersion)!
|
||||||
await exec('git', ['push', 'origin', `${gitTag}:refs/heads/v${major}.${minor}.x`])
|
await exec('git', ['push', 'origin', `${gitTag}:refs/heads/v${major}.${minor}.x`])
|
||||||
await exec('git', ['push', 'origin', `${gitTag}:docs-production`, `--force`])
|
await publishProductionDocsAndExamples({ gitRef: gitTag })
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a release on github
|
// create a release on github
|
||||||
|
|
|
@ -7,7 +7,12 @@ import { didAnyPackageChange } from './lib/didAnyPackageChange'
|
||||||
import { exec } from './lib/exec'
|
import { exec } from './lib/exec'
|
||||||
import { generateAutoRcFile } from './lib/labels'
|
import { generateAutoRcFile } from './lib/labels'
|
||||||
import { nicelog } from './lib/nicelog'
|
import { nicelog } from './lib/nicelog'
|
||||||
import { getLatestVersion, publish, setAllVersions } from './lib/publishing'
|
import {
|
||||||
|
getLatestVersion,
|
||||||
|
publish,
|
||||||
|
publishProductionDocsAndExamples,
|
||||||
|
setAllVersions,
|
||||||
|
} from './lib/publishing'
|
||||||
import { getAllWorkspacePackages } from './lib/workspace'
|
import { getAllWorkspacePackages } from './lib/workspace'
|
||||||
import { uploadStaticAssets } from './upload-static-assets'
|
import { uploadStaticAssets } from './upload-static-assets'
|
||||||
|
|
||||||
|
@ -41,7 +46,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLatestVersion) {
|
if (isLatestVersion) {
|
||||||
await exec('git', ['push', 'origin', `HEAD:docs-production`, '--force'])
|
await publishProductionDocsAndExamples()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip releasing a new version if the package contents are identical.
|
// Skip releasing a new version if the package contents are identical.
|
||||||
|
|
72
yarn.lock
72
yarn.lock
|
@ -5997,9 +5997,10 @@ __metadata:
|
||||||
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
"@cloudflare/workers-types": "npm:^4.20240620.0"
|
||||||
"@tldraw/dotcom-shared": "workspace:*"
|
"@tldraw/dotcom-shared": "workspace:*"
|
||||||
"@tldraw/store": "workspace:*"
|
"@tldraw/store": "workspace:*"
|
||||||
|
"@tldraw/sync": "workspace:*"
|
||||||
"@tldraw/tlschema": "workspace:*"
|
"@tldraw/tlschema": "workspace:*"
|
||||||
"@tldraw/tlsync": "workspace:*"
|
|
||||||
"@tldraw/utils": "workspace:*"
|
"@tldraw/utils": "workspace:*"
|
||||||
|
"@tldraw/validate": "workspace:*"
|
||||||
"@tldraw/worker-shared": "workspace:*"
|
"@tldraw/worker-shared": "workspace:*"
|
||||||
esbuild: "npm:^0.21.5"
|
esbuild: "npm:^0.21.5"
|
||||||
itty-router: "npm:^4.0.13"
|
itty-router: "npm:^4.0.13"
|
||||||
|
@ -6085,8 +6086,8 @@ __metadata:
|
||||||
"@supabase/supabase-js": "npm:^2.33.2"
|
"@supabase/supabase-js": "npm:^2.33.2"
|
||||||
"@tldraw/dotcom-shared": "workspace:*"
|
"@tldraw/dotcom-shared": "workspace:*"
|
||||||
"@tldraw/store": "workspace:*"
|
"@tldraw/store": "workspace:*"
|
||||||
|
"@tldraw/sync": "workspace:*"
|
||||||
"@tldraw/tlschema": "workspace:*"
|
"@tldraw/tlschema": "workspace:*"
|
||||||
"@tldraw/tlsync": "workspace:*"
|
|
||||||
"@tldraw/utils": "workspace:*"
|
"@tldraw/utils": "workspace:*"
|
||||||
"@tldraw/validate": "workspace:*"
|
"@tldraw/validate": "workspace:*"
|
||||||
"@tldraw/worker-shared": "workspace:*"
|
"@tldraw/worker-shared": "workspace:*"
|
||||||
|
@ -6254,6 +6255,48 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"@tldraw/sync-react@workspace:*, @tldraw/sync-react@workspace:packages/sync-react":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@tldraw/sync-react@workspace:packages/sync-react"
|
||||||
|
dependencies:
|
||||||
|
"@tldraw/sync": "workspace:*"
|
||||||
|
"@tldraw/utils": "workspace:*"
|
||||||
|
lodash.isequal: "npm:^4.5.0"
|
||||||
|
nanoevents: "npm:^7.0.1"
|
||||||
|
nanoid: "npm:4.0.2"
|
||||||
|
tldraw: "workspace:*"
|
||||||
|
typescript: "npm:^5.3.3"
|
||||||
|
uuid-by-string: "npm:^4.0.0"
|
||||||
|
uuid-readable: "npm:^0.0.2"
|
||||||
|
ws: "npm:^8.16.0"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18
|
||||||
|
react-dom: ^18
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
|
"@tldraw/sync@workspace:*, @tldraw/sync@workspace:packages/sync":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@tldraw/sync@workspace:packages/sync"
|
||||||
|
dependencies:
|
||||||
|
"@tldraw/state": "workspace:*"
|
||||||
|
"@tldraw/store": "workspace:*"
|
||||||
|
"@tldraw/tlschema": "workspace:*"
|
||||||
|
"@tldraw/utils": "workspace:*"
|
||||||
|
lodash.isequal: "npm:^4.5.0"
|
||||||
|
nanoevents: "npm:^7.0.1"
|
||||||
|
nanoid: "npm:4.0.2"
|
||||||
|
tldraw: "workspace:*"
|
||||||
|
typescript: "npm:^5.3.3"
|
||||||
|
uuid-by-string: "npm:^4.0.0"
|
||||||
|
uuid-readable: "npm:^0.0.2"
|
||||||
|
ws: "npm:^8.16.0"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18
|
||||||
|
react-dom: ^18
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@tldraw/tldraw@workspace:packages/namespaced-tldraw":
|
"@tldraw/tldraw@workspace:packages/namespaced-tldraw":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@tldraw/tldraw@workspace:packages/namespaced-tldraw"
|
resolution: "@tldraw/tldraw@workspace:packages/namespaced-tldraw"
|
||||||
|
@ -6283,28 +6326,6 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"@tldraw/tlsync@workspace:*, @tldraw/tlsync@workspace:packages/tlsync":
|
|
||||||
version: 0.0.0-use.local
|
|
||||||
resolution: "@tldraw/tlsync@workspace:packages/tlsync"
|
|
||||||
dependencies:
|
|
||||||
"@tldraw/state": "workspace:*"
|
|
||||||
"@tldraw/store": "workspace:*"
|
|
||||||
"@tldraw/tlschema": "workspace:*"
|
|
||||||
"@tldraw/utils": "workspace:*"
|
|
||||||
lodash.isequal: "npm:^4.5.0"
|
|
||||||
nanoevents: "npm:^7.0.1"
|
|
||||||
nanoid: "npm:4.0.2"
|
|
||||||
tldraw: "workspace:*"
|
|
||||||
typescript: "npm:^5.3.3"
|
|
||||||
uuid-by-string: "npm:^4.0.0"
|
|
||||||
uuid-readable: "npm:^0.0.2"
|
|
||||||
ws: "npm:^8.16.0"
|
|
||||||
peerDependencies:
|
|
||||||
react: ^18
|
|
||||||
react-dom: ^18
|
|
||||||
languageName: unknown
|
|
||||||
linkType: soft
|
|
||||||
|
|
||||||
"@tldraw/utils@workspace:*, @tldraw/utils@workspace:packages/utils":
|
"@tldraw/utils@workspace:*, @tldraw/utils@workspace:packages/utils":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@tldraw/utils@workspace:packages/utils"
|
resolution: "@tldraw/utils@workspace:packages/utils"
|
||||||
|
@ -10334,7 +10355,8 @@ __metadata:
|
||||||
"@sentry/react": "npm:^7.77.0"
|
"@sentry/react": "npm:^7.77.0"
|
||||||
"@tldraw/assets": "workspace:*"
|
"@tldraw/assets": "workspace:*"
|
||||||
"@tldraw/dotcom-shared": "workspace:*"
|
"@tldraw/dotcom-shared": "workspace:*"
|
||||||
"@tldraw/tlsync": "workspace:*"
|
"@tldraw/sync": "workspace:*"
|
||||||
|
"@tldraw/sync-react": "workspace:*"
|
||||||
"@tldraw/utils": "workspace:*"
|
"@tldraw/utils": "workspace:*"
|
||||||
"@tldraw/validate": "workspace:*"
|
"@tldraw/validate": "workspace:*"
|
||||||
"@types/qrcode": "npm:^1.5.0"
|
"@types/qrcode": "npm:^1.5.0"
|
||||||
|
|
Loading…
Reference in a new issue