@edv4h/usketch-plugin-sync-ywebsocket 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EdV4H
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @edv4h/usketch-plugin-sync-ywebsocket
2
+
3
+ Bridge a uSketch app to any [y-websocket](https://github.com/yjs/y-websocket) server. Wraps `WebsocketProvider` and exposes a `WsProviderHandle`-compatible adapter so it plugs directly into plugins like `@edv4h/usketch-plugin-presence-cursor`.
4
+
5
+ Designed for teams that already run a y-websocket backend (or need to migrate off another realtime engine) and want to keep their sync layer while adopting uSketch on the client.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @edv4h/usketch-plugin-sync-ywebsocket
11
+ ```
12
+
13
+ ## Basic usage
14
+
15
+ `getWsProvider()` is only valid *after* the sync plugin's `setup()` has run. Since
16
+ `createApp` calls `setup()` in plugin order, the recommended pattern is to defer
17
+ the `wsProvider` lookup into a thin wrapper plugin that runs after the sync
18
+ plugin. This keeps everything inside a single `createApp` call:
19
+
20
+ ```ts
21
+ import { createApp } from "@edv4h/usketch-core";
22
+ import { createBoardStore } from "@edv4h/usketch-store";
23
+ import type { UsketchPlugin } from "@edv4h/usketch-shared";
24
+ import { createPresenceCursorPlugin } from "@edv4h/usketch-plugin-presence-cursor";
25
+ import { createYwebsocketSyncPlugin, type YwebsocketSyncPlugin } from "@edv4h/usketch-plugin-sync-ywebsocket";
26
+
27
+ const syncPlugin = createYwebsocketSyncPlugin({
28
+ url: "wss://yws.example.com",
29
+ roomName: "board-123",
30
+ });
31
+
32
+ // Wrap presence-cursor so the provider is read at setup time, when syncPlugin.setup
33
+ // has already run (plugins are set up in order).
34
+ function presenceCursorWithSync(
35
+ sync: YwebsocketSyncPlugin,
36
+ opts: { userId: string; userName: string },
37
+ ): UsketchPlugin {
38
+ let inner: UsketchPlugin | null = null;
39
+ return {
40
+ id: "usketch-plugin-presence-cursor",
41
+ name: "Presence Cursor",
42
+ async setup(ctx) {
43
+ inner = createPresenceCursorPlugin({
44
+ wsProvider: sync.getWsProvider(),
45
+ userId: opts.userId,
46
+ userName: opts.userName,
47
+ });
48
+ await inner.setup(ctx);
49
+ },
50
+ teardown() {
51
+ inner?.teardown?.();
52
+ },
53
+ };
54
+ }
55
+
56
+ const app = await createApp({
57
+ store: createBoardStore(),
58
+ plugins: [
59
+ syncPlugin,
60
+ presenceCursorWithSync(syncPlugin, { userId: "alice", userName: "Alice" }),
61
+ ],
62
+ });
63
+ ```
64
+
65
+ This pattern generalizes to any plugin that needs a `WsProviderHandle`: wrap it
66
+ in a small factory that reads `sync.getWsProvider()` inside its own `setup()`.
67
+
68
+ ## Options
69
+
70
+ ```ts
71
+ createYwebsocketSyncPlugin({
72
+ url, // ws(s):// base URL of the y-websocket server
73
+ roomName, // server-side document identifier
74
+ shapesMapKey, // optional — defaults to "shapes" (weboard uses "map")
75
+ resolveParams, // async/sync callback to supply URL query params on each connect
76
+ onCloseCode, // classify close codes: "retry" | "stop" | undefined (default backoff)
77
+ idleTimeoutMs, // 0 (off) — disconnect after this many ms of inactivity
78
+ autoConnect, // default true — connect on plugin setup
79
+ doc, // optional — bring your own Y.Doc (useful for mid-life migrations)
80
+ WebSocketPolyfill, // optional — Node test environments only
81
+ });
82
+ ```
83
+
84
+ ### Resolving auth tokens on each connect
85
+
86
+ `resolveParams` is called before every connect and reconnect. Use it to hand back freshly refreshed tokens so the server sees a valid credential on reconnect:
87
+
88
+ ```ts
89
+ createYwebsocketSyncPlugin({
90
+ url: "wss://yws.example.com",
91
+ roomName: "board-123",
92
+ async resolveParams({ attempt, previousCloseCode }) {
93
+ // Force a fresh token if the previous connection was rejected for auth.
94
+ const forceRefresh = previousCloseCode === 4003 || previousCloseCode === 4004;
95
+ const token = await cognitoSession.getAccessToken({ forceRefresh });
96
+ return {
97
+ params: {
98
+ token: token.jwt,
99
+ employeeId: String(token.employeeId),
100
+ companyId: String(token.companyId),
101
+ },
102
+ };
103
+ },
104
+ onCloseCode(code) {
105
+ if (code === 4003 || code === 4004) return "retry"; // immediate retry; resolveParams will fetch a fresh token
106
+ return undefined; // fall through to exponential backoff
107
+ },
108
+ });
109
+ ```
110
+
111
+ ### Idle disconnect
112
+
113
+ Set `idleTimeoutMs` to disconnect after inactivity (no local board edits / store mutations). The timer also resets whenever the socket reports `connected` or `resume()` is called. Call `handle.resume()` to reconnect on the next user interaction.
114
+
115
+ ```ts
116
+ const plugin = createYwebsocketSyncPlugin({
117
+ url: "wss://yws.example.com",
118
+ roomName: "board-123",
119
+ idleTimeoutMs: 5 * 60 * 1000, // 5 minutes
120
+ });
121
+
122
+ // Later, in an interaction handler:
123
+ plugin.getHandle().resume();
124
+ ```
125
+
126
+ ## Advanced: accessing the handle
127
+
128
+ ```ts
129
+ const handle = syncPlugin.getHandle();
130
+ handle.disconnect(); // manual disconnect; stays disconnected
131
+ handle.resume(); // reconnect after a disconnect/idle
132
+ handle.status.subscribe(() => console.log(handle.status.getSnapshot()));
133
+ handle.doc; // the underlying Y.Doc
134
+ handle.whenSynced; // promise that resolves on first server sync
135
+ handle.wsProvider; // WsProviderHandle adapter (same as getWsProvider())
136
+ ```
137
+
138
+ ## WsProviderHandle compatibility
139
+
140
+ `handle.wsProvider` implements the `WsProviderHandle` contract from `@edv4h/usketch-sync` so plugins that consume `WsProviderHandle` work out of the box:
141
+
142
+ | member | supported |
143
+ | ------------------- | ----------------------------------------- |
144
+ | `connected` | yes |
145
+ | `awareness` | yes (always — shared across reconnects) |
146
+ | `onStatusChange` | yes |
147
+ | `onBroadcast` | listeners accepted; never emits (no-op) |
148
+ | `broadcast` | no-op — y-websocket has no side-channel |
149
+ | `requestPartition` | no-op — y-websocket has no partitions |
150
+ | `onPartitionMeta` | no-op — y-websocket has no partitions |
151
+ | `destroy` | yes |
152
+
153
+ If you need broadcast / partition semantics, stay on `@edv4h/usketch-sync`'s `createWsProvider()` with a uSketch-native server.
154
+
155
+ ## License
156
+
157
+ MIT
@@ -0,0 +1,40 @@
1
+ import type { BoardStore } from "@edv4h/usketch-shared";
2
+ import type * as Y from "yjs";
3
+ import { SyncStatusTracker } from "./sync-status-tracker.js";
4
+ /**
5
+ * Standalone divergence tracker for apps that wire IDB sync + WsProvider
6
+ * **manually** (rather than via `createYwebsocketSyncPlugin`). It watches the
7
+ * store, the Y.Map of shapes, and the WebSocket connection, then exposes a
8
+ * `SyncStatusTracker` whose `unconfirmedShapeIds` reflects shapes that exist
9
+ * locally but the server hasn't acknowledged.
10
+ *
11
+ * Why this exists: the full plugin embeds y-websocket's `provider.on("sync")`
12
+ * event for the "first server sync" stamp, but `@edv4h/usketch-sync`'s
13
+ * `createWsProvider` doesn't expose that signal. We approximate by stamping
14
+ * `firstServerSyncAt` on the first remote-origin Y.Doc update, which is the
15
+ * earliest moment we can be sure the server has talked back to us.
16
+ */
17
+ export interface DivergenceTrackerOptions {
18
+ store: BoardStore;
19
+ doc: Y.Doc;
20
+ shapesMap: Y.Map<Record<string, unknown>>;
21
+ /** Subscribe to socket-level connection state (`"connected"` / `"connecting"` / `"disconnected"`). */
22
+ onConnectionStatusChange: (handler: (status: string) => void) => () => void;
23
+ /**
24
+ * Grace period (ms) to wait after the socket reaches `"connected"` before
25
+ * concluding "first sync completed" if no remote Y.Doc update has arrived.
26
+ * The y-websocket SYNC_STEP1/STEP2 round-trip is normally fast, so even an
27
+ * empty server (no shapes to push) will resolve well under a second. We
28
+ * pick a generous default to avoid false positives on slow networks.
29
+ *
30
+ * Defaults to 2000ms. Set to 0 to disable the timer-based fallback (only
31
+ * the first remote update will stamp).
32
+ */
33
+ emptyServerGraceMs?: number;
34
+ }
35
+ export interface DivergenceTrackerHandle {
36
+ status: SyncStatusTracker;
37
+ destroy: () => void;
38
+ }
39
+ export declare function createDivergenceTracker(opts: DivergenceTrackerOptions): DivergenceTrackerHandle;
40
+ //# sourceMappingURL=divergence-tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"divergence-tracker.d.ts","sourceRoot":"","sources":["../src/divergence-tracker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,KAAK,KAAK,CAAC,MAAM,KAAK,CAAC;AAC9B,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,wBAAwB;IACxC,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC;IACX,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC1C,sGAAsG;IACtG,wBAAwB,EAAE,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;IAC5E;;;;;;;;;OASG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACvC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACpB;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,wBAAwB,GAAG,uBAAuB,CAwI/F"}
@@ -0,0 +1,134 @@
1
+ import { SyncStatusTracker } from "./sync-status-tracker.js";
2
+ export function createDivergenceTracker(opts) {
3
+ const { store, doc, shapesMap, onConnectionStatusChange, emptyServerGraceMs = 2000 } = opts;
4
+ const status = new SyncStatusTracker();
5
+ let currentWsStatus = "disconnected";
6
+ let serverSynced = false;
7
+ // Timer that fires `markServerSynced()` if `connected` persists for
8
+ // `emptyServerGraceMs` without any remote Y.Doc update. Covers the
9
+ // "empty server / no history" case where a remote update never comes.
10
+ let emptyServerTimer = null;
11
+ function clearEmptyServerTimer() {
12
+ if (emptyServerTimer !== null) {
13
+ clearTimeout(emptyServerTimer);
14
+ emptyServerTimer = null;
15
+ }
16
+ }
17
+ function markServerSynced() {
18
+ if (serverSynced)
19
+ return;
20
+ serverSynced = true;
21
+ clearEmptyServerTimer();
22
+ status.batch(() => {
23
+ status.markFirstServerSyncObserved();
24
+ if (currentWsStatus === "connected") {
25
+ status.update({ state: "synced" });
26
+ }
27
+ });
28
+ }
29
+ // 1. Connection state — drives the "is local add already confirmed?" decision
30
+ // in the store mutation handler below, and is mirrored onto the snapshot
31
+ // `state` field so consumers (e.g. Debug HUD's Persistence indicator)
32
+ // show the right thing.
33
+ const offStatus = onConnectionStatusChange((next) => {
34
+ currentWsStatus = next;
35
+ // Mirror transport state into the snapshot using the same mapping the
36
+ // full ywebsocket plugin uses: socket-level "connected" maps to
37
+ // "syncing" until the first remote update arrives, then "synced".
38
+ const mappedState = next === "connected"
39
+ ? serverSynced
40
+ ? "synced"
41
+ : "syncing"
42
+ : next === "connecting"
43
+ ? "connecting"
44
+ : next === "disconnected"
45
+ ? "disconnected"
46
+ : "error";
47
+ status.update({
48
+ state: mappedState,
49
+ error: next === "failed" ? "connection failed" : null,
50
+ });
51
+ // Empty-server fallback: when we connect but the server has nothing to
52
+ // push (new room, lost history), `doc.on("update")` never fires. Start
53
+ // a timer so `firstServerSyncAt` is still stamped after the SYNC round-
54
+ // trip has had time to complete. `emptyServerGraceMs: 0` opts out.
55
+ if (next === "connected" && !serverSynced && emptyServerGraceMs > 0) {
56
+ clearEmptyServerTimer();
57
+ emptyServerTimer = setTimeout(markServerSynced, emptyServerGraceMs);
58
+ }
59
+ else if (next !== "connected") {
60
+ // Drop the timer if we got disconnected before the grace expired.
61
+ clearEmptyServerTimer();
62
+ }
63
+ });
64
+ // 2. Initial load: every shape already in the Y.Map (typically restored from
65
+ // IndexedDB) is "local-only" until the server confirms.
66
+ if (shapesMap.size > 0) {
67
+ const initialIds = [];
68
+ for (const id of shapesMap.keys())
69
+ initialIds.push(id);
70
+ status.noteShapesLoaded(initialIds, "local");
71
+ }
72
+ // 3. Local mutations from the store — online → confirmed (broadcast goes
73
+ // out immediately), offline → unconfirmed.
74
+ const offMutation = store.onMutation((event) => {
75
+ const payload = event.payload;
76
+ if (!payload?.id)
77
+ return;
78
+ status.batch(() => {
79
+ if (event.type === "shape:added") {
80
+ const isOnline = currentWsStatus === "connected";
81
+ status.noteShapeAdded(payload.id, isOnline ? "remote" : "local");
82
+ }
83
+ else if (event.type === "shape:removed") {
84
+ status.noteShapeRemoved(payload.id);
85
+ }
86
+ status.update({ shapeCount: shapesMap.size, lastSyncedAt: Date.now() });
87
+ });
88
+ });
89
+ // 4. Y.Map observe — for remote-origin additions (server pushed a shape
90
+ // we didn't have, or another peer added one).
91
+ const observer = (events) => {
92
+ const isLocalTxn = events.transaction.local;
93
+ status.batch(() => {
94
+ for (const [key, change] of events.changes.keys) {
95
+ if (change.action === "add" && !isLocalTxn) {
96
+ // Remote add → confirmed by server.
97
+ status.noteShapeAdded(key, "remote");
98
+ }
99
+ else if (change.action === "delete") {
100
+ status.noteShapeRemoved(key);
101
+ }
102
+ }
103
+ status.update({ shapeCount: shapesMap.size, lastSyncedAt: Date.now() });
104
+ });
105
+ };
106
+ shapesMap.observe(observer);
107
+ // 5. First remote-origin doc update marks the server as "synced with us".
108
+ // We deliberately DO NOT bulk-confirm `shapesMap.keys()` here — that
109
+ // would silently mark every IndexedDB-restored shape as "the server
110
+ // knows about it", which is exactly what we want to NOT assume (the
111
+ // whole point of this overlay is to surface phantom/orphaned shapes).
112
+ // Instead, the Y.Map observer above marks individual remote-origin
113
+ // additions as confirmed via `noteShapeAdded(key, "remote")`. Anything
114
+ // the server didn't push to us during this session stays unconfirmed.
115
+ //
116
+ // Note: an "empty" server sync (no shapes to push) won't trigger this
117
+ // handler — the `emptyServerTimer` started above is the fallback that
118
+ // catches that case.
119
+ function onDocUpdate(_update, origin) {
120
+ if (origin !== "remote")
121
+ return;
122
+ markServerSynced();
123
+ }
124
+ doc.on("update", onDocUpdate);
125
+ function destroy() {
126
+ clearEmptyServerTimer();
127
+ offStatus();
128
+ offMutation();
129
+ shapesMap.unobserve(observer);
130
+ doc.off("update", onDocUpdate);
131
+ }
132
+ return { status, destroy };
133
+ }
134
+ //# sourceMappingURL=divergence-tracker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"divergence-tracker.js","sourceRoot":"","sources":["../src/divergence-tracker.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAuC7D,MAAM,UAAU,uBAAuB,CAAC,IAA8B;IACrE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,wBAAwB,EAAE,kBAAkB,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;IAC5F,MAAM,MAAM,GAAG,IAAI,iBAAiB,EAAE,CAAC;IAEvC,IAAI,eAAe,GAAG,cAAc,CAAC;IACrC,IAAI,YAAY,GAAG,KAAK,CAAC;IACzB,oEAAoE;IACpE,mEAAmE;IACnE,sEAAsE;IACtE,IAAI,gBAAgB,GAAyC,IAAI,CAAC;IAElE,SAAS,qBAAqB;QAC7B,IAAI,gBAAgB,KAAK,IAAI,EAAE,CAAC;YAC/B,YAAY,CAAC,gBAAgB,CAAC,CAAC;YAC/B,gBAAgB,GAAG,IAAI,CAAC;QACzB,CAAC;IACF,CAAC;IAED,SAAS,gBAAgB;QACxB,IAAI,YAAY;YAAE,OAAO;QACzB,YAAY,GAAG,IAAI,CAAC;QACpB,qBAAqB,EAAE,CAAC;QACxB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE;YACjB,MAAM,CAAC,2BAA2B,EAAE,CAAC;YACrC,IAAI,eAAe,KAAK,WAAW,EAAE,CAAC;gBACrC,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;YACpC,CAAC;QACF,CAAC,CAAC,CAAC;IACJ,CAAC;IAED,8EAA8E;IAC9E,4EAA4E;IAC5E,yEAAyE;IACzE,2BAA2B;IAC3B,MAAM,SAAS,GAAG,wBAAwB,CAAC,CAAC,IAAI,EAAE,EAAE;QACnD,eAAe,GAAG,IAAI,CAAC;QACvB,sEAAsE;QACtE,gEAAgE;QAChE,kEAAkE;QAClE,MAAM,WAAW,GAChB,IAAI,KAAK,WAAW;YACnB,CAAC,CAAC,YAAY;gBACb,CAAC,CAAC,QAAQ;gBACV,CAAC,CAAC,SAAS;YACZ,CAAC,CAAC,IAAI,KAAK,YAAY;gBACtB,CAAC,CAAC,YAAY;gBACd,CAAC,CAAC,IAAI,KAAK,cAAc;oBACxB,CAAC,CAAC,cAAc;oBAChB,CAAC,CAAC,OAAO,CAAC;QACd,MAAM,CAAC,MAAM,CAAC;YACb,KAAK,EAAE,WAAW;YAClB,KAAK,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,IAAI;SACrD,CAAC,CAAC;QAEH,uEAAuE;QACvE,uEAAuE;QACvE,wEAAwE;QACxE,mEAAmE;QACnE,IAAI,IAAI,KAAK,WAAW,IAAI,CAAC,YAAY,IAAI,kBAAkB,GAAG,CAAC,EAAE,CAAC;YACrE,qBAAqB,EAAE,CAAC;YACxB,gBAAgB,GAAG,UAAU,CAAC,gBAAgB,EAAE,kBAAkB,CAAC,CAAC;QACrE,CAAC;aAAM,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;YACjC,kEAAkE;YAClE,qBAAqB,EAAE,CAAC;QACzB,CAAC;IACF,CAAC,CAAC,CAAC;IAEH,6EAA6E;IAC7E,2DAA2D;IAC3D,IAAI,SAAS,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,UAAU,GAAa,EAAE,CAAC;QAChC,KAAK,MAAM,EAAE,IAAI,SAAS,CAAC,IAAI,EAAE;YAAE,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACvD,MAAM,CAAC,gBAAgB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C,CAAC;IAED,yEAAyE;IACzE,8CAA8C;IAC9C,MAAM,WAAW,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,EAAE;QAC9C,MAAM,OAAO,GAAG,KAAK,CAAC,OAAsC,CAAC;QAC7D,IAAI,CAAC,OAAO,EAAE,EAAE;YAAE,OAAO;QACzB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE;YACjB,IAAI,KAAK,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;gBAClC,MAAM,QAAQ,GAAG,eAAe,KAAK,WAAW,CAAC;gBACjD,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,EAAY,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;YAC5E,CAAC;iBAAM,IAAI,KAAK,CAAC,IAAI,KAAK,eAAe,EAAE,CAAC;gBAC3C,MAAM,CAAC,gBAAgB,CAAC,OAAO,CAAC,EAAY,CAAC,CAAC;YAC/C,CAAC;YACD,MAAM,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,SAAS,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACzE,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,wEAAwE;IACxE,iDAAiD;IACjD,MAAM,QAAQ,GAAG,CAAC,MAA4C,EAAQ,EAAE;QACvE,MAAM,UAAU,GAAG,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC;QAC5C,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE;YACjB,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;gBACjD,IAAI,MAAM,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,UAAU,EAAE,CAAC;oBAC5C,oCAAoC;oBACpC,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;gBACtC,CAAC;qBAAM,IAAI,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;oBACvC,MAAM,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;gBAC9B,CAAC;YACF,CAAC;YACD,MAAM,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,SAAS,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QACzE,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC;IACF,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE5B,0EAA0E;IAC1E,wEAAwE;IACxE,uEAAuE;IACvE,uEAAuE;IACvE,yEAAyE;IACzE,sEAAsE;IACtE,0EAA0E;IAC1E,yEAAyE;IACzE,EAAE;IACF,yEAAyE;IACzE,yEAAyE;IACzE,wBAAwB;IACxB,SAAS,WAAW,CAAC,OAAmB,EAAE,MAAe;QACxD,IAAI,MAAM,KAAK,QAAQ;YAAE,OAAO;QAChC,gBAAgB,EAAE,CAAC;IACpB,CAAC;IACD,GAAG,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;IAE9B,SAAS,OAAO;QACf,qBAAqB,EAAE,CAAC;QACxB,SAAS,EAAE,CAAC;QACZ,WAAW,EAAE,CAAC;QACd,SAAS,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;QAC9B,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;IAChC,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AAC5B,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { createDivergenceTracker, type DivergenceTrackerHandle, type DivergenceTrackerOptions, } from "./divergence-tracker.js";
2
+ export { createYwebsocketSyncPlugin, type YwebsocketSyncPlugin } from "./plugin.js";
3
+ export { type SyncState, type SyncStatusSnapshot, SyncStatusTracker, } from "./sync-status-tracker.js";
4
+ export type { ConnectionParams, ResolveParamsContext, WsConnectionStatus, YwebsocketSyncHandle, YwebsocketSyncOptions, } from "./types.js";
5
+ export { UnconfirmedOverlay } from "./unconfirmed-overlay.js";
6
+ export { createYwebsocketSync } from "./yws-sync.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACN,uBAAuB,EACvB,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,GAC7B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,0BAA0B,EAAE,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACpF,OAAO,EACN,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,iBAAiB,GACjB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACX,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,oBAAoB,EACpB,qBAAqB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // Standalone helpers for apps that wire IDB sync + WsProvider manually
2
+ // (rather than via `createYwebsocketSyncPlugin`). `createDivergenceTracker`
3
+ // produces a SyncStatusTracker driven by a Y.Doc + WsProvider, and
4
+ // `<UnconfirmedOverlay />` renders the SVG badge layer.
5
+ export { createDivergenceTracker, } from "./divergence-tracker.js";
6
+ export { createYwebsocketSyncPlugin } from "./plugin.js";
7
+ export { SyncStatusTracker, } from "./sync-status-tracker.js";
8
+ export { UnconfirmedOverlay } from "./unconfirmed-overlay.js";
9
+ export { createYwebsocketSync } from "./yws-sync.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,4EAA4E;AAC5E,mEAAmE;AACnE,wDAAwD;AACxD,OAAO,EACN,uBAAuB,GAGvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,0BAA0B,EAA6B,MAAM,aAAa,CAAC;AACpF,OAAO,EAGN,iBAAiB,GACjB,MAAM,0BAA0B,CAAC;AAQlC,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,18 @@
1
+ import type { UsketchPlugin } from "@edv4h/usketch-shared";
2
+ import type { WsProviderHandle } from "@edv4h/usketch-sync";
3
+ import type { YwebsocketSyncHandle, YwebsocketSyncOptions } from "./types.js";
4
+ export interface YwebsocketSyncPlugin extends UsketchPlugin {
5
+ /**
6
+ * Returns the underlying `WsProviderHandle` for consumption by other plugins
7
+ * (e.g. `@edv4h/usketch-plugin-presence-cursor`).
8
+ *
9
+ * Only valid *after* the plugin's `setup()` has run. See the README for the
10
+ * recommended pattern (wrap the dependent plugin so it reads the provider
11
+ * from inside its own `setup()`).
12
+ */
13
+ getWsProvider(): WsProviderHandle;
14
+ /** Access the full sync handle — status, Y.Doc, disconnect/resume/destroy. */
15
+ getHandle(): YwebsocketSyncHandle;
16
+ }
17
+ export declare function createYwebsocketSyncPlugin(options: YwebsocketSyncOptions): YwebsocketSyncPlugin;
18
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAI9E,MAAM,WAAW,oBAAqB,SAAQ,aAAa;IAC1D;;;;;;;OAOG;IACH,aAAa,IAAI,gBAAgB,CAAC;IAClC,8EAA8E;IAC9E,SAAS,IAAI,oBAAoB,CAAC;CAClC;AAID,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CAmE/F"}
package/dist/plugin.js ADDED
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { UnconfirmedOverlay } from "./unconfirmed-overlay.js";
3
+ import { createYwebsocketSync } from "./yws-sync.js";
4
+ const UNCONFIRMED_OVERLAY_LAYER_ID = "unconfirmed-shapes-overlay";
5
+ export function createYwebsocketSyncPlugin(options) {
6
+ let handle = null;
7
+ let unregisterOverlay = null;
8
+ const plugin = {
9
+ id: "usketch-plugin-sync-ywebsocket",
10
+ name: "Realtime Sync (y-websocket)",
11
+ async setup(ctx) {
12
+ handle = createYwebsocketSync(ctx.store, options);
13
+ globalThis.__usketchSyncStatus = handle.status;
14
+ // Diagnostic overlay: draws a red badge on shapes that exist locally
15
+ // but haven't been confirmed by the server. Sits between standard
16
+ // shapes (default order) and the debug HUD (9999).
17
+ const overlayHandle = handle;
18
+ ctx.layers.register({
19
+ id: UNCONFIRMED_OVERLAY_LAYER_ID,
20
+ order: 250,
21
+ fixed: true,
22
+ render: (renderCtx) => (_jsx(UnconfirmedOverlay, { store: ctx.store, shapes: ctx.shapes, viewport: renderCtx.viewport, syncStatus: overlayHandle.status })),
23
+ });
24
+ unregisterOverlay = () => ctx.layers.unregister(UNCONFIRMED_OVERLAY_LAYER_ID);
25
+ // When `autoConnect: false`, `connect()` is never called, so awaiting
26
+ // `whenSynced` here would hang the entire plugin setup chain. Skip it
27
+ // and let the caller drive connection via `handle.resume()`.
28
+ if (options.autoConnect !== false) {
29
+ await handle.whenSynced;
30
+ }
31
+ },
32
+ teardown() {
33
+ unregisterOverlay?.();
34
+ unregisterOverlay = null;
35
+ handle?.destroy();
36
+ delete globalThis.__usketchSyncStatus;
37
+ handle = null;
38
+ },
39
+ getWsProvider() {
40
+ if (!handle) {
41
+ throw new Error("[usketch-plugin-sync-ywebsocket] getWsProvider() called before setup(); register the plugin with createApp() first, or call getWsProvider() from inside another plugin's setup().");
42
+ }
43
+ return handle.wsProvider;
44
+ },
45
+ getHandle() {
46
+ if (!handle) {
47
+ throw new Error("[usketch-plugin-sync-ywebsocket] getHandle() called before setup(); register the plugin with createApp() first, or call getHandle() from inside another plugin's setup().");
48
+ }
49
+ return handle;
50
+ },
51
+ };
52
+ return plugin;
53
+ }
54
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAgBrD,MAAM,4BAA4B,GAAG,4BAA4B,CAAC;AAElE,MAAM,UAAU,0BAA0B,CAAC,OAA8B;IACxE,IAAI,MAAM,GAAgC,IAAI,CAAC;IAC/C,IAAI,iBAAiB,GAAwB,IAAI,CAAC;IAElD,MAAM,MAAM,GAAyB;QACpC,EAAE,EAAE,gCAAgC;QACpC,IAAI,EAAE,6BAA6B;QAEnC,KAAK,CAAC,KAAK,CAAC,GAAG;YACd,MAAM,GAAG,oBAAoB,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;YACjD,UAAsC,CAAC,mBAAmB,GAAG,MAAM,CAAC,MAAM,CAAC;YAE5E,qEAAqE;YACrE,kEAAkE;YAClE,mDAAmD;YACnD,MAAM,aAAa,GAAG,MAAM,CAAC;YAC7B,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC;gBACnB,EAAE,EAAE,4BAA4B;gBAChC,KAAK,EAAE,GAAG;gBACV,KAAK,EAAE,IAAI;gBACX,MAAM,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,CACtB,KAAC,kBAAkB,IAClB,KAAK,EAAE,GAAG,CAAC,KAAK,EAChB,MAAM,EAAE,GAAG,CAAC,MAAM,EAClB,QAAQ,EAAE,SAAS,CAAC,QAAQ,EAC5B,UAAU,EAAE,aAAa,CAAC,MAAM,GAC/B,CACF;aACD,CAAC,CAAC;YACH,iBAAiB,GAAG,GAAG,EAAE,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC,4BAA4B,CAAC,CAAC;YAE9E,sEAAsE;YACtE,sEAAsE;YACtE,6DAA6D;YAC7D,IAAI,OAAO,CAAC,WAAW,KAAK,KAAK,EAAE,CAAC;gBACnC,MAAM,MAAM,CAAC,UAAU,CAAC;YACzB,CAAC;QACF,CAAC;QAED,QAAQ;YACP,iBAAiB,EAAE,EAAE,CAAC;YACtB,iBAAiB,GAAG,IAAI,CAAC;YACzB,MAAM,EAAE,OAAO,EAAE,CAAC;YAClB,OAAQ,UAAsC,CAAC,mBAAmB,CAAC;YACnE,MAAM,GAAG,IAAI,CAAC;QACf,CAAC;QAED,aAAa;YACZ,IAAI,CAAC,MAAM,EAAE,CAAC;gBACb,MAAM,IAAI,KAAK,CACd,mLAAmL,CACnL,CAAC;YACH,CAAC;YACD,OAAO,MAAM,CAAC,UAAU,CAAC;QAC1B,CAAC;QAED,SAAS;YACR,IAAI,CAAC,MAAM,EAAE,CAAC;gBACb,MAAM,IAAI,KAAK,CACd,2KAA2K,CAC3K,CAAC;YACH,CAAC;YACD,OAAO,MAAM,CAAC;QACf,CAAC;KACD,CAAC;IAEF,OAAO,MAAM,CAAC;AACf,CAAC"}
@@ -0,0 +1,112 @@
1
+ export type SyncState = "loading" | "connecting" | "synced" | "syncing" | "disconnected" | "error";
2
+ export interface SyncStatusSnapshot {
3
+ state: SyncState;
4
+ shapeCount: number;
5
+ /**
6
+ * Timestamp of the most recent activity that touched the snapshot — local
7
+ * mutations and server syncs alike. Useful for "last activity" displays
8
+ * but NOT a reliable indicator that the server has acknowledged anything.
9
+ * For divergence gating use `firstServerSyncAt` instead.
10
+ */
11
+ lastSyncedAt: number | null;
12
+ /**
13
+ * Timestamp of the first successful `setConfirmedFromServer(...)` call
14
+ * (i.e. the first `provider.on("sync", true)` event). Stays `null` until
15
+ * the server has actually merged its state with us. Set once and never
16
+ * cleared: a later disconnection should still allow consumers to surface
17
+ * divergence (offline edits accumulate exactly when the user needs the
18
+ * warning).
19
+ */
20
+ firstServerSyncAt: number | null;
21
+ error: string | null;
22
+ /**
23
+ * Shape IDs present in the local Y.Doc that the server has NOT confirmed.
24
+ *
25
+ * Populated by `noteShapeAdded(id, "local")` (a shape created locally that
26
+ * hasn't been seen by the server yet) and by `noteShapesLoaded(...)` for
27
+ * pre-connection IndexedDB restoration. Becomes accurate only **after** the
28
+ * first `setConfirmedFromServer(...)` (`sync` event with `isSynced: true`):
29
+ * before that, every shape from a persisted Y.Doc looks "local-only" because
30
+ * we genuinely don't know what the server holds yet.
31
+ *
32
+ * Consumers should gate divergence UI on `firstServerSyncAt !== null` so
33
+ * the warning isn't surfaced during the initial `loading` / `connecting`
34
+ * phase (every cold start would otherwise look full of "unconfirmed"
35
+ * shapes). Once that first server sync has happened, the gate stays open
36
+ * even across later disconnections.
37
+ */
38
+ unconfirmedShapeIds: readonly string[];
39
+ }
40
+ export declare class SyncStatusTracker {
41
+ private snapshot;
42
+ private listeners;
43
+ private readonly shapeIds;
44
+ private readonly confirmedShapeIds;
45
+ private batchDepth;
46
+ private batchDirty;
47
+ getSnapshot(): SyncStatusSnapshot;
48
+ subscribe(listener: () => void): () => void;
49
+ /**
50
+ * Update the "free-form" snapshot fields (state / shapeCount / lastSyncedAt
51
+ * / error). The divergence-tracking fields (`unconfirmedShapeIds` and
52
+ * `firstServerSyncAt`) are intentionally excluded so callers can't bypass
53
+ * the dedicated APIs and break the invariant that `firstServerSyncAt` is
54
+ * stamped exactly once on the first server sync.
55
+ *
56
+ * @internal
57
+ */
58
+ update(partial: Partial<Omit<SyncStatusSnapshot, "unconfirmedShapeIds" | "firstServerSyncAt">>): void;
59
+ /**
60
+ * Replace the "server-confirmed" set with the given IDs. Use this only when
61
+ * you have authoritative knowledge of which IDs the server has acknowledged
62
+ * — typically from y-websocket's `provider.on("sync")` event, where the
63
+ * Y.Map post-merge represents the union of (server view ∪ our uploads), so
64
+ * every current key IS confirmed.
65
+ *
66
+ * Do **not** call this with `shapesMap.keys()` if your transport can't tell
67
+ * you which keys originated from the server: that would silently confirm
68
+ * orphaned IndexedDB shapes the server never had, defeating the divergence
69
+ * detection. Use `markFirstServerSyncObserved()` + the `noteShapeAdded(...,
70
+ * "remote")` per-key path instead.
71
+ *
72
+ * Also stamps `firstServerSyncAt` on the first call so consumers can gate
73
+ * divergence UI on "have we ever heard back from the server?".
74
+ */
75
+ setConfirmedFromServer(ids: Iterable<string>): void;
76
+ /**
77
+ * Stamp `firstServerSyncAt` on the first call without touching the
78
+ * confirmed set. For transports that can't atomically tell you which
79
+ * shape IDs the server already knew at sync time (e.g. raw `WsProvider`
80
+ * without an `onSync` event), this is the right hook: the per-key
81
+ * `noteShapeAdded(id, "remote")` calls from the Y.Map observer fill in
82
+ * the confirmed set incrementally.
83
+ */
84
+ markFirstServerSyncObserved(): void;
85
+ private markFirstServerSyncObservedInternal;
86
+ /**
87
+ * Note a shape addition. `source = "remote"` means the shape arrived via the
88
+ * Yjs provider (server origin), so it's already confirmed. `source = "local"`
89
+ * means we created it client-side and the server has yet to acknowledge.
90
+ */
91
+ noteShapeAdded(id: string, source: "local" | "remote"): void;
92
+ /**
93
+ * Bulk variant of `noteShapeAdded` for initial load. Avoids the O(n²)
94
+ * cost of calling the single-shape mutator in a loop (each call would
95
+ * re-scan `shapeIds` and notify subscribers). Recompute and notify
96
+ * happen once after all IDs are ingested.
97
+ */
98
+ noteShapesLoaded(ids: Iterable<string>, source: "local" | "remote"): void;
99
+ noteShapeRemoved(id: string): void;
100
+ private recompute;
101
+ /**
102
+ * Combine multiple mutator calls into a single notification. Useful when
103
+ * the caller wants to e.g. `noteShapeAdded(...)` and `update({ shapeCount })`
104
+ * back-to-back without paying for two re-renders.
105
+ *
106
+ * Re-entrant: nested calls are tolerated, the listeners fire once when the
107
+ * outermost batch closes.
108
+ */
109
+ batch<T>(fn: () => T): T;
110
+ private notify;
111
+ }
112
+ //# sourceMappingURL=sync-status-tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-status-tracker.d.ts","sourceRoot":"","sources":["../src/sync-status-tracker.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,YAAY,GAAG,QAAQ,GAAG,SAAS,GAAG,cAAc,GAAG,OAAO,CAAC;AAEnG,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;;OAOG;IACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;AAED,qBAAa,iBAAiB;IAC7B,OAAO,CAAC,QAAQ,CAOd;IACF,OAAO,CAAC,SAAS,CAAyB;IAG1C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqB;IAIvD,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,UAAU,CAAS;IAE3B,WAAW,IAAI,kBAAkB;IAIjC,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAO3C;;;;;;;;OAQG;IACH,MAAM,CACL,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,kBAAkB,EAAE,qBAAqB,GAAG,mBAAmB,CAAC,CAAC,GACrF,IAAI;IAKP;;;;;;;;;;;;;;;OAeG;IACH,sBAAsB,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI;IAUnD;;;;;;;OAOG;IACH,2BAA2B,IAAI,IAAI;IAMnC,OAAO,CAAC,mCAAmC;IAM3C;;;;OAIG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI;IAQ5D;;;;;OAKG;IACH,gBAAgB,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI;IAUzE,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAMlC,OAAO,CAAC,SAAS;IAajB;;;;;;;OAOG;IACH,KAAK,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC;IAaxB,OAAO,CAAC,MAAM;CAOd"}