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:
|
y-partyserver:
|
||||||
specifier: ^2.2.0
|
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)
|
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:
|
yjs:
|
||||||
specifier: ^13.6.18
|
specifier: ^13.6.18
|
||||||
version: 13.6.31
|
version: 13.6.31
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"y-partyserver": "^2.2.0",
|
"y-partyserver": "^2.2.0",
|
||||||
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.18",
|
"yjs": "^13.6.18",
|
||||||
"zod": "^3.24.1"
|
"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,
|
currentScope,
|
||||||
libsSourceConfig,
|
libsSourceConfig,
|
||||||
modelsSourceConfig,
|
modelsSourceConfig,
|
||||||
|
presenceUser,
|
||||||
yjsProviderConfig,
|
yjsProviderConfig,
|
||||||
type DocSource,
|
type DocSource,
|
||||||
} from "@/lib/config";
|
} from "@/lib/config";
|
||||||
|
|
@ -44,11 +45,19 @@ import type {
|
||||||
KicadCollabHandle,
|
KicadCollabHandle,
|
||||||
KicadDocSession,
|
KicadDocSession,
|
||||||
KicadItemsWindow,
|
KicadItemsWindow,
|
||||||
|
YjsProvider,
|
||||||
} from "@/wasm/collab";
|
} from "@/wasm/collab";
|
||||||
|
import {
|
||||||
|
createPresence,
|
||||||
|
type PresenceHandle,
|
||||||
|
type PresencePeer,
|
||||||
|
} from "@/wasm/collab/presence";
|
||||||
|
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||||
import {
|
import {
|
||||||
createSheetCollabManager,
|
createSheetCollabManager,
|
||||||
registerSheetChangedHook,
|
registerSheetChangedHook,
|
||||||
registerSheetCreatedHook,
|
registerSheetCreatedHook,
|
||||||
|
type ActiveSheet,
|
||||||
type SheetChangedWindow,
|
type SheetChangedWindow,
|
||||||
type SheetCollabManager,
|
type SheetCollabManager,
|
||||||
type SheetCreatedWindow,
|
type SheetCreatedWindow,
|
||||||
|
|
@ -400,7 +409,7 @@ async function startSheetCollab(
|
||||||
session?: KicadDocSession;
|
session?: KicadDocSession;
|
||||||
/** The entry file was materialized from `session`'s doc (baseline-only first seed). */
|
/** The entry file was materialized from `session`'s doc (baseline-only first seed). */
|
||||||
editorMatchesDoc?: boolean;
|
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. */
|
/** Upload sink (project-backed sessions) — used to register a just-created subsheet. */
|
||||||
saveBytes?: SaveBytes;
|
saveBytes?: SaveBytes;
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
|
|
@ -570,6 +579,7 @@ export function WasmTool({
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const startedRef = React.useRef(false);
|
const startedRef = React.useRef(false);
|
||||||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||||
|
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
||||||
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
||||||
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
||||||
// (miss 08B); eeschema routes per sheet through the manager instead.
|
// (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 —
|
// Board 3D-model prefetch in flight (background; the viewer works without it —
|
||||||
// anything still missing lazy-loads per model). Small badge, not an overlay.
|
// anything still missing lazy-loads per model). Small badge, not an overlay.
|
||||||
const [modelsSync, setModelsSync] = React.useState<string | null>(null);
|
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(
|
const append = React.useCallback(
|
||||||
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
(msg: string) => setLogs((prev) => [...prev.slice(-800), msg]),
|
||||||
|
|
@ -729,6 +743,29 @@ export function WasmTool({
|
||||||
const { proceed } = oom.start();
|
const { proceed } = oom.start();
|
||||||
if (!proceed) return;
|
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
|
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
|
||||||
// browser's "save page" dialog (observed in Firefox) — the keydown still
|
// browser's "save page" dialog (observed in Firefox) — the keydown still
|
||||||
// propagates to the wx canvas handler, which performs the actual save.
|
// propagates to the wx canvas handler, which performs the actual save.
|
||||||
|
|
@ -854,10 +891,11 @@ export function WasmTool({
|
||||||
session,
|
session,
|
||||||
saveBytes,
|
saveBytes,
|
||||||
editorMatchesDoc: !!targetBytes,
|
editorMatchesDoc: !!targetBytes,
|
||||||
// Re-point drift detection at whichever sheet is currently bound.
|
// Re-point drift detection + presence at whichever sheet is bound.
|
||||||
onActiveChange: (activeRoom) => {
|
onActiveChange: (activeRoom) => {
|
||||||
driftRef.current?.stop();
|
driftRef.current?.stop();
|
||||||
driftRef.current = null;
|
driftRef.current = null;
|
||||||
|
startPresence(activeRoom?.provider, activeRoom?.sheetPath);
|
||||||
if (activeRoom) {
|
if (activeRoom) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: activeRoom.doc,
|
doc: activeRoom.doc,
|
||||||
|
|
@ -885,6 +923,7 @@ export function WasmTool({
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
collabDocRef.current = collabHandle?.doc ?? null;
|
collabDocRef.current = collabHandle?.doc ?? null;
|
||||||
|
startPresence(collabHandle?.provider);
|
||||||
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
if (collabHandle && targetPath && COLLAB_TOOLS.has(tool)) {
|
||||||
driftRef.current = startDriftDetection({
|
driftRef.current = startDriftDetection({
|
||||||
doc: collabHandle.doc,
|
doc: collabHandle.doc,
|
||||||
|
|
@ -910,6 +949,8 @@ export function WasmTool({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||||
|
presenceRef.current?.destroy();
|
||||||
|
presenceRef.current = null;
|
||||||
driftRef.current?.stop();
|
driftRef.current?.stop();
|
||||||
driftRef.current = null;
|
driftRef.current = null;
|
||||||
// Tears down every warm room's provider/doc (the only place providers are
|
// Tears down every warm room's provider/doc (the only place providers are
|
||||||
|
|
@ -1034,10 +1075,12 @@ export function WasmTool({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Where this project lives + whether Save persists (top-right). */}
|
{/* Top-right overlay chips: who else is in this file (awareness roster) +
|
||||||
{ready && sourceDescriptor && (
|
where this project lives / whether Save persists. */}
|
||||||
<div className="absolute right-3 top-3 z-20">
|
{ready && (peers.length > 0 || sourceDescriptor) && (
|
||||||
<SourceChip descriptor={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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ export const PROJECT_MANIFEST_URL = import.meta.env.VITE_PROJECT_MANIFEST_URL ||
|
||||||
export const LOCAL_PROJECTS_ENABLED =
|
export const LOCAL_PROJECTS_ENABLED =
|
||||||
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
|
||||||
|
|
||||||
|
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
|
||||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||||
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
|
||||||
import { cdnModelsSource, type Model3dSource } from "@/wasm/libs/models-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()`
|
* The active scope (first URL segment) for API calls. Mirrors how `userSlug()`
|
||||||
* reads the URL, so the source layer scopes requests without threading scope
|
* 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 type * as Y from "yjs";
|
||||||
|
import { Awareness } from "y-protocols/awareness";
|
||||||
import { connectBroadcastChannel } from "./broadcast-transport";
|
import { connectBroadcastChannel } from "./broadcast-transport";
|
||||||
|
import { connectAwarenessBroadcast } from "./awareness-bc";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Y.Doc transport, normalized across backends. The reconciler/seed logic only
|
* A Y.Doc transport, normalized across backends. The reconciler/seed logic only
|
||||||
|
|
@ -18,6 +20,13 @@ export interface YjsProvider {
|
||||||
*/
|
*/
|
||||||
whenSynced(): Promise<void>;
|
whenSynced(): Promise<void>;
|
||||||
destroy(): 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 =
|
export type ProviderKind =
|
||||||
|
|
@ -59,11 +68,19 @@ function broadcastChannelProvider(
|
||||||
settleMs: number,
|
settleMs: number,
|
||||||
): YjsProvider {
|
): YjsProvider {
|
||||||
const transport = connectBroadcastChannel(doc, room);
|
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 {
|
return {
|
||||||
// No server to sync with — keep the original seed-once heuristic: give a
|
// 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.
|
// peer tab a moment to answer the state query before deciding seed/adopt.
|
||||||
whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)),
|
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);
|
provider.on("sync", onSync);
|
||||||
}),
|
}),
|
||||||
destroy: () => provider.destroy(),
|
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);
|
provider.on("synced", onSync);
|
||||||
}),
|
}),
|
||||||
destroy: () => provider.destroy(),
|
destroy: () => provider.destroy(),
|
||||||
|
awareness: provider.awareness ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
type KicadItemsModule,
|
type KicadItemsModule,
|
||||||
type KicadItemsWindow,
|
type KicadItemsWindow,
|
||||||
} from "./kicad-binding";
|
} from "./kicad-binding";
|
||||||
import type { ProviderConfig } from "./provider";
|
import type { ProviderConfig, YjsProvider } from "./provider";
|
||||||
import { clog, cwarn } from "./debug";
|
import { clog, cwarn } from "./debug";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,12 +46,20 @@ export interface SheetCollabManager {
|
||||||
* otherwise only carries them from seed time. No-op for unknown sheets.
|
* otherwise only carries them from seed time. No-op for unknown sheets.
|
||||||
*/
|
*/
|
||||||
syncLayoutFromSave(sheetPath: string, fileText: string): void;
|
syncLayoutFromSave(sheetPath: string, fileText: string): void;
|
||||||
/** The currently-bound sheet, for drift-detection wiring (null before first switch). */
|
/** The currently-bound sheet, for drift-detection + presence wiring (null
|
||||||
active(): { sheetPath: string; doc: Y.Doc } | null;
|
* before first switch). `provider` carries the room's awareness. */
|
||||||
|
active(): ActiveSheet | null;
|
||||||
/** Tear down ALL bindings + providers + docs (session end / unmount). */
|
/** Tear down ALL bindings + providers + docs (session end / unmount). */
|
||||||
destroy(): void;
|
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 {
|
export interface SheetManagerOptions {
|
||||||
/** The Emscripten Module exposing the v2 items bridge exports. */
|
/** The Emscripten Module exposing the v2 items bridge exports. */
|
||||||
mod: KicadItemsModule;
|
mod: KicadItemsModule;
|
||||||
|
|
@ -70,7 +78,7 @@ export interface SheetManagerOptions {
|
||||||
* Called whenever the active sheet changes (or clears on destroy) so the host can
|
* Called whenever the active sheet changes (or clears on destroy) so the host can
|
||||||
* (re)start drift detection on the now-active doc.
|
* (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;
|
log: (m: string) => void;
|
||||||
/**
|
/**
|
||||||
* `docSource: "ydoc"` only: the entry sheet's room is already connected (and possibly
|
* `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.dirty = false;
|
||||||
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
|
room.editorMatchesDoc = false; // only meaningful for the first ydoc-entry seed
|
||||||
activePath = sheetPath;
|
activePath = sheetPath;
|
||||||
opts.onActiveChange?.({ sheetPath, doc: room.doc });
|
opts.onActiveChange?.({ sheetPath, doc: room.doc, provider: room.session.provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTo(sheetPath: string): Promise<void> {
|
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;
|
if (!activePath) return null;
|
||||||
const room = rooms.get(activePath);
|
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 {
|
function destroy(): void {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue