From 6a39efb69f1fa130190391e2333672d772623764 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 5 Feb 2026 01:24:31 -0600 Subject: [PATCH 01/12] working state --- apps/roam/package.json | 1 + apps/roam/src/components/canvas/Tldraw.tsx | 39 ++++++++++++- .../canvas/TldrawCanvasCloudflareSync.tsx | 25 +++++++++ .../canvas/cloudflareSyncAssetStore.ts | 26 +++++++++ .../canvas/multiplayerAssetStore.tsx | 32 +++++++++++ .../src/components/canvas/useRoamStore.ts | 11 ++++ pnpm-lock.yaml | 56 +++++++++++++++++++ 7 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx create mode 100644 apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts create mode 100644 apps/roam/src/components/canvas/multiplayerAssetStore.tsx diff --git a/apps/roam/package.json b/apps/roam/package.json index a3dd53321..18b7acdd9 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -47,6 +47,7 @@ "@tldraw/state": "2.4.6", "@tldraw/state-react": "2.4.6", "@tldraw/store": "2.4.6", + "@tldraw/sync": "2.4.6", "@tldraw/tlschema": "2.4.6", "@tldraw/utils": "2.4.6", "@tldraw/validate": "2.4.6", diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index de96db5a1..68ce37ddb 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import React, { useState, useRef, useMemo, useEffect, useCallback } from "react"; +import React, { + useState, + useRef, + useMemo, + useEffect, + useCallback, +} from "react"; import ExtensionApiContextProvider, { useExtensionAPI, } from "roamjs-components/components/ExtensionApiContext"; @@ -61,7 +67,12 @@ import { createNodeShapeTools, createNodeShapeUtils, } from "./DiscourseNodeUtil"; -import { useRoamStore } from "./useRoamStore"; +import { hasRoamPersistedCanvasData, useRoamStore } from "./useRoamStore"; +import { + TLDRAW_CLOUDFLARE_SYNC_ENABLED, + TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL, + TldrawCanvasCloudflareSync, +} from "./TldrawCanvasCloudflareSync"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import getUids from "roamjs-components/dom/getUids"; @@ -133,6 +144,28 @@ export const isPageUid = (uid: string) => ]; const TldrawCanvas = ({ title }: { title: string }) => { + const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); + const useCloudflareSync = + TLDRAW_CLOUDFLARE_SYNC_ENABLED && + !!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL && + !hasRoamPersistedCanvasData(pageUid); + + if (useCloudflareSync) { + return ( +
+ + +
+ ); + } + + return ; +}; + +const TldrawCanvasRoam = ({ title }: { title: string }) => { const appRef = useRef(null); const lastInsertRef = useRef(); const containerRef = useRef(null); @@ -761,7 +794,7 @@ const TldrawCanvas = ({ title }: { title: string }) => { // apps\examples\src\examples\exploded\ExplodedExample.tsx // We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor and TldrawUi -const InsideEditorAndUiContext = ({ +export const InsideEditorAndUiContext = ({ extensionAPI, allNodes, // allRelationIds, diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx new file mode 100644 index 000000000..70ba05d54 --- /dev/null +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from "react"; +import { Tldraw } from "tldraw"; +import "tldraw/tldraw.css"; +import { multiplayerAssetStore } from "./multiplayerAssetStore"; +import { useSync } from "@tldraw/sync"; + +/** When true, newly created canvases (no Roam-persisted state) use tldraw sync via Cloudflare. PoC only. */ +export const TLDRAW_CLOUDFLARE_SYNC_ENABLED = true; +/** Base URL for tldraw-sync-cloudflare worker. Use https (not wss) - useSync upgrades to WebSocket. */ +export const TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL = + "https://multiplayer-template-246.discoursegraphs.workers.dev"; + +export const TldrawCanvasCloudflareSync = ({ + pageUid, +}: { + pageUid: string; +}) => { + const store = useSync({ + uri: `${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/connect/${pageUid}`, + assets: multiplayerAssetStore, + }); + + return ; +}; diff --git a/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts b/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts new file mode 100644 index 000000000..c07527bbb --- /dev/null +++ b/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts @@ -0,0 +1,26 @@ +import type { TLAssetStore } from "tldraw"; +import { uniqueId } from "tldraw"; + +/** + * Minimal asset store for tldraw-sync-cloudflare backend. + * Uploads to /uploads/ on the sync worker; resolves via the returned URL. + */ +export const createCloudflareSyncAssetStore = ( + wsBaseUrl: string, +): TLAssetStore => { + const uploadBase = `${wsBaseUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:")}/uploads`; + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + upload: async (_asset, file) => { + const id = uniqueId(); + const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, "-"); + const url = `${uploadBase}/${objectName}`; + const response = await fetch(url, { method: "POST", body: file }); + if (!response.ok) { + throw new Error(`Failed to upload asset: ${response.statusText}`); + } + return url; + }, + resolve: (asset) => asset.props.src, + }; +}; diff --git a/apps/roam/src/components/canvas/multiplayerAssetStore.tsx b/apps/roam/src/components/canvas/multiplayerAssetStore.tsx new file mode 100644 index 000000000..f30a036fb --- /dev/null +++ b/apps/roam/src/components/canvas/multiplayerAssetStore.tsx @@ -0,0 +1,32 @@ +import { TLAssetStore, uniqueId } from "tldraw"; + +const WORKER_URL = + "https://multiplayer-template-246.discoursegraphs.workers.dev"; + +// How does our server handle assets like images and videos? +export const multiplayerAssetStore: TLAssetStore = { + // to upload an asset, we... + upload: async (_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) => asset.props.src, +}; diff --git a/apps/roam/src/components/canvas/useRoamStore.ts b/apps/roam/src/components/canvas/useRoamStore.ts index 5475a7f66..a28821452 100644 --- a/apps/roam/src/components/canvas/useRoamStore.ts +++ b/apps/roam/src/components/canvas/useRoamStore.ts @@ -38,6 +38,17 @@ export const isTLStoreSnapshot = (value: unknown): value is TLStoreSnapshot => { ); }; +/** True if the canvas has existing tldraw state persisted in Roam (any format). */ +export const hasRoamPersistedCanvasData = (pageUid: string): boolean => { + const props = getBlockProps(pageUid) as Record; + const rjsqb = + typeof props?.["roamjs-query-builder"] === "object" + ? (props["roamjs-query-builder"] as Record) + : {}; + const tldraw = rjsqb?.tldraw; + return tldraw !== undefined && tldraw !== null; +}; + const fixShapeIndices = ( data: SerializedStore, ): SerializedStore => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a715ce10d..c6ab73280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: '@tldraw/store': specifier: 2.4.6 version: 2.4.6(react@18.2.0) + '@tldraw/sync': + specifier: 2.4.6 + version: 2.4.6(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tldraw/tlschema': specifier: 2.4.6 version: 2.4.6(react@18.2.0) @@ -3970,6 +3973,18 @@ packages: peerDependencies: react: ^18.2.0 || ^19.0.0 + '@tldraw/sync-core@2.4.6': + resolution: {integrity: sha512-D5X/tDoT/dOE6NGRo1FnY0kUYrAQLIO2/TpBs83BfdTx22rWUF2djBTzaz3PCZPC5BM+Famlo3hq+TPKrtcs6g==} + peerDependencies: + react: ^18 + react-dom: ^18 + + '@tldraw/sync@2.4.6': + resolution: {integrity: sha512-OgAKoC1MO/NNZFdc6kwPCIb31x7m+qoIiRhoMWVh70OSueQwLH6XGoLPxiodfB/hy+1YnsIXHcJi5N05gkHhXA==} + peerDependencies: + react: ^18 + react-dom: ^18 + '@tldraw/tlschema@2.4.6': resolution: {integrity: sha512-wsV+w86jn0sT2XB4i/E/XW4L4u5UJyyQHWgwr5fmMA38LYe1n/akvGpcrc99Tayk5PvArkT6HRSX1oy6iaMsXw==} peerDependencies: @@ -7295,6 +7310,10 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoevents@7.0.1: + resolution: {integrity: sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q==} + engines: {node: ^14.0.0 || ^16.0.0 || >=18.0.0} + nanoid@2.0.4: resolution: {integrity: sha512-sOJnBmY3TJQBVIBqKHoifuwygrocXg3NjS9rZSMnVl05XWSHK7Qxb177AIZQyMDjP86bz+yneozj/h9qsPLcCA==} @@ -13656,6 +13675,41 @@ snapshots: '@tldraw/utils': 3.14.2 react: 19.0.0 + '@tldraw/sync-core@2.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@tldraw/state': 2.4.6 + '@tldraw/store': 2.4.6(react@18.2.0) + '@tldraw/tlschema': 2.4.6(react@18.2.0) + '@tldraw/utils': 2.4.6 + lodash.isequal: 4.5.0 + nanoevents: 7.0.1 + nanoid: 4.0.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tldraw/sync@2.4.6(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@tldraw/state': 2.4.6 + '@tldraw/state-react': 2.4.6(react@18.2.0) + '@tldraw/sync-core': 2.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@tldraw/utils': 2.4.6 + lodash.isequal: 4.5.0 + nanoevents: 7.0.1 + nanoid: 4.0.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tldraw: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + ws: 8.18.3 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - bufferutil + - utf-8-validate + '@tldraw/tlschema@2.4.6(react@18.2.0)': dependencies: '@tldraw/state': 2.4.6 @@ -17712,6 +17766,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoevents@7.0.1: {} + nanoid@2.0.4: {} nanoid@3.3.11: {} From 4ac5c520f7cf818ab25fe50986fb801f8c089992 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 21:42:44 -0600 Subject: [PATCH 02/12] Remove multiplayer asset store and refactor Tldraw components to integrate Cloudflare sync store. Update TldrawCanvasCloudflareSync to utilize new asset store and improve store management with custom shape and binding utilities. --- apps/roam/src/components/canvas/Tldraw.tsx | 377 ++++++++++++++---- .../canvas/TldrawCanvasCloudflareSync.tsx | 74 +++- .../canvas/multiplayerAssetStore.tsx | 32 -- 3 files changed, 363 insertions(+), 120 deletions(-) delete mode 100644 apps/roam/src/components/canvas/multiplayerAssetStore.tsx diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 68ce37ddb..9bebcee63 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -38,6 +38,8 @@ import { getHashForString, TLShapeId, TLShape, + TLStore, + TLStoreWithStatus, useToasts, useTranslation, DEFAULT_SUPPORTED_IMAGE_TYPES, @@ -49,6 +51,9 @@ import { StateNode, DefaultSpinner, Box, + MigrationSequence, + TLAnyBindingUtilConstructor, + TLAnyShapeUtilConstructor, } from "tldraw"; import "tldraw/tldraw.css"; import tldrawStyles from "./tldrawStyles"; @@ -71,7 +76,7 @@ import { hasRoamPersistedCanvasData, useRoamStore } from "./useRoamStore"; import { TLDRAW_CLOUDFLARE_SYNC_ENABLED, TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL, - TldrawCanvasCloudflareSync, + useCloudflareSyncStore, } from "./TldrawCanvasCloudflareSync"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; @@ -151,21 +156,117 @@ const TldrawCanvas = ({ title }: { title: string }) => { !hasRoamPersistedCanvasData(pageUid); if (useCloudflareSync) { - return ( -
- - -
- ); + return ; } - return ; + return ; +}; + +type CanvasStoreAdapterArgs = { + pageUid: string; + migrations: MigrationSequence[]; + customShapeUtils: readonly TLAnyShapeUtilConstructor[]; + customBindingUtils: readonly TLAnyBindingUtilConstructor[]; + customShapeTypes: string[]; + customBindingTypes: string[]; +}; + +type CanvasStoreAdapterResult = { + store: TLStore | TLStoreWithStatus | null; + needsUpgrade: boolean; + performUpgrade: () => void; + error: Error | null; + isLoading: boolean; +}; + +const useRoamCanvasStore = ({ + pageUid, + migrations, + customShapeUtils, + customBindingUtils, +}: CanvasStoreAdapterArgs): CanvasStoreAdapterResult => { + const { store, needsUpgrade, performUpgrade, error } = useRoamStore({ + migrations, + customShapeUtils, + customBindingUtils, + pageUid, + }); + + return { + store, + needsUpgrade, + performUpgrade, + error, + isLoading: false, + }; +}; + +const useCloudflareCanvasStore = ({ + pageUid, + migrations, + customShapeUtils, + customBindingUtils, + customShapeTypes, + customBindingTypes, +}: CanvasStoreAdapterArgs): CanvasStoreAdapterResult => { + const { store, error, isLoading } = useCloudflareSyncStore({ + pageUid, + migrations, + customShapeUtils, + customBindingUtils, + customShapeTypes, + customBindingTypes, + }); + return { + store, + error, + isLoading, + needsUpgrade: false, + performUpgrade: () => {}, + }; }; -const TldrawCanvasRoam = ({ title }: { title: string }) => { +const TldrawCanvasRoam = ({ + title, + pageUid, +}: { + title: string; + pageUid: string; +}) => { + return ( + + ); +}; + +const TldrawCanvasCloudflare = ({ + title, + pageUid, +}: { + title: string; + pageUid: string; +}) => { + return ( + + ); +}; + +const TldrawCanvasShared = ({ + title, + pageUid, + useStoreAdapter, +}: { + title: string; + pageUid: string; + useStoreAdapter: (args: CanvasStoreAdapterArgs) => CanvasStoreAdapterResult; +}) => { const appRef = useRef(null); const lastInsertRef = useRef(); const containerRef = useRef(null); @@ -175,7 +276,7 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { const [isConvertToDialogOpen, setConvertToDialogOpen] = useState(false); - const updateViewportScreenBounds = (el: HTMLDivElement) => { + const updateViewportScreenBounds = useCallback((el: HTMLDivElement) => { // Use tldraw's built-in viewport bounds update with centering requestAnimationFrame(() => { const rect = el.getBoundingClientRect(); @@ -184,8 +285,8 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { true, ); }); - }; - const handleMaximizedChange = () => { + }, []); + const handleMaximizedChange = useCallback(() => { // Direct DOM manipulation to avoid React re-renders if (!containerRef.current) return; const tldrawEl = containerRef.current; @@ -204,7 +305,7 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { tldrawEl.classList.remove("absolute", "inset-0"); updateViewportScreenBounds(tldrawEl); } - }; + }, [updateViewportScreenBounds]); // Workaround to avoid a race condition when loading a canvas page directly // Start false to avoid noisy warnings on first render if timer isn't initialized yet @@ -460,71 +561,140 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { }; // COMPONENTS - const defaultEditorComponents: TLEditorComponents = { - Scribble: TldrawScribble, - CollaboratorScribble: TldrawScribble, - SelectionForeground: TldrawSelectionForeground, - SelectionBackground: TldrawSelectionBackground, - Handles: TldrawHandles, - }; - const editorComponents: TLEditorComponents = { - ...defaultEditorComponents, - OnTheCanvas: ToastListener, - }; - const customUiComponents: TLUiComponents = createUiComponents({ - allNodes, - allRelationNames, - allAddReferencedNodeActions, - }); + const defaultEditorComponents: TLEditorComponents = useMemo( + () => ({ + Scribble: TldrawScribble, + CollaboratorScribble: TldrawScribble, + SelectionForeground: TldrawSelectionForeground, + SelectionBackground: TldrawSelectionBackground, + Handles: TldrawHandles, + }), + [], + ); + const editorComponents: TLEditorComponents = useMemo( + () => ({ + ...defaultEditorComponents, + OnTheCanvas: ToastListener, + }), + [defaultEditorComponents], + ); + const customUiComponents: TLUiComponents = useMemo( + () => + createUiComponents({ + allNodes, + allRelationNames, + allAddReferencedNodeActions, + }), + [allNodes, allRelationNames, allAddReferencedNodeActions], + ); // UTILS - const discourseNodeUtils = createNodeShapeUtils(allNodes); - const discourseRelationUtils = createAllRelationShapeUtils(allRelationIds); - const referencedNodeUtils = createAllReferencedNodeUtils( - allAddReferencedNodeByAction, + const discourseNodeUtils = useMemo( + () => createNodeShapeUtils(allNodes), + [allNodes], + ); + const discourseRelationUtils = useMemo( + () => createAllRelationShapeUtils(allRelationIds), + [allRelationIds], + ); + const referencedNodeUtils = useMemo( + () => createAllReferencedNodeUtils(allAddReferencedNodeByAction), + [allAddReferencedNodeByAction], + ); + const customShapeUtils = useMemo( + () => [ + ...discourseNodeUtils, + ...discourseRelationUtils, + ...referencedNodeUtils, + ], + [discourseNodeUtils, discourseRelationUtils, referencedNodeUtils], ); - const customShapeUtils = [ - ...discourseNodeUtils, - ...discourseRelationUtils, - ...referencedNodeUtils, - ]; // TOOLS - const discourseGraphTool = class DiscourseGraphTool extends StateNode { - static override id = "discourse-tool"; - static override initial = "idle"; - }; - const discourseNodeTools = createNodeShapeTools(allNodes); - const discourseRelationTools = createAllRelationShapeTools(allRelationNames); - const referencedNodeTools = createAllReferencedNodeTools( - allAddReferencedNodeByAction, + const discourseGraphTool = useMemo( + () => + class DiscourseGraphTool extends StateNode { + static override id = "discourse-tool"; + static override initial = "idle"; + }, + [], + ); + const discourseNodeTools = useMemo( + () => createNodeShapeTools(allNodes), + [allNodes], + ); + const discourseRelationTools = useMemo( + () => createAllRelationShapeTools(allRelationNames), + [allRelationNames], + ); + const referencedNodeTools = useMemo( + () => createAllReferencedNodeTools(allAddReferencedNodeByAction), + [allAddReferencedNodeByAction], + ); + const customTools = useMemo( + () => [ + discourseGraphTool, + ...discourseNodeTools, + ...discourseRelationTools, + ...referencedNodeTools, + ], + [ + discourseGraphTool, + discourseNodeTools, + discourseRelationTools, + referencedNodeTools, + ], ); - const customTools = [ - discourseGraphTool, - ...discourseNodeTools, - ...discourseRelationTools, - ...referencedNodeTools, - ]; // BINDINGS - const relationBindings = createAllRelationBindings(allRelationIds); - const referencedNodeBindings = createAllReferencedNodeBindings( - allAddReferencedNodeByAction, + const relationBindings = useMemo( + () => createAllRelationBindings(allRelationIds), + [allRelationIds], + ); + const referencedNodeBindings = useMemo( + () => createAllReferencedNodeBindings(allAddReferencedNodeByAction), + [allAddReferencedNodeByAction], + ); + const customBindingUtils = useMemo( + () => [...relationBindings, ...referencedNodeBindings], + [relationBindings, referencedNodeBindings], + ); + const customShapeTypes = useMemo( + () => + customShapeUtils + .map((s) => (s as unknown as { type?: string }).type) + .filter((t): t is string => !!t), + [customShapeUtils], + ); + const customBindingTypes = useMemo( + () => + customBindingUtils + .map((b) => (b as unknown as { type?: string }).type) + .filter((t): t is string => !!t), + [customBindingUtils], ); - const customBindingUtils = [...relationBindings, ...referencedNodeBindings]; // UI OVERRIDES - const uiOverrides = createUiOverrides({ - allNodes, - allRelationNames, - allAddReferencedNodeByAction, - toggleMaximized: handleMaximizedChange, - setConvertToDialogOpen, - discourseContext, - }); + const uiOverrides = useMemo( + () => + createUiOverrides({ + allNodes, + allRelationNames, + allAddReferencedNodeByAction, + toggleMaximized: handleMaximizedChange, + setConvertToDialogOpen, + discourseContext, + }), + [ + allNodes, + allRelationNames, + allAddReferencedNodeByAction, + handleMaximizedChange, + setConvertToDialogOpen, + ], + ); // STORE - const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); const arrowShapeMigrations = useMemo( () => createMigrations({ @@ -535,13 +705,19 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { [allRelationIds, allAddReferencedNodeActions, allNodes], ); - const migrations = [arrowShapeMigrations]; - const { store, needsUpgrade, performUpgrade, error } = useRoamStore({ - migrations, - customShapeUtils, - customBindingUtils, - pageUid, - }); + const migrations = useMemo( + () => [arrowShapeMigrations], + [arrowShapeMigrations], + ); + const { store, needsUpgrade, performUpgrade, error, isLoading } = + useStoreAdapter({ + migrations, + customShapeUtils, + customBindingUtils, + customShapeTypes, + customBindingTypes, + pageUid, + }); // ASSETS const assetLoading = usePreloadAssets(defaultEditorAssetUrls); @@ -630,6 +806,43 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => { }; }, [title]); + const lastReportedStoreErrorRef = useRef(""); + useEffect(() => { + if (!error) return; + const errorKey = `${pageUid}:${error.message}`; + if (lastReportedStoreErrorRef.current === errorKey) return; + lastReportedStoreErrorRef.current = errorKey; + + const isInvalidRecord = /invalidRecord/i.test(error.message); + console.error("[DG Canvas] Store error", { + title, + pageUid, + message: error.message, + stack: error.stack, + ...(isInvalidRecord + ? { + hint: "Cloudflare sync worker schema is rejecting one or more custom records.", + customShapeTypes, + customBindingTypes, + } + : {}), + }); + internalError({ + error, + type: "Canvas Store Error", + context: { + title, + pageUid, + ...(isInvalidRecord + ? { + customShapeTypes, + customBindingTypes, + } + : {}), + }, + }); + }, [error, pageUid, title, customShapeTypes, customBindingTypes]); + return (
{
- ) : !store || !assetLoading.done || !extensionAPI || !isPluginReady ? ( + ) : isLoading || + !!error || + !store || + !assetLoading.done || + !extensionAPI || + !isPluginReady ? (

@@ -675,7 +893,12 @@ const TldrawCanvasRoam = ({ title }: { title: string }) => {

{error || assetLoading.error ? ( - "There was a problem loading the Tldraw canvas. Please try again later." + + {error?.message?.includes("invalidRecord") + ? "Cloudflare sync rejected a custom Discourse Graph record (invalidRecord). The sync worker schema must include DG custom shapes and bindings." + : "There was a problem loading the Tldraw canvas."}{" "} + {error?.message ? `Details: ${error.message}` : ""} + ) : ( )} diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx index 70ba05d54..6af4054a2 100644 --- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -1,25 +1,77 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import React from "react"; -import { Tldraw } from "tldraw"; -import "tldraw/tldraw.css"; -import { multiplayerAssetStore } from "./multiplayerAssetStore"; import { useSync } from "@tldraw/sync"; +import { + TLAnyBindingUtilConstructor, + TLAnyShapeUtilConstructor, + TLStoreWithStatus, + defaultBindingUtils, + defaultShapeUtils, + MigrationSequence, +} from "tldraw"; +import { useMemo } from "react"; +import { createCloudflareSyncAssetStore } from "./cloudflareSyncAssetStore"; /** When true, newly created canvases (no Roam-persisted state) use tldraw sync via Cloudflare. PoC only. */ export const TLDRAW_CLOUDFLARE_SYNC_ENABLED = true; /** Base URL for tldraw-sync-cloudflare worker. Use https (not wss) - useSync upgrades to WebSocket. */ export const TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL = - "https://multiplayer-template-246.discoursegraphs.workers.dev"; + "https://multiplayer-dg-sync-poc.discoursegraphs.workers.dev"; -export const TldrawCanvasCloudflareSync = ({ +export type CloudflareCanvasStoreAdapterResult = { + store: TLStoreWithStatus; + error: Error | null; + isLoading: boolean; +}; + +export const useCloudflareSyncStore = ({ pageUid, + migrations, + customShapeUtils, + customBindingUtils, + customShapeTypes, + customBindingTypes, }: { pageUid: string; -}) => { + migrations: MigrationSequence[]; + customShapeUtils: readonly TLAnyShapeUtilConstructor[]; + customBindingUtils: readonly TLAnyBindingUtilConstructor[]; + customShapeTypes: string[]; + customBindingTypes: string[]; +}): CloudflareCanvasStoreAdapterResult => { + const assets = useMemo( + () => createCloudflareSyncAssetStore(TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL), + [], + ); + const shapeUtils = useMemo( + () => [...defaultShapeUtils, ...customShapeUtils], + [customShapeUtils], + ); + const bindingUtils = useMemo( + () => [...defaultBindingUtils, ...customBindingUtils], + [customBindingUtils], + ); + + const uri = useMemo(() => { + const query = new URLSearchParams(); + for (const shapeType of customShapeTypes) { + query.append("shapeType", shapeType); + } + for (const bindingType of customBindingTypes) { + query.append("bindingType", bindingType); + } + return `${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/connect/${pageUid}?${query.toString()}`; + }, [customShapeTypes, customBindingTypes, pageUid]); + const store = useSync({ - uri: `${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/connect/${pageUid}`, - assets: multiplayerAssetStore, + uri, + assets, + migrations, + shapeUtils, + bindingUtils, }); - return ; + return { + store, + error: store.status === "error" ? store.error : null, + isLoading: store.status === "loading", + }; }; diff --git a/apps/roam/src/components/canvas/multiplayerAssetStore.tsx b/apps/roam/src/components/canvas/multiplayerAssetStore.tsx deleted file mode 100644 index f30a036fb..000000000 --- a/apps/roam/src/components/canvas/multiplayerAssetStore.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { TLAssetStore, uniqueId } from "tldraw"; - -const WORKER_URL = - "https://multiplayer-template-246.discoursegraphs.workers.dev"; - -// How does our server handle assets like images and videos? -export const multiplayerAssetStore: TLAssetStore = { - // to upload an asset, we... - upload: async (_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) => asset.props.src, -}; From 206dcdd98e6eaf4d4faf0b07a226216047f6a3e1 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 21:42:48 -0600 Subject: [PATCH 03/12] Update pnpm-lock.yaml with new dependencies for tldraw-sync-worker and various Cloudflare packages. Added peer dependencies for esbuild plugins and updated existing package versions for improved compatibility. --- apps/tldraw-sync-worker/package.json | 25 + apps/tldraw-sync-worker/tsconfig.json | 16 + .../worker/TldrawDurableObject.ts | 202 ++++ .../tldraw-sync-worker/worker/assetUploads.ts | 100 ++ apps/tldraw-sync-worker/worker/types.ts | 6 + apps/tldraw-sync-worker/worker/worker.ts | 43 + apps/tldraw-sync-worker/wrangler.toml | 24 + pnpm-lock.yaml | 911 +++++++++++++++++- 8 files changed, 1317 insertions(+), 10 deletions(-) create mode 100644 apps/tldraw-sync-worker/package.json create mode 100644 apps/tldraw-sync-worker/tsconfig.json create mode 100644 apps/tldraw-sync-worker/worker/TldrawDurableObject.ts create mode 100644 apps/tldraw-sync-worker/worker/assetUploads.ts create mode 100644 apps/tldraw-sync-worker/worker/types.ts create mode 100644 apps/tldraw-sync-worker/worker/worker.ts create mode 100644 apps/tldraw-sync-worker/wrangler.toml diff --git a/apps/tldraw-sync-worker/package.json b/apps/tldraw-sync-worker/package.json new file mode 100644 index 000000000..234850a91 --- /dev/null +++ b/apps/tldraw-sync-worker/package.json @@ -0,0 +1,25 @@ +{ + "name": "@repo/tldraw-sync-worker", + "private": true, + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "check-types": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cloudflare/types": "^6.29.0", + "@tldraw/sync-core": "2.4.6", + "@tldraw/tlschema": "2.4.6", + "cloudflare-workers-unfurl": "^0.0.7", + "itty-router": "^5.0.17", + "lodash.throttle": "^4.1.1" + }, + "devDependencies": { + "@types/lodash.throttle": "^4", + "typescript": "^5.0.2", + "wrangler": "^3.64.0" + } +} diff --git a/apps/tldraw-sync-worker/tsconfig.json b/apps/tldraw-sync-worker/tsconfig.json new file mode 100644 index 000000000..5b14916fe --- /dev/null +++ b/apps/tldraw-sync-worker/tsconfig.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"] +} diff --git a/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts b/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts new file mode 100644 index 000000000..c85484db6 --- /dev/null +++ b/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts @@ -0,0 +1,202 @@ +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' + +type RoomSchemaConfig = { + shapeTypes: string[] + bindingTypes: string[] +} + +const STORAGE_SCHEMA_CONFIG_KEY = 'schemaConfig' + +const createRoomSchema = ({ shapeTypes, bindingTypes }: RoomSchemaConfig) => { + const customShapeSchemas = Object.fromEntries(shapeTypes.map((type) => [type, {}])) + const customBindingSchemas = Object.fromEntries(bindingTypes.map((type) => [type, {}])) + + return createTLSchema({ + shapes: { + ...defaultShapeSchemas, + ...customShapeSchemas, + }, + bindings: { + ...defaultBindingSchemas, + ...customBindingSchemas, + }, + }) +} + +const dedupeAndSort = (values: string[]): string[] => { + return Array.from(new Set(values.filter(Boolean))).sort((a, b) => + a.localeCompare(b) + ) +} + +const mergeSchemaConfig = ( + baseConfig: RoomSchemaConfig, + incomingConfig: RoomSchemaConfig +): RoomSchemaConfig => ({ + shapeTypes: dedupeAndSort(baseConfig.shapeTypes.concat(incomingConfig.shapeTypes)), + bindingTypes: dedupeAndSort(baseConfig.bindingTypes.concat(incomingConfig.bindingTypes)), +}) + +const isSameSchemaConfig = (a: RoomSchemaConfig, b: RoomSchemaConfig): boolean => { + return ( + a.shapeTypes.length === b.shapeTypes.length && + a.bindingTypes.length === b.bindingTypes.length && + a.shapeTypes.every((value, index) => value === b.shapeTypes[index]) && + a.bindingTypes.every((value, index) => value === b.bindingTypes[index]) + ) +} + +// 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 + private roomSchemaConfig: RoomSchemaConfig = { shapeTypes: [], bindingTypes: [] } + // 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> | 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 + const schemaConfig = + ((await this.ctx.storage.get(STORAGE_SCHEMA_CONFIG_KEY)) as RoomSchemaConfig) ?? + null + if (schemaConfig) { + this.roomSchemaConfig = { + shapeTypes: dedupeAndSort(schemaConfig.shapeTypes ?? []), + bindingTypes: dedupeAndSort(schemaConfig.bindingTypes ?? []), + } + } + }) + } + + 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 { + return this.router.fetch(request) + } + + // what happens when someone tries to connect to this room? + async handleConnect(request: IRequest): Promise { + // extract query params from request + const sessionId = request.query.sessionId as string + if (!sessionId) return error(400, 'Missing sessionId') + const incomingSchemaConfig = this.getIncomingSchemaConfig(request) + await this.ensureSchemaConfig(incomingSchemaConfig) + + // 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 }) + } + + private getIncomingSchemaConfig(request: IRequest): RoomSchemaConfig { + const url = new URL(request.url) + return { + shapeTypes: dedupeAndSort(url.searchParams.getAll('shapeType')), + bindingTypes: dedupeAndSort(url.searchParams.getAll('bindingType')), + } + } + + private async ensureSchemaConfig(incomingSchemaConfig: RoomSchemaConfig): Promise { + const nextConfig = mergeSchemaConfig(this.roomSchemaConfig, incomingSchemaConfig) + if (isSameSchemaConfig(this.roomSchemaConfig, nextConfig)) return + + this.roomSchemaConfig = nextConfig + await this.ctx.storage.put(STORAGE_SCHEMA_CONFIG_KEY, this.roomSchemaConfig) + + if (this.roomPromise) { + const previousRoom = await this.roomPromise + const snapshot = previousRoom.getCurrentSnapshot() + previousRoom.close() + this.roomPromise = Promise.resolve(this.createRoom(snapshot)) + } + } + + private createRoom(initialSnapshot?: RoomSnapshot): TLSocketRoom { + return new TLSocketRoom({ + schema: createRoomSchema(this.roomSchemaConfig), + initialSnapshot, + onDataChange: () => { + // and persist whenever the data in the room changes + this.schedulePersistToR2() + }, + }) + } + + 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 this.createRoom(initialSnapshot) + })() + } + + 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) +} diff --git a/apps/tldraw-sync-worker/worker/assetUploads.ts b/apps/tldraw-sync-worker/worker/assetUploads.ts new file mode 100644 index 000000000..c27808768 --- /dev/null +++ b/apps/tldraw-sync-worker/worker/assetUploads.ts @@ -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 }) +} diff --git a/apps/tldraw-sync-worker/worker/types.ts b/apps/tldraw-sync-worker/worker/types.ts new file mode 100644 index 000000000..fb063e660 --- /dev/null +++ b/apps/tldraw-sync-worker/worker/types.ts @@ -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 +} diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts new file mode 100644 index 000000000..5202ca1f2 --- /dev/null +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -0,0 +1,43 @@ +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: unknown /* ExecutionContext */] +>({ + before: [preflight], + finally: [corsify], + catch: (e) => { + console.error(e); // eslint-disable-line no-console ? + 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; diff --git a/apps/tldraw-sync-worker/wrangler.toml b/apps/tldraw-sync-worker/wrangler.toml new file mode 100644 index 000000000..5d4b57a7b --- /dev/null +++ b/apps/tldraw-sync-worker/wrangler.toml @@ -0,0 +1,24 @@ +name = "multiplayer-dg-sync-poc" +main = "worker/worker.ts" +compatibility_date = "2024-07-01" + +[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_sqlite_classes = ["TldrawDurableObject"] + +# We store rooms and asset uploads in an R2 bucket +[[r2_buckets]] +binding = 'TLDRAW_BUCKET' +bucket_name = 'tldraw-content' +preview_bucket_name = 'tldraw-content-preview' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ab73280..46cd4f1cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,37 @@ importers: specifier: ^4.19.2 version: 4.20.5 + apps/tldraw-sync-worker: + dependencies: + '@cloudflare/types': + specifier: ^6.29.0 + version: 6.29.1(react@19.1.1) + '@tldraw/sync-core': + specifier: 2.4.6 + version: 2.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@tldraw/tlschema': + specifier: 2.4.6 + version: 2.4.6(react@19.1.1) + cloudflare-workers-unfurl: + specifier: ^0.0.7 + version: 0.0.7 + itty-router: + specifier: ^5.0.17 + version: 5.0.22 + lodash.throttle: + specifier: ^4.1.1 + version: 4.1.1 + devDependencies: + '@types/lodash.throttle': + specifier: ^4 + version: 4.1.9 + typescript: + specifier: ^5.0.2 + version: 5.9.2 + wrangler: + specifier: ^3.64.0 + version: 3.114.17 + apps/website: dependencies: '@repo/database': @@ -1035,6 +1066,62 @@ packages: '@bundled-es-modules/statuses@1.0.1': resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + '@cloudflare/intl-types@1.5.7': + resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==} + peerDependencies: + react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 + + '@cloudflare/kv-asset-handler@0.3.4': + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + + '@cloudflare/types@6.29.1': + resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==} + peerDependencies: + react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 + + '@cloudflare/unenv-preset@2.0.2': + resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/util-en-garde@8.0.13': + resolution: {integrity: sha512-TJc3D+aA7bU/exJJbKgc07SLpYn1cpDMiYaz8NIxI4MJ8ZbAbZIWt6WYJKrh0m7sUu6+F2eIJPIV5/CFXOWmMw==} + + '@cloudflare/workerd-darwin-64@1.20250718.0': + resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20250718.0': + resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20250718.0': + resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@codemirror/state@6.5.2': resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} @@ -1151,15 +1238,22 @@ packages: '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild-plugins/node-globals-polyfill@0.2.3': + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + + '@esbuild-plugins/node-modules-polyfill@0.2.2': + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -1178,6 +1272,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.17.19': + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.17.3': resolution: {integrity: sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==} engines: {node: '>=12'} @@ -1202,6 +1302,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.17.19': + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.17.3': resolution: {integrity: sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==} engines: {node: '>=12'} @@ -1226,6 +1332,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.17.19': + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.17.3': resolution: {integrity: sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==} engines: {node: '>=12'} @@ -1250,6 +1362,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.17.19': + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.17.3': resolution: {integrity: sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==} engines: {node: '>=12'} @@ -1274,6 +1392,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.17.19': + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.17.3': resolution: {integrity: sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==} engines: {node: '>=12'} @@ -1298,6 +1422,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.17.19': + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.17.3': resolution: {integrity: sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==} engines: {node: '>=12'} @@ -1322,6 +1452,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.17.19': + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.17.3': resolution: {integrity: sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==} engines: {node: '>=12'} @@ -1346,6 +1482,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.17.19': + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.17.3': resolution: {integrity: sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==} engines: {node: '>=12'} @@ -1370,6 +1512,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.17.19': + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.17.3': resolution: {integrity: sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==} engines: {node: '>=12'} @@ -1394,6 +1542,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.17.19': + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.17.3': resolution: {integrity: sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==} engines: {node: '>=12'} @@ -1418,6 +1572,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.17.19': + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.17.3': resolution: {integrity: sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==} engines: {node: '>=12'} @@ -1442,6 +1602,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.17.19': + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.17.3': resolution: {integrity: sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==} engines: {node: '>=12'} @@ -1466,6 +1632,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.17.19': + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.17.3': resolution: {integrity: sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==} engines: {node: '>=12'} @@ -1490,6 +1662,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.17.19': + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.17.3': resolution: {integrity: sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==} engines: {node: '>=12'} @@ -1514,6 +1692,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.17.19': + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.17.3': resolution: {integrity: sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==} engines: {node: '>=12'} @@ -1538,6 +1722,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.17.19': + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.17.3': resolution: {integrity: sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==} engines: {node: '>=12'} @@ -1574,6 +1764,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.17.19': + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.17.3': resolution: {integrity: sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==} engines: {node: '>=12'} @@ -1610,6 +1806,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.17.19': + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.17.3': resolution: {integrity: sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==} engines: {node: '>=12'} @@ -1646,6 +1848,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.17.19': + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.17.3': resolution: {integrity: sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==} engines: {node: '>=12'} @@ -1670,6 +1878,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.17.19': + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.17.3': resolution: {integrity: sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==} engines: {node: '>=12'} @@ -1694,6 +1908,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.17.19': + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.17.3': resolution: {integrity: sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==} engines: {node: '>=12'} @@ -1718,6 +1938,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.17.19': + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.17.3': resolution: {integrity: sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==} engines: {node: '>=12'} @@ -1805,33 +2031,65 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-arm64@0.34.3': resolution: {integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.3': resolution: {integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.2.0': resolution: {integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.0': resolution: {integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm64@1.2.0': resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + '@img/sharp-libvips-linux-arm@1.2.0': resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==} cpu: [arm] @@ -1842,32 +2100,64 @@ packages: cpu: [ppc64] os: [linux] + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.2.0': resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linux-x64@1.2.0': resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.2.0': resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.2.0': resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==} cpu: [x64] os: [linux] + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm64@0.34.3': resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + '@img/sharp-linux-arm@0.34.3': resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1880,30 +2170,59 @@ packages: cpu: [ppc64] os: [linux] + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-s390x@0.34.3': resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linux-x64@0.34.3': resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.3': resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.34.3': resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + '@img/sharp-wasm32@0.34.3': resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1915,12 +2234,24 @@ packages: cpu: [arm64] os: [win32] + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-ia32@0.34.3': resolution: {integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@img/sharp-win32-x64@0.34.3': resolution: {integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4125,6 +4456,12 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash.throttle@4.1.9': + resolution: {integrity: sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==} + + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -4591,10 +4928,19 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -4752,6 +5098,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + assertion-error-formatter@3.0.0: resolution: {integrity: sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==} @@ -4852,6 +5201,9 @@ packages: bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -5075,6 +5427,9 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cloudflare-workers-unfurl@0.0.7: + resolution: {integrity: sha512-34yY66OBWtX4nSq8cYvOG4pZ4ufaPEnBfm00Z6sfLcXT0JdUFcCROt9LRD2cWRd1euQ3cdhFXw782WEui9rHxw==} + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -5314,6 +5669,9 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@2.0.2: + resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -5409,6 +5767,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} @@ -5613,6 +5974,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.17.3: resolution: {integrity: sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==} engines: {node: '>=12'} @@ -5867,6 +6233,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -5912,6 +6281,10 @@ packages: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -5922,6 +6295,9 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -6069,6 +6445,9 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -6152,6 +6531,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-source@2.0.12: + resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + get-stream@4.1.0: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} @@ -6186,6 +6568,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -6493,6 +6878,11 @@ packages: internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -6759,6 +7149,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + itty-router@5.0.22: + resolution: {integrity: sha512-9hmdGErWdYDOurGYxSbqLhy4EFReIwk71hMZTJ5b+zfa2zjMNV1ftFno2b8VjAQvX615gNB8Qxbl9JMRqHnIVA==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -7046,6 +7439,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -7209,6 +7605,11 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + miniflare@3.20250718.3: + resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==} + engines: {node: '>=16.13'} + hasBin: true + minimatch@10.0.3: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -7300,6 +7701,10 @@ packages: typescript: optional: true + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -7515,6 +7920,9 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -7731,6 +8139,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -7927,6 +8338,9 @@ packages: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} + printable-characters@1.0.42: + resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + prismjs@1.27.0: resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} engines: {node: '>=6'} @@ -8429,6 +8843,16 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -8553,6 +8977,10 @@ packages: resolution: {integrity: sha512-/zxjmHGbaYVFtI6bUridFVV7VFStIv3vU/w1h7xenhz7KRzc9pqHsyFvcExZprG7dlA5kW9knRgv8+Cl/H7w9w==} hasBin: true + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.34.3: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -8648,6 +9076,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} @@ -8680,6 +9112,9 @@ packages: stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + stacktracey@2.1.8: + resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + stat-mode@0.3.0: resolution: {integrity: sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==} @@ -8703,6 +9138,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + stream-to-array@2.3.0: resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} @@ -9237,6 +9676,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -9263,6 +9705,9 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + unenv@2.0.0-rc.14: + resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -9505,6 +9950,21 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20250718.0: + resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@3.114.17: + resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250408.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -9524,6 +9984,18 @@ packages: resolution: {integrity: sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==} engines: {node: ^20.17.0 || >=22.9.0} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -9628,6 +10100,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + youch@3.3.4: + resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + yup@1.7.0: resolution: {integrity: sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==} @@ -9645,6 +10120,9 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} @@ -10432,6 +10910,46 @@ snapshots: dependencies: statuses: 2.0.2 + '@cloudflare/intl-types@1.5.7(react@19.1.1)': + dependencies: + react: 19.1.1 + + '@cloudflare/kv-asset-handler@0.3.4': + dependencies: + mime: 3.0.0 + + '@cloudflare/types@6.29.1(react@19.1.1)': + dependencies: + '@cloudflare/intl-types': 1.5.7(react@19.1.1) + '@cloudflare/util-en-garde': 8.0.13 + react: 19.1.1 + + '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)': + dependencies: + unenv: 2.0.0-rc.14 + optionalDependencies: + workerd: 1.20250718.0 + + '@cloudflare/util-en-garde@8.0.13': + dependencies: + fp-ts: 2.16.11 + io-ts: 2.2.22(fp-ts@2.16.11) + + '@cloudflare/workerd-darwin-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20250718.0': + optional: true + '@codemirror/state@6.5.2': dependencies: '@marijn/find-cluster-break': 1.0.2 @@ -10619,11 +11137,6 @@ snapshots: tslib: 2.5.1 optional: true - '@emnapi/runtime@1.5.0': - dependencies: - tslib: 2.5.1 - optional: true - '@emnapi/runtime@1.7.1': dependencies: tslib: 2.5.1 @@ -10634,6 +11147,16 @@ snapshots: tslib: 2.5.1 optional: true + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + + '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -10643,6 +11166,9 @@ snapshots: '@esbuild/android-arm64@0.17.14': optional: true + '@esbuild/android-arm64@0.17.19': + optional: true + '@esbuild/android-arm64@0.17.3': optional: true @@ -10655,6 +11181,9 @@ snapshots: '@esbuild/android-arm@0.17.14': optional: true + '@esbuild/android-arm@0.17.19': + optional: true + '@esbuild/android-arm@0.17.3': optional: true @@ -10667,6 +11196,9 @@ snapshots: '@esbuild/android-x64@0.17.14': optional: true + '@esbuild/android-x64@0.17.19': + optional: true + '@esbuild/android-x64@0.17.3': optional: true @@ -10679,6 +11211,9 @@ snapshots: '@esbuild/darwin-arm64@0.17.14': optional: true + '@esbuild/darwin-arm64@0.17.19': + optional: true + '@esbuild/darwin-arm64@0.17.3': optional: true @@ -10691,6 +11226,9 @@ snapshots: '@esbuild/darwin-x64@0.17.14': optional: true + '@esbuild/darwin-x64@0.17.19': + optional: true + '@esbuild/darwin-x64@0.17.3': optional: true @@ -10703,6 +11241,9 @@ snapshots: '@esbuild/freebsd-arm64@0.17.14': optional: true + '@esbuild/freebsd-arm64@0.17.19': + optional: true + '@esbuild/freebsd-arm64@0.17.3': optional: true @@ -10715,6 +11256,9 @@ snapshots: '@esbuild/freebsd-x64@0.17.14': optional: true + '@esbuild/freebsd-x64@0.17.19': + optional: true + '@esbuild/freebsd-x64@0.17.3': optional: true @@ -10727,6 +11271,9 @@ snapshots: '@esbuild/linux-arm64@0.17.14': optional: true + '@esbuild/linux-arm64@0.17.19': + optional: true + '@esbuild/linux-arm64@0.17.3': optional: true @@ -10739,6 +11286,9 @@ snapshots: '@esbuild/linux-arm@0.17.14': optional: true + '@esbuild/linux-arm@0.17.19': + optional: true + '@esbuild/linux-arm@0.17.3': optional: true @@ -10751,6 +11301,9 @@ snapshots: '@esbuild/linux-ia32@0.17.14': optional: true + '@esbuild/linux-ia32@0.17.19': + optional: true + '@esbuild/linux-ia32@0.17.3': optional: true @@ -10763,6 +11316,9 @@ snapshots: '@esbuild/linux-loong64@0.17.14': optional: true + '@esbuild/linux-loong64@0.17.19': + optional: true + '@esbuild/linux-loong64@0.17.3': optional: true @@ -10775,6 +11331,9 @@ snapshots: '@esbuild/linux-mips64el@0.17.14': optional: true + '@esbuild/linux-mips64el@0.17.19': + optional: true + '@esbuild/linux-mips64el@0.17.3': optional: true @@ -10787,6 +11346,9 @@ snapshots: '@esbuild/linux-ppc64@0.17.14': optional: true + '@esbuild/linux-ppc64@0.17.19': + optional: true + '@esbuild/linux-ppc64@0.17.3': optional: true @@ -10799,6 +11361,9 @@ snapshots: '@esbuild/linux-riscv64@0.17.14': optional: true + '@esbuild/linux-riscv64@0.17.19': + optional: true + '@esbuild/linux-riscv64@0.17.3': optional: true @@ -10811,6 +11376,9 @@ snapshots: '@esbuild/linux-s390x@0.17.14': optional: true + '@esbuild/linux-s390x@0.17.19': + optional: true + '@esbuild/linux-s390x@0.17.3': optional: true @@ -10823,6 +11391,9 @@ snapshots: '@esbuild/linux-x64@0.17.14': optional: true + '@esbuild/linux-x64@0.17.19': + optional: true + '@esbuild/linux-x64@0.17.3': optional: true @@ -10841,6 +11412,9 @@ snapshots: '@esbuild/netbsd-x64@0.17.14': optional: true + '@esbuild/netbsd-x64@0.17.19': + optional: true + '@esbuild/netbsd-x64@0.17.3': optional: true @@ -10859,6 +11433,9 @@ snapshots: '@esbuild/openbsd-x64@0.17.14': optional: true + '@esbuild/openbsd-x64@0.17.19': + optional: true + '@esbuild/openbsd-x64@0.17.3': optional: true @@ -10877,6 +11454,9 @@ snapshots: '@esbuild/sunos-x64@0.17.14': optional: true + '@esbuild/sunos-x64@0.17.19': + optional: true + '@esbuild/sunos-x64@0.17.3': optional: true @@ -10889,6 +11469,9 @@ snapshots: '@esbuild/win32-arm64@0.17.14': optional: true + '@esbuild/win32-arm64@0.17.19': + optional: true + '@esbuild/win32-arm64@0.17.3': optional: true @@ -10901,6 +11484,9 @@ snapshots: '@esbuild/win32-ia32@0.17.14': optional: true + '@esbuild/win32-ia32@0.17.19': + optional: true + '@esbuild/win32-ia32@0.17.3': optional: true @@ -10913,6 +11499,9 @@ snapshots: '@esbuild/win32-x64@0.17.14': optional: true + '@esbuild/win32-x64@0.17.19': + optional: true + '@esbuild/win32-x64@0.17.3': optional: true @@ -10997,48 +11586,92 @@ snapshots: '@iarna/toml@2.2.5': {} + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + '@img/sharp-darwin-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.0 optional: true + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + '@img/sharp-darwin-x64@0.34.3': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.0 optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.0': optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.0': optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.0': optional: true + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + '@img/sharp-libvips-linux-arm@1.2.0': optional: true '@img/sharp-libvips-linux-ppc64@1.2.0': optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.0': optional: true + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + '@img/sharp-libvips-linux-x64@1.2.0': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.0': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.0': optional: true + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + '@img/sharp-linux-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.0 optional: true + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + '@img/sharp-linux-arm@0.34.3': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.0 @@ -11049,37 +11682,68 @@ snapshots: '@img/sharp-libvips-linux-ppc64': 1.2.0 optional: true + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + '@img/sharp-linux-s390x@0.34.3': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.0 optional: true + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + '@img/sharp-linux-x64@0.34.3': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.0 optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.3': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.0 optional: true + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + '@img/sharp-linuxmusl-x64@0.34.3': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.0 optional: true + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.7.1 + optional: true + '@img/sharp-wasm32@0.34.3': dependencies: - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 optional: true '@img/sharp-win32-arm64@0.34.3': optional: true + '@img/sharp-win32-ia32@0.33.5': + optional: true + '@img/sharp-win32-ia32@0.34.3': optional: true + '@img/sharp-win32-x64@0.33.5': + optional: true + '@img/sharp-win32-x64@0.34.3': optional: true @@ -11235,7 +11899,7 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true @@ -13669,6 +14333,14 @@ snapshots: nanoid: 4.0.2 react: 18.2.0 + '@tldraw/store@2.4.6(react@19.1.1)': + dependencies: + '@tldraw/state': 2.4.6 + '@tldraw/utils': 2.4.6 + lodash.isequal: 4.5.0 + nanoid: 4.0.2 + react: 19.1.1 + '@tldraw/store@3.14.2(react@19.0.0)': dependencies: '@tldraw/state': 3.14.2 @@ -13691,6 +14363,22 @@ snapshots: - bufferutil - utf-8-validate + '@tldraw/sync-core@2.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@tldraw/state': 2.4.6 + '@tldraw/store': 2.4.6(react@19.1.1) + '@tldraw/tlschema': 2.4.6(react@19.1.1) + '@tldraw/utils': 2.4.6 + lodash.isequal: 4.5.0 + nanoevents: 7.0.1 + nanoid: 4.0.2 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@tldraw/sync@2.4.6(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@tldraw/state': 2.4.6 @@ -13719,6 +14407,15 @@ snapshots: nanoid: 4.0.2 react: 18.2.0 + '@tldraw/tlschema@2.4.6(react@19.1.1)': + dependencies: + '@tldraw/state': 2.4.6 + '@tldraw/store': 2.4.6(react@19.1.1) + '@tldraw/utils': 2.4.6 + '@tldraw/validate': 2.4.6 + nanoid: 4.0.2 + react: 19.1.1 + '@tldraw/tlschema@3.14.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@tldraw/state': 3.14.2 @@ -13900,6 +14597,12 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/lodash.throttle@4.1.9': + dependencies: + '@types/lodash': 4.17.23 + + '@types/lodash@4.17.23': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -14590,10 +15293,14 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-walk@8.3.2: {} + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 + acorn@8.14.0: {} + acorn@8.15.0: {} agent-base@6.0.2: @@ -14798,6 +15505,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + as-table@1.0.55: + dependencies: + printable-characters: 1.0.42 + assertion-error-formatter@3.0.0: dependencies: diff: 4.0.2 @@ -14895,6 +15606,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -15149,6 +15862,8 @@ snapshots: clone@1.0.4: {} + cloudflare-workers-unfurl@0.0.7: {} + clsx@1.2.1: {} clsx@2.1.1: {} @@ -15358,6 +16073,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@2.0.2: {} + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -15447,6 +16164,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + degenerator@5.0.1: dependencies: ast-types: 0.13.4 @@ -15728,6 +16447,31 @@ snapshots: '@esbuild/win32-ia32': 0.17.14 '@esbuild/win32-x64': 0.17.14 + esbuild@0.17.19: + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + esbuild@0.17.3: optionalDependencies: '@esbuild/android-arm': 0.17.3 @@ -16116,6 +16860,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@0.6.1: {} + estree-walker@2.0.2: {} esutils@2.0.3: {} @@ -16181,6 +16927,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 3.0.0 + exit-hook@2.2.1: {} + express-rate-limit@7.5.1(express@5.1.0): dependencies: express: 5.1.0 @@ -16217,6 +16965,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.8: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -16370,6 +17120,8 @@ snapshots: forwarded@0.2.0: {} + fp-ts@2.16.11: {} + fraction.js@4.3.7: {} fractional-indexing-jittered@0.9.1: {} @@ -16455,6 +17207,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-source@2.0.12: + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + get-stream@4.1.0: dependencies: pump: 3.0.3 @@ -16493,6 +17250,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -16908,6 +17667,10 @@ snapshots: internmap@1.0.1: {} + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -17142,6 +17905,8 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + itty-router@5.0.22: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -17420,6 +18185,10 @@ snapshots: lz-string@1.5.0: {} + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -17668,6 +18437,23 @@ snapshots: min-indent@1.0.1: {} + miniflare@3.20250718.3: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250718.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -17756,6 +18542,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + mustache@4.2.0: {} + mute-stream@0.0.8: {} mute-stream@2.0.0: {} @@ -17954,6 +18742,8 @@ snapshots: '@types/codemirror': 5.60.8 moment: 2.29.4 + ohash@2.0.11: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -18230,6 +19020,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + peberminta@0.9.0: {} pend@1.2.0: {} @@ -18364,6 +19156,8 @@ snapshots: dependencies: parse-ms: 2.1.0 + printable-characters@1.0.42: {} + prismjs@1.27.0: {} prismjs@1.30.0: {} @@ -19112,6 +19906,20 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + rope-sequence@1.3.4: {} router@2.2.0: @@ -19288,6 +20096,33 @@ snapshots: - supports-color - typescript + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.2 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + sharp@0.34.3: dependencies: color: 4.2.3 @@ -19414,6 +20249,8 @@ snapshots: source-map@0.6.1: {} + sourcemap-codec@1.4.8: {} + space-separated-tokens@1.1.5: {} space-separated-tokens@2.0.2: {} @@ -19442,6 +20279,11 @@ snapshots: stackframe@1.3.4: {} + stacktracey@2.1.8: + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + stat-mode@0.3.0: {} statuses@1.5.0: {} @@ -19459,6 +20301,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stoppable@1.1.0: {} + stream-to-array@2.3.0: dependencies: any-promise: 1.3.0 @@ -20127,6 +20971,8 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.6.3: {} + uglify-js@3.19.3: optional: true @@ -20151,6 +20997,14 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + unenv@2.0.0-rc.14: + dependencies: + defu: 6.1.4 + exsolve: 1.0.8 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.3 + unicorn-magic@0.1.0: {} unified@11.0.5: @@ -20471,6 +21325,33 @@ snapshots: wordwrap@1.0.0: {} + workerd@1.20250718.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250718.0 + '@cloudflare/workerd-darwin-arm64': 1.20250718.0 + '@cloudflare/workerd-linux-64': 1.20250718.0 + '@cloudflare/workerd-linux-arm64': 1.20250718.0 + '@cloudflare/workerd-windows-64': 1.20250718.0 + + wrangler@3.114.17: + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250718.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + optionalDependencies: + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -20496,6 +21377,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.18.0: {} + ws@8.18.3: {} xdg-app-paths@5.1.0: @@ -20576,6 +21459,12 @@ snapshots: yoctocolors-cjs@2.1.3: {} + youch@3.3.4: + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.1.8 + yup@1.7.0: dependencies: property-expr: 2.0.6 @@ -20597,6 +21486,8 @@ snapshots: dependencies: zod: 3.25.76 + zod@3.22.3: {} + zod@3.22.4: {} zod@3.25.76: {} From 03f8ac7a42747bf5073c3e2f01dcab5d83af76fd Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 21:52:08 -0600 Subject: [PATCH 04/12] Remove Cloudflare asset upload handling and refactor Tldraw components to utilize Roam's native asset store. Update TldrawCanvasCloudflareSync to integrate the new asset management approach. --- .../canvas/TldrawCanvasCloudflareSync.tsx | 22 +++- .../canvas/cloudflareSyncAssetStore.ts | 26 ----- .../tldraw-sync-worker/worker/assetUploads.ts | 100 ------------------ apps/tldraw-sync-worker/worker/worker.ts | 6 +- apps/tldraw-sync-worker/wrangler.toml | 2 +- 5 files changed, 21 insertions(+), 135 deletions(-) delete mode 100644 apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts delete mode 100644 apps/tldraw-sync-worker/worker/assetUploads.ts diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx index 6af4054a2..aa7c8c81a 100644 --- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -2,13 +2,13 @@ import { useSync } from "@tldraw/sync"; import { TLAnyBindingUtilConstructor, TLAnyShapeUtilConstructor, + TLAssetStore, TLStoreWithStatus, defaultBindingUtils, defaultShapeUtils, MigrationSequence, } from "tldraw"; import { useMemo } from "react"; -import { createCloudflareSyncAssetStore } from "./cloudflareSyncAssetStore"; /** When true, newly created canvases (no Roam-persisted state) use tldraw sync via Cloudflare. PoC only. */ export const TLDRAW_CLOUDFLARE_SYNC_ENABLED = true; @@ -22,6 +22,21 @@ export type CloudflareCanvasStoreAdapterResult = { isLoading: boolean; }; +const parseRoamUploadResponse = (value: string): string => { + return value.replace(/^!\[\]\(/, "").replace(/\)$/, ""); +}; + +const createRoamAssetStore = (): TLAssetStore => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + upload: async (_asset, file) => { + const response = await window.roamAlphaAPI.file.upload({ file }); + return parseRoamUploadResponse(response); + }, + resolve: (asset) => asset.props.src, + }; +}; + export const useCloudflareSyncStore = ({ pageUid, migrations, @@ -37,10 +52,7 @@ export const useCloudflareSyncStore = ({ customShapeTypes: string[]; customBindingTypes: string[]; }): CloudflareCanvasStoreAdapterResult => { - const assets = useMemo( - () => createCloudflareSyncAssetStore(TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL), - [], - ); + const assets = useMemo(() => createRoamAssetStore(), []); const shapeUtils = useMemo( () => [...defaultShapeUtils, ...customShapeUtils], [customShapeUtils], diff --git a/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts b/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts deleted file mode 100644 index c07527bbb..000000000 --- a/apps/roam/src/components/canvas/cloudflareSyncAssetStore.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { TLAssetStore } from "tldraw"; -import { uniqueId } from "tldraw"; - -/** - * Minimal asset store for tldraw-sync-cloudflare backend. - * Uploads to /uploads/ on the sync worker; resolves via the returned URL. - */ -export const createCloudflareSyncAssetStore = ( - wsBaseUrl: string, -): TLAssetStore => { - const uploadBase = `${wsBaseUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:")}/uploads`; - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - upload: async (_asset, file) => { - const id = uniqueId(); - const objectName = `${id}-${file.name}`.replace(/[^a-zA-Z0-9.]/g, "-"); - const url = `${uploadBase}/${objectName}`; - const response = await fetch(url, { method: "POST", body: file }); - if (!response.ok) { - throw new Error(`Failed to upload asset: ${response.statusText}`); - } - return url; - }, - resolve: (asset) => asset.props.src, - }; -}; diff --git a/apps/tldraw-sync-worker/worker/assetUploads.ts b/apps/tldraw-sync-worker/worker/assetUploads.ts deleted file mode 100644 index c27808768..000000000 --- a/apps/tldraw-sync-worker/worker/assetUploads.ts +++ /dev/null @@ -1,100 +0,0 @@ -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 }) -} diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts index 5202ca1f2..96044778d 100644 --- a/apps/tldraw-sync-worker/worker/worker.ts +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -1,6 +1,5 @@ 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 @@ -30,11 +29,12 @@ const router = AutoRouter< }); }) + // We store assest in native apps // assets can be uploaded to the bucket under /uploads: - .post("/uploads/:uploadId", handleAssetUpload) + // .post("/uploads/:uploadId", handleAssetUpload) // they can be retrieved from the bucket too: - .get("/uploads/:uploadId", handleAssetDownload) + // .get("/uploads/:uploadId", handleAssetDownload) // bookmarks need to extract metadata from pasted URLs: .get("/unfurl", handleUnfurlRequest); diff --git a/apps/tldraw-sync-worker/wrangler.toml b/apps/tldraw-sync-worker/wrangler.toml index 5d4b57a7b..46fe6611c 100644 --- a/apps/tldraw-sync-worker/wrangler.toml +++ b/apps/tldraw-sync-worker/wrangler.toml @@ -17,7 +17,7 @@ bindings = [ tag = "v1" new_sqlite_classes = ["TldrawDurableObject"] -# We store rooms and asset uploads in an R2 bucket +# We store room snapshots (assest are stored in native apps) in an R2 bucket [[r2_buckets]] binding = 'TLDRAW_BUCKET' bucket_name = 'tldraw-content' From 803c4750cf30c03f9b4cbc3ced600ab281fe51b3 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 22:09:53 -0600 Subject: [PATCH 05/12] Update dependencies in tldraw-sync-worker, removing outdated Cloudflare types and adding workers-types. Refactor TldrawDurableObject to specify type for schedulePersistToR2. Adjust router type definitions in worker.ts for improved clarity. --- apps/tldraw-sync-worker/package.json | 2 +- .../worker/TldrawDurableObject.ts | 2 +- apps/tldraw-sync-worker/worker/worker.ts | 14 +---- pnpm-lock.yaml | 58 ++++--------------- 4 files changed, 15 insertions(+), 61 deletions(-) diff --git a/apps/tldraw-sync-worker/package.json b/apps/tldraw-sync-worker/package.json index 234850a91..e7c9a0d64 100644 --- a/apps/tldraw-sync-worker/package.json +++ b/apps/tldraw-sync-worker/package.json @@ -10,7 +10,6 @@ "check-types": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@cloudflare/types": "^6.29.0", "@tldraw/sync-core": "2.4.6", "@tldraw/tlschema": "2.4.6", "cloudflare-workers-unfurl": "^0.0.7", @@ -18,6 +17,7 @@ "lodash.throttle": "^4.1.1" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20240208.0", "@types/lodash.throttle": "^4", "typescript": "^5.0.2", "wrangler": "^3.64.0" diff --git a/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts b/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts index c85484db6..a80c240e7 100644 --- a/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts +++ b/apps/tldraw-sync-worker/worker/TldrawDurableObject.ts @@ -191,7 +191,7 @@ export class TldrawDurableObject { } // we throttle persistance so it only happens every 10 seconds - schedulePersistToR2 = throttle(async () => { + schedulePersistToR2: () => void = throttle(async () => { if (!this.roomPromise || !this.roomId) return const room = await this.getRoom() diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts index 96044778d..0dc74d7f8 100644 --- a/apps/tldraw-sync-worker/worker/worker.ts +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -8,14 +8,11 @@ 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: unknown /* ExecutionContext */] ->({ +const router = AutoRouter({ before: [preflight], finally: [corsify], catch: (e) => { - console.error(e); // eslint-disable-line no-console ? + console.error(e); return error(e); }, }) @@ -29,13 +26,6 @@ const router = AutoRouter< }); }) - // We store assest in native apps - // 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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46cd4f1cd..c946e7237 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,9 +372,6 @@ importers: apps/tldraw-sync-worker: dependencies: - '@cloudflare/types': - specifier: ^6.29.0 - version: 6.29.1(react@19.1.1) '@tldraw/sync-core': specifier: 2.4.6 version: 2.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -391,6 +388,9 @@ importers: specifier: ^4.1.1 version: 4.1.1 devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20240208.0 + version: 4.20260207.0 '@types/lodash.throttle': specifier: ^4 version: 4.1.9 @@ -399,7 +399,7 @@ importers: version: 5.9.2 wrangler: specifier: ^3.64.0 - version: 3.114.17 + version: 3.114.17(@cloudflare/workers-types@4.20260207.0) apps/website: dependencies: @@ -1066,20 +1066,10 @@ packages: '@bundled-es-modules/statuses@1.0.1': resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} - '@cloudflare/intl-types@1.5.7': - resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==} - peerDependencies: - react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 - '@cloudflare/kv-asset-handler@0.3.4': resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} - '@cloudflare/types@6.29.1': - resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==} - peerDependencies: - react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 - '@cloudflare/unenv-preset@2.0.2': resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} peerDependencies: @@ -1089,9 +1079,6 @@ packages: workerd: optional: true - '@cloudflare/util-en-garde@8.0.13': - resolution: {integrity: sha512-TJc3D+aA7bU/exJJbKgc07SLpYn1cpDMiYaz8NIxI4MJ8ZbAbZIWt6WYJKrh0m7sUu6+F2eIJPIV5/CFXOWmMw==} - '@cloudflare/workerd-darwin-64@1.20250718.0': resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} engines: {node: '>=16'} @@ -1122,6 +1109,9 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workers-types@4.20260207.0': + resolution: {integrity: sha512-PSxgnAOK0EtTytlY7/+gJcsQJYg0Qo7KlOMSC/wiBE+pBqKjuKdd1ZgM+NvpPNqZAjWV5jqAMTTNYEmgk27gYw==} + '@codemirror/state@6.5.2': resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} @@ -6445,9 +6435,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fp-ts@2.16.11: - resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} - fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -6878,11 +6865,6 @@ packages: internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} - io-ts@2.2.22: - resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} - peerDependencies: - fp-ts: ^2.5.0 - ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -10910,31 +10892,16 @@ snapshots: dependencies: statuses: 2.0.2 - '@cloudflare/intl-types@1.5.7(react@19.1.1)': - dependencies: - react: 19.1.1 - '@cloudflare/kv-asset-handler@0.3.4': dependencies: mime: 3.0.0 - '@cloudflare/types@6.29.1(react@19.1.1)': - dependencies: - '@cloudflare/intl-types': 1.5.7(react@19.1.1) - '@cloudflare/util-en-garde': 8.0.13 - react: 19.1.1 - '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)': dependencies: unenv: 2.0.0-rc.14 optionalDependencies: workerd: 1.20250718.0 - '@cloudflare/util-en-garde@8.0.13': - dependencies: - fp-ts: 2.16.11 - io-ts: 2.2.22(fp-ts@2.16.11) - '@cloudflare/workerd-darwin-64@1.20250718.0': optional: true @@ -10950,6 +10917,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20250718.0': optional: true + '@cloudflare/workers-types@4.20260207.0': {} + '@codemirror/state@6.5.2': dependencies: '@marijn/find-cluster-break': 1.0.2 @@ -17120,8 +17089,6 @@ snapshots: forwarded@0.2.0: {} - fp-ts@2.16.11: {} - fraction.js@4.3.7: {} fractional-indexing-jittered@0.9.1: {} @@ -17667,10 +17634,6 @@ snapshots: internmap@1.0.1: {} - io-ts@2.2.22(fp-ts@2.16.11): - dependencies: - fp-ts: 2.16.11 - ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -21333,7 +21296,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20250718.0 '@cloudflare/workerd-windows-64': 1.20250718.0 - wrangler@3.114.17: + wrangler@3.114.17(@cloudflare/workers-types@4.20260207.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) @@ -21346,6 +21309,7 @@ snapshots: unenv: 2.0.0-rc.14 workerd: 1.20250718.0 optionalDependencies: + '@cloudflare/workers-types': 4.20260207.0 fsevents: 2.3.3 sharp: 0.33.5 transitivePeerDependencies: From b9e5f9116097c85d05d7635929baab8b08a1c912 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 22:32:36 -0600 Subject: [PATCH 06/12] Add room ID generation for Cloudflare sync in Tldraw components - Introduced `getSyncRoomId` function to create a unique room ID based on the page UID and graph name. - Updated `useCloudflareSyncStore` to utilize the new room ID for WebSocket connections. - Added comments in `Tldraw.tsx` to clarify canvas identity handling in Roam. --- apps/roam/src/components/canvas/Tldraw.tsx | 2 ++ .../src/components/canvas/TldrawCanvasCloudflareSync.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 9bebcee63..381b123ce 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -149,6 +149,8 @@ export const isPageUid = (uid: string) => ]; const TldrawCanvas = ({ title }: { title: string }) => { + // In Roam, canvas identity is currently keyed by the page UID. + // Room sync is graphName/uid const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); const useCloudflareSync = TLDRAW_CLOUDFLARE_SYNC_ENABLED && diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx index aa7c8c81a..99ee1f62a 100644 --- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -22,6 +22,11 @@ export type CloudflareCanvasStoreAdapterResult = { isLoading: boolean; }; +const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => { + const graphName = window.roamAlphaAPI.graph.name; + return `${graphName}/${pageUid}`; +}; + const parseRoamUploadResponse = (value: string): string => { return value.replace(/^!\[\]\(/, "").replace(/\)$/, ""); }; @@ -63,6 +68,7 @@ export const useCloudflareSyncStore = ({ ); const uri = useMemo(() => { + const roomId = encodeURIComponent(getSyncRoomId({ pageUid })); const query = new URLSearchParams(); for (const shapeType of customShapeTypes) { query.append("shapeType", shapeType); @@ -70,7 +76,7 @@ export const useCloudflareSyncStore = ({ for (const bindingType of customBindingTypes) { query.append("bindingType", bindingType); } - return `${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/connect/${pageUid}?${query.toString()}`; + return `${TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL}/connect/${roomId}?${query.toString()}`; }, [customShapeTypes, customBindingTypes, pageUid]); const store = useSync({ From 32b14f755170b47c4c2d4f54447276302171dbf2 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 23:10:26 -0600 Subject: [PATCH 07/12] Refine room ID generation for Cloudflare sync in Tldraw components - Updated `getSyncRoomId` to encode the room ID as a base64url token, enhancing security and compatibility. - Clarified comments in `Tldraw.tsx` regarding canvas identity handling in Roam. --- apps/roam/src/components/canvas/Tldraw.tsx | 2 +- .../canvas/TldrawCanvasCloudflareSync.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 381b123ce..53e31770d 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -150,7 +150,7 @@ export const isPageUid = (uid: string) => const TldrawCanvas = ({ title }: { title: string }) => { // In Roam, canvas identity is currently keyed by the page UID. - // Room sync is graphName/uid + // Room sync is graph/page encoded as an opaque base64url token. const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); const useCloudflareSync = TLDRAW_CLOUDFLARE_SYNC_ENABLED && diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx index 99ee1f62a..345e2a9dd 100644 --- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -24,7 +24,17 @@ export type CloudflareCanvasStoreAdapterResult = { const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => { const graphName = window.roamAlphaAPI.graph.name; - return `${graphName}/${pageUid}`; + const payload = JSON.stringify({ graphName, pageUid }); + const bytes = new TextEncoder().encode(payload); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); }; const parseRoamUploadResponse = (value: string): string => { @@ -68,7 +78,7 @@ export const useCloudflareSyncStore = ({ ); const uri = useMemo(() => { - const roomId = encodeURIComponent(getSyncRoomId({ pageUid })); + const roomId = getSyncRoomId({ pageUid }); const query = new URLSearchParams(); for (const shapeType of customShapeTypes) { query.append("shapeType", shapeType); From 1ea9d2652e11492e66a4ea4d08e47bcf6d5a7537 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 23:10:33 -0600 Subject: [PATCH 08/12] Update project name in wrangler.toml for multiplayer-dg-sync-poc to version 2 --- apps/tldraw-sync-worker/wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tldraw-sync-worker/wrangler.toml b/apps/tldraw-sync-worker/wrangler.toml index 46fe6611c..9093cef3c 100644 --- a/apps/tldraw-sync-worker/wrangler.toml +++ b/apps/tldraw-sync-worker/wrangler.toml @@ -1,4 +1,4 @@ -name = "multiplayer-dg-sync-poc" +name = "multiplayer-dg-sync-poc-2" main = "worker/worker.ts" compatibility_date = "2024-07-01" From 538138598416f0888ec5a26efe08f94e3078da37 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sat, 7 Feb 2026 23:33:30 -0600 Subject: [PATCH 09/12] Implement CORS handling in tldraw-sync-worker - Introduced a set of functions to manage CORS headers and validate allowed origins. - Replaced the previous CORS setup with a more flexible approach, allowing specific origins and Vercel preview URLs. - Updated the router to enforce CORS checks and handle preflight requests for OPTIONS method. - Enhanced the /connect and /unfurl routes to include CORS headers in responses. --- apps/tldraw-sync-worker/worker/worker.ts | 78 +++++++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts index 0dc74d7f8..4e41cf023 100644 --- a/apps/tldraw-sync-worker/worker/worker.ts +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -1,33 +1,93 @@ import { handleUnfurlRequest } from "cloudflare-workers-unfurl"; -import { AutoRouter, cors, error, IRequest } from "itty-router"; +import { AutoRouter, error, IRequest } from "itty-router"; 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 ALLOWED_ORIGINS = [ + "https://roamresearch.com", + "http://localhost:3000", + "app://obsidian.md", +]; + +const isVercelPreviewUrl = (origin: string): boolean => + /^https:\/\/.*-discourse-graph-[a-z0-9]+\.vercel\.app$/.test(origin); + +const isAllowedOrigin = (origin: string): boolean => + ALLOWED_ORIGINS.includes(origin) || + ALLOWED_ORIGINS.some((allowedOrigin) => origin.startsWith(allowedOrigin)) || + isVercelPreviewUrl(origin); + +const setCorsHeaders = ({ + request, + response, +}: { + request: IRequest; + response: Response; +}): Response => { + const origin = request.headers.get("origin"); + if (origin && isAllowedOrigin(origin)) { + response.headers.set("Access-Control-Allow-Origin", origin); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, x-vercel-protection-bypass", + ); + } + return response; +}; + +const handlePreflight = (request: IRequest): Response => { + const origin = request.headers.get("origin"); + if (!origin || !isAllowedOrigin(origin)) { + return error(403, "Origin not allowed"); + } + + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-vercel-protection-bypass", + "Access-Control-Max-Age": "86400", + }, + }); +}; + +const enforceAllowedOrigin = (request: IRequest): Response | void => { + if (request.method === "OPTIONS") return; + const origin = request.headers.get("origin"); + if (origin && !isAllowedOrigin(origin)) { + return error(403, "Origin not allowed"); + } +}; + const router = AutoRouter({ - before: [preflight], - finally: [corsify], + before: [enforceAllowedOrigin], catch: (e) => { console.error(e); return error(e); }, }) + .options("*", handlePreflight) // requests to /connect are routed to the Durable Object, and handle realtime websocket syncing - .get("/connect/:roomId", (request, env) => { + .get("/connect/: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, { + const response = await room.fetch(request.url, { headers: request.headers, body: request.body, }); + return setCorsHeaders({ request, response }); }) // bookmarks need to extract metadata from pasted URLs: - .get("/unfurl", handleUnfurlRequest); + .get("/unfurl", async (request) => { + const response = await handleUnfurlRequest(request); + return setCorsHeaders({ request, response }); + }); // export our router for cloudflare export default router; From da7012cb0bc5c62aacc8a7a8786ec2f63b66a33b Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 8 Feb 2026 01:07:52 -0600 Subject: [PATCH 10/12] Add Cloudflare sync indicator to Tldraw components - Integrated a visual indicator for Cloudflare sync status in the TldrawCanvasShared component. - Updated props to include `isCloudflareSync` for both Roam and Cloudflare canvas components. - Enhanced user experience by displaying a tooltip with a cloud icon when Cloudflare sync is enabled. --- apps/roam/src/components/canvas/Tldraw.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 53e31770d..cf66686bd 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useCallback, } from "react"; +import { Icon, Tooltip } from "@blueprintjs/core"; import ExtensionApiContextProvider, { useExtensionAPI, } from "roamjs-components/components/ExtensionApiContext"; @@ -240,6 +241,7 @@ const TldrawCanvasRoam = ({ title={title} pageUid={pageUid} useStoreAdapter={useRoamCanvasStore} + isCloudflareSync={false} /> ); }; @@ -256,6 +258,7 @@ const TldrawCanvasCloudflare = ({ title={title} pageUid={pageUid} useStoreAdapter={useCloudflareCanvasStore} + isCloudflareSync={true} /> ); }; @@ -264,10 +267,12 @@ const TldrawCanvasShared = ({ title, pageUid, useStoreAdapter, + isCloudflareSync, }: { title: string; pageUid: string; useStoreAdapter: (args: CanvasStoreAdapterArgs) => CanvasStoreAdapterResult; + isCloudflareSync: boolean; }) => { const appRef = useRef(null); const lastInsertRef = useRef(); @@ -853,6 +858,14 @@ const TldrawCanvasShared = ({ onDragOver={handleDragOver} onDrop={handleDrop} > + {isCloudflareSync && ( +

+ +
+ )} {needsUpgrade ? ( From 1b64c06ce887a2db1413866306dd79d32cc1d595 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 22 Feb 2026 21:36:00 -0600 Subject: [PATCH 11/12] ENG-1401 change the sync vs no sync behavior to a flag (#815) * Add per-canvas sync mode flag implementation for Roam Canvas - Introduced a new `canvasSyncMode` flag to explicitly manage sync behavior for each canvas, replacing the previous implicit heuristic. - Added utilities for reading and writing the sync mode, ensuring persistence and defaulting to 'local'. - Updated canvas entrypoint and main menu to reflect the new sync mode, including a toggle for users. - Implemented UI indicators for mixed-version clarity and ensured no heuristic fallback in mode selection. - Established test cases to validate mode persistence and behavior in various scenarios. * Enhance canvas sync functionality in Tldraw components - Refactored Tldraw components to incorporate a per-canvas sync mode, allowing users to toggle between 'sync' and 'local' modes. - Updated the main menu to include a checkbox for sync mode, with visual feedback for availability. - Integrated new utilities for managing sync mode state and ensured proper propagation of sync settings across components. - Improved user experience with tooltips and notifications related to sync mode changes. * Remove deprecated per-canvas sync mode plan and enhance Tldraw components - Deleted the `eng-1324-plan.md` file outlining the previous sync mode implementation. - Updated Tldraw components to integrate a new `shouldWarnSyncModeStartsBlank` utility, which checks for existing sync rooms before enabling sync mode. - Refactored the main menu to include a toggle for sync mode, providing user feedback on the current state and warnings when necessary. - Improved the Cloudflare sync status handling in the Tldraw components, ensuring a seamless user experience when switching between sync and local modes. * Refactor Tldraw components to remove unused sync mode checks and update Cloudflare sync URL - Removed the `shouldWarnSyncModeStartsBlank` utility and related props from Tldraw components, streamlining the sync mode logic. - Updated the Cloudflare sync WebSocket base URL for improved connectivity. - Enhanced user interface elements to reflect changes in sync mode messaging and availability. * fixed. lame. --- .gitignore | 2 + apps/roam/src/components/canvas/Tldraw.tsx | 91 +++++++++++++++++-- .../canvas/TldrawCanvasCloudflareSync.tsx | 4 +- .../src/components/canvas/canvasSyncMode.ts | 67 ++++++++++++++ .../src/components/canvas/uiOverrides.tsx | 88 ++++++++++++------ .../src/components/canvas/useRoamStore.ts | 11 --- apps/tldraw-sync-worker/worker/worker.ts | 2 + apps/tldraw-sync-worker/wrangler.toml | 2 +- 8 files changed, 217 insertions(+), 50 deletions(-) create mode 100644 apps/roam/src/components/canvas/canvasSyncMode.ts diff --git a/.gitignore b/.gitignore index 6b5f96f5e..ff2137ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ yarn-error.log* # Local development files local/* .cursor/debug.log +apps/tldraw-sync-worker/tsconfig.tsbuildinfo +apps/tldraw-sync-worker/.wrangler/* diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index cf66686bd..d0383f5c9 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useCallback, } from "react"; -import { Icon, Tooltip } from "@blueprintjs/core"; +import { Icon } from "@blueprintjs/core"; import ExtensionApiContextProvider, { useExtensionAPI, } from "roamjs-components/components/ExtensionApiContext"; @@ -73,7 +73,7 @@ import { createNodeShapeTools, createNodeShapeUtils, } from "./DiscourseNodeUtil"; -import { hasRoamPersistedCanvasData, useRoamStore } from "./useRoamStore"; +import { useRoamStore } from "./useRoamStore"; import { TLDRAW_CLOUDFLARE_SYNC_ENABLED, TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL, @@ -115,6 +115,12 @@ import { TLRecord } from "@tldraw/tlschema"; import { WHITE_LOGO_SVG } from "~/icons"; import { BLOCK_REF_REGEX } from "roamjs-components/dom"; import { defaultHandleExternalTextContent } from "./defaultHandleExternalTextContent"; +import { + CanvasSyncMode, + ensureCanvasSyncMode, + getEffectiveCanvasSyncMode, + setCanvasSyncMode, +} from "./canvasSyncMode"; declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -153,16 +159,47 @@ const TldrawCanvas = ({ title }: { title: string }) => { // In Roam, canvas identity is currently keyed by the page UID. // Room sync is graph/page encoded as an opaque base64url token. const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); + const [canvasSyncMode, setCanvasSyncModeState] = useState( + () => getEffectiveCanvasSyncMode({ pageUid }), + ); + const isCloudflareSyncAvailable = + TLDRAW_CLOUDFLARE_SYNC_ENABLED && !!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL; const useCloudflareSync = - TLDRAW_CLOUDFLARE_SYNC_ENABLED && - !!TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL && - !hasRoamPersistedCanvasData(pageUid); + canvasSyncMode === "sync" && isCloudflareSyncAvailable; + + useEffect(() => { + setCanvasSyncModeState(ensureCanvasSyncMode({ pageUid })); + }, [pageUid]); + + const onCanvasSyncModeChange = useCallback( + (mode: CanvasSyncMode) => { + setCanvasSyncMode({ pageUid, mode }); + setCanvasSyncModeState(mode); + }, + [pageUid], + ); if (useCloudflareSync) { - return ; + return ( + + ); } - return ; + return ( + + ); }; type CanvasStoreAdapterArgs = { @@ -232,9 +269,15 @@ const useCloudflareCanvasStore = ({ const TldrawCanvasRoam = ({ title, pageUid, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, }: { title: string; pageUid: string; + canvasSyncMode: CanvasSyncMode; + isCloudflareSyncAvailable: boolean; + onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; }) => { return ( ); }; @@ -249,9 +295,15 @@ const TldrawCanvasRoam = ({ const TldrawCanvasCloudflare = ({ title, pageUid, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, }: { title: string; pageUid: string; + canvasSyncMode: CanvasSyncMode; + isCloudflareSyncAvailable: boolean; + onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; }) => { return ( ); }; @@ -268,11 +323,17 @@ const TldrawCanvasShared = ({ pageUid, useStoreAdapter, isCloudflareSync, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, }: { title: string; pageUid: string; useStoreAdapter: (args: CanvasStoreAdapterArgs) => CanvasStoreAdapterResult; isCloudflareSync: boolean; + canvasSyncMode: CanvasSyncMode; + isCloudflareSyncAvailable: boolean; + onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; }) => { const appRef = useRef(null); const lastInsertRef = useRef(); @@ -591,8 +652,18 @@ const TldrawCanvasShared = ({ allNodes, allRelationNames, allAddReferencedNodeActions, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, }), - [allNodes, allRelationNames, allAddReferencedNodeActions], + [ + allNodes, + allRelationNames, + allAddReferencedNodeActions, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, + ], ); // UTILS @@ -860,8 +931,8 @@ const TldrawCanvasShared = ({ > {isCloudflareSync && (
diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx index 345e2a9dd..53a6b7ef9 100644 --- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx +++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx @@ -14,7 +14,7 @@ import { useMemo } from "react"; export const TLDRAW_CLOUDFLARE_SYNC_ENABLED = true; /** Base URL for tldraw-sync-cloudflare worker. Use https (not wss) - useSync upgrades to WebSocket. */ export const TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL = - "https://multiplayer-dg-sync-poc.discoursegraphs.workers.dev"; + "https://multiplayer-dg-sync.discoursegraphs.workers.dev"; export type CloudflareCanvasStoreAdapterResult = { store: TLStoreWithStatus; @@ -22,7 +22,7 @@ export type CloudflareCanvasStoreAdapterResult = { isLoading: boolean; }; -const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => { +export const getSyncRoomId = ({ pageUid }: { pageUid: string }): string => { const graphName = window.roamAlphaAPI.graph.name; const payload = JSON.stringify({ graphName, pageUid }); const bytes = new TextEncoder().encode(payload); diff --git a/apps/roam/src/components/canvas/canvasSyncMode.ts b/apps/roam/src/components/canvas/canvasSyncMode.ts new file mode 100644 index 000000000..5543e201a --- /dev/null +++ b/apps/roam/src/components/canvas/canvasSyncMode.ts @@ -0,0 +1,67 @@ +import getBlockProps, { json } from "~/utils/getBlockProps"; +import setBlockProps from "~/utils/setBlockProps"; + +export type CanvasSyncMode = "local" | "sync"; + +const QUERY_BUILDER_PROP_KEY = "roamjs-query-builder"; +const CANVAS_SYNC_MODE_KEY = "canvasSyncMode"; +const DEFAULT_CANVAS_SYNC_MODE: CanvasSyncMode = "local"; + +const isCanvasSyncMode = (value: unknown): value is CanvasSyncMode => + value === "local" || value === "sync"; + +const getRoamJsQueryBuilderProps = ( + pageUid: string, +): Record => { + const props = getBlockProps(pageUid); + const value = props[QUERY_BUILDER_PROP_KEY]; + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + return {}; +}; + +export const getPersistedCanvasSyncMode = ({ + pageUid, +}: { + pageUid: string; +}): CanvasSyncMode | null => { + const rjsqb = getRoamJsQueryBuilderProps(pageUid); + const mode = rjsqb[CANVAS_SYNC_MODE_KEY]; + return isCanvasSyncMode(mode) ? mode : null; +}; + +export const getEffectiveCanvasSyncMode = ({ + pageUid, +}: { + pageUid: string; +}): CanvasSyncMode => { + return getPersistedCanvasSyncMode({ pageUid }) ?? DEFAULT_CANVAS_SYNC_MODE; +}; + +export const setCanvasSyncMode = ({ + pageUid, + mode, +}: { + pageUid: string; + mode: CanvasSyncMode; +}): void => { + const rjsqb = getRoamJsQueryBuilderProps(pageUid); + setBlockProps(pageUid, { + [QUERY_BUILDER_PROP_KEY]: { + ...rjsqb, + [CANVAS_SYNC_MODE_KEY]: mode, + }, + }); +}; + +export const ensureCanvasSyncMode = ({ + pageUid, +}: { + pageUid: string; +}): CanvasSyncMode => { + const mode = getPersistedCanvasSyncMode({ pageUid }); + if (mode) return mode; + setCanvasSyncMode({ pageUid, mode: DEFAULT_CANVAS_SYNC_MODE }); + return DEFAULT_CANVAS_SYNC_MODE; +}; diff --git a/apps/roam/src/components/canvas/uiOverrides.tsx b/apps/roam/src/components/canvas/uiOverrides.tsx index c3aafa80e..f69866065 100644 --- a/apps/roam/src/components/canvas/uiOverrides.tsx +++ b/apps/roam/src/components/canvas/uiOverrides.tsx @@ -15,19 +15,17 @@ import { DefaultToolbarContent, TldrawUiMenuItem, DefaultMainMenu, + DefaultMainMenuContent, TldrawUiMenuGroup, + TldrawUiDropdownMenuItem, + TldrawUiButton, + TldrawUiButtonLabel, + TldrawUiIcon, useActions, DefaultContextMenu, DefaultContextMenuContent, TLUiComponents, - EditSubmenu, - ExportFileContentSubMenu, - ExtrasGroup, - PreferencesGroup, TldrawUiMenuSubmenu, - ZoomTo100MenuItem, - ZoomToFitMenuItem, - ZoomToSelectionMenuItem, useEditor, useValue, useToasts, @@ -46,6 +44,41 @@ import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings"; import { getSetting } from "~/utils/extensionSettings"; import { CustomDefaultToolbar } from "./CustomDefaultToolbar"; import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog"; +import { CanvasSyncMode } from "./canvasSyncMode"; + +const SyncModeMenuSwitchItem = ({ + checked, + disabled, + label, + onToggle, +}: { + checked: boolean; + disabled: boolean; + label: string; + onToggle: () => void; +}): React.ReactElement => { + return ( + + + {label} + + + + + + ); +}; export const getOnSelectForShape = ({ shape, @@ -185,10 +218,16 @@ export const createUiComponents = ({ allNodes, allAddReferencedNodeActions, allRelationNames, + canvasSyncMode, + isCloudflareSyncAvailable, + onCanvasSyncModeChange, }: { allNodes: DiscourseNode[]; allRelationNames: string[]; allAddReferencedNodeActions: string[]; + canvasSyncMode: CanvasSyncMode; + isCloudflareSyncAvailable: boolean; + onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; }): TLUiComponents => { return { Toolbar: (props) => { @@ -219,29 +258,26 @@ export const createUiComponents = ({ ); }, MainMenu: () => { - const CustomViewMenu = () => { - const actions = useActions(); - return ( - - - - - - - - - - - ); + const onToggleSyncMode = (): void => { + const nextMode: CanvasSyncMode = + canvasSyncMode === "sync" ? "local" : "sync"; + onCanvasSyncModeChange(nextMode); }; + const syncModeLabel = isCloudflareSyncAvailable + ? "Use cloud canvas" + : "Cloud canvas unavailable"; return ( - - {/* Replaced */} - - - + + + + ); }, diff --git a/apps/roam/src/components/canvas/useRoamStore.ts b/apps/roam/src/components/canvas/useRoamStore.ts index a28821452..5475a7f66 100644 --- a/apps/roam/src/components/canvas/useRoamStore.ts +++ b/apps/roam/src/components/canvas/useRoamStore.ts @@ -38,17 +38,6 @@ export const isTLStoreSnapshot = (value: unknown): value is TLStoreSnapshot => { ); }; -/** True if the canvas has existing tldraw state persisted in Roam (any format). */ -export const hasRoamPersistedCanvasData = (pageUid: string): boolean => { - const props = getBlockProps(pageUid) as Record; - const rjsqb = - typeof props?.["roamjs-query-builder"] === "object" - ? (props["roamjs-query-builder"] as Record) - : {}; - const tldraw = rjsqb?.tldraw; - return tldraw !== undefined && tldraw !== null; -}; - const fixShapeIndices = ( data: SerializedStore, ): SerializedStore => { diff --git a/apps/tldraw-sync-worker/worker/worker.ts b/apps/tldraw-sync-worker/worker/worker.ts index 4e41cf023..c51bdb043 100644 --- a/apps/tldraw-sync-worker/worker/worker.ts +++ b/apps/tldraw-sync-worker/worker/worker.ts @@ -26,6 +26,8 @@ const setCorsHeaders = ({ request: IRequest; response: Response; }): Response => { + if (response.status === 101) return response; // WebSocket upgrade response; headers are immutable here, so skip CORS header mutation. + const origin = request.headers.get("origin"); if (origin && isAllowedOrigin(origin)) { response.headers.set("Access-Control-Allow-Origin", origin); diff --git a/apps/tldraw-sync-worker/wrangler.toml b/apps/tldraw-sync-worker/wrangler.toml index 9093cef3c..a7dd25c0a 100644 --- a/apps/tldraw-sync-worker/wrangler.toml +++ b/apps/tldraw-sync-worker/wrangler.toml @@ -1,4 +1,4 @@ -name = "multiplayer-dg-sync-poc-2" +name = "multiplayer-dg-sync" main = "worker/worker.ts" compatibility_date = "2024-07-01" From af38f0b12776c258fdebab3d954b0ed8dc89dc7a Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 22 Feb 2026 23:00:00 -0600 Subject: [PATCH 12/12] ENG-1399 bug cannot change atoms during reaction cycle (#816) * Add per-canvas sync mode flag implementation for Roam Canvas - Introduced a new `canvasSyncMode` flag to explicitly manage sync behavior for each canvas, replacing the previous implicit heuristic. - Added utilities for reading and writing the sync mode, ensuring persistence and defaulting to 'local'. - Updated canvas entrypoint and main menu to reflect the new sync mode, including a toggle for users. - Implemented UI indicators for mixed-version clarity and ensured no heuristic fallback in mode selection. - Established test cases to validate mode persistence and behavior in various scenarios. * Enhance canvas sync functionality in Tldraw components - Refactored Tldraw components to incorporate a per-canvas sync mode, allowing users to toggle between 'sync' and 'local' modes. - Updated the main menu to include a checkbox for sync mode, with visual feedback for availability. - Integrated new utilities for managing sync mode state and ensured proper propagation of sync settings across components. - Improved user experience with tooltips and notifications related to sync mode changes. * Remove deprecated per-canvas sync mode plan and enhance Tldraw components - Deleted the `eng-1324-plan.md` file outlining the previous sync mode implementation. - Updated Tldraw components to integrate a new `shouldWarnSyncModeStartsBlank` utility, which checks for existing sync rooms before enabling sync mode. - Refactored the main menu to include a toggle for sync mode, providing user feedback on the current state and warnings when necessary. - Improved the Cloudflare sync status handling in the Tldraw components, ensuring a seamless user experience when switching between sync and local modes. * Refactor Tldraw components to remove unused sync mode checks and update Cloudflare sync URL - Removed the `shouldWarnSyncModeStartsBlank` utility and related props from Tldraw components, streamlining the sync mode logic. - Updated the Cloudflare sync WebSocket base URL for improved connectivity. - Enhanced user interface elements to reflect changes in sync mode messaging and availability. * add hack fix for cannot change atoms bug * review --- apps/roam/src/components/canvas/Tldraw.tsx | 61 ++++++++++++++++++---- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index d0383f5c9..a8e70a425 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -150,6 +150,34 @@ export const MAX_WIDTH = "400px"; const ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`; +// hack for "cannot change atoms during reaction cycle" bug +const isAtomReactionCycleError = (error: unknown): error is Error => + error instanceof Error && + /cannot change atoms during reaction cycle/i.test(error.message); +const installSafeHintingSetter = ({ app }: { app: Editor }): void => { + const originalSetHintingShapes = app.setHintingShapes.bind(app); + + app.setHintingShapes = ((shapeIds: TLShapeId[]) => { + try { + return originalSetHintingShapes(shapeIds); + } catch (error) { + if (!isAtomReactionCycleError(error)) { + throw error; + } + + app.timers.setTimeout(() => { + try { + originalSetHintingShapes(shapeIds); + } catch { + // Ignore deferred hinting updates if the editor is gone or still reacting. + } + }, 0); + + return app; + } + }) as Editor["setHintingShapes"]; +}; + export const isPageUid = (uid: string) => !!window.roamAlphaAPI.pull("[:node/title]", [":block/uid", uid])?.[ ":node/title" @@ -343,6 +371,8 @@ const TldrawCanvasShared = ({ ); const [isConvertToDialogOpen, setConvertToDialogOpen] = useState(false); + // hack for "cannot change atoms during reaction cycle" bug + const [hasMountedEditor, setHasMountedEditor] = useState(false); const updateViewportScreenBounds = useCallback((el: HTMLDivElement) => { // Use tldraw's built-in viewport bounds update with centering @@ -921,6 +951,16 @@ const TldrawCanvasShared = ({ }); }, [error, pageUid, title, customShapeTypes, customBindingTypes]); + // Keep the mounted editor alive through transient reconnect errors, + // but still surface errors when the store is unavailable. + const blockingStoreError = error && (!hasMountedEditor || !store) ? error : null; + const isCanvasBlocked = + !store || + !assetLoading.done || + !extensionAPI || + !isPluginReady || + (!hasMountedEditor && (isLoading || !!error)); + return (
- ) : isLoading || - !!error || - !store || - !assetLoading.done || - !extensionAPI || - !isPluginReady ? ( + ) : isCanvasBlocked ? (

- {error || assetLoading.error + {blockingStoreError || assetLoading.error ? "Error Loading Canvas" : "Loading Canvas"}

- {error || assetLoading.error ? ( + {blockingStoreError || assetLoading.error ? ( - {error?.message?.includes("invalidRecord") + {blockingStoreError?.message?.includes("invalidRecord") ? "Cloudflare sync rejected a custom Discourse Graph record (invalidRecord). The sync worker schema must include DG custom shapes and bindings." : "There was a problem loading the Tldraw canvas."}{" "} - {error?.message ? `Details: ${error.message}` : ""} + {blockingStoreError?.message + ? `Details: ${blockingStoreError.message}` + : ""} ) : ( @@ -1018,6 +1055,10 @@ const TldrawCanvasShared = ({ appRef.current = app; + // hack for "cannot change atoms during reaction cycle" bug + installSafeHintingSetter({ app }); + setHasMountedEditor(true); + app.on("change", (entry) => { lastActionsRef.current.push(entry); if (lastActionsRef.current.length > 5)