commit
94bec533c4
|
|
@ -180,6 +180,7 @@ dist
|
|||
|
||||
# Environment variables
|
||||
.env*
|
||||
.env.development
|
||||
!.env.example
|
||||
.vercel
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
{
|
||||
"name": "jeffemmett",
|
||||
"version": "1.0.0",
|
||||
"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 --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",
|
||||
"deploy": "yarn build && vercel deploy --prod"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jeff Emmett",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d": "^0.11.2",
|
||||
"@tldraw/assets": "^2.0.0",
|
||||
"@tldraw/tldraw": "^3.4.1",
|
||||
"@tldraw/sync": "^2.4.6",
|
||||
"@tldraw/sync-core": "^2.4.6",
|
||||
"@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",
|
||||
"markdown-it": "^14.1.0",
|
||||
"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",
|
||||
"use-local-storage-state": "^19.5.0",
|
||||
"vercel": "^39.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.4.1",
|
||||
"@cloudflare/types": "^6.29.1",
|
||||
"@cloudflare/workers-types": "^4.20240821.1",
|
||||
"@types/lodash.throttle": "^4",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||
"@typescript-eslint/parser": "^5.59.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-static-copy": "^1.0.6",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"wrangler": "^3.88.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "^18.2.0",
|
||||
"@types/react": "^18.2.0"
|
||||
}
|
||||
}
|
||||
12
package.json
12
package.json
|
|
@ -17,11 +17,11 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d": "^0.11.2",
|
||||
"@tldraw/assets": "^3.5.0",
|
||||
"@tldraw/sync": "^3.4.1",
|
||||
"@tldraw/sync-core": "^3.4.1",
|
||||
"@tldraw/tldraw": "^3.4.1",
|
||||
"@tldraw/tlschema": "^3.4.1",
|
||||
"@tldraw/assets": "^3.6.0",
|
||||
"@tldraw/sync": "^3.6.0",
|
||||
"@tldraw/sync-core": "^3.6.0",
|
||||
"@tldraw/tldraw": "^3.6.0",
|
||||
"@tldraw/tlschema": "^3.6.0",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"cloudflare-workers-unfurl": "^0.0.7",
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"tldraw": "^3.4.1",
|
||||
"tldraw": "^3.6.0",
|
||||
"use-local-storage-state": "^19.5.0",
|
||||
"vercel": "^39.1.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
import { useSync } from '@tldraw/sync'
|
||||
import {
|
||||
AssetRecordType,
|
||||
getHashForString,
|
||||
TLBookmarkAsset,
|
||||
Tldraw,
|
||||
// useLocalStorageState,
|
||||
} 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'
|
||||
import { VideoChatShape } from '@/shapes/VideoChatShapeUtil'
|
||||
import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
|
||||
import { customSchema } from '../../worker/TldrawDurableObject'
|
||||
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||
import { EmbedTool } from '@/tools/EmbedTool'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
||||
import { components, uiOverrides } from '@/ui-overrides'
|
||||
|
||||
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||
|
||||
const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape]
|
||||
const tools = [ChatBoxTool, VideoChatTool, EmbedTool]; // Array of tools
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
setUserName(event.target.value);
|
||||
};
|
||||
|
||||
const [persistedStore, setPersistedStore] = useLocalStorageState('board-store', { defaultValue: store }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPersistedStore(store);
|
||||
}, [store]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
//store={persistedStore}
|
||||
store={store}
|
||||
shapeUtils={shapeUtils}
|
||||
overrides={uiOverrides}
|
||||
components={components}
|
||||
tools={tools}
|
||||
onMount={(editor) => {
|
||||
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
|
||||
editor.setCurrentTool('hand')
|
||||
}}
|
||||
/>
|
||||
{isChatBoxVisible && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
<ChatBox
|
||||
userName={userName}
|
||||
roomId={roomId} // Added roomId
|
||||
w={200} // Set appropriate width
|
||||
h={200} // Set appropriate height
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isVideoChatVisible && ( // Render the button to join video chat
|
||||
<button onClick={() => setVideoChatVisible(false)} className="bg-green-500 text-white px-4 py-2 rounded">
|
||||
Join Video Call
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// How does our server handle bookmark unfurling?
|
||||
async function unfurlBookmarkUrl({ url }: { url: string }): Promise<TLBookmarkAsset> {
|
||||
const asset: TLBookmarkAsset = {
|
||||
id: AssetRecordType.createId(getHashForString(url)),
|
||||
typeName: 'asset',
|
||||
type: 'bookmark',
|
||||
meta: {},
|
||||
props: {
|
||||
src: url,
|
||||
description: '',
|
||||
image: '',
|
||||
favicon: '',
|
||||
title: '',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`)
|
||||
const data = await response.json() as { description: string, image: string, favicon: string, title: string }
|
||||
|
||||
asset.props.description = data?.description ?? ''
|
||||
asset.props.image = data?.image ?? ''
|
||||
asset.props.favicon = data?.favicon ?? ''
|
||||
asset.props.title = data?.title ?? ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return asset
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useSync } from '@tldraw/sync'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
AssetRecordType,
|
||||
getHashForString,
|
||||
|
|
@ -10,7 +11,6 @@ import {
|
|||
TLUiEventSource,
|
||||
} 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'
|
||||
|
|
@ -19,6 +19,7 @@ import { multiplayerAssetStore } from '../client/multiplayerAssetStore'
|
|||
import { customSchema } from '../../worker/TldrawDurableObject'
|
||||
import { EmbedShape } from '@/shapes/EmbedShapeUtil'
|
||||
import { EmbedTool } from '@/tools/EmbedTool'
|
||||
import { defaultShapeUtils, defaultBindingUtils } from 'tldraw'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ChatBox } from '@/shapes/ChatBoxShapeUtil';
|
||||
|
|
@ -26,22 +27,25 @@ import { components, uiOverrides } from '@/ui-overrides'
|
|||
import { useCameraControls } from '@/hooks/useCameraControls'
|
||||
import { zoomToSelection } from '../ui-overrides'
|
||||
|
||||
//const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
|
||||
// Default to production URL if env var isn't available
|
||||
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 }>();
|
||||
const roomId = slug || 'default-room';
|
||||
const store = usePersistentBoard(roomId);
|
||||
|
||||
const storeConfig = useMemo(() => ({
|
||||
uri: `${WORKER_URL}/connect/${roomId}`,
|
||||
assets: multiplayerAssetStore,
|
||||
shapeUtils: [...shapeUtils, ...defaultShapeUtils],
|
||||
bindingUtils: [...defaultBindingUtils],
|
||||
}), [roomId]);
|
||||
|
||||
const store = useSync(storeConfig);
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
const { zoomToFrame, copyFrameLink, copyLocationLink, revertCamera } = useCameraControls(editor)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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<TLRecord[]>(`gset-${roomId}`, {
|
||||
defaultValue: []
|
||||
});
|
||||
|
||||
// Keep GSet instance in a ref to persist between renders
|
||||
const gsetRef = useRef<GSet<TLRecord>>();
|
||||
if (!gsetRef.current) {
|
||||
gsetRef.current = new GSet<TLRecord>();
|
||||
// Initialize G-Set with local data
|
||||
if (localSet && Array.isArray(localSet)) {
|
||||
localSet.forEach(record => gsetRef.current?.add(record));
|
||||
}
|
||||
}
|
||||
|
||||
const addRecord = useCallback((record: TLRecord) => {
|
||||
if (!gsetRef.current) return;
|
||||
gsetRef.current.add(record);
|
||||
setLocalSet(Array.from(gsetRef.current.values()));
|
||||
}, [setLocalSet]);
|
||||
|
||||
const merge = useCallback((remoteSet: Set<TLRecord>) => {
|
||||
if (!gsetRef.current) return new Set<TLRecord>();
|
||||
remoteSet.forEach(record => gsetRef.current?.add(record));
|
||||
setLocalSet(Array.from(gsetRef.current.values()));
|
||||
return gsetRef.current.values();
|
||||
}, [setLocalSet]);
|
||||
|
||||
return {
|
||||
values: gsetRef.current.values(),
|
||||
add: addRecord,
|
||||
merge,
|
||||
localSet
|
||||
};
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import useLocalStorageState from 'use-local-storage-state';
|
||||
import { TLRecord, createTLStore, SerializedStore } from 'tldraw';
|
||||
import { customSchema } from '../../worker/TldrawDurableObject';
|
||||
|
||||
export function useLocalStorageRoom(roomId: string) {
|
||||
const [records, setRecords] = useLocalStorageState<SerializedStore<TLRecord>>(`tldraw-room-${roomId}`, {
|
||||
defaultValue: createTLStore({ schema: customSchema }).serialize()
|
||||
});
|
||||
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
initialData: records,
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
records,
|
||||
setRecords
|
||||
};
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { useSync } from '@tldraw/sync'
|
||||
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 { 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 initialSyncRef = useRef(false)
|
||||
const mergeInProgressRef = useRef(false)
|
||||
|
||||
const syncedStore = useSync({
|
||||
uri: `${WORKER_URL.replace('https://', 'wss://')}/connect/${roomId}`,
|
||||
schema: customSchema,
|
||||
assets: multiplayerAssetStore,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const mergeRecords = useCallback((records: Set<TLRecord>) => {
|
||||
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))
|
||||
}
|
||||
|
||||
const unsubscribe = syncedStore.store.listen((event) => {
|
||||
if ('changes' in event) {
|
||||
const changedRecords = Object.values(event.changes)
|
||||
if (changedRecords.length > 0) {
|
||||
mergeRecords(new Set(changedRecords))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
} else if (!isOnline) {
|
||||
const currentRecords = Object.values(localStore.allRecords())
|
||||
if (currentRecords.length > 0) {
|
||||
mergeRecords(new Set(currentRecords))
|
||||
}
|
||||
}
|
||||
}, [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,
|
||||
mergeRecords
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TLDRAW_WORKER_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { markdownPlugin } from './build/markdownPlugin';
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
|
@ -35,4 +35,7 @@ export default defineConfig({
|
|||
'@': '/src',
|
||||
},
|
||||
},
|
||||
})
|
||||
define: {
|
||||
'import.meta.env.VITE_WORKER_URL': JSON.stringify(process.env.VITE_WORKER_URL)
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
TLRecord,
|
||||
TLShape,
|
||||
createTLSchema,
|
||||
// defaultBindingSchemas,
|
||||
defaultBindingSchemas,
|
||||
defaultShapeSchemas,
|
||||
} from '@tldraw/tlschema'
|
||||
import { AutoRouter, IRequest, error } from 'itty-router'
|
||||
|
|
@ -20,11 +20,20 @@ import GSet from 'crdts/src/G-Set'
|
|||
export const customSchema = createTLSchema({
|
||||
shapes: {
|
||||
...defaultShapeSchemas,
|
||||
ChatBox: ChatBoxShape,
|
||||
VideoChat: VideoChatShape,
|
||||
Embed: EmbedShape
|
||||
ChatBox: {
|
||||
props: ChatBoxShape.props,
|
||||
migrations: ChatBoxShape.migrations,
|
||||
},
|
||||
// bindings: { ...defaultBindingSchemas },
|
||||
VideoChat: {
|
||||
props: VideoChatShape.props,
|
||||
migrations: VideoChatShape.migrations,
|
||||
},
|
||||
Embed: {
|
||||
props: EmbedShape.props,
|
||||
migrations: EmbedShape.migrations,
|
||||
},
|
||||
},
|
||||
bindings: defaultBindingSchemas,
|
||||
})
|
||||
|
||||
// each whiteboard room is hosted in a DurableObject:
|
||||
|
|
@ -67,41 +76,105 @@ export class TldrawDurableObject {
|
|||
}
|
||||
return this.handleConnect(request)
|
||||
})
|
||||
.get('/room/:roomId', async () => {
|
||||
.get('/room/:roomId', async (request) => {
|
||||
const room = await this.getRoom()
|
||||
const snapshot = room.getCurrentSnapshot()
|
||||
return new Response(JSON.stringify(snapshot.documents))
|
||||
return new Response(JSON.stringify(snapshot.documents), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
})
|
||||
})
|
||||
.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)))
|
||||
|
||||
return new Response(JSON.stringify(Array.from(mergedRecords)), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// `fetch` is the entry point for all requests to the Durable Object
|
||||
fetch(request: Request): Response | Promise<Response> {
|
||||
try {
|
||||
return this.router.fetch(request)
|
||||
} catch (err) {
|
||||
console.error('Error in DO fetch:', err);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Internal Server Error',
|
||||
message: (err as Error).message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, UPGRADE',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Upgrade, Connection',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// what happens when someone tries to connect to this room?
|
||||
async handleConnect(request: IRequest): Promise<Response> {
|
||||
// extract query params from request
|
||||
const sessionId = request.query.sessionId as string
|
||||
if (!sessionId) return error(400, 'Missing sessionId')
|
||||
if (!this.roomId) {
|
||||
return new Response('Room not initialized', { status: 400 });
|
||||
}
|
||||
|
||||
// Create the websocket pair for the client
|
||||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
|
||||
// @ts-ignore
|
||||
serverWebSocket.accept()
|
||||
const sessionId = request.query.sessionId as string;
|
||||
if (!sessionId) {
|
||||
return new Response('Missing sessionId', { status: 400 });
|
||||
}
|
||||
|
||||
// load the room, or retrieve it if it's already loaded
|
||||
const room = await this.getRoom()
|
||||
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair();
|
||||
|
||||
// connect the client to the room
|
||||
room.handleSocketConnect({ sessionId, socket: serverWebSocket })
|
||||
try {
|
||||
serverWebSocket.accept();
|
||||
const room = await this.getRoom();
|
||||
|
||||
// return the websocket connection to the client
|
||||
return new Response(null, { status: 101, webSocket: clientWebSocket })
|
||||
// Handle socket connection with proper error boundaries
|
||||
room.handleSocketConnect({
|
||||
sessionId,
|
||||
socket: {
|
||||
send: serverWebSocket.send.bind(serverWebSocket),
|
||||
close: serverWebSocket.close.bind(serverWebSocket),
|
||||
addEventListener: serverWebSocket.addEventListener.bind(serverWebSocket),
|
||||
removeEventListener: serverWebSocket.removeEventListener.bind(serverWebSocket),
|
||||
readyState: serverWebSocket.readyState,
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: clientWebSocket,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, UPGRADE',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Upgrade': 'websocket',
|
||||
'Connection': 'Upgrade'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('WebSocket connection error:', error);
|
||||
serverWebSocket.close(1011, 'Failed to initialize connection');
|
||||
return new Response('Failed to establish WebSocket connection', {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getRoom() {
|
||||
|
|
|
|||
|
|
@ -21,31 +21,27 @@ 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) => {
|
||||
if (!origin) return undefined
|
||||
const allowedOrigins = [
|
||||
'https://jeffemmett.com',
|
||||
'https://www.jeffemmett.com',
|
||||
'https://jeffemmett-canvas.jeffemmett.workers.dev',
|
||||
'https://jeffemmett.com/board/*',
|
||||
];
|
||||
|
||||
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$/
|
||||
]
|
||||
// Always allow if no origin (like from a local file)
|
||||
if (!origin) return '*';
|
||||
|
||||
// Check if origin matches any of our patterns
|
||||
const isAllowed = allowedPatterns.some(pattern =>
|
||||
pattern instanceof RegExp
|
||||
? pattern.test(origin)
|
||||
: pattern === origin
|
||||
)
|
||||
// Check exact matches
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
return isAllowed ? origin : undefined
|
||||
// For development - check if it's a localhost or local IP
|
||||
if (origin.match(/^http:\/\/(localhost|127\.0\.0\.192\.168\.|169\.254\.|10\.)/)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'OPTIONS', 'UPGRADE'],
|
||||
allowHeaders: [
|
||||
|
|
@ -56,10 +52,10 @@ const { preflight, corsify } = cors({
|
|||
'Sec-WebSocket-Key',
|
||||
'Sec-WebSocket-Version',
|
||||
'Sec-WebSocket-Extensions',
|
||||
'Sec-WebSocket-Protocol',
|
||||
...Object.keys(securityHeaders)
|
||||
'Sec-WebSocket-Protocol'
|
||||
],
|
||||
maxAge: 86400,
|
||||
credentials: true
|
||||
})
|
||||
const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||
before: [preflight],
|
||||
|
|
@ -81,7 +77,11 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
.get('/connect/:roomId', (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, { headers: request.headers, body: request.body })
|
||||
return room.fetch(request.url, {
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: request.method
|
||||
})
|
||||
})
|
||||
|
||||
// assets can be uploaded to the bucket under /uploads:
|
||||
|
|
@ -93,11 +93,14 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
// bookmarks need to extract metadata from pasted URLs:
|
||||
.get('/unfurl', handleUnfurlRequest)
|
||||
|
||||
.get('/room/:roomId', async (request, env) => {
|
||||
.get('/room/:roomId', (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
|
||||
return room.fetch(request.url, {
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: request.method
|
||||
})
|
||||
})
|
||||
|
||||
.post('/room/:roomId', async (request, env) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue