CRDTs working, still finalizing local board state browser storage for offline board access
This commit is contained in:
parent
66b59b2fea
commit
2e70d75a66
|
|
@ -172,4 +172,5 @@ dist
|
|||
.pnp.\*
|
||||
|
||||
.wrangler/
|
||||
.*.md
|
||||
.*.md
|
||||
.vercel
|
||||
|
|
|
|||
73
index.html
73
index.html
|
|
@ -1,42 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Jeff Emmett</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<!-- Social Meta Tags -->
|
||||
<meta name="description"
|
||||
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
<head>
|
||||
<title>Jeff Emmett</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=4" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Recursive:slnt,wght,CASL,CRSV,MONO@-15..0,300..1000,0..1,0..1,0..1&display=swap"
|
||||
rel="stylesheet">
|
||||
|
||||
<meta property="og:url" content="https://jeffemmett.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Jeff Emmett">
|
||||
<meta property="og:description"
|
||||
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
<meta property="og:image" content="/website-embed.png">
|
||||
<!-- Social Meta Tags -->
|
||||
<meta name="description"
|
||||
content="My research investigates the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="jeffemmett.com">
|
||||
<meta property="twitter:url" content="https://jeffemmett.com">
|
||||
<meta name="twitter:title" content="Jeff Emmett">
|
||||
<meta name="twitter:description"
|
||||
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
<meta name="twitter:image" content="/website-embed.png">
|
||||
<meta property="og:url" content="https://jeffemmett.com">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Jeff Emmett">
|
||||
<meta property="og:description"
|
||||
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
<meta property="og:image" content="/website-embed.png">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="jeffemmett.com">
|
||||
<meta property="twitter:url" content="https://jeffemmett.com">
|
||||
<meta name="twitter:title" content="Jeff Emmett">
|
||||
<meta name="twitter:description"
|
||||
content="My research doesn't investigate the intersection of computing, human-system interfaces, and emancipatory politics. I am interested in the potential of computing as a medium for thought, as a tool for collective action, and as a means of emancipation.">
|
||||
<meta name="twitter:image" content="/website-embed.png">
|
||||
|
||||
<!-- Analytics -->
|
||||
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/App.tsx"></script>
|
||||
</body>
|
||||
|
||||
<!-- Analytics -->
|
||||
<script data-goatcounter="https://jeff.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/App.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
package.json
20
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement>) => {
|
||||
setUserName(event.target.value);
|
||||
};
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const roomId = slug || 'default-room';
|
||||
const { store } = usePersistentBoard(roomId);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
|
|
@ -56,27 +51,6 @@ export function Board() {
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TLRecord[]>(`gset-${roomId}`, {
|
||||
defaultValue: []
|
||||
});
|
||||
|
||||
const gset = new GSet<TLRecord>();
|
||||
|
||||
// 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<TLRecord>) => {
|
||||
remoteSet.forEach(record => gset.add(record));
|
||||
setLocalSet(Array.from(gset.values()));
|
||||
return gset.values();
|
||||
};
|
||||
|
||||
return {
|
||||
values: gset.values(),
|
||||
add: addRecord,
|
||||
merge,
|
||||
localSet
|
||||
};
|
||||
}
|
||||
|
|
@ -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<SerializedStore<TLRecord>>(`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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
declare module 'crdts/src/G-Set' {
|
||||
export default class GSet<T = any> {
|
||||
add(value: T): void;
|
||||
values(): Set<T>;
|
||||
}
|
||||
}
|
||||
11
vercel.json
11
vercel.json
|
|
@ -24,5 +24,16 @@
|
|||
"source": "/books",
|
||||
"destination": "/"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/assets/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000, immutable"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
|
|
@ -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<TLRecord>();
|
||||
|
||||
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': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||
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<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue