multiplayer
This commit is contained in:
parent
45374928ee
commit
c576c4e241
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
33
package.json
33
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
// 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 className="tldraw__editor">
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<Tldraw
|
||||
onMount={(editor: Editor) => {
|
||||
editor.putContentOntoCurrentPage(getBoard() as any)
|
||||
// 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
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { Editor, Tldraw } from "@tldraw/tldraw";
|
||||
import { Editor, Tldraw } from "tldraw";
|
||||
import { canvas } from "@/canvas01";
|
||||
|
||||
export function Books() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Geometry2d, Vec, VecLike } from "@tldraw/tldraw";
|
||||
import { Geometry2d, Vec, VecLike } from "tldraw";
|
||||
|
||||
type ShapeTransform = {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createShapeId } from "@tldraw/tldraw";
|
||||
import { createShapeId } from "tldraw";
|
||||
|
||||
export function createShapes(elementsInfo: any) {
|
||||
const shapes = elementsInfo.map((element: any) => ({
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "worker"],
|
||||
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue