import type * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; import { parseCollabRoomId } from "@pcbjam/shared"; import { connectBroadcastChannel } from "./broadcast-transport"; import { connectAwarenessBroadcast } from "./awareness-bc"; /** * 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; /** * The room's Yjs awareness sidecar (presence: roster/cursor/selection — * collab-presence 0001). Network providers surface their own instance (the * server relays it); the BroadcastChannel provider ferries one over a * sibling channel. Absent for `none` — presence UI then renders nothing. */ awareness?: Awareness; /** * Gateway transport only (load-path-rework 0003): upgrade a `passive` * subscription to a real y-protocol participant and resolve once the doc * holds the server state. Providers without it are always fully synced * after `whenSynced` — callers treat absence as an already-synced no-op. */ activate?(): Promise; /** * Gateway transport only: the doc changed server-side while this * subscription was passive (the sheet manager's parked-dirty flag). */ onTouched?(cb: () => void): void; /** * Gateway transport only (load-path-rework 0004 §2.3): the doc's server-side * history was replaced and this passive copy cannot be merged — the owner * must destroy doc + provider and connect afresh. */ onReset?(cb: () => void): 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); // Presence sidecar over its own channel — the doc transport stays untouched. const awareness = new Awareness(doc); const awarenessRelay = connectAwarenessBroadcast(awareness, `${room}:awareness`); 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: () => { awarenessRelay.destroy(); awareness.destroy(); transport.destroy(); }, awareness, }; } async function partyKitProvider( doc: Y.Doc, endpoint: string, room: string, params: ProviderConfig["params"], ): Promise { const { default: YProvider } = await import("y-partyserver/provider"); // The provider splices the room into the WS URL verbatim, so it must travel // as ONE percent-encoded path segment — otherwise a docPath containing // `/`, ` `, `#`, `%` or `?` truncates or corrupts the room name. The sync // worker decodes it once at its edge; the decoded string is the canonical // room/DO name everywhere. const provider = new YProvider(endpoint, encodeURIComponent(room), doc, { party: PARTYKIT_PARTY, connect: true, params, // Default maxBackoffTime is 2.5s — a room that's down would re-dial (and // re-run the server's authorize round trip) every 2.5s forever, per room. maxBackoffTime: 30_000, }); 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(), // y-partyserver's provider carries its own awareness; the BoardRoom DO // (YServer) relays awareness messages as part of the protocol. awareness: provider.awareness, }; } 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(), awareness: provider.awareness ?? undefined, }; } /** * Connect a Y.Doc to the env-selected provider. Network libraries are imported * lazily so a deployment only ships the one it uses. * * `passive` (gateway transport only): register interest without demanding doc * state — the sheet manager's warm pool. Other providers ignore it and stay * fully synced, a harmless superset. */ export async function connectProvider( doc: Y.Doc, config: ProviderConfig, opts: { room: string; passive?: boolean; passiveSync?: boolean }, ): Promise { switch (config.kind) { case "broadcastchannel": return broadcastChannelProvider(doc, opts.room, config.settleMs ?? 300); case "partykit": { // Load-path-rework 0003: board/presence rooms ride the per-project // gateway socket (one ws per project instead of one per room). The // legacy per-room dial stays only for room ids that don't parse — an // operator/debug shape the gateway can't address. const parsed = parseCollabRoomId(opts.room); if (parsed) { const { GatewayDocFacade } = await import("./gateway"); return new GatewayDocFacade(doc, { endpoint: requireEndpoint(config), scopeId: parsed.scopeId, projectId: parsed.projectId, docPath: parsed.docPath, token: config.params?.token, passive: opts.passive, passiveSync: opts.passiveSync, }); } 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(); } } }