almost everything working, except maybe offline storage state (and browser reload)

This commit is contained in:
Jeff Emmett 2024-11-25 22:09:41 +07:00
parent 100b88268b
commit db3205f97a
4 changed files with 91 additions and 76 deletions

View File

@ -1,32 +1,38 @@
import useLocalStorageState from 'use-local-storage-state'; import useLocalStorageState from 'use-local-storage-state';
import GSet from 'crdts/src/G-Set'; import GSet from 'crdts/src/G-Set';
import { TLRecord } from 'tldraw'; import { TLRecord } from 'tldraw';
import { useRef, useCallback } from 'react';
export function useGSetState(roomId: string) { export function useGSetState(roomId: string) {
const [localSet, setLocalSet] = useLocalStorageState<TLRecord[]>(`gset-${roomId}`, { const [localSet, setLocalSet] = useLocalStorageState<TLRecord[]>(`gset-${roomId}`, {
defaultValue: [] defaultValue: []
}); });
const gset = new GSet<TLRecord>(); // Keep GSet instance in a ref to persist between renders
const gsetRef = useRef<GSet<TLRecord>>();
// Initialize G-Set with local data if (!gsetRef.current) {
if (localSet && Array.isArray(localSet)) { gsetRef.current = new GSet<TLRecord>();
localSet.forEach(record => gset.add(record)); // Initialize G-Set with local data
if (localSet && Array.isArray(localSet)) {
localSet.forEach(record => gsetRef.current?.add(record));
}
} }
const addRecord = (record: TLRecord) => { const addRecord = useCallback((record: TLRecord) => {
gset.add(record); if (!gsetRef.current) return;
setLocalSet(Array.from(gset.values())); gsetRef.current.add(record);
}; setLocalSet(Array.from(gsetRef.current.values()));
}, [setLocalSet]);
const merge = (remoteSet: Set<TLRecord>) => { const merge = useCallback((remoteSet: Set<TLRecord>) => {
remoteSet.forEach(record => gset.add(record)); if (!gsetRef.current) return new Set<TLRecord>();
setLocalSet(Array.from(gset.values())); remoteSet.forEach(record => gsetRef.current?.add(record));
return gset.values(); setLocalSet(Array.from(gsetRef.current.values()));
}; return gsetRef.current.values();
}, [setLocalSet]);
return { return {
values: gset.values(), values: gsetRef.current.values(),
add: addRecord, add: addRecord,
merge, merge,
localSet localSet

View File

@ -1,7 +1,6 @@
import useLocalStorageState from 'use-local-storage-state'; import useLocalStorageState from 'use-local-storage-state';
import { TLRecord, createTLStore, SerializedStore } from 'tldraw'; import { TLRecord, createTLStore, SerializedStore } from 'tldraw';
import { customSchema } from '../../worker/TldrawDurableObject'; import { customSchema } from '../../worker/TldrawDurableObject';
import { TLSocketRoom } from '@tldraw/sync-core';
export function useLocalStorageRoom(roomId: string) { export function useLocalStorageRoom(roomId: string) {
const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(`tldraw-room-${roomId}`, { const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(`tldraw-room-${roomId}`, {
@ -13,23 +12,8 @@ export function useLocalStorageRoom(roomId: string) {
initialData: records, 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 { return {
store, store,
socketRoom,
records, records,
setRecords setRecords
}; };

View File

@ -1,39 +1,25 @@
import { useSync } from '@tldraw/sync' import { useSync } from '@tldraw/sync'
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { customSchema } from '../../worker/TldrawDurableObject' import { customSchema } from '../../worker/TldrawDurableObject'
import { multiplayerAssetStore } from '../client/multiplayerAssetStore' import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
import { useGSetState } from './useGSetState' import { useGSetState } from './useGSetState'
import { useLocalStorageRoom } from './useLocalStorageRoom' import { useLocalStorageRoom } from './useLocalStorageRoom'
import { RecordType, BaseRecord } from '@tldraw/store'
import { TLRecord } from 'tldraw' import { TLRecord } from 'tldraw'
import { WORKER_URL } from '../components/Board'
export function usePersistentBoard(roomId: string) { export function usePersistentBoard(roomId: string) {
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId) const { store: localStore, records, setRecords } = useLocalStorageRoom(roomId)
const { values, add, merge } = useGSetState(roomId) const { values, add, merge } = useGSetState(roomId)
const initialSyncRef = useRef(false)
const getWebSocketUrl = (baseUrl: string) => { const mergeInProgressRef = useRef(false)
// 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 syncedStore = useSync({ const syncedStore = useSync({
uri: import.meta.env.TLDRAW_WORKER_URL uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
? `${getWebSocketUrl(import.meta.env.TLDRAW_WORKER_URL)}/connect/${roomId}`
: `wss://jeffemmett-canvas.jeffemmett.workers.dev/connect/${roomId}`,
schema: customSchema, schema: customSchema,
assets: multiplayerAssetStore, assets: multiplayerAssetStore,
}) })
// Handle online/offline status
useEffect(() => { useEffect(() => {
const handleOnline = () => setIsOnline(true) const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false) const handleOffline = () => setIsOnline(false)
@ -47,40 +33,60 @@ export function usePersistentBoard(roomId: string) {
} }
}, []) }, [])
// Handle online/offline synchronization const mergeRecords = useCallback((records: Set<TLRecord>) => {
useEffect(() => { if (mergeInProgressRef.current || records.size === 0) return
if (isOnline && syncedStore?.store) {
// Sync server records to local try {
const serverRecords = Object.values(syncedStore.store.allRecords()) mergeInProgressRef.current = true
merge(new Set(serverRecords)) 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) => { const unsubscribe = syncedStore.store.listen((event) => {
if ('changes' in event) { if ('changes' in event) {
const changedRecords = Object.values(event.changes) const changedRecords = Object.values(event.changes)
merge(new Set(changedRecords)) if (changedRecords.length > 0) {
// Also update local storage mergeRecords(new Set(changedRecords))
setRecords(syncedStore.store.serialize()) }
} }
}) })
return () => unsubscribe() return () => unsubscribe()
} else if (!isOnline && localStore) { } else if (!isOnline) {
// When going offline, ensure we have the latest state in local storage
const currentRecords = Object.values(localStore.allRecords()) 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 { return {
store: isOnline ? syncedStore?.store : localStore, store: isOnline ? syncedStore?.store : localStore,
isOnline, isOnline,
addRecord: (record: TLRecord) => { addRecord,
add(record) mergeRecords
if (!isOnline) {
setRecords(localStore.serialize())
}
},
mergeRecords: merge
} }
} }

View File

@ -21,12 +21,31 @@ const securityHeaders = {
// we're hosting the worker separately to the client. you should restrict this to your own domain. // we're hosting the worker separately to the client. you should restrict this to your own domain.
const { preflight, corsify } = cors({ const { preflight, corsify } = cors({
origin: (origin) => { origin: (origin) => {
const allowedOrigins = [ if (!origin) return undefined
'http://localhost:5172',
'http://192.168.1.7:5172', const allowedPatterns = [
'https://jeffemmett.com' // 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'], allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'],
allowHeaders: [ allowHeaders: [