pcbjam/web/standalone/src/wasm/collab/provider.ts

218 lines
7.6 KiB
TypeScript
Raw Normal View History

import type * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
import { parseCollabRoomId } from "@pcbjam/shared";
import { connectBroadcastChannel } from "./broadcast-transport";
import { connectAwarenessBroadcast } from "./awareness-bc";
/**
* A Y.Doc transport, normalized across backends. The reconciler/seed logic only
* needs to know when the initial state has arrived (`whenSynced`) and how to
* tear down (`destroy`); everything else is provider-specific.
*
* Providers are network libraries (MIT/ISC) linked by the GPL editor — fine, and
* the closed PartyKit server is reached only over WSS (no linking).
*/
export interface YjsProvider {
/**
* Resolves once the doc holds the authoritative initial state: for network
* providers when the server has synced; for BroadcastChannel after a short
* settle window (a peer tab may have answered). The seed-vs-adopt decision
* runs after this.
*/
whenSynced(): Promise<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;
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
/**
* Gateway transport only (load-path-rework 0003): upgrade a `passive`
* subscription to a real y-protocol participant and resolve once the doc
* holds the server state. Providers without it are always fully synced
* after `whenSynced` — callers treat absence as an already-synced no-op.
*/
activate?(): Promise<void>;
/**
* Gateway transport only: the doc changed server-side while this
* subscription was passive (the sheet manager's parked-dirty flag).
*/
onTouched?(cb: () => void): void;
/**
* Gateway transport only (load-path-rework 0004 §2.3): the doc's server-side
* history was replaced and this passive copy cannot be merged — the owner
* must destroy doc + provider and connect afresh.
*/
onReset?(cb: () => void): void;
}
export type ProviderKind =
| "none"
| "broadcastchannel"
| "partykit"
| "hocuspocus";
export interface ProviderConfig {
kind: ProviderKind;
/** Host/URL for network providers (partykit, hocuspocus). */
endpoint?: string;
/** Connection params (e.g. `{ token }`); see @pcbjam/shared `collabConnectParams`. */
params?: { token?: string };
/** BroadcastChannel only: how long to wait for a peer tab before seeding. */
settleMs?: number;
}
// The closed sync server (apps/sync) binds the room DO as `BoardRoom`;
// partyserver routes it under the kebab-cased party name.
const PARTYKIT_PARTY = "board-room";
function requireEndpoint(config: ProviderConfig): string {
if (!config.endpoint) {
throw new Error(
`collab provider "${config.kind}" requires an endpoint (VITE_YJS_ENDPOINT)`,
);
}
return config.endpoint;
}
function noopProvider(): YjsProvider {
return { whenSynced: () => Promise.resolve(), destroy: () => {} };
}
function broadcastChannelProvider(
doc: Y.Doc,
room: string,
settleMs: number,
): YjsProvider {
const transport = connectBroadcastChannel(doc, room);
// Presence sidecar over its own channel — the doc transport stays untouched.
const awareness = new Awareness(doc);
const awarenessRelay = connectAwarenessBroadcast(awareness, `${room}:awareness`);
return {
// No server to sync with — keep the original seed-once heuristic: give a
// peer tab a moment to answer the state query before deciding seed/adopt.
whenSynced: () => new Promise((resolve) => setTimeout(resolve, settleMs)),
destroy: () => {
awarenessRelay.destroy();
awareness.destroy();
transport.destroy();
},
awareness,
};
}
async function partyKitProvider(
doc: Y.Doc,
endpoint: string,
room: string,
params: ProviderConfig["params"],
): Promise<YjsProvider> {
const { default: YProvider } = await import("y-partyserver/provider");
// The provider splices the room into the WS URL verbatim, so it must travel
// as ONE percent-encoded path segment — otherwise a docPath containing
// `/`, ` `, `#`, `%` or `?` truncates or corrupts the room name. The sync
// worker decodes it once at its edge; the decoded string is the canonical
// room/DO name everywhere.
const provider = new YProvider(endpoint, encodeURIComponent(room), doc, {
party: PARTYKIT_PARTY,
connect: true,
params,
fix(editor): a browser refresh reloads the editor instead of bouncing away Refreshing ANY editor URL navigated to the management app's project overview instead of reloading. Not related to spaces in paths — verified path-independent by reproducing it on a fileless `-/pcbnew` route with no file path at all. Mechanism, from probing the actual ordering rather than reading the code: the wx port registers its unload handler via emscripten_set_beforeunload_callback, which lands in the CAPTURE phase and so runs before any bubble-phase listener regardless of registration order. It closes the top frame -> fires wxAppTopWindowClosed -> the quit dispatcher -> location.assign(exitUrl), and that navigation overrode the in-flight reload (the reload itself failed with NS_ERROR_FAILURE). The quit hook already latched off for our OWN navigations (markDeliberateNavigation) and on pagehide — which the existing comment notes fires too late, at commit time. Nothing covered a browser-INITIATED unload: reload, Back, closing the tab. This adds that latch, in the capture phase so it precedes emscripten's handler; a bubble-phase listener registered at module scope was NOT enough, which the probe showed directly. Tradeoff, documented at the call site: if a beforeunload prompt appears and the user stays, the latch remains set and a later File->Quit won't self-navigate. That is strictly better than a page that cannot be refreshed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
2026-07-31 13:19:10 +02:00
// Default maxBackoffTime is 2.5s — a room that's down would re-dial (and
// re-run the server's authorize round trip) every 2.5s forever, per room.
maxBackoffTime: 30_000,
});
return {
whenSynced: () =>
new Promise<void>((resolve) => {
if (provider.synced) return resolve();
const onSync = (state: boolean) => {
if (state) {
provider.off("sync", onSync);
resolve();
}
};
provider.on("sync", onSync);
}),
destroy: () => provider.destroy(),
// y-partyserver's provider carries its own awareness; the BoardRoom DO
// (YServer) relays awareness messages as part of the protocol.
awareness: provider.awareness,
};
}
async function hocuspocusProvider(
doc: Y.Doc,
endpoint: string,
room: string,
params: ProviderConfig["params"],
): Promise<YjsProvider> {
const { HocuspocusProvider } = await import("@hocuspocus/provider");
const provider = new HocuspocusProvider({
url: endpoint,
name: room,
document: doc,
...(params?.token ? { token: params.token } : {}),
});
return {
whenSynced: () =>
new Promise<void>((resolve) => {
if (provider.synced) return resolve();
const onSync = () => {
provider.off("synced", onSync);
resolve();
};
provider.on("synced", onSync);
}),
destroy: () => provider.destroy(),
awareness: provider.awareness ?? undefined,
};
}
/**
* Connect a Y.Doc to the env-selected provider. Network libraries are imported
* lazily so a deployment only ships the one it uses.
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
*
* `passive` (gateway transport only): register interest without demanding doc
* state — the sheet manager's warm pool. Other providers ignore it and stay
* fully synced, a harmless superset.
*/
export async function connectProvider(
doc: Y.Doc,
config: ProviderConfig,
opts: { room: string; passive?: boolean; passiveSync?: boolean },
): Promise<YjsProvider> {
switch (config.kind) {
case "broadcastchannel":
return broadcastChannelProvider(doc, opts.room, config.settleMs ?? 300);
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
case "partykit": {
// Load-path-rework 0003: board/presence rooms ride the per-project
// gateway socket (one ws per project instead of one per room). The
// legacy per-room dial stays only for room ids that don't parse — an
// operator/debug shape the gateway can't address.
const parsed = parseCollabRoomId(opts.room);
if (parsed) {
const { GatewayDocFacade } = await import("./gateway");
return new GatewayDocFacade(doc, {
endpoint: requireEndpoint(config),
scopeId: parsed.scopeId,
projectId: parsed.projectId,
docPath: parsed.docPath,
token: config.params?.token,
passive: opts.passive,
passiveSync: opts.passiveSync,
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
});
}
return partyKitProvider(doc, requireEndpoint(config), opts.room, config.params);
feat: load-path §5 client — gateway transport behind the YjsProvider seam One websocket per project (GatewayConnection, refcounted per endpoint+project, single reconnect ladder) with per-doc facades: a mini y-websocket client over varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per facade so skeleton presence stays per-room). connectProvider's partykit kind routes every parseable board/presence room through the gateway; the per-room dial remains only for unparseable operator rooms. Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive the parked dirty flag; doSwitch awaits the new provider.activate() sync barrier before bind/seed; syncLayoutFromSave activates before writing; a stray local write into a passive doc auto-activates. suberr (invalid-file 409, presence 403) surfaces as CollabSubRejectedError — terminal in the switch retry ladder like SexprVersionError, ending the old blind re-dial. Verified: standalone 346 unit tests green (10 new facade tests incl. the no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets total, sub table presence+root active / 3 children passive. tests/web: no regressions (4 failures reproduced identically without this diff — pre-existing on the JSPI line; 1 parallel-load flake passes alone). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
2026-08-18 14:02:40 +02:00
}
case "hocuspocus":
return hocuspocusProvider(doc, requireEndpoint(config), opts.room, config.params);
case "none":
return noopProvider();
default: {
const _exhaustive: never = config.kind;
return _exhaustive ?? noopProvider();
}
}
}