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:
parent
6fc6a64cd0
commit
22528f450b
12 changed files with 538 additions and 15 deletions
61
tests/web/presence-roster.spec.ts
Normal file
61
tests/web/presence-roster.spec.ts
Normal 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
3
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
40
web/standalone/src/components/PresenceRoster.tsx
Normal file
40
web/standalone/src/components/PresenceRoster.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
web/standalone/src/wasm/collab/awareness-bc.ts
Normal file
76
web/standalone/src/wasm/collab/awareness-bc.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
135
web/standalone/src/wasm/collab/presence.test.ts
Normal file
135
web/standalone/src/wasm/collab/presence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
122
web/standalone/src/wasm/collab/presence.ts
Normal file
122
web/standalone/src/wasm/collab/presence.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue