feat(collab): presence P1 — awareness transport + who's-here roster (collab-presence 0001)

- provider.ts: expose awareness on YjsProvider (partykit/hocuspocus surface
  their own; BroadcastChannel gets a sibling-channel relay via awareness-bc.ts)
- presence.ts: createPresence — publish local PresenceState, subscribable
  deduped peers() roster, setCursor/setSelection for P2, pagehide fast removal
- PresenceRoster.tsx + WasmTool wiring: facepile chip next to SourceChip;
  eeschema rebinds per active sheet (sheet-manager ActiveSheet now carries the
  room's provider); config presenceUser() = slug + colorForUser
- new dep y-protocols; unit tests (presence.test.ts) + e2e
  tests/web/presence-roster.spec.ts (two tabs, verified vs real partykit)
- bump pcbjam-shared: presence-wire schema (MIT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-06 14:28:10 +02:00
commit 22528f450b
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 538 additions and 15 deletions

View file

@ -0,0 +1,61 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Presence roster e2e (collab-presence 0001): two tabs of the real React app on
* the SAME project file share one collab room over the default BroadcastChannel
* provider — each tab's PresenceRoster chip must show the OTHER user, and a tab
* leaving must drop off the peer's roster promptly (pagehide broadcast, not the
* 30s awareness timeout).
*
* Uses pl_editor (demo.kicad_wks): it is in COLLAB_TOOLS and is the smallest
* wasm, so the two boots stay cheap. Identity comes from the `?user=` slug
* (the e2e isolation hook config.ts already honors).
*
* Provider-agnostic: BC (default checkout) removes instantly via the pagehide
* broadcast; partykit removes when the server sees the WS close — measured
* ~5-8s under local wrangler dev (immediate close on real tab close; an
* about:blank navigation can BFCache the page and stretch this to ~15s, which
* is why the leave step CLOSES the tab).
*/
const SCOPE = 'default';
const ROUTE = 'demo.kicad_wks';
const TITLE = /demo — Drawing Sheet Editor/i;
async function bootAs(page: Page, user: string): Promise<void> {
await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`);
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
await expect
.poll(() => page.title(), {
message: `${user}: editor never reached the expected title`,
timeout: 120000,
intervals: [1000],
})
.toMatch(TITLE);
}
test('two tabs on one file see each other in the roster; leaving removes promptly', async ({
page,
context,
}) => {
test.setTimeout(360000); // two full tool boots
await bootAs(page, 'alice');
// Before anyone else joins, alice has no roster chip at all.
await expect(page.getByTestId('presence-roster')).toHaveCount(0);
const pageB = await context.newPage();
await bootAs(pageB, 'bob');
// Each side shows the other user (avatar keyed by slug), not itself.
await expect(page.locator('[data-presence-user="bob"]')).toBeVisible({ timeout: 30000 });
await expect(pageB.locator('[data-presence-user="alice"]')).toBeVisible({ timeout: 30000 });
await expect(page.locator('[data-presence-user="alice"]')).toHaveCount(0);
await expect(pageB.locator('[data-presence-user="bob"]')).toHaveCount(0);
// bob leaves (tab close → BC pagehide broadcast / partykit WS close): alice's
// roster must clear well inside the 30s awareness timeout.
await pageB.close();
await expect(page.getByTestId('presence-roster')).toHaveCount(0, { timeout: 20000 });
});

@ -1 +1 @@
Subproject commit 2387cecba80221e463fde02636d9ce4c3c24a044
Subproject commit a99d1dfff92d1e8e7a77d39dc920c4db7e955208

3
web/pnpm-lock.yaml generated
View file

@ -125,6 +125,9 @@ importers:
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)
y-protocols:
specifier: ^1.0.6
version: 1.0.7(yjs@13.6.31)
yjs:
specifier: ^13.6.18
version: 13.6.31

View file

@ -31,6 +31,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"y-partyserver": "^2.2.0",
"y-protocols": "^1.0.6",
"yjs": "^13.6.18",
"zod": "^3.24.1"
},

View file

@ -0,0 +1,40 @@
import type { PresencePeer } from "@/wasm/collab/presence";
/**
* "Who else is in this file" (collab-presence 0001): a compact facepile of the
* room's OTHER users, one colored initial-avatar per person, fed by the collab
* session's awareness. Rendered in the editor's top-right overlay stack next to
* SourceChip; the parent hides it when there are no peers. Chip styling mirrors
* SourceChip (solid fill + inset ring) so it is legible on any backdrop.
*/
const MAX_AVATARS = 5;
export function PresenceRoster({ peers }: { peers: PresencePeer[] }) {
if (!peers.length) return null;
const names = peers.map((p) => p.user.name).join(", ");
const shown = peers.slice(0, MAX_AVATARS);
return (
<span
data-testid="presence-roster"
title={`Also here: ${names}`}
className="inline-flex items-center rounded-full bg-black/70 py-0.5 pl-1 pr-1.5 shadow-sm ring-1 ring-inset ring-white/20"
>
{shown.map((p) => (
<span
key={p.user.id}
data-presence-user={p.user.id}
title={p.user.name}
style={{ backgroundColor: p.user.color }}
className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold text-white ring-2 ring-black/50 first:ml-0"
>
{p.user.name.charAt(0).toUpperCase()}
</span>
))}
{peers.length > MAX_AVATARS && (
<span className="ml-1 text-[10px] font-medium text-white/80">
+{peers.length - MAX_AVATARS}
</span>
)}
</span>
);
}

View file

@ -19,6 +19,7 @@ import {
currentScope,
libsSourceConfig,
modelsSourceConfig,
presenceUser,
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
@ -44,11 +45,19 @@ import type {
KicadCollabHandle,
KicadDocSession,
KicadItemsWindow,
YjsProvider,
} from "@/wasm/collab";
import {
createPresence,
type PresenceHandle,
type PresencePeer,
} from "@/wasm/collab/presence";
import { PresenceRoster } from "@/components/PresenceRoster";
import {
createSheetCollabManager,
registerSheetChangedHook,
registerSheetCreatedHook,
type ActiveSheet,
type SheetChangedWindow,
type SheetCollabManager,
type SheetCreatedWindow,
@ -400,7 +409,7 @@ async function startSheetCollab(
session?: KicadDocSession;
/** The entry file was materialized from `session`'s doc (baseline-only first seed). */
editorMatchesDoc?: boolean;
onActiveChange: (active: { sheetPath: string; doc: Y.Doc } | null) => void;
onActiveChange: (active: ActiveSheet | null) => void;
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
saveBytes?: SaveBytes;
log: (m: string) => void;
@ -570,6 +579,7 @@ export function WasmTool({
const containerRef = React.useRef<HTMLDivElement>(null);
const startedRef = React.useRef(false);
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
@ -607,6 +617,10 @@ export function WasmTool({
// Board 3D-model prefetch in flight (background; the viewer works without it —
// anything still missing lazy-loads per model). Small badge, not an overlay.
const [modelsSync, setModelsSync] = React.useState<string | null>(null);
// The OTHER users in this document's collab room (awareness roster) — drives
// the PresenceRoster chip next to SourceChip. Empty when collab is off, the
// provider has no awareness (kind "none"), or nobody else is here.
const [peers, setPeers] = React.useState<PresencePeer[]>([]);
const append = React.useCallback(
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
@ -729,6 +743,29 @@ export function WasmTool({
const { proceed } = oom.start();
if (!proceed) return;
// (Re)bind presence to a collab room's awareness (collab-presence 0001):
// publish this user's identity and mirror the peers into the roster chip.
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
// roster shows who is on the SAME sheet (room = sheet).
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
presenceRef.current?.destroy();
presenceRef.current = null;
const awareness = provider?.awareness;
if (!awareness) {
setPeers([]);
return;
}
const presence = createPresence({
awareness,
user: presenceUser(),
tool,
sheetPath,
});
presenceRef.current = presence;
presence.subscribe(setPeers);
setPeers(presence.peers());
};
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
// browser's "save page" dialog (observed in Firefox) — the keydown still
// propagates to the wx canvas handler, which performs the actual save.
@ -854,10 +891,11 @@ export function WasmTool({
session,
saveBytes,
editorMatchesDoc: !!targetBytes,
// Re-point drift detection at whichever sheet is currently bound.
// Re-point drift detection + presence at whichever sheet is bound.
onActiveChange: (activeRoom) => {
driftRef.current?.stop();
driftRef.current = null;
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
if (activeRoom) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
@ -885,6 +923,7 @@ export function WasmTool({
onStatus: setStatus,
});
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider);
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
driftRef.current = startDriftDetection({
doc: collabHandle.doc,
@ -910,6 +949,8 @@ export function WasmTool({
return () => {
win.removeEventListener("keydown", swallowBrowserSave, true);
presenceRef.current?.destroy();
presenceRef.current = null;
driftRef.current?.stop();
driftRef.current = null;
// Tears down every warm room's provider/doc (the only place providers are
@ -1034,10 +1075,12 @@ export function WasmTool({
</div>
)}
{/* Where this project lives + whether Save persists (top-right). */}
{ready && sourceDescriptor && (
<div className="absolute right-3 top-3 z-20">
<SourceChip descriptor={sourceDescriptor} />
{/* Top-right overlay chips: who else is in this file (awareness roster) +
where this project lives / whether Save persists. */}
{ready && (peers.length > 0 || sourceDescriptor) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && <PresenceRoster peers={peers} />}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
</div>
)}

View file

@ -98,6 +98,7 @@ export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL ||
export const LOCAL_PROJECTS_ENABLED =
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-source";
@ -169,6 +170,17 @@ export function userSlug(): string {
);
}
/**
* The local user's presence identity (collab-presence 0001): the pre-auth slug
* doubles as id + display name, color is the deterministic palette hash — so
* every peer computes the same identity for this user with no coordination.
* Real auth/avatars later replace only how this object is built.
*/
export function presenceUser(): PresenceUser {
const slug = userSlug();
return { id: slug, name: slug, color: colorForUser(slug) };
}
/**
* The active scope (first URL segment) for API calls. Mirrors how `userSlug()`
* reads the URL, so the source layer scopes requests without threading scope

View file

@ -0,0 +1,76 @@
import {
applyAwarenessUpdate,
encodeAwarenessUpdate,
removeAwarenessStates,
type Awareness,
} from "y-protocols/awareness";
import { clog } from "./debug";
/**
* BroadcastChannel relay for a Yjs Awareness instance (collab-presence 0001):
* the network providers (partykit/hocuspocus) multiplex awareness over their
* own websocket, but the BroadcastChannel doc transport carries doc updates
* only — this ferries the awareness sidecar between same-origin tabs so
* presence works in the default zero-backend setup too.
*
* Same shape as `broadcast-transport.ts`: relay local update events out, apply
* incoming ones with a marker origin so they don't echo back, and query peers
* on join so a late tab sees existing states immediately. The Awareness
* heartbeat (periodic local-clock bump) rides the same update event, so remote
* expiry (~30s of silence) works unchanged.
*/
type Msg = { t: "aw"; u: Uint8Array } | { t: "aw-query" };
const BC_REMOTE_ORIGIN = "aw-bc-remote";
export function connectAwarenessBroadcast(
awareness: Awareness,
channelName: string,
): { destroy(): void } {
const bc = new BroadcastChannel(channelName);
clog("awareness BC open:", channelName);
const onUpdate = (
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown,
) => {
if (origin === BC_REMOTE_ORIGIN) return; // don't echo applied remote updates
const changed = added.concat(updated, removed);
if (!changed.length) return;
bc.postMessage({ t: "aw", u: encodeAwarenessUpdate(awareness, changed) } satisfies Msg);
};
awareness.on("update", onUpdate);
bc.onmessage = (e: MessageEvent<Msg>) => {
const msg = e.data;
if (!msg) return;
switch (msg.t) {
case "aw":
applyAwarenessUpdate(awareness, new Uint8Array(msg.u), BC_REMOTE_ORIGIN);
break;
case "aw-query":
if (awareness.getLocalState() !== null) {
bc.postMessage({
t: "aw",
u: encodeAwarenessUpdate(awareness, [awareness.clientID]),
} satisfies Msg);
}
break;
}
};
// Ask existing tabs for their states (they answer with their own entry only;
// every tab is on the channel, so the union covers the room).
bc.postMessage({ t: "aw-query" } satisfies Msg);
return {
destroy: () => {
// Broadcast our removal BEFORE detaching, so peers drop us immediately
// instead of waiting out the awareness timeout.
removeAwarenessStates(awareness, [awareness.clientID], "destroy");
awareness.off("update", onUpdate);
bc.close();
},
};
}

View file

@ -0,0 +1,135 @@
import { afterEach, describe, expect, it } from "vitest";
import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { connectAwarenessBroadcast } from "./awareness-bc";
import { createPresence, type PresenceHandle } from "./presence";
/**
* Presence unit tests (collab-presence 0001): two Awareness instances relayed
* over a real BroadcastChannel (node ≥18 has it globally) stand in for two
* tabs. BC delivery is async — `settle()` lets a posted message round-trip.
*/
const settle = () => new Promise((r) => setTimeout(r, 50));
function user(id: string): PresenceUser {
return { id, name: id, color: colorForUser(id) };
}
interface Client {
awareness: Awareness;
presence?: PresenceHandle;
destroy(): void;
}
let clients: Client[] = [];
let channelSeq = 0;
function client(channel: string): Client {
const doc = new Y.Doc();
const awareness = new Awareness(doc);
const relay = connectAwarenessBroadcast(awareness, channel);
const c: Client = {
awareness,
destroy() {
this.presence?.destroy();
relay.destroy();
awareness.destroy();
doc.destroy();
},
};
clients.push(c);
return c;
}
afterEach(() => {
for (const c of clients) c.destroy();
clients = [];
});
describe("presence over the BroadcastChannel awareness relay", () => {
it("peers see each other's state; destroy removes it immediately", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);
const b = client(channel);
a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" });
b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" });
await settle();
expect(a.presence.peers().map((p) => p.user.id)).toEqual(["bob"]);
expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]);
expect(a.presence.peers()[0]!.cursor).toBeNull();
// destroy() nulls the local state → relayed → bob leaves alice's roster
// without waiting for the awareness timeout.
b.presence.destroy();
b.presence = undefined;
await settle();
expect(a.presence.peers()).toEqual([]);
});
it("a late-joining tab sees existing peers (join query)", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);
a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" });
await settle();
const b = client(channel);
b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" });
await settle();
expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]);
});
it("dedupes the same user across tabs and hides own slug", async () => {
const channel = `presence-test-${channelSeq++}`;
const a1 = client(channel);
const a2 = client(channel);
const b = client(channel);
a1.presence = createPresence({ awareness: a1.awareness, user: user("alice"), tool: "pcbnew" });
a2.presence = createPresence({ awareness: a2.awareness, user: user("alice"), tool: "pcbnew" });
b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" });
await settle();
// bob sees ONE alice (two tabs, one person); alice's tabs don't see each other.
expect(b.presence.peers().map((p) => p.user.id)).toEqual(["alice"]);
expect(a1.presence.peers().map((p) => p.user.id)).toEqual(["bob"]);
});
it("drops malformed peer states instead of crashing the roster", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);
const b = client(channel);
a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" });
b.awareness.setLocalState({ some: "legacy-shape" });
await settle();
expect(a.presence.peers()).toEqual([]);
});
it("subscribe fires on change and setters keep sibling fields intact", async () => {
const channel = `presence-test-${channelSeq++}`;
const a = client(channel);
const b = client(channel);
a.presence = createPresence({ awareness: a.awareness, user: user("alice"), tool: "pcbnew" });
b.presence = createPresence({ awareness: b.awareness, user: user("bob"), tool: "pcbnew" });
await settle();
let latest: string[] | null = null;
const unsub = a.presence.subscribe((peers) => {
latest = peers.map((p) => `${p.user.id}:${p.selection.join(",")}`);
});
b.presence.setSelection(["uuid-1"]);
b.presence.setCursor({ x: 5, y: 7 });
await settle();
expect(latest).toEqual(["bob:uuid-1"]);
const bob = a.presence.peers()[0]!;
// setCursor must not have clobbered the selection set just before it.
expect(bob.selection).toEqual(["uuid-1"]);
expect(bob.cursor).toEqual({ x: 5, y: 7 });
expect(bob.user.color).toBe(colorForUser("bob"));
unsub();
});
});

View file

@ -0,0 +1,122 @@
import type { Awareness } from "y-protocols/awareness";
import {
presenceStateSchema,
type PresenceState,
type PresenceUser,
} from "@pcbjam/shared";
import { clog } from "./debug";
/**
* Room presence over Yjs awareness (collab-presence 0001): publish this
* client's `PresenceState` into the room and expose the peers' states as a
* subscribable roster. Ephemeral by design — the provider relays awareness,
* nothing is persisted, and peers expire a silent client automatically.
*
* One handle per bound room. eeschema rebinds on sheet navigation (the sheet
* manager tears this down and creates one on the new room), so a peer's
* roster always reflects the room they'd collide with.
*/
/** A peer's validated presence state plus its awareness clientID. */
export interface PresencePeer extends PresenceState {
clientId: number;
}
export interface PresenceHandle {
/**
* The OTHER users in the room: own client excluded, entries that fail the
* wire schema dropped (older builds), and one entry per `user.id` (the same
* user in two tabs is one person — keep the freshest state).
*/
peers(): PresencePeer[];
/** Fires with the new roster on every awareness change. Returns unsubscribe. */
subscribe(cb: (peers: PresencePeer[]) => void): () => void;
/** 0002: publish the local pointer's world position (null = off canvas). */
setCursor(pos: { x: number; y: number } | null): void;
/** 0002: publish the local selection's KIID strings. */
setSelection(uuids: string[]): void;
/** Clear the local state and detach. The awareness instance itself belongs
* to the provider and stays alive (rebind re-uses it). */
destroy(): void;
}
export function createPresence(opts: {
awareness: Awareness;
user: PresenceUser;
tool: string;
sheetPath?: string;
}): PresenceHandle {
const { awareness, user } = opts;
const patch = (fields: Partial<PresenceState>) => {
const current = (awareness.getLocalState() ?? {}) as Partial<PresenceState>;
awareness.setLocalState({ ...current, ...fields, updatedAt: Date.now() });
};
patch({
user,
tool: opts.tool,
...(opts.sheetPath !== undefined ? { sheetPath: opts.sheetPath } : {}),
cursor: null,
selection: [],
});
clog("presence: published local state for", user.id, "tool", opts.tool);
function peers(): PresencePeer[] {
const byUser = new Map<string, PresencePeer>();
for (const [clientId, raw] of awareness.getStates()) {
if (clientId === awareness.clientID) continue;
const parsed = presenceStateSchema.safeParse(raw);
if (!parsed.success) continue;
const peer: PresencePeer = { ...parsed.data, clientId };
// Another tab of the SAME user isn't a peer — the roster shows other people.
if (peer.user.id === user.id) continue;
const existing = byUser.get(peer.user.id);
if (!existing || peer.updatedAt > existing.updatedAt) {
byUser.set(peer.user.id, peer);
}
}
return [...byUser.values()].sort((a, b) => a.user.id.localeCompare(b.user.id));
}
const subscribers = new Set<(peers: PresencePeer[]) => void>();
const onChange = () => {
if (!subscribers.size) return;
const snapshot = peers();
for (const cb of subscribers) cb(snapshot);
};
awareness.on("change", onChange);
// Best-effort fast removal on tab close/navigation: broadcasting the null
// state lets peers drop us immediately instead of waiting out the awareness
// timeout. Guarded — unit tests run in node.
const onPageHide = () => awareness.setLocalState(null);
if (typeof window !== "undefined") {
window.addEventListener("pagehide", onPageHide);
}
let destroyed = false;
return {
peers,
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
setCursor(pos) {
patch({ cursor: pos });
},
setSelection(uuids) {
patch({ selection: uuids });
},
destroy() {
if (destroyed) return;
destroyed = true;
if (typeof window !== "undefined") {
window.removeEventListener("pagehide", onPageHide);
}
awareness.off("change", onChange);
subscribers.clear();
awareness.setLocalState(null);
},
};
}

View file

@ -1,5 +1,7 @@
import type * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import { connectBroadcastChannel } from "./broadcast-transport";
import { connectAwarenessBroadcast } from "./awareness-bc";
/**
* A Y.Doc transport, normalized across backends. The reconciler/seed logic only
@ -18,6 +20,13 @@ export interface YjsProvider {
*/
whenSynced(): Promise<void>;
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;
}
export type ProviderKind =
@ -59,11 +68,19 @@ function broadcastChannelProvider(
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: () => transport.destroy(),
destroy: () => {
awarenessRelay.destroy();
awareness.destroy();
transport.destroy();
},
awareness,
};
}
@ -92,6 +109,9 @@ async function partyKitProvider(
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,
};
}
@ -119,6 +139,7 @@ async function hocuspocusProvider(
provider.on("synced", onSync);
}),
destroy: () => provider.destroy(),
awareness: provider.awareness ?? undefined,
};
}

View file

@ -8,7 +8,7 @@ import {
type KicadItemsModule,
type KicadItemsWindow,
} from "./kicad-binding";
import type { ProviderConfig } from "./provider";
import type { ProviderConfig, YjsProvider } from "./provider";
import { clog, cwarn } from "./debug";
/**
@ -46,12 +46,20 @@ export interface SheetCollabManager {
* otherwise only carries them from seed time. No-op for unknown sheets.
*/
syncLayoutFromSave(sheetPath: string, fileText: string): void;
/** The currently-bound sheet, for drift-detection wiring (null before first switch). */
active(): { sheetPath: string; doc: Y.Doc } | null;
/** The currently-bound sheet, for drift-detection + presence wiring (null
* before first switch). `provider` carries the room's awareness. */
active(): ActiveSheet | null;
/** Tear down ALL bindings + providers + docs (session end / unmount). */
destroy(): void;
}
/** The bound sheet's room: its doc (drift detection) + provider (presence). */
export interface ActiveSheet {
sheetPath: string;
doc: Y.Doc;
provider: YjsProvider;
}
export interface SheetManagerOptions {
/** The Emscripten Module exposing the v2 items bridge exports. */
mod: KicadItemsModule;
@ -70,7 +78,7 @@ export interface SheetManagerOptions {
* Called whenever the active sheet changes (or clears on destroy) so the host can
* (re)start drift detection on the now-active doc.
*/
onActiveChange?: (active: { sheetPath: string; doc: Y.Doc } | null) => void;
onActiveChange?: (active: ActiveSheet | null) => void;
log: (m: string) => void;
/**
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
@ -210,7 +218,7 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
room.dirty = false;
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
activePath = sheetPath;
opts.onActiveChange?.({ sheetPath, doc: room.doc });
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
}
function switchTo(sheetPath: string): Promise<void> {
@ -282,10 +290,11 @@ export function createSheetCollabManager(opts: SheetManagerOptions): SheetCollab
);
}
function active(): { sheetPath: string; doc: Y.Doc } | null {
function active(): ActiveSheet | null {
if (!activePath) return null;
const room = rooms.get(activePath);
return room ? { sheetPath: activePath, doc: room.doc } : null;
if (!room) return null;
return { sheetPath: activePath, doc: room.doc, provider: room.session.provider };
}
function destroy(): void {