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:
David Sheldrick 2024-07-03 15:10:54 +01:00 committed by GitHub
parent 8906bd8ffa
commit c1fe8ec99a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 571 additions and 120 deletions

View file

@ -32,3 +32,5 @@ apps/vscode/extension/editor/tldraw-assets.json
apps/dotcom/public/sw.js
patchedJestJsDom.js
**/.clasp.json

View file

@ -28,3 +28,5 @@ apps/docs/utils/vector-db
apps/docs/content/releases/**/*
apps/docs/content/reference/**/*
packages/**/api
**/.clasp.json

View file

@ -28,3 +28,5 @@ apps/docs/content/reference/**/*
**/.out/*
**/.temp/*
apps/dotcom/public/**/*.*
**/.clasp.json

View file

@ -21,9 +21,10 @@
"dependencies": {
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/sync": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/tlsync": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"@tldraw/worker-shared": "workspace:*",
"itty-router": "^4.0.13",
"nanoid": "4.0.2",

View file

@ -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 { Router } from 'itty-router'
import { IRequest, Router } from 'itty-router'
import { Environment } from './types'
const connectRequestQuery = T.object({
sessionKey: T.string,
storeId: T.string.optional(),
})
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) {
const sentry = createSentry(this.ctx, this.env, request)
console.error(e)
@ -12,19 +36,18 @@ export class BemoDO extends DurableObject<Environment> {
}
private readonly router = Router()
.get('/do', async () => {
return Response.json({ message: 'Hello from a durable object!' })
.all('/connect/:slug', async (req) => {
if (!this._slug) {
await this.state.blockConcurrencyWhile(async () => {
await this.state.storage.put('slug', req.params.slug)
this._slug = req.params.slug
})
.get('/do/error', async () => {
this.doAnError()
}
return this.handleConnect(req)
})
.all('*', async () => new Response('Not found', { status: 404 }))
private doAnError() {
throw new Error('this is an error from a DO')
}
override async fetch(request: Request<unknown, CfProperties<unknown>>): Promise<Response> {
override async fetch(request: Request): Promise<Response> {
try {
return await this.router.handle(request)
} 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'
}

View file

@ -41,10 +41,13 @@ export default class Worker extends WorkerEntrypoint<Environment> {
const query = parseRequestQuery(request, urlMetadataQueryValidator)
return Response.json(await getUrlMetadata(query))
})
.get('/do', async (request) => {
const bemo = this.env.BEMO_DO.get(this.env.BEMO_DO.idFromName('bemo-do'))
const message = await (await bemo.fetch(request)).json()
return Response.json(message)
.get('/connect/:slug', (request) => {
const slug = request.params.slug
if (!slug) return new Response('Not found', { status: 404 })
// 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)

View file

@ -11,19 +11,22 @@
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/worker-shared"
"path": "../../packages/store"
},
{
"path": "../../packages/store"
"path": "../../packages/sync"
},
{
"path": "../../packages/tlschema"
},
{
"path": "../../packages/tlsync"
"path": "../../packages/utils"
},
{
"path": "../../packages/utils"
"path": "../../packages/validate"
},
{
"path": "../../packages/worker-shared"
}
]
}

View file

@ -20,7 +20,7 @@ We have a [sockets example](https://github.com/tldraw/tldraw-sockets-example) th
### 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.

View file

@ -409,7 +409,7 @@ Tldraw ships with a local-only sync engine based on `IndexedDb` and `BroadcastCh
### 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.
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/)
We don't suggest using our code directly yet, but it may serve as a good reference for your own sync engine.

View file

@ -23,8 +23,8 @@
"@supabase/supabase-js": "^2.33.2",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/sync": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/tlsync": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"@tldraw/worker-shared": "workspace:*",

View file

@ -9,21 +9,20 @@ import {
ROOM_PREFIX,
type RoomOpenMode,
} from '@tldraw/dotcom-shared'
import { TLRecord } from '@tldraw/tlschema'
import {
RoomSnapshot,
TLCloseEventCode,
TLSocketRoom,
type PersistedRoomSnapshotForSupabase,
} from '@tldraw/tlsync'
} from '@tldraw/sync'
import { TLRecord } from '@tldraw/tlschema'
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 { AlarmScheduler } from './AlarmScheduler'
import { PERSIST_INTERVAL_MS } from './config'
import { getR2KeyForRoom } from './r2'
import { Analytics, DBLoadResult, Environment, TLServerEvent } from './types'
import { createPersistQueue } from './utils/createPersistQueue'
import { createSupabaseClient } from './utils/createSupabaseClient'
import { getSlug } from './utils/roomOpenMode'
import { throttle } from './utils/throttle'

View file

@ -1,5 +1,5 @@
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot, schema } from '@tldraw/tlsync'
import { RoomSnapshot, schema } from '@tldraw/sync'
import { IRequest } from 'itty-router'
import { nanoid } from 'nanoid'
import { getR2KeyForRoom } from '../r2'

View file

@ -1,5 +1,5 @@
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync'
import { RoomSnapshot } from '@tldraw/sync'
import { IRequest } from 'itty-router'
import { nanoid } from 'nanoid'
import { getR2KeyForSnapshot } from '../r2'

View file

@ -1,4 +1,4 @@
import { RoomSnapshot } from '@tldraw/tlsync'
import { RoomSnapshot } from '@tldraw/sync'
import { notFound } from '@tldraw/worker-shared'
import { IRequest } from 'itty-router'
import { getR2KeyForSnapshot } from '../r2'

View file

@ -1,6 +1,6 @@
// 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
export interface Analytics {

View file

@ -1,6 +1,6 @@
import { SerializedSchema, SerializedStore } from '@tldraw/store'
import { schema } from '@tldraw/sync'
import { TLRecord } from '@tldraw/tlschema'
import { schema } from '@tldraw/tlsync'
import { Result, objectMapEntries } from '@tldraw/utils'
interface SnapshotRequestBody {

View file

@ -20,7 +20,7 @@
"path": "../../packages/tlschema"
},
{
"path": "../../packages/tlsync"
"path": "../../packages/sync"
},
{
"path": "../../packages/utils"

View file

@ -24,7 +24,8 @@
"@sentry/react": "^7.77.0",
"@tldraw/assets": "workspace:*",
"@tldraw/dotcom-shared": "workspace:*",
"@tldraw/tlsync": "workspace:*",
"@tldraw/sync": "workspace:*",
"@tldraw/sync-react": "workspace:*",
"@tldraw/utils": "workspace:*",
"@vercel/analytics": "^1.1.1",
"browser-fs-access": "^0.35.0",
@ -54,7 +55,6 @@
"ws": "^8.16.0"
},
"jest": {
"resolver": "<rootDir>/jestResolver.js",
"preset": "config/jest/node",
"roots": [
"<rootDir>"

View file

@ -1,5 +1,5 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync'
import { RoomSnapshot } from '@tldraw/sync'
import { useCallback, useState } from 'react'
import { Tldraw, fetch } from 'tldraw'
import '../../../styles/core.css'

View file

@ -1,4 +1,5 @@
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
import { useRemoteSyncClient } from '@tldraw/sync-react'
import { useCallback, useEffect } from 'react'
import {
DefaultContextMenu,
@ -22,7 +23,6 @@ import {
useActions,
useValue,
} from 'tldraw'
import { useRemoteSyncClient } from '../hooks/useRemoteSyncClient'
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
import { resolveAsset } from '../utils/assetHandler'
import { assetUrls } from '../utils/assetUrls'

View file

@ -1,12 +1,11 @@
import { TLIncompatibilityReason } from '@tldraw/tlsync'
import { TLIncompatibilityReason, TLRemoteSyncError } from '@tldraw/sync'
import { exhaustiveSwitchError } from 'tldraw'
import { RemoteSyncError } from '../utils/remote-sync/remote-sync'
import { ErrorPage } from './ErrorPage/ErrorPage'
export function StoreErrorScreen({ error }: { error: Error }) {
let header = 'Could not connect to server.'
let message = ''
if (error instanceof RemoteSyncError) {
if (error instanceof TLRemoteSyncError) {
switch (error.reason) {
case TLIncompatibilityReason.ClientTooOld: {
return (

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

View file

@ -1,4 +1,4 @@
import { schema } from '@tldraw/tlsync'
import { schema } from '@tldraw/sync'
import { useEffect, useState } from 'react'
import {
MigrationFailureReason,

View file

@ -1,5 +1,5 @@
import { ROOM_PREFIX } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync'
import { RoomSnapshot } from '@tldraw/sync'
import { fetch } from 'tldraw'
import '../../styles/globals.css'
import { BoardHistorySnapshot } from '../components/BoardHistorySnapshot/BoardHistorySnapshot'

View file

@ -1,5 +1,5 @@
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 '../../styles/globals.css'
import { ErrorPage } from '../components/ErrorPage/ErrorPage'

View file

@ -1,5 +1,5 @@
import { CreateRoomRequestBody, ROOM_PREFIX, Snapshot } from '@tldraw/dotcom-shared'
import { schema } from '@tldraw/tlsync'
import { schema } from '@tldraw/sync'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { TldrawUiButton, fetch } from 'tldraw'

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

View file

@ -10,6 +10,11 @@ import { Outlet, Route, createRoutesFromElements, useRouteError } from 'react-ro
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
import { ErrorPage } from './components/ErrorPage/ErrorPage'
const enableTemporaryLocalBemo =
window.location.hostname === 'localhost' &&
window.location.port === '3000' &&
typeof jest === 'undefined'
export const router = createRoutesFromElements(
<Route
element={
@ -50,6 +55,9 @@ export const router = createRoutesFromElements(
lazy={() => import('./pages/public-readonly-legacy')}
/>
<Route path={`/${READ_ONLY_PREFIX}/:roomId`} lazy={() => import('./pages/public-readonly')} />
{enableTemporaryLocalBemo && (
<Route path={`/bemo/:roomId`} lazy={() => import('./pages/temporary-bemo')} />
)}
</Route>
<Route path="*" lazy={() => import('./pages/not-found')} />
</Route>

View file

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

View file

@ -32,10 +32,13 @@
"path": "../../packages/dotcom-shared"
},
{
"path": "../../packages/tldraw"
"path": "../../packages/sync"
},
{
"path": "../../packages/tlsync"
"path": "../../packages/sync-react"
},
{
"path": "../../packages/tldraw"
},
{
"path": "../../packages/utils"

View file

@ -1,8 +1,7 @@
import { TLStoreSnapshot } from '@tldraw/tlschema'
import { areObjectsShallowEqual } from '@tldraw/utils'
import { useState } from 'react'
import { TLEditorSnapshot } from '../..'
import { loadSnapshot } from '../config/TLEditorSnapshot'
import { TLEditorSnapshot, loadSnapshot } from '../config/TLEditorSnapshot'
import { TLStoreOptions, createTLStore } from '../config/createTLStore'
/** @public */

View file

@ -0,0 +1,3 @@
# @tldraw/sync-react
react bindings for tldraw sync

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

View file

View file

@ -0,0 +1,3 @@
test('make ci pass with empty test', () => {
// empty
})

View file

@ -0,0 +1 @@
export { useRemoteSyncClient, type RemoteTLStoreWithStatus } from './useRemoteSyncClient'

View file

@ -1,16 +1,21 @@
import {
ClientWebSocketAdapter,
TLCloseEventCode,
TLIncompatibilityReason,
TLPersistentClientSocketStatus,
TLRemoteSyncError,
TLSyncClient,
schema,
} from '@tldraw/tlsync'
} from '@tldraw/sync'
import { useEffect, useState } from 'react'
import {
Signal,
TAB_ID,
TLRecord,
TLStore,
TLStoreSnapshot,
TLStoreWithStatus,
TLUserPreferences,
computed,
createPresenceStateDerivation,
defaultUserPreferences,
@ -18,9 +23,6 @@ import {
useTLStore,
useValue,
} 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'
@ -41,6 +43,7 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
const store = useTLStore({ schema })
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
const track = opts.trackAnalyticsEvent
useEffect(() => {
if (error) return
@ -67,8 +70,8 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
socket.onStatusChange((val: TLPersistentClientSocketStatus, closeCode?: number) => {
if (val === 'error' && closeCode === TLCloseEventCode.NOT_FOUND) {
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
setState({ error: new RemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
track?.(MULTIPLAYER_EVENT_NAME, { name: 'room-not-found', roomId })
setState({ error: new TLRemoteSyncError(TLIncompatibilityReason.RoomNotFound) })
client.close()
socket.close()
return
@ -82,17 +85,17 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
socket,
didCancel: () => didCancel,
onLoad(client) {
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load', roomId })
setState({ readyClient: client })
},
onLoadError(err) {
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'load-error', roomId })
track?.(MULTIPLAYER_EVENT_NAME, { name: 'load-error', roomId })
console.error(err)
setState({ error: err })
},
onSyncError(reason) {
trackAnalyticsEvent(MULTIPLAYER_EVENT_NAME, { name: 'sync-error', roomId, reason })
setState({ error: new RemoteSyncError(reason) })
track?.(MULTIPLAYER_EVENT_NAME, { name: 'sync-error', roomId, reason })
setState({ error: new TLRemoteSyncError(reason) })
},
onAfterConnect() {
// 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()
socket.close()
}
}, [prefs, roomId, store, uri, error])
}, [prefs, roomId, store, uri, error, track])
return useValue<RemoteTLStoreWithStatus>(
'remote synced store',
@ -129,3 +132,13 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
[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
}

View 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
View file

@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View file

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../config/api-extractor.json"
}

View file

@ -1,5 +1,5 @@
{
"name": "@tldraw/tlsync",
"name": "@tldraw/sync",
"description": "A tiny little drawing app (multiplayer sync).",
"version": "2.0.0-alpha.11",
"private": true,
@ -43,6 +43,7 @@
"uuid-readable": "^0.0.2"
},
"jest": {
"resolver": "<rootDir>/jestResolver.js",
"preset": "config/jest/node",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"moduleNameMapper": {

View file

@ -1,3 +1,5 @@
export { ClientWebSocketAdapter } from './lib/ClientWebSocketAdapter'
export { TLRemoteSyncError } from './lib/TLRemoteSyncError'
export { TLSocketRoom } from './lib/TLSocketRoom'
export {
TLCloseEventCode,

View file

@ -1,8 +1,8 @@
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from '@tldraw/tlsync'
import { TLRecord } from 'tldraw'
import { ClientWebSocketAdapter, INACTIVE_MIN_DELAY } from './ClientWebSocketAdapter'
// NOTE: there is a hack in apps/dotcom/jestResolver.js to make this import work
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from './protocol'
async function waitFor(predicate: () => boolean) {
let safety = 0

View file

@ -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 {
chunk,
TLCloseEventCode,
TLPersistentClientSocket,
TLPersistentClientSocketStatus,
TLSocketClientSentEvent,
TLSocketServerSentEvent,
} from '@tldraw/tlsync'
import { assert } from '@tldraw/utils'
import { atom, Atom, TLRecord } from 'tldraw'
} from './TLSyncClient'
function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {
target.addEventListener(event, handler)

View 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}`)
}
}

View file

@ -36,8 +36,8 @@ import {
} from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { useDefaultColorTheme } from '../../..'
import { startEditingShapeWithLabel } from '../../tools/SelectTool/selectHelpers'
import { useDefaultColorTheme } from '../shared/useDefaultColorTheme'
import {
CLONE_HANDLE_MARGIN,
NOTE_CENTER_OFFSET,

View file

@ -1,6 +1,7 @@
/// <reference no-default-lib="true"/>
/// <reference types="@cloudflare/workers-types" />
export { createPersistQueue } from './createPersistQueue'
export { notFound } from './errors'
export { getUrlMetadata, urlMetadataQueryValidator } from './getUrlMetadata'
export {

View file

@ -202,3 +202,9 @@ function retry(
attempt()
})
}
export async function publishProductionDocsAndExamples({
gitRef = 'HEAD',
}: { gitRef?: string } = {}) {
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
}

View file

@ -1,6 +1,6 @@
import { appendFileSync } from 'fs'
import { exec } from './lib/exec'
import { getLatestVersion, publish } from './lib/publishing'
import { getLatestVersion, publish, publishProductionDocsAndExamples } from './lib/publishing'
import { uploadStaticAssets } from './upload-static-assets'
// This expects the package.json files to be in the correct state.
@ -20,6 +20,10 @@ async function main() {
await uploadStaticAssets(latestVersionInBranch.version)
await publish()
if (isLatestVersion) {
await publishProductionDocsAndExamples()
}
}
main()

View file

@ -7,7 +7,12 @@ import { SemVer, parse } from 'semver'
import { exec } from './lib/exec'
import { generateAutoRcFile } from './lib/labels'
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 { uploadStaticAssets } from './upload-static-assets'
@ -126,7 +131,7 @@ async function main() {
if (!isPrerelease) {
const { major, minor } = parse(nextVersion)!
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

View file

@ -7,7 +7,12 @@ import { didAnyPackageChange } from './lib/didAnyPackageChange'
import { exec } from './lib/exec'
import { generateAutoRcFile } from './lib/labels'
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 { uploadStaticAssets } from './upload-static-assets'
@ -41,7 +46,7 @@ async function main() {
}
if (isLatestVersion) {
await exec('git', ['push', 'origin', `HEAD:docs-production`, '--force'])
await publishProductionDocsAndExamples()
}
// Skip releasing a new version if the package contents are identical.

View file

@ -5997,9 +5997,10 @@ __metadata:
"@cloudflare/workers-types": "npm:^4.20240620.0"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/store": "workspace:*"
"@tldraw/sync": "workspace:*"
"@tldraw/tlschema": "workspace:*"
"@tldraw/tlsync": "workspace:*"
"@tldraw/utils": "workspace:*"
"@tldraw/validate": "workspace:*"
"@tldraw/worker-shared": "workspace:*"
esbuild: "npm:^0.21.5"
itty-router: "npm:^4.0.13"
@ -6085,8 +6086,8 @@ __metadata:
"@supabase/supabase-js": "npm:^2.33.2"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/store": "workspace:*"
"@tldraw/sync": "workspace:*"
"@tldraw/tlschema": "workspace:*"
"@tldraw/tlsync": "workspace:*"
"@tldraw/utils": "workspace:*"
"@tldraw/validate": "workspace:*"
"@tldraw/worker-shared": "workspace:*"
@ -6254,6 +6255,48 @@ __metadata:
languageName: unknown
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":
version: 0.0.0-use.local
resolution: "@tldraw/tldraw@workspace:packages/namespaced-tldraw"
@ -6283,28 +6326,6 @@ __metadata:
languageName: unknown
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":
version: 0.0.0-use.local
resolution: "@tldraw/utils@workspace:packages/utils"
@ -10334,7 +10355,8 @@ __metadata:
"@sentry/react": "npm:^7.77.0"
"@tldraw/assets": "workspace:*"
"@tldraw/dotcom-shared": "workspace:*"
"@tldraw/tlsync": "workspace:*"
"@tldraw/sync": "workspace:*"
"@tldraw/sync-react": "workspace:*"
"@tldraw/utils": "workspace:*"
"@tldraw/validate": "workspace:*"
"@types/qrcode": "npm:^1.5.0"