multiplayer

This commit is contained in:
Jeff Emmett 2024-08-29 20:20:12 +02:00
parent 249031619d
commit 932c9935d5
26 changed files with 13334 additions and 45 deletions

View File

@ -0,0 +1,30 @@
const urls = new Set();
function checkURL(request, init) {
const url =
request instanceof URL
? request
: new URL(
(typeof request === "string"
? new Request(request, init)
: request
).url
);
if (url.port && url.port !== "443" && url.protocol === "https:") {
if (!urls.has(url.toString())) {
urls.add(url.toString());
console.warn(
`WARNING: known issue with \`fetch()\` requests to custom HTTPS ports in published Workers:\n` +
` - ${url.toString()} - the custom port will be ignored when the Worker is published using the \`wrangler deploy\` command.\n`
);
}
}
}
globalThis.fetch = new Proxy(globalThis.fetch, {
apply(target, thisArg, argArray) {
const [request, init] = argArray;
checkURL(request, init);
return Reflect.apply(target, thisArg, argArray);
},
});

View File

@ -0,0 +1,11 @@
import worker, * as OTHER_EXPORTS from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
import * as __MIDDLEWARE_0__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-ensure-req-body-drained.ts";
import * as __MIDDLEWARE_1__ from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\middleware-miniflare3-json-error.ts";
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\worker\\worker.ts";
export const __INTERNAL_WRANGLER_MIDDLEWARE__ = [
__MIDDLEWARE_0__.default,__MIDDLEWARE_1__.default
]
export default worker;

View File

@ -0,0 +1,134 @@
// This loads all middlewares exposed on the middleware object and then starts
// the invocation chain. The big idea is that we can add these to the middleware
// export dynamically through wrangler, or we can potentially let users directly
// add them as a sort of "plugin" system.
import ENTRY, { __INTERNAL_WRANGLER_MIDDLEWARE__ } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-fO5AAb\\middleware-insertion-facade.js";
import { __facade_invoke__, __facade_register__, Dispatcher } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\node_modules\\wrangler\\templates\\middleware\\common.ts";
import type { WorkerEntrypointConstructor } from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-fO5AAb\\middleware-insertion-facade.js";
// Preserve all the exports from the worker
export * from "C:\\Users\\jeffe\\Documents\\GitHub\\canvas-website\\.wrangler\\tmp\\bundle-fO5AAb\\middleware-insertion-facade.js";
class __Facade_ScheduledController__ implements ScheduledController {
readonly #noRetry: ScheduledController["noRetry"];
constructor(
readonly scheduledTime: number,
readonly cron: string,
noRetry: ScheduledController["noRetry"]
) {
this.#noRetry = noRetry;
}
noRetry() {
if (!(this instanceof __Facade_ScheduledController__)) {
throw new TypeError("Illegal invocation");
}
// Need to call native method immediately in case uncaught error thrown
this.#noRetry();
}
}
function wrapExportedHandler(worker: ExportedHandler): ExportedHandler {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return worker;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
const fetchDispatcher: ExportedHandlerFetchHandler = function (
request,
env,
ctx
) {
if (worker.fetch === undefined) {
throw new Error("Handler does not export a fetch() function.");
}
return worker.fetch(request, env, ctx);
};
return {
...worker,
fetch(request, env, ctx) {
const dispatcher: Dispatcher = function (type, init) {
if (type === "scheduled" && worker.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return worker.scheduled(controller, env, ctx);
}
};
return __facade_invoke__(request, env, ctx, dispatcher, fetchDispatcher);
},
};
}
function wrapWorkerEntrypoint(
klass: WorkerEntrypointConstructor
): WorkerEntrypointConstructor {
// If we don't have any middleware defined, just return the handler as is
if (
__INTERNAL_WRANGLER_MIDDLEWARE__ === undefined ||
__INTERNAL_WRANGLER_MIDDLEWARE__.length === 0
) {
return klass;
}
// Otherwise, register all middleware once
for (const middleware of __INTERNAL_WRANGLER_MIDDLEWARE__) {
__facade_register__(middleware);
}
// `extend`ing `klass` here so other RPC methods remain callable
return class extends klass {
#fetchDispatcher: ExportedHandlerFetchHandler<Record<string, unknown>> = (
request,
env,
ctx
) => {
this.env = env;
this.ctx = ctx;
if (super.fetch === undefined) {
throw new Error("Entrypoint class does not define a fetch() function.");
}
return super.fetch(request);
};
#dispatcher: Dispatcher = (type, init) => {
if (type === "scheduled" && super.scheduled !== undefined) {
const controller = new __Facade_ScheduledController__(
Date.now(),
init.cron ?? "",
() => {}
);
return super.scheduled(controller);
}
};
fetch(request: Request<unknown, IncomingRequestCfProperties>) {
return __facade_invoke__(
request,
this.env,
this.ctx,
this.#dispatcher,
this.#fetchDispatcher
);
}
};
}
let WRAPPED_ENTRY: ExportedHandler | WorkerEntrypointConstructor | undefined;
if (typeof ENTRY === "object") {
WRAPPED_ENTRY = wrapExportedHandler(ENTRY);
} else if (typeof ENTRY === "function") {
WRAPPED_ENTRY = wrapWorkerEntrypoint(ENTRY);
}
export default WRAPPED_ENTRY;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

37
getBookmarkPreview.tsx Normal file
View File

@ -0,0 +1,37 @@
import { AssetRecordType, TLAsset, TLBookmarkAsset, getHashForString } from 'tldraw'
// How does our server handle bookmark unfurling?
export async function getBookmarkPreview({ url }: { url: string }): Promise<TLAsset> {
// we start with an empty asset record
const asset: TLBookmarkAsset = {
id: AssetRecordType.createId(getHashForString(url)),
typeName: 'asset',
type: 'bookmark',
meta: {},
props: {
src: url,
description: '',
image: '',
favicon: '',
title: '',
},
}
try {
// try to fetch the preview data from the server
const response = await fetch(
`${process.env.TLDRAW_WORKER_URL}/unfurl?url=${encodeURIComponent(url)}`
)
const data = await response.json()
// fill in our asset with whatever info we found
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
}

33
multiplayerAssetStore.tsx Normal file
View File

@ -0,0 +1,33 @@
import { TLAssetStore, uniqueId } from 'tldraw'
const WORKER_URL = process.env.TLDRAW_WORKER_URL
// How does our server handle assets like images and videos?
export const multiplayerAssetStore: TLAssetStore = {
// to upload an asset, we...
async upload(_asset, file) {
// ...create a unique name & URL...
const id = uniqueId()
const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, '-')
const url = `${WORKER_URL}/uploads/${objectName}`
// ...POST it to out worker to upload it...
const response = await fetch(url, {
method: 'POST',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
// ...and return the URL to be stored with the asset record.
return url
},
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
// auth, or to serve optimized versions / sizes of the asset.
resolve(asset) {
return asset.props.src
},
}

View File

@ -1,19 +1,22 @@
{
"name": "orionreed",
"name": "jeffemmett",
"version": "1.0.0",
"description": "Orion Reed's personal website",
"description": "Jeff Emmett's personal website",
"type": "module",
"scripts": {
"dev": "vite --host",
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red yarn:dev:client yarn:dev:worker",
"dev:client": "vite",
"dev:worker": "wrangler dev",
"build": "tsc && vite build",
"preview": "tsc && vite build && vite preview"
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"keywords": [],
"author": "Orion Reed",
"license": "ISC",
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"@tldraw/tldraw": "2.0.2",
"@tldraw/sync": "^2.4.6",
"@types/markdown-it": "^14.1.1",
"@vercel/analytics": "^1.2.2",
"gray-matter": "^4.0.3",
@ -21,7 +24,14 @@
"markdown-it-latex2img": "^0.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3"
"react-router-dom": "^6.22.3",
"tldraw": "^2.4.6",
"@cloudflare/types": "^6.29.0",
"@tldraw/sync-core": "latest",
"@tldraw/tlschema": "latest",
"cloudflare-workers-unfurl": "^0.0.7",
"itty-router": "^5.0.17",
"lodash.throttle": "^4.1.1"
},
"devDependencies": {
"@biomejs/biome": "1.4.1",
@ -32,6 +42,15 @@
"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"
"vite-plugin-wasm": "^3.2.2",
"wrangler": "^3.72.3",
"@types/lodash.throttle": "^4",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"@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"
}
}
}

View File

@ -1,5 +1,5 @@
import { inject } from '@vercel/analytics';
import "@tldraw/tldraw/tldraw.css";
import "tldraw/tldraw.css";
import "@/css/style.css"
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
@ -27,7 +27,7 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/card/contact" element={<Contact />} />
<Route path="/posts/:slug" element={<Post />} />
<Route path="/board/:slug" element={<Board />} />
<Route path="/board" element={<Board />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/books" element={<Books />} />
</Routes>

View File

@ -1,29 +1,96 @@
import { useLocation, useParams } from 'react-router-dom';
import { Editor, Tldraw } from "@tldraw/tldraw";
import { canvas } from "@/canvas01";
import { sharedcalendar2025 } from "@/sharedcalendar2025";
import { useSync } from '@tldraw/sync'
import {
AssetRecordType,
getHashForString,
TLAssetStore,
TLBookmarkAsset,
Tldraw,
uniqueId,
} from 'tldraw'
const WORKER_URL = `https://jeffemmett-canvas.jeffemmett.workers.dev`
// In this example, the room ID is hard-coded. You can set this however you like though.
const roomId = 'test-room'
export function Board() {
const { pathname } = useLocation();
const boardId = pathname.substring(pathname.lastIndexOf('/') + 1);
const getBoard = () => {
switch (boardId) {
case 'canvas01':
return canvas;
case 'sharedcalendar2025':
return sharedcalendar2025;
default:
return canvas; // Default to canvas if no match
}
};
return (
<div className="tldraw__editor">
<Tldraw
onMount={(editor: Editor) => {
editor.putContentOntoCurrentPage(getBoard() as any)
}}
/>
</div>
);
// Create a store connected to multiplayer.
const store = useSync({
// We need to know the websocket's URI...
uri: `${WORKER_URL}/connect/${roomId}`,
// ...and how to handle static assets like images & videos
assets: multiplayerAssets,
})
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
// we can pass the connected store into the Tldraw component which will handle
// loading states & enable multiplayer UX like cursors & a presence menu
store={store}
onMount={(editor) => {
// when the editor is ready, we need to register out bookmark unfurling service
editor.registerExternalAssetHandler('url', unfurlBookmarkUrl)
}}
/>
</div>
)
}
// How does our server handle assets like images and videos?
const multiplayerAssets: TLAssetStore = {
// to upload an asset, we prefix it with a unique id, POST it to our worker, and return the URL
async upload(_asset, file) {
const id = uniqueId()
const objectName = `${id}-${file.name}`
const url = `${WORKER_URL}/uploads/${encodeURIComponent(objectName)}`
const response = await fetch(url, {
method: 'PUT',
body: file,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
return url
},
// to retrieve an asset, we can just use the same URL. you could customize this to add extra
// auth, or to serve optimized versions / sizes of the asset.
resolve(asset) {
return asset.props.src
},
}
// 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()
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
}

View File

@ -1,5 +1,5 @@
import { Editor, Tldraw } from "@tldraw/tldraw";
import { Editor, Tldraw } from "tldraw";
import { canvas } from "@/canvas01";
export function Books() {

View File

@ -1,4 +1,4 @@
import { Editor, Tldraw, TLShape, TLUiComponents } from "@tldraw/tldraw";
import { Editor, Tldraw, TLShape, TLUiComponents } from "tldraw";
import { SimController } from "@/physics/PhysicsControls";
import { HTMLShapeUtil } from "@/shapes/HTMLShapeUtil";

View File

@ -1,4 +1,4 @@
import { createShapeId, Editor, Tldraw, TLGeoShape, TLShapePartial } from "@tldraw/tldraw";
import { createShapeId, Editor, Tldraw, TLGeoShape, TLShapePartial } from "tldraw";
import { useEffect, useRef } from "react";
export function Inbox() {

View File

@ -1,4 +1,4 @@
import { Editor, TLUnknownShape, createShapeId, useEditor } from "@tldraw/tldraw";
import { Editor, TLUnknownShape, createShapeId, useEditor } from "tldraw";
import { useEffect, useState } from "react";
import { usePhysicsSimulation } from "./simulation";

View File

@ -1,4 +1,4 @@
import { Geometry2d, Vec, VecLike } from "@tldraw/tldraw";
import { Geometry2d, Vec, VecLike } from "tldraw";
type ShapeTransform = {
x: number;

View File

@ -1,6 +1,6 @@
import RAPIER from "@dimforge/rapier2d";
import { CHARACTER, GRAVITY, MATERIAL, getFrictionFromColor, getGravityFromColor, getRestitutionFromColor, isRigidbody } from "./config";
import { Editor, Geometry2d, TLDrawShape, TLGeoShape, TLGroupShape, TLShape, TLShapeId, VecLike } from "@tldraw/tldraw";
import { Editor, Geometry2d, TLDrawShape, TLGeoShape, TLGroupShape, TLShape, TLShapeId, VecLike } from "tldraw";
import { useEffect, useRef } from "react";
import { centerToCorner, convertVerticesToFloat32Array, cornerToCenter, getDisplacement } from "./math";

View File

@ -1,4 +1,4 @@
import { Rectangle2d, resizeBox, TLBaseShape, TLOnBeforeUpdateHandler, TLOnResizeHandler } from '@tldraw/tldraw';
import { Rectangle2d, resizeBox, TLBaseShape, TLOnBeforeUpdateHandler, TLOnResizeHandler } from 'tldraw';
import { ShapeUtil } from 'tldraw'
export type HTMLShape = TLBaseShape<'html', { w: number; h: number, html: string }>

View File

@ -1,4 +1,4 @@
import { createShapeId } from "@tldraw/tldraw";
import { createShapeId } from "tldraw";
export function createShapes(elementsInfo: any) {
const shapes = elementsInfo.map((element: any) => ({

View File

@ -25,7 +25,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"include": ["src", "worker"],
"references": [{ "path": "./tsconfig.node.json" }]
}

16
tsconfig.worker.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["worker"]
}

View File

@ -7,6 +7,10 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({
define: {
'process.env.TLDRAW_WORKER_URL':
process.env.TLDRAW_WORKER_URL ?? '`http://${location.hostname}:5172`',
},
plugins: [
react(),
wasm(),

View File

@ -0,0 +1,123 @@
import { RoomSnapshot, TLSocketRoom } from '@tldraw/sync-core'
import {
TLRecord,
createTLSchema,
// defaultBindingSchemas,
defaultShapeSchemas,
} from '@tldraw/tlschema'
import { AutoRouter, IRequest, error } from 'itty-router'
import throttle from 'lodash.throttle'
import { Environment } from './types'
// add custom shapes and bindings here if needed:
const schema = createTLSchema({
shapes: { ...defaultShapeSchemas },
// bindings: { ...defaultBindingSchemas },
})
// each whiteboard room is hosted in a DurableObject:
// https://developers.cloudflare.com/durable-objects/
// there's only ever one durable object instance per room. it keeps all the room state in memory and
// handles websocket connections. periodically, it persists the room state to the R2 bucket.
export class TldrawDurableObject {
private r2: R2Bucket
// the room ID will be missing whilst the room is being initialized
private roomId: string | null = null
// when we load the room from the R2 bucket, we keep it here. it's a promise so we only ever
// load it once.
private roomPromise: Promise<TLSocketRoom<TLRecord, void>> | null = null
constructor(
private readonly ctx: DurableObjectState,
env: Environment
) {
this.r2 = env.TLDRAW_BUCKET
ctx.blockConcurrencyWhile(async () => {
this.roomId = ((await this.ctx.storage.get('roomId')) ?? null) as string | null
})
}
private readonly router = AutoRouter({
catch: (e) => {
console.log(e)
return error(e)
},
})
// when we get a connection request, we stash the room id if needed and handle the connection
.get('/connect/:roomId', async (request) => {
if (!this.roomId) {
await this.ctx.blockConcurrencyWhile(async () => {
await this.ctx.storage.put('roomId', request.params.roomId)
this.roomId = request.params.roomId
})
}
return this.handleConnect(request)
})
// `fetch` is the entry point for all requests to the Durable Object
fetch(request: Request): Response | Promise<Response> {
return this.router.fetch(request)
}
// 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')
// Create the websocket pair for the client
const { 0: clientWebSocket, 1: serverWebSocket } = new WebSocketPair()
serverWebSocket.accept()
// load the room, or retrieve it if it's already loaded
const room = await this.getRoom()
// connect the client to the room
room.handleSocketConnect({ sessionId, socket: serverWebSocket })
// return the websocket connection to the client
return new Response(null, { status: 101, webSocket: clientWebSocket })
}
getRoom() {
const roomId = this.roomId
if (!roomId) throw new Error('Missing roomId')
if (!this.roomPromise) {
this.roomPromise = (async () => {
// fetch the room from R2
const roomFromBucket = await this.r2.get(`rooms/${roomId}`)
// if it doesn't exist, we'll just create a new empty room
const initialSnapshot = roomFromBucket
? ((await roomFromBucket.json()) as RoomSnapshot)
: undefined
// create a new TLSocketRoom. This handles all the sync protocol & websocket connections.
// it's up to us to persist the room state to R2 when needed though.
return new TLSocketRoom<TLRecord, void>({
schema,
initialSnapshot,
onDataChange: () => {
// and persist whenever the data in the room changes
this.schedulePersistToR2()
},
})
})()
}
return this.roomPromise
}
// we throttle persistance so it only happens every 10 seconds
schedulePersistToR2 = throttle(async () => {
if (!this.roomPromise || !this.roomId) return
const room = await this.getRoom()
// convert the room to JSON and upload it to R2
const snapshot = JSON.stringify(room.getCurrentSnapshot())
await this.r2.put(`rooms/${this.roomId}`, snapshot)
}, 10_000)
}

100
worker/assetUploads.ts Normal file
View File

@ -0,0 +1,100 @@
import { IRequest, error } from 'itty-router'
import { Environment } from './types'
// assets are stored in the bucket under the /uploads path
function getAssetObjectName(uploadId: string) {
return `uploads/${uploadId.replace(/[^a-zA-Z0-9\_\-]+/g, '_')}`
}
// when a user uploads an asset, we store it in the bucket. we only allow image and video assets.
export async function handleAssetUpload(request: IRequest, env: Environment) {
const objectName = getAssetObjectName(request.params.uploadId)
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.startsWith('image/') && !contentType.startsWith('video/')) {
return error(400, 'Invalid content type')
}
if (await env.TLDRAW_BUCKET.head(objectName)) {
return error(409, 'Upload already exists')
}
await env.TLDRAW_BUCKET.put(objectName, request.body, {
httpMetadata: request.headers,
})
return { ok: true }
}
// when a user downloads an asset, we retrieve it from the bucket. we also cache the response for performance.
export async function handleAssetDownload(
request: IRequest,
env: Environment,
ctx: ExecutionContext
) {
const objectName = getAssetObjectName(request.params.uploadId)
// if we have a cached response for this request (automatically handling ranges etc.), return it
const cacheKey = new Request(request.url, { headers: request.headers })
const cachedResponse = await caches.default.match(cacheKey)
if (cachedResponse) {
return cachedResponse
}
// if not, we try to fetch the asset from the bucket
const object = await env.TLDRAW_BUCKET.get(objectName, {
range: request.headers,
onlyIf: request.headers,
})
if (!object) {
return error(404)
}
// write the relevant metadata to the response headers
const headers = new Headers()
object.writeHttpMetadata(headers)
// assets are immutable, so we can cache them basically forever:
headers.set('cache-control', 'public, max-age=31536000, immutable')
headers.set('etag', object.httpEtag)
// we set CORS headers so all clients can access assets. we do this here so our `cors` helper in
// worker.ts doesn't try to set extra cors headers on responses that have been read from the
// cache, which isn't allowed by cloudflare.
headers.set('access-control-allow-origin', '*')
// cloudflare doesn't set the content-range header automatically in writeHttpMetadata, so we
// need to do it ourselves.
let contentRange
if (object.range) {
if ('suffix' in object.range) {
const start = object.size - object.range.suffix
const end = object.size - 1
contentRange = `bytes ${start}-${end}/${object.size}`
} else {
const start = object.range.offset ?? 0
const end = object.range.length ? start + object.range.length - 1 : object.size - 1
if (start !== 0 || end !== object.size - 1) {
contentRange = `bytes ${start}-${end}/${object.size}`
}
}
}
if (contentRange) {
headers.set('content-range', contentRange)
}
// make sure we get the correct body/status for the response
const body = 'body' in object && object.body ? object.body : null
const status = body ? (contentRange ? 206 : 200) : 304
// we only cache complete (200) responses
if (status === 200) {
const [cacheBody, responseBody] = body!.tee()
ctx.waitUntil(caches.default.put(cacheKey, new Response(cacheBody, { headers, status })))
return new Response(responseBody, { headers, status })
}
return new Response(body, { headers, status })
}

6
worker/types.ts Normal file
View File

@ -0,0 +1,6 @@
// the contents of the environment should mostly be determined by wrangler.toml. These entries match
// the bindings defined there.
export interface Environment {
TLDRAW_BUCKET: R2Bucket
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
}

37
worker/worker.ts Normal file
View File

@ -0,0 +1,37 @@
import { handleUnfurlRequest } from 'cloudflare-workers-unfurl'
import { AutoRouter, cors, error, IRequest } from 'itty-router'
import { handleAssetDownload, handleAssetUpload } from './assetUploads'
import { Environment } from './types'
// make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from './TldrawDurableObject'
// 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 router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
before: [preflight],
finally: [corsify],
catch: (e) => {
console.error(e)
return error(e)
},
})
// requests to /connect are routed to the Durable Object, and handle realtime websocket syncing
.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 })
})
// assets can be uploaded to the bucket under /uploads:
.post('/uploads/:uploadId', handleAssetUpload)
// they can be retrieved from the bucket too:
.get('/uploads/:uploadId', handleAssetDownload)
// bookmarks need to extract metadata from pasted URLs:
.get('/unfurl', handleUnfurlRequest)
// export our router for cloudflare
export default router

25
wrangler.toml Normal file
View File

@ -0,0 +1,25 @@
main = "worker/worker.ts"
compatibility_date = "2024-07-01"
name = "jeffemmett-canvas"
[dev]
port = 5172
ip = "0.0.0.0"
# Set up the durable object used for each tldraw room
[durable_objects]
bindings = [
{ name = "TLDRAW_DURABLE_OBJECT", class_name = "TldrawDurableObject" },
]
# Durable objects require migrations to create/modify/delete them
[[migrations]]
tag = "v1"
new_classes = ["TldrawDurableObject"]
# We store rooms and asset uploads in an R2 bucket
[[r2_buckets]]
binding = 'TLDRAW_BUCKET'
bucket_name = 'jeffemmett-canvas'
preview_bucket_name = 'jeffemmett-canvas-preview'
workers_dev = true