From 285efe7a06f1b0dba6cbe980f10a98d01be11755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 10 Jun 2026 14:23:42 +0200 Subject: [PATCH] feat(standalone): env-selected Yjs provider abstraction (ysync 0004) Generalize the collab transport into a provider registry (none | broadcastchannel | partykit | hocuspocus), one active per env. startCollab takes { provider, room } and awaits provider.whenSynced() before seed/adopt. Network libs lazily imported; room id from @pcbjam/shared collabRoomId. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/pnpm-lock.yaml | 87 ++++++++++++ web/standalone/package.json | 2 + web/standalone/src/components/WasmTool.tsx | 25 ++-- web/standalone/src/lib/config.ts | 18 +++ web/standalone/src/pages/HomePage.tsx | 1 + web/standalone/src/pages/ToolPage.tsx | 1 + web/standalone/src/vite-env.d.ts | 6 + web/standalone/src/wasm/collab/index.ts | 48 ++++--- web/standalone/src/wasm/collab/provider.ts | 148 +++++++++++++++++++++ 9 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 web/standalone/src/wasm/collab/provider.ts diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 19ba2c1..4582fd8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: standalone: dependencies: + '@hocuspocus/provider': + specifier: ^4.1.1 + version: 4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31) '@pcbjam/shared': specifier: workspace:* version: link:../pcbjam-shared @@ -97,6 +100,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.4)) + y-partyserver: + specifier: ^2.2.0 + version: 2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31) yjs: specifier: ^13.6.18 version: 13.6.31 @@ -218,6 +224,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@cloudflare/workers-types@4.20260610.1': + resolution: {integrity: sha512-Mk/f3lUygeIHzQ4HnJjU/JvGg/kllgp9gISty9nylHE/2M2MFeKO+hgAKSgiPpmwUbuhewdYGgqFGgT/ADK0/g==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -545,6 +554,15 @@ packages: '@fastify/merge-json-schemas@0.1.1': resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + '@hocuspocus/common@4.1.1': + resolution: {integrity: sha512-7jvCti57eYp6Yf7MHzPI4QI75o4zHg4G2f2HC3jbAdpTb+aKxAEPpnDxxpl2Lj4XzyDcisZfwB0Rvf7lbPXAog==} + + '@hocuspocus/provider@4.1.1': + resolution: {integrity: sha512-LSbr50a1+1yoyO7tiWBkBxZZQzSRkW6xQRfEx6phNhQDzn5E4lL24mvfkUIyVFrr+TzPDGMFz5CBx6cXSaFYzw==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -561,6 +579,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lifeomic/attempt@3.1.0': + resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1316,6 +1337,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1350,6 +1374,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + node-releases@2.0.46: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} @@ -1373,6 +1402,11 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + partyserver@0.5.6: + resolution: {integrity: sha512-/LKCqlq9nWzNXA8UXZFO/Xz15QDCjJnGAgRQVLnXJO9bA0HKt5J8VM8wLnGc814WatzuQgeG17tqzI//y5WFGA==} + peerDependencies: + '@cloudflare/workers-types': ^4.20260424.1 + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1735,6 +1769,19 @@ packages: yaml: optional: true + y-partyserver@2.2.0: + resolution: {integrity: sha512-AIsTEZ3jPicBe3VHO5c/VdSGSaFr0J9cAGj6+sgRL/RnXLTNSkP3oQXyXf1y8hQNeXv/j9zYDT5NnRv2k1eMGA==} + peerDependencies: + '@cloudflare/workers-types': ^4.20260424.1 + partyserver: '>=0.2.0 <1.0.0' + yjs: ^13.6.14 + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1861,6 +1908,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cloudflare/workers-types@4.20260610.1': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -2038,6 +2087,18 @@ snapshots: dependencies: fast-deep-equal: 3.1.3 + '@hocuspocus/common@4.1.1': + dependencies: + lib0: 0.2.117 + + '@hocuspocus/provider@4.1.1(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)': + dependencies: + '@hocuspocus/common': 4.1.1 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2057,6 +2118,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lifeomic/attempt@3.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2717,6 +2780,8 @@ snapshots: lines-and-columns@1.2.4: {} + lodash.debounce@4.0.8: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2750,6 +2815,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.11: {} + node-releases@2.0.46: {} normalize-path@3.0.0: {} @@ -2762,6 +2829,11 @@ snapshots: on-exit-leak-free@2.1.2: {} + partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1): + dependencies: + '@cloudflare/workers-types': 4.20260610.1 + nanoid: 5.1.11 + path-parse@1.0.7: {} picocolors@1.1.1: {} @@ -3111,6 +3183,21 @@ snapshots: jiti: 1.21.7 tsx: 4.22.4 + y-partyserver@2.2.0(@cloudflare/workers-types@4.20260610.1)(partyserver@0.5.6(@cloudflare/workers-types@4.20260610.1))(yjs@13.6.31): + dependencies: + '@cloudflare/workers-types': 4.20260610.1 + lib0: 0.2.117 + lodash.debounce: 4.0.8 + nanoid: 5.1.11 + partyserver: 0.5.6(@cloudflare/workers-types@4.20260610.1) + y-protocols: 1.0.7(yjs@13.6.31) + yjs: 13.6.31 + + y-protocols@1.0.7(yjs@13.6.31): + dependencies: + lib0: 0.2.117 + yjs: 13.6.31 + yallist@3.1.1: {} yjs@13.6.31: diff --git a/web/standalone/package.json b/web/standalone/package.json index d149cb8..51acf3a 100644 --- a/web/standalone/package.json +++ b/web/standalone/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hocuspocus/provider": "^4.1.1", "@pcbjam/shared": "workspace:*", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-label": "^2.1.1", @@ -25,6 +26,7 @@ "react-router-dom": "^6.28.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "y-partyserver": "^2.2.0", "yjs": "^13.6.18", "zod": "^3.24.1" }, diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 44a3347..68288ff 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import type { Tool } from "@pcbjam/shared"; +import { collabRoomId, type Tool } from "@pcbjam/shared"; import { ChevronDown, ChevronUp } from "lucide-react"; -import { WASM_ASSET_BASE_URL } from "@/lib/config"; +import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config"; import { bootKicadTool } from "@/wasm/boot"; import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner"; import type { CollabWindow } from "@/wasm/collab"; @@ -22,7 +22,7 @@ async function maybeStartCollab( win: ToolWindow, opts: { tool: Tool; - slug: string; + projectId: string; targetPath?: string; log: (m: string) => void; onStatus: (t: string) => void; @@ -58,12 +58,16 @@ async function maybeStartCollab( } const { startCollab } = await import("@/wasm/collab"); - const channel = `kicad-collab:${opts.slug}:${opts.targetPath ?? ""}`; - clog("starting on channel", channel); - await startCollab(mod, win as unknown as CollabWindow, { channel }); - opts.log(`[collab] connected on ${channel}`); + const provider = yjsProviderConfig(); + // One room per (project, document). Two tabs of the same build compute the + // same id, so cross-tab BroadcastChannel still works; network providers use it + // verbatim to namespace + persist (see @pcbjam/shared collabRoomId). + const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool); + clog("starting collab", provider.kind, "room", room); + await startCollab(mod, win as unknown as CollabWindow, { provider, room }); + opts.log(`[collab] ${provider.kind} connected on ${room}`); opts.onStatus("Collab: connected"); - clog("connected ✓ — edit in one tab, watch the other"); + clog("connected ✓"); } /** @@ -76,6 +80,7 @@ async function maybeStartCollab( export function WasmTool({ tool, slug, + projectId, files, targetPath, fetchBytes, @@ -83,6 +88,8 @@ export function WasmTool({ }: { tool: Tool; slug: string; + /** Stable project id — used to key the collab room (see @pcbjam/shared). */ + projectId: string; files: ToolFile[]; targetPath?: string; /** Fetch one project-relative file's bytes (contract loader or local folder). */ @@ -126,7 +133,7 @@ export function WasmTool({ log: append, onStatus: setStatus, }); - await maybeStartCollab(win, { tool, slug, targetPath, log: append, onStatus: setStatus }); + await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus }); } catch (err) { append(`[fatal] ${String(err)}`); setStatus(`Error: ${String(err)}`); diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index bfdef07..c610437 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -7,3 +7,21 @@ export const API_BASE_URL = // to also satisfy the worker/COEP constraints. export const WASM_ASSET_BASE_URL = import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm"; + +import type { ProviderConfig, ProviderKind } from "@/wasm/collab"; + +/** + * Which Yjs collab provider this deployment uses (one active per env), and its + * endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps + * the cross-tab-only behavior with no backend. Built here at the composition + * root and passed into `startCollab`, so `wasm/collab` stays env-agnostic. + */ +export function yjsProviderConfig(): ProviderConfig { + const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind; + const token = import.meta.env.VITE_YJS_TOKEN; + return { + kind, + endpoint: import.meta.env.VITE_YJS_ENDPOINT, + params: token ? { token } : undefined, + }; +} diff --git a/web/standalone/src/pages/HomePage.tsx b/web/standalone/src/pages/HomePage.tsx index 1f41c7d..9a0c3a0 100644 --- a/web/standalone/src/pages/HomePage.tsx +++ b/web/standalone/src/pages/HomePage.tsx @@ -84,6 +84,7 @@ export function HomePage() { fetchFileBytes(slug, relPath)} diff --git a/web/standalone/src/vite-env.d.ts b/web/standalone/src/vite-env.d.ts index 4658b36..3dd96bf 100644 --- a/web/standalone/src/vite-env.d.ts +++ b/web/standalone/src/vite-env.d.ts @@ -3,6 +3,12 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; readonly VITE_WASM_ASSET_BASE_URL?: string; + /** Yjs collab provider: none | broadcastchannel | partykit | hocuspocus. */ + readonly VITE_YJS_PROVIDER?: string; + /** Host/URL for network collab providers (partykit, hocuspocus). */ + readonly VITE_YJS_ENDPOINT?: string; + /** Optional connection token for the collab provider. */ + readonly VITE_YJS_TOKEN?: string; } interface ImportMeta { diff --git a/web/standalone/src/wasm/collab/index.ts b/web/standalone/src/wasm/collab/index.ts index 897c50a..325921c 100644 --- a/web/standalone/src/wasm/collab/index.ts +++ b/web/standalone/src/wasm/collab/index.ts @@ -1,12 +1,22 @@ import * as Y from "yjs"; -import { connectBroadcastChannel, type Transport } from "./broadcast-transport"; import { clog } from "./debug"; +import { + connectProvider, + type ProviderConfig, + type YjsProvider, +} from "./provider"; import { createReconciler, type Reconciler } from "./reconciler"; import type { CollabBridge } from "./types"; export type { CollabBridge, CollabDelta, CollabItem } from "./types"; export { createReconciler } from "./reconciler"; export { connectBroadcastChannel } from "./broadcast-transport"; +export { + connectProvider, + type ProviderConfig, + type ProviderKind, + type YjsProvider, +} from "./provider"; /** The subset of the Emscripten Module the collab bridge needs (embind functions). */ export interface CollabModule { @@ -31,51 +41,51 @@ export function moduleBridge(mod: CollabModule, win: CollabWindow): CollabBridge } export interface StartCollabOptions { - /** BroadcastChannel name — tabs sharing this name share the document. */ - channel: string; - /** - * How long to wait for an existing tab's state before deciding seed-vs-adopt - * (seed-once rule). First tab: no reply, seeds from its local model. Later tab: - * receives state within this window, then adopts it. Default 300ms. - */ - settleMs?: number; + /** Which Yjs provider to use + its endpoint/params (env-selected upstream). */ + provider: ProviderConfig; + /** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */ + room: string; } export interface CollabHandle { doc: Y.Doc; reconciler: Reconciler; - transport: Transport; + provider: YjsProvider; destroy(): void; } /** - * Wire a running pl_editor wasm Module into a collaborative session: Module ⇄ Y.Doc ⇄ - * BroadcastChannel. Returns once the initial seed/adopt has run. The editor must - * already have its document loaded (so kicadCollabSnapshot reflects it). + * Wire a running KiCad wasm Module into a collaborative session: + * Module ⇄ Y.Doc ⇄ provider. Returns once the initial seed/adopt has run. The + * editor must already have its document loaded (so kicadCollabSnapshot reflects + * it), so that — if this is the first/only client — `seed()` captures it. + * + * Seed-vs-adopt: after `provider.whenSynced()` the Y.Doc holds the authoritative + * state (the server's, a peer tab's, or — first ever — empty). `seed()` then + * seeds from the local model if the doc is empty, else adopts the shared doc. */ export async function startCollab( mod: CollabModule, win: CollabWindow, opts: StartCollabOptions, ): Promise { - clog("startCollab: channel =", opts.channel); + clog("startCollab:", opts.provider.kind, "room =", opts.room); const doc = new Y.Doc(); const bridge = moduleBridge(mod, win); const reconciler = createReconciler(doc, bridge); - const transport = connectBroadcastChannel(doc, opts.channel); + const provider = await connectProvider(doc, opts.provider, { room: opts.room }); - // Let any existing tab answer our state query before we decide to seed. - await new Promise((r) => setTimeout(r, opts.settleMs ?? 300)); + await provider.whenSynced(); reconciler.seed(); clog("startCollab: ready; doc items =", reconciler.items.size); return { doc, reconciler, - transport, + provider, destroy() { reconciler.destroy(); - transport.destroy(); + provider.destroy(); doc.destroy(); }, }; diff --git a/web/standalone/src/wasm/collab/provider.ts b/web/standalone/src/wasm/collab/provider.ts new file mode 100644 index 0000000..b07cf4f --- /dev/null +++ b/web/standalone/src/wasm/collab/provider.ts @@ -0,0 +1,148 @@ +import type * as Y from "yjs"; +import { connectBroadcastChannel } from "./broadcast-transport"; + +/** + * A Y.Doc transport, normalized across backends. The reconciler/seed logic only + * needs to know when the initial state has arrived (`whenSynced`) and how to + * tear down (`destroy`); everything else is provider-specific. + * + * Providers are network libraries (MIT/ISC) linked by the GPL editor — fine, and + * the closed PartyKit server is reached only over WSS (no linking). + */ +export interface YjsProvider { + /** + * Resolves once the doc holds the authoritative initial state: for network + * providers when the server has synced; for BroadcastChannel after a short + * settle window (a peer tab may have answered). The seed-vs-adopt decision + * runs after this. + */ + whenSynced(): Promise; + destroy(): void; +} + +export type ProviderKind = + | "none" + | "broadcastchannel" + | "partykit" + | "hocuspocus"; + +export interface ProviderConfig { + kind: ProviderKind; + /** Host/URL for network providers (partykit, hocuspocus). */ + endpoint?: string; + /** Connection params (e.g. `{ token }`); see @pcbjam/shared `collabConnectParams`. */ + params?: { token?: string }; + /** BroadcastChannel only: how long to wait for a peer tab before seeding. */ + settleMs?: number; +} + +// The closed sync server (apps/sync) binds the room DO as `BoardRoom`; +// partyserver routes it under the kebab-cased party name. +const PARTYKIT_PARTY = "board-room"; + +function requireEndpoint(config: ProviderConfig): string { + if (!config.endpoint) { + throw new Error( + `collab provider "${config.kind}" requires an endpoint (VITE_YJS_ENDPOINT)`, + ); + } + return config.endpoint; +} + +function noopProvider(): YjsProvider { + return { whenSynced: () => Promise.resolve(), destroy: () => {} }; +} + +function broadcastChannelProvider( + doc: Y.Doc, + room: string, + settleMs: number, +): YjsProvider { + const transport = connectBroadcastChannel(doc, room); + return { + // No server to sync with — keep the original seed-once heuristic: give a + // peer tab a moment to answer the state query before deciding seed/adopt. + whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)), + destroy: () => transport.destroy(), + }; +} + +async function partyKitProvider( + doc: Y.Doc, + endpoint: string, + room: string, + params: ProviderConfig["params"], +): Promise { + const { default: YProvider } = await import("y-partyserver/provider"); + const provider = new YProvider(endpoint, room, doc, { + party: PARTYKIT_PARTY, + connect: true, + params, + }); + return { + whenSynced: () => + new Promise((resolve) => { + if (provider.synced) return resolve(); + const onSync = (state: boolean) => { + if (state) { + provider.off("sync", onSync); + resolve(); + } + }; + provider.on("sync", onSync); + }), + destroy: () => provider.destroy(), + }; +} + +async function hocuspocusProvider( + doc: Y.Doc, + endpoint: string, + room: string, + params: ProviderConfig["params"], +): Promise { + const { HocuspocusProvider } = await import("@hocuspocus/provider"); + const provider = new HocuspocusProvider({ + url: endpoint, + name: room, + document: doc, + ...(params?.token ? { token: params.token } : {}), + }); + return { + whenSynced: () => + new Promise((resolve) => { + if (provider.synced) return resolve(); + const onSync = () => { + provider.off("synced", onSync); + resolve(); + }; + provider.on("synced", onSync); + }), + destroy: () => provider.destroy(), + }; +} + +/** + * Connect a Y.Doc to the env-selected provider. Network libraries are imported + * lazily so a deployment only ships the one it uses. + */ +export async function connectProvider( + doc: Y.Doc, + config: ProviderConfig, + opts: { room: string }, +): Promise { + switch (config.kind) { + case "broadcastchannel": + return broadcastChannelProvider(doc, opts.room, config.settleMs ?? 300); + case "partykit": + return partyKitProvider(doc, requireEndpoint(config), opts.room, config.params); + case "hocuspocus": + return hocuspocusProvider(doc, requireEndpoint(config), opts.room, config.params); + case "none": + return noopProvider(); + default: { + const _exhaustive: never = config.kind; + return _exhaustive ?? noopProvider(); + } + } +}