CRDTs working, still finalizing local board state browser storage for offline board access

This commit is contained in:
Jeff Emmett 2024-11-25 16:18:05 +07:00
parent 66b59b2fea
commit 2e70d75a66
14 changed files with 388 additions and 91 deletions

3
.gitignore vendored
View File

@ -172,4 +172,5 @@ dist
.pnp.\*
.wrangler/
.*.md
.*.md
.vercel

View File

@ -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>

View File

@ -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"
}
}
}

View File

@ -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>
)
}

View File

@ -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;
}
}

34
src/hooks/useGSetState.ts Normal file
View File

@ -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
};
}

View File

@ -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
};
}

View File

@ -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
}
}

6
src/types/crdts.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'crdts/src/G-Set' {
export default class GSet<T = any> {
add(value: T): void;
values(): Set<T>;
}
}

View File

@ -24,5 +24,16 @@
"source": "/books",
"destination": "/"
}
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}

View File

@ -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,
},

View File

@ -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': '*',
},
})
}
}

View File

@ -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

View File

@ -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]