- GatewayDocFacade: passiveSync (Step1 on subscribe + on touched, answered by the gateway from at-rest state), onReset; activate() after a passive fill still sends act + a participant Step1. - sibling-restage subscribes passive+passiveSync; reset drops the watch (no flush) and re-dials while a peer still has the sheet open. - files-watch: upload/job hints on room-backed paths restage + announce onRoomBackedChanged; sheet-manager.invalidate() drops a parked room. - kicad-binding: normalize server-serialized bodies on the editorMatchesDoc path (runner-seeded ydocs carry kicad-cli's serialization). - pcbjam-shared -> 0f4d3a1 (reset control, kicadDocToYdocUpdate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQsdqaX57xzcCWUjqP7ctV
218 lines
7.6 KiB
TypeScript
218 lines
7.6 KiB
TypeScript
import type * as Y from "yjs";
|
|
import { Awareness } from "y-protocols/awareness";
|
|
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;
|
|
/**
|
|
* 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,
|
|
// 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.
|
|
*
|
|
* `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);
|
|
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,
|
|
});
|
|
}
|
|
return partyKitProvider(doc, requireEndpoint(config), opts.room, config.params);
|
|
}
|
|
case "hocuspocus":
|
|
return hocuspocusProvider(doc, requireEndpoint(config), opts.room, config.params);
|
|
case "none":
|
|
return noopProvider();
|
|
default: {
|
|
const _exhaustive: never = config.kind;
|
|
return _exhaustive ?? noopProvider();
|
|
}
|
|
}
|
|
}
|