pcbjam/web/standalone/src/wasm/collab/broadcast-transport.ts

65 lines
2 KiB
TypeScript
Raw Normal View History

feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2) Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002. C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only, zero added fork divergence beyond the OnModify hook): - snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify, emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta - kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect) by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw - kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a kicadCollabTestAddText() PoC local-edit hook - wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields} JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic): - reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply, origin-tagged echo suppression; seed-once join adopts the doc authoritatively - broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up) - WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation. Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:25:22 +02:00
import * as Y from "yjs";
import { clog } from "./debug";
/**
* Minimal BroadcastChannel sync provider for a Y.Doc (features/yjs-bridge/0001 §6,
* PoC transport — two same-origin tabs share one doc, zero backend). Encodes Yjs
* updates and ferries them between tabs; on join it requests the current state so a
* late tab catches up.
*
* Remote updates are applied with origin "remote" so (a) the reconciler treats them
* as peer changes (its own writes use a different origin) and (b) we don't re-broadcast
* them into a loop.
*/
export interface Transport {
destroy(): void;
}
type Msg =
| { t: "update"; u: Uint8Array }
| { t: "query" }
| { t: "state"; u: Uint8Array };
export const REMOTE_ORIGIN = "remote";
export function connectBroadcastChannel(
doc: Y.Doc,
channelName: string,
): Transport {
const bc = new BroadcastChannel(channelName);
clog("BroadcastChannel open:", channelName);
const onUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === REMOTE_ORIGIN) return; // don't echo applied remote updates
clog("→ BC send update", update.byteLength, "bytes (origin:", String(origin) + ")");
bc.postMessage({ t: "update", u: update } satisfies Msg);
};
doc.on("update", onUpdate);
bc.onmessage = (e: MessageEvent<Msg>) => {
const msg = e.data;
if (!msg) return;
switch (msg.t) {
case "update":
case "state":
clog("← BC recv", msg.t, msg.u?.byteLength, "bytes → applyUpdate");
Y.applyUpdate(doc, new Uint8Array(msg.u), REMOTE_ORIGIN);
break;
case "query":
clog("← BC recv query → replying with state");
bc.postMessage({ t: "state", u: Y.encodeStateAsUpdate(doc) } satisfies Msg);
break;
}
};
// Ask any existing tab for the current state.
clog("→ BC send query (asking peers for state)");
bc.postMessage({ t: "query" } satisfies Msg);
return {
destroy: () => {
doc.off("update", onUpdate);
bc.close();
},
};
}