diff --git a/src/hooks/useGSetState.ts b/src/hooks/useGSetState.ts index de096a9..e825fa1 100644 --- a/src/hooks/useGSetState.ts +++ b/src/hooks/useGSetState.ts @@ -1,32 +1,38 @@ import useLocalStorageState from 'use-local-storage-state'; import GSet from 'crdts/src/G-Set'; import { TLRecord } from 'tldraw'; +import { useRef, useCallback } from 'react'; export function useGSetState(roomId: string) { const [localSet, setLocalSet] = useLocalStorageState(`gset-${roomId}`, { defaultValue: [] }); - const gset = new GSet(); - - // Initialize G-Set with local data - if (localSet && Array.isArray(localSet)) { - localSet.forEach(record => gset.add(record)); + // Keep GSet instance in a ref to persist between renders + const gsetRef = useRef>(); + if (!gsetRef.current) { + gsetRef.current = new GSet(); + // Initialize G-Set with local data + if (localSet && Array.isArray(localSet)) { + localSet.forEach(record => gsetRef.current?.add(record)); + } } - const addRecord = (record: TLRecord) => { - gset.add(record); - setLocalSet(Array.from(gset.values())); - }; + const addRecord = useCallback((record: TLRecord) => { + if (!gsetRef.current) return; + gsetRef.current.add(record); + setLocalSet(Array.from(gsetRef.current.values())); + }, [setLocalSet]); - const merge = (remoteSet: Set) => { - remoteSet.forEach(record => gset.add(record)); - setLocalSet(Array.from(gset.values())); - return gset.values(); - }; + const merge = useCallback((remoteSet: Set) => { + if (!gsetRef.current) return new Set(); + remoteSet.forEach(record => gsetRef.current?.add(record)); + setLocalSet(Array.from(gsetRef.current.values())); + return gsetRef.current.values(); + }, [setLocalSet]); return { - values: gset.values(), + values: gsetRef.current.values(), add: addRecord, merge, localSet diff --git a/src/hooks/useLocalStorageRoom.ts b/src/hooks/useLocalStorageRoom.ts index 919bc95..946fef5 100644 --- a/src/hooks/useLocalStorageRoom.ts +++ b/src/hooks/useLocalStorageRoom.ts @@ -1,7 +1,6 @@ import useLocalStorageState from 'use-local-storage-state'; import { TLRecord, createTLStore, SerializedStore } from 'tldraw'; import { customSchema } from '../../worker/TldrawDurableObject'; -import { TLSocketRoom } from '@tldraw/sync-core'; export function useLocalStorageRoom(roomId: string) { const [records, setRecords] = useLocalStorageState>(`tldraw-room-${roomId}`, { @@ -13,23 +12,8 @@ export function useLocalStorageRoom(roomId: string) { initialData: records, }); - const socketRoom = new TLSocketRoom({ - initialSnapshot: { - store: store.serialize(), - schema: customSchema.serialize(), - }, - schema: customSchema, - onDataChange: () => { - const serializedStore = store.serialize(); - setRecords(serializedStore); - // Broadcast changes to other clients - store.mergeRemoteChanges(() => Object.values(serializedStore)); - }, - }); - return { store, - socketRoom, records, setRecords }; diff --git a/src/hooks/usePersistentBoard.ts b/src/hooks/usePersistentBoard.ts index e255ece..128bf51 100644 --- a/src/hooks/usePersistentBoard.ts +++ b/src/hooks/usePersistentBoard.ts @@ -1,39 +1,25 @@ import { useSync } from '@tldraw/sync' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { customSchema } from '../../worker/TldrawDurableObject' import { multiplayerAssetStore } from '../client/multiplayerAssetStore' import { useGSetState } from './useGSetState' import { useLocalStorageRoom } from './useLocalStorageRoom' -import { RecordType, BaseRecord } from '@tldraw/store' import { TLRecord } from 'tldraw' +import { WORKER_URL } from '../components/Board' export function usePersistentBoard(roomId: string) { const [isOnline, setIsOnline] = useState(navigator.onLine) const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId) const { values, add, merge } = useGSetState(roomId) - - const getWebSocketUrl = (baseUrl: string) => { - // Remove any trailing slashes - baseUrl = baseUrl.replace(/\/$/, '') - - // Handle different protocols - if (baseUrl.startsWith('https://')) { - return baseUrl.replace('https://', 'wss://') - } else if (baseUrl.startsWith('http://')) { - return baseUrl.replace('http://', 'ws://') - } - return baseUrl - } + const initialSyncRef = useRef(false) + const mergeInProgressRef = useRef(false) const syncedStore = useSync({ - uri: import.meta.env.TLDRAW_WORKER_URL - ? `${getWebSocketUrl(import.meta.env.TLDRAW_WORKER_URL)}/connect/${roomId}` - : `wss://jeffemmett-canvas.jeffemmett.workers.dev/connect/${roomId}`, + uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`, schema: customSchema, assets: multiplayerAssetStore, }) - // Handle online/offline status useEffect(() => { const handleOnline = () => setIsOnline(true) const handleOffline = () => setIsOnline(false) @@ -47,40 +33,60 @@ export function usePersistentBoard(roomId: string) { } }, []) - // Handle online/offline synchronization - useEffect(() => { - if (isOnline && syncedStore?.store) { - // Sync server records to local - const serverRecords = Object.values(syncedStore.store.allRecords()) - merge(new Set(serverRecords)) + const mergeRecords = useCallback((records: Set) => { + if (mergeInProgressRef.current || records.size === 0) return + + try { + mergeInProgressRef.current = true + merge(records) + if (!isOnline && localStore) { + setRecords(localStore.serialize()) + } + } finally { + mergeInProgressRef.current = false + } + }, [isOnline, localStore, merge, setRecords]) + + useEffect(() => { + if (!syncedStore?.store || !localStore) return + + if (isOnline && !initialSyncRef.current) { + initialSyncRef.current = true + const serverRecords = Object.values(syncedStore.store.allRecords()) + if (serverRecords.length > 0) { + mergeRecords(new Set(serverRecords)) + } - // Set up store change listener const unsubscribe = syncedStore.store.listen((event) => { if ('changes' in event) { const changedRecords = Object.values(event.changes) - merge(new Set(changedRecords)) - // Also update local storage - setRecords(syncedStore.store.serialize()) + if (changedRecords.length > 0) { + mergeRecords(new Set(changedRecords)) + } } }) return () => unsubscribe() - } else if (!isOnline && localStore) { - // When going offline, ensure we have the latest state in local storage + } else if (!isOnline) { const currentRecords = Object.values(localStore.allRecords()) - merge(new Set(currentRecords)) + if (currentRecords.length > 0) { + mergeRecords(new Set(currentRecords)) + } } - }, [isOnline, syncedStore?.store, localStore]) + }, [isOnline, syncedStore?.store, localStore, mergeRecords]) + + const addRecord = useCallback((record: TLRecord) => { + if (!record) return + add(record) + if (!isOnline && localStore) { + setRecords(localStore.serialize()) + } + }, [add, isOnline, localStore, setRecords]) return { store: isOnline ? syncedStore?.store : localStore, isOnline, - addRecord: (record: TLRecord) => { - add(record) - if (!isOnline) { - setRecords(localStore.serialize()) - } - }, - mergeRecords: merge + addRecord, + mergeRecords } } \ No newline at end of file diff --git a/worker/worker.ts b/worker/worker.ts index 4a4e6b3..f938bc9 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -21,12 +21,31 @@ const securityHeaders = { // we're hosting the worker separately to the client. you should restrict this to your own domain. const { preflight, corsify } = cors({ origin: (origin) => { - const allowedOrigins = [ - 'http://localhost:5172', - 'http://192.168.1.7:5172', - 'https://jeffemmett.com' + if (!origin) return undefined + + const allowedPatterns = [ + // Localhost with any port + /^http:\/\/localhost:\d+$/, + // 127.0.0.1 with any port + /^http:\/\/127\.0\.0\.1:\d+$/, + // 192.168.*.* with any port + /^http:\/\/192\.168\.\d+\.\d+:\d+$/, + // 169.254.*.* with any port + /^http:\/\/169\.254\.\d+\.\d+:\d+$/, + // 10.*.*.* with any port + /^http:\/\/10\.\d+\.\d+\.\d+:\d+$/, + // Production domain + /^https:\/\/jeffemmett\.com$/ ] - return allowedOrigins.includes(origin) ? origin : undefined + + // Check if origin matches any of our patterns + const isAllowed = allowedPatterns.some(pattern => + pattern instanceof RegExp + ? pattern.test(origin) + : pattern === origin + ) + + return isAllowed ? origin : undefined }, allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], allowHeaders: [