diff --git a/.gitignore b/.gitignore index 66b82c9..981a75d 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,5 @@ dist .pnp.\* .wrangler/ -.*.md \ No newline at end of file +.*.md +.vercel diff --git a/index.html b/index.html index 063435a..f59f4da 100644 --- a/index.html +++ b/index.html @@ -1,42 +1,45 @@ - - Jeff Emmett - - - - - - - - - + + Jeff Emmett + + + + + + + - - - - - + + - - - - - - + + + + + + + + + + + + + + + + + + + +
+ + - - - - -
- - \ No newline at end of file diff --git a/package.json b/package.json index 70423e7..c97ba26 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "description": "Jeff Emmett's personal website", "type": "module", "scripts": { - "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red yarn:dev:client yarn:dev:worker", - "dev:client": "vite --host", - "dev:worker": "wrangler dev", - "build": "tsc && vite build", + "dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"yarn dev:client\" \"yarn dev:worker\"", + "dev:client": "vite --host --port 5173", + "dev:worker": "wrangler dev --local --port 5172 --ip 0.0.0.0", + "build": "tsc && vite build && wrangler deploy", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "deploy": "yarn build && vercel deploy --prod" }, "keywords": [], "author": "Jeff Emmett", @@ -18,11 +19,13 @@ "@dimforge/rapier2d": "^0.11.2", "@tldraw/sync": "^2.4.6", "@tldraw/sync-core": "^2.4.6", + "@tldraw/tldraw": "^3.4.1", "@tldraw/tlschema": "^2.4.6", "@types/markdown-it": "^14.1.1", "@vercel/analytics": "^1.2.2", "@whereby.com/browser-sdk": "^3.9.2", "cloudflare-workers-unfurl": "^0.0.7", + "crdts": "^0.2.0", "gray-matter": "^4.0.3", "itty-router": "^5.0.17", "lodash.throttle": "^4.1.1", @@ -30,8 +33,11 @@ "markdown-it-latex2img": "^0.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.1.2", "react-router-dom": "^6.22.3", - "tldraw": "^2.4.6" + "tldraw": "^2.4.6", + "use-local-storage-state": "^19.5.0", + "vercel": "^39.1.1" }, "devDependencies": { "@biomejs/biome": "1.4.1", @@ -55,4 +61,4 @@ "vite-plugin-wasm": "^3.2.2", "wrangler": "^3.88.0" } -} +} \ No newline at end of file diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 4fe8fba..600ae39 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -3,9 +3,11 @@ import { AssetRecordType, getHashForString, TLBookmarkAsset, + TLRecord, Tldraw, } from 'tldraw' import { useParams } from 'react-router-dom' +import useLocalStorageState from 'use-local-storage-state' import { ChatBoxTool } from '@/tools/ChatBoxTool' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { VideoChatTool } from '@/tools/VideoChatTool' @@ -15,33 +17,26 @@ import { customSchema } from '../../worker/TldrawDurableObject' import { EmbedShape } from '@/shapes/EmbedShapeUtil' import { EmbedTool } from '@/tools/EmbedTool' -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { ChatBox } from '@/shapes/ChatBoxShapeUtil'; import { components, uiOverrides } from '@/ui-overrides' -const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` +//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev` +export const WORKER_URL = 'https://jeffemmett-canvas.jeffemmett.workers.dev'; const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape] const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools +// Add these imports +import { useGSetState } from '@/hooks/useGSetState'; +import { useLocalStorageRoom } from '@/hooks/useLocalStorageRoom'; +import { usePersistentBoard } from '@/hooks/usePersistentBoard'; + + export function Board() { - const { slug } = useParams<{ slug: string }>(); // Ensure this is inside the Board component - const roomId = slug || 'default-room'; // Declare roomId here - - const store = useSync({ - uri: `${WORKER_URL}/connect/${roomId}`, - assets: multiplayerAssetStore, - shapeUtils: shapeUtils, - schema: customSchema, - }); - - const [isChatBoxVisible, setChatBoxVisible] = useState(false); - const [userName, setUserName] = useState(''); - const [isVideoChatVisible, setVideoChatVisible] = useState(false); // Added state for video chat visibility - - const handleNameChange = (event: React.ChangeEvent) => { - setUserName(event.target.value); - }; + const { slug } = useParams<{ slug: string }>(); + const roomId = slug || 'default-room'; + const { store } = usePersistentBoard(roomId); return (
@@ -56,27 +51,6 @@ export function Board() { editor.setCurrentTool('hand') }} /> - {isChatBoxVisible && ( -
- - -
- )} - {isVideoChatVisible && ( // Render the button to join video chat - - )}
) } diff --git a/src/css/style.css b/src/css/style.css index 39fd45e..9902992 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -30,9 +30,11 @@ main { h1 { font-size: 2rem; } + h2 { font-size: 1.5rem; } + h1, h2, h3, @@ -54,7 +56,7 @@ i { font-variation-settings: "slnt" -15; } -pre > code { +pre>code { width: 100%; padding: 1em; display: block; @@ -82,6 +84,7 @@ blockquote { margin-top: 1em; margin-bottom: 1em; border-radius: 4px; + & p { font-variation-settings: "CASL" 1; margin: 0; @@ -103,6 +106,7 @@ table { margin-bottom: 1em; font-variation-settings: "mono" 1; font-variation-settings: "casl" 0; + th, td { padding: 0.5em; @@ -121,9 +125,11 @@ table { a { font-variation-settings: "CASL" 0; + &:hover { animation: casl-forward 0.2s ease forwards; } + &:not(:hover) { /* text-decoration: none; */ animation: casl-reverse 0.2s ease backwards; @@ -136,18 +142,21 @@ a { "CASL" 0, "wght" 400; } + to { font-variation-settings: "CASL" 1, "wght" 600; } } + @keyframes casl-reverse { from { font-variation-settings: "CASL" 1, "wght" 600; } + to { font-variation-settings: "CASL" 0, @@ -172,6 +181,7 @@ ul { padding-left: 0; margin-top: 0; font-size: 1rem; + & li::marker { color: rgba(0, 0, 0, 0.322); } @@ -186,9 +196,11 @@ img { main { padding: 2em; } + header { margin-bottom: 1em; } + ol { list-style-position: inside; } @@ -202,6 +214,7 @@ table:not(:has(+ p)) { p:has(+ ul) { margin-bottom: 0.5em; } + p:has(+ ol) { margin-bottom: 0.5em; } @@ -233,17 +246,21 @@ p:has(+ ol) { border: none; cursor: pointer; opacity: 0.25; + &:hover { opacity: 1; } + & img { width: 100%; height: 100%; } } + #toggle-canvas { top: 10px; } + #toggle-physics { top: 60px; display: none; @@ -253,6 +270,7 @@ p:has(+ ol) { font-family: "Recursive"; font-variation-settings: "MONO" 1; font-variation-settings: "CASL" 1; + & h1, p, span, @@ -265,6 +283,7 @@ p:has(+ ol) { & header { font-size: 1.5rem; } + & p { font-size: 1.1rem; } @@ -277,6 +296,7 @@ p:has(+ ol) { .canvas-mode { overflow: hidden; + & #toggle-physics { display: block; } @@ -287,6 +307,9 @@ p:has(+ ol) { position: fixed; inset: 0px; overflow: hidden; + touch-action: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } .tl-background { @@ -301,4 +324,4 @@ p:has(+ ol) { box-shadow: 0 0px 16px rgba(0, 0, 0, 0.15); overflow: hidden; background-color: white; -} +} \ No newline at end of file diff --git a/src/hooks/useGSetState.ts b/src/hooks/useGSetState.ts new file mode 100644 index 0000000..de096a9 --- /dev/null +++ b/src/hooks/useGSetState.ts @@ -0,0 +1,34 @@ +import useLocalStorageState from 'use-local-storage-state'; +import GSet from 'crdts/src/G-Set'; +import { TLRecord } from 'tldraw'; + +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)); + } + + const addRecord = (record: TLRecord) => { + gset.add(record); + setLocalSet(Array.from(gset.values())); + }; + + const merge = (remoteSet: Set) => { + remoteSet.forEach(record => gset.add(record)); + setLocalSet(Array.from(gset.values())); + return gset.values(); + }; + + return { + values: gset.values(), + add: addRecord, + merge, + localSet + }; +} \ No newline at end of file diff --git a/src/hooks/useLocalStorageRoom.ts b/src/hooks/useLocalStorageRoom.ts new file mode 100644 index 0000000..919bc95 --- /dev/null +++ b/src/hooks/useLocalStorageRoom.ts @@ -0,0 +1,36 @@ +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}`, { + defaultValue: createTLStore({ schema: customSchema }).serialize() + }); + + const store = createTLStore({ + schema: customSchema, + 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 + }; +} \ No newline at end of file diff --git a/src/hooks/usePersistentBoard.ts b/src/hooks/usePersistentBoard.ts new file mode 100644 index 0000000..e255ece --- /dev/null +++ b/src/hooks/usePersistentBoard.ts @@ -0,0 +1,86 @@ +import { useSync } from '@tldraw/sync' +import { useState, useEffect } 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' + +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 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}`, + schema: customSchema, + assets: multiplayerAssetStore, + }) + + // Handle online/offline status + useEffect(() => { + const handleOnline = () => setIsOnline(true) + const handleOffline = () => setIsOnline(false) + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + // 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)) + + // 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()) + } + }) + + return () => unsubscribe() + } else if (!isOnline && localStore) { + // When going offline, ensure we have the latest state in local storage + const currentRecords = Object.values(localStore.allRecords()) + merge(new Set(currentRecords)) + } + }, [isOnline, syncedStore?.store, localStore]) + + return { + store: isOnline ? syncedStore?.store : localStore, + isOnline, + addRecord: (record: TLRecord) => { + add(record) + if (!isOnline) { + setRecords(localStore.serialize()) + } + }, + mergeRecords: merge + } +} \ No newline at end of file diff --git a/src/types/crdts.d.ts b/src/types/crdts.d.ts new file mode 100644 index 0000000..7ae7961 --- /dev/null +++ b/src/types/crdts.d.ts @@ -0,0 +1,6 @@ +declare module 'crdts/src/G-Set' { + export default class GSet { + add(value: T): void; + values(): Set; + } +} \ No newline at end of file diff --git a/vercel.json b/vercel.json index 9880969..5f5753c 100644 --- a/vercel.json +++ b/vercel.json @@ -24,5 +24,16 @@ "source": "/books", "destination": "/" } + ], + "headers": [ + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } ] } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index c10cd5c..ba7d3f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,10 +5,9 @@ import wasm from "vite-plugin-wasm"; import topLevelAwait from "vite-plugin-top-level-await"; import { viteStaticCopy } from 'vite-plugin-static-copy'; - export default defineConfig({ define: { - 'process.env.TLDRAW_WORKER_URL': JSON.stringify('https://jeffemmett-canvas.jeffemmett.workers.dev') + 'process.env.TLDRAW_WORKER_URL': JSON.stringify(process.env.TLDRAW_WORKER_URL || 'https://jeffemmett-canvas.jeffemmett.workers.dev') }, plugins: [ react(), @@ -24,6 +23,10 @@ export default defineConfig({ ] }) ], + server: { + host: '0.0.0.0', + port: 5173, + }, build: { sourcemap: true, }, diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index 6d99202..5e4a496 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -14,6 +14,7 @@ import { Environment } from './types' import { ChatBoxShape } from '@/shapes/ChatBoxShapeUtil' import { VideoChatShape } from '@/shapes/VideoChatShapeUtil' import { EmbedShape } from '@/shapes/EmbedShapeUtil' +import GSet from 'crdts/src/G-Set' // add custom shapes and bindings here if needed: export const customSchema = createTLSchema({ @@ -66,6 +67,16 @@ export class TldrawDurableObject { } return this.handleConnect(request) }) + .get('/room/:roomId', async () => { + const room = await this.getRoom() + const snapshot = room.getCurrentSnapshot() + return new Response(JSON.stringify(snapshot.documents)) + }) + .post('/room/:roomId', async (request) => { + const records = await request.json() as TLRecord[] + const mergedRecords = await this.mergeCrdtState(records) + return new Response(JSON.stringify(Array.from(mergedRecords))) + }) // `fetch` is the entry point for all requests to the Durable Object fetch(request: Request): Response | Promise { @@ -136,4 +147,48 @@ export class TldrawDurableObject { const snapshot = JSON.stringify(room.getCurrentSnapshot()) await this.r2.put(`rooms/${this.roomId}`, snapshot) }, 10_000) + + async mergeCrdtState(records: TLRecord[]) { + const room = await this.getRoom(); + const gset = new GSet(); + + const store = room.getCurrentSnapshot(); + if (!store) { + throw new Error('Room store not initialized'); + } + + // First cast to unknown, then to TLRecord + store.documents.forEach((record) => gset.add(record as unknown as TLRecord)); + + // Merge new records + records.forEach((record: TLRecord) => gset.add(record)); + return gset.values(); + } + + // Add CORS headers for WebSocket upgrade + handleWebSocket(request: Request) { + const upgradeHeader = request.headers.get('Upgrade') + if (!upgradeHeader || upgradeHeader !== 'websocket') { + return new Response('Expected Upgrade: websocket', { status: 426 }) + } + + const webSocketPair = new WebSocketPair() + const [client, server] = Object.values(webSocketPair) + + server.accept() + + // Add error handling + server.addEventListener('error', (err) => { + console.error('WebSocket error:', err) + }) + + return new Response(null, { + status: 101, + webSocket: client, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + }, + }) + } } diff --git a/worker/worker.ts b/worker/worker.ts index b288744..4a4e6b3 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -6,12 +6,53 @@ import { Environment } from './types' // make sure our sync durable object is made available to cloudflare export { TldrawDurableObject } from './TldrawDurableObject' +// Define security headers +const securityHeaders = { + 'Content-Security-Policy': "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' +} + // we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because // we're hosting the worker separately to the client. you should restrict this to your own domain. -const { preflight, corsify } = cors({ origin: '*' }) +const { preflight, corsify } = cors({ + origin: (origin) => { + const allowedOrigins = [ + 'http://localhost:5172', + 'http://192.168.1.7:5172', + 'https://jeffemmett.com' + ] + return allowedOrigins.includes(origin) ? origin : undefined + }, + allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'], + allowHeaders: [ + 'Content-Type', + 'Authorization', + 'Upgrade', + 'Connection', + 'Sec-WebSocket-Key', + 'Sec-WebSocket-Version', + 'Sec-WebSocket-Extensions', + 'Sec-WebSocket-Protocol', + ...Object.keys(securityHeaders) + ], + maxAge: 86400, +}) const router = AutoRouter({ before: [preflight], - finally: [corsify], + finally: [(response) => { + // Add security headers to all responses except WebSocket upgrades + if (response.status !== 101) { + Object.entries(securityHeaders).forEach(([key, value]) => { + response.headers.set(key, value) + }) + } + return corsify(response) + }], catch: (e) => { console.error(e) return error(e) @@ -33,5 +74,21 @@ const router = AutoRouter({ // bookmarks need to extract metadata from pasted URLs: .get('/unfurl', handleUnfurlRequest) + .get('/room/:roomId', async (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + const response = await room.fetch(request.url) + return response + }) + + .post('/room/:roomId', async (request, env) => { + const id = env.TLDRAW_DURABLE_OBJECT.idFromName(request.params.roomId) + const room = env.TLDRAW_DURABLE_OBJECT.get(id) + return room.fetch(request.url, { + method: 'POST', + body: request.body + }) + }) + // export our router for cloudflare export default router diff --git a/wrangler.toml b/wrangler.toml index 0d5d04e..7e3c1ab 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,8 @@ main = "worker/worker.ts" compatibility_date = "2024-07-01" name = "jeffemmett-canvas" +account_id = "0e7b3338d5278ed1b148e6456b940913" +zone_id = "45c200f8dc2a01852e41b9bb09eb7359" [vars] TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" @@ -8,6 +10,8 @@ TLDRAW_WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" [dev] port = 5172 ip = "0.0.0.0" +local_protocol = "http" +upstream_protocol = "https" # Set up the durable object used for each tldraw room [durable_objects] @@ -25,8 +29,6 @@ new_classes = ["TldrawDurableObject"] binding = 'TLDRAW_BUCKET' bucket_name = 'jeffemmett-canvas' preview_bucket_name = 'jeffemmett-canvas-preview' -workers_dev = true -logpush = true # wrangler.toml (wrangler v3.79.0^) [observability]