@jxsuite/collab 0.3.0 → 0.4.1
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/package.json +2 -2
- package/src/envelope.ts +7 -0
- package/src/provider.ts +6 -0
- package/src/ws-client.ts +22 -0
- package/src/ws-room.ts +54 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:coverage": "bun test --isolate --coverage"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@jxsuite/schema": "^1.
|
|
35
|
+
"@jxsuite/schema": "^1.2.0",
|
|
36
36
|
"lib0": "^0.2.99",
|
|
37
37
|
"y-protocols": "^1.0.6",
|
|
38
38
|
"yjs": "13.6.27"
|
package/src/envelope.ts
CHANGED
|
@@ -47,6 +47,13 @@ export type ControlMessage =
|
|
|
47
47
|
| { type: "flush"; path: string }
|
|
48
48
|
/** Server → client: the flush landed. */
|
|
49
49
|
| { type: "flush-ack"; path: string }
|
|
50
|
+
/**
|
|
51
|
+
* Server → client: the room's unsaved-since-last-persist state changed (drives the Save
|
|
52
|
+
* affordance). Room-level and authoritative: any peer's edit dirties the room for everyone; a
|
|
53
|
+
* persist clears it for everyone. Sent to a newly-subscribing connection during the open
|
|
54
|
+
* handshake so it learns the current state.
|
|
55
|
+
*/
|
|
56
|
+
| { type: "doc-dirty"; path: string; dirty: boolean }
|
|
50
57
|
/**
|
|
51
58
|
* Server → client: a request was refused (codes: read-only, content-not-loaded, too-large,
|
|
52
59
|
* rate-limited, binary-file, unknown-frame).
|
package/src/provider.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface CollabHandle {
|
|
|
39
39
|
* is dead: destroy it and re-acquire a fresh one via platform.collab.
|
|
40
40
|
*/
|
|
41
41
|
onReset: (cb: () => void) => () => void;
|
|
42
|
+
/**
|
|
43
|
+
* Room-level unsaved state changed: any peer edited since the last persist, or a persist cleared
|
|
44
|
+
* it. Fires immediately with the current value on subscribe (the server sends it during the open
|
|
45
|
+
* handshake, so the callback must run synchronously to win that race). Returns an unsubscribe.
|
|
46
|
+
*/
|
|
47
|
+
onDirty: (cb: (dirty: boolean) => void) => () => void;
|
|
42
48
|
destroy: () => void;
|
|
43
49
|
}
|
|
44
50
|
|
package/src/ws-client.ts
CHANGED
|
@@ -67,6 +67,9 @@ interface DocEntry {
|
|
|
67
67
|
resetCbs: Set<() => void>;
|
|
68
68
|
pendingFlush: Set<() => void>;
|
|
69
69
|
handleDestroyed: boolean;
|
|
70
|
+
/** Latest room-level dirty state from the server (survives reconnect on the same entry). */
|
|
71
|
+
dirty: boolean;
|
|
72
|
+
dirtyCbs: Set<(dirty: boolean) => void>;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
|
|
@@ -180,6 +183,16 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
180
183
|
}
|
|
181
184
|
return;
|
|
182
185
|
}
|
|
186
|
+
case "doc-dirty": {
|
|
187
|
+
const entry = docs.get(message.path);
|
|
188
|
+
if (entry) {
|
|
189
|
+
entry.dirty = message.dirty;
|
|
190
|
+
for (const cb of entry.dirtyCbs) {
|
|
191
|
+
cb(message.dirty);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
183
196
|
case "error": {
|
|
184
197
|
if (!message.path) {
|
|
185
198
|
return;
|
|
@@ -349,6 +362,8 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
349
362
|
resolveSynced = resolve;
|
|
350
363
|
});
|
|
351
364
|
const entry: DocEntry = {
|
|
365
|
+
dirty: false,
|
|
366
|
+
dirtyCbs: new Set(),
|
|
352
367
|
doc: new Y.Doc(),
|
|
353
368
|
epoch: 0,
|
|
354
369
|
handleDestroyed: false,
|
|
@@ -418,6 +433,13 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
418
433
|
sendFrame({ message: { path, type: "flush" }, type: "control" });
|
|
419
434
|
}),
|
|
420
435
|
identity: () => identity,
|
|
436
|
+
onDirty: (cb) => {
|
|
437
|
+
entry.dirtyCbs.add(cb);
|
|
438
|
+
// Deliver current state synchronously: the server's open-handshake `doc-dirty` may have
|
|
439
|
+
// Already landed before the caller subscribes, and it must not miss the initial value.
|
|
440
|
+
cb(entry.dirty);
|
|
441
|
+
return () => entry.dirtyCbs.delete(cb);
|
|
442
|
+
},
|
|
421
443
|
onReset: (cb) => {
|
|
422
444
|
entry.resetCbs.add(cb);
|
|
423
445
|
return () => entry.resetCbs.delete(cb);
|
package/src/ws-room.ts
CHANGED
|
@@ -44,6 +44,10 @@ interface Room {
|
|
|
44
44
|
epoch: number;
|
|
45
45
|
subscribers: Set<HostConnection>;
|
|
46
46
|
ready: Promise<void>;
|
|
47
|
+
/** The source text as of the last persist (seed or markPersisted) — the room-dirty baseline. */
|
|
48
|
+
baseline: string;
|
|
49
|
+
/** True while the live source differs from `baseline`; mirrored to peers via `doc-dirty`. */
|
|
50
|
+
dirty: boolean;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface HostConnection {
|
|
@@ -64,6 +68,12 @@ export interface CollabHost {
|
|
|
64
68
|
subscriberCount: (path: string) => number;
|
|
65
69
|
/** Destroy an empty room (embedder teardown after its grace period). */
|
|
66
70
|
destroyRoomIfEmpty: (path: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* The embedder folded the room's source to durable storage: reset the dirty baseline to the
|
|
73
|
+
* current source and broadcast `doc-dirty{false}` to every subscriber. Called from `onFlush`
|
|
74
|
+
* after a successful write.
|
|
75
|
+
*/
|
|
76
|
+
markPersisted: (path: string) => void;
|
|
67
77
|
destroy: () => void;
|
|
68
78
|
}
|
|
69
79
|
|
|
@@ -114,27 +124,32 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
114
124
|
};
|
|
115
125
|
|
|
116
126
|
const ensureRoom = (path: string): Room => {
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
return
|
|
127
|
+
const existing = rooms.get(path);
|
|
128
|
+
if (existing) {
|
|
129
|
+
return existing;
|
|
120
130
|
}
|
|
121
131
|
const doc = new Y.Doc();
|
|
122
132
|
const epoch = epochs.get(path) ?? 0;
|
|
123
133
|
epochs.set(path, epoch);
|
|
124
|
-
room = {
|
|
134
|
+
const room: Room = {
|
|
135
|
+
baseline: "",
|
|
136
|
+
dirty: false,
|
|
125
137
|
doc,
|
|
126
138
|
epoch,
|
|
127
|
-
ready: (
|
|
128
|
-
const source = await options.loadSource(path);
|
|
129
|
-
if (source === null) {
|
|
130
|
-
throw new Error("content-not-loaded");
|
|
131
|
-
}
|
|
132
|
-
doc.transact(() => {
|
|
133
|
-
sourceText(doc).insert(0, source);
|
|
134
|
-
}, "seed");
|
|
135
|
-
})(),
|
|
139
|
+
ready: Promise.resolve(),
|
|
136
140
|
subscribers: new Set(),
|
|
137
141
|
};
|
|
142
|
+
room.ready = (async () => {
|
|
143
|
+
const source = await options.loadSource(path);
|
|
144
|
+
if (source === null) {
|
|
145
|
+
throw new Error("content-not-loaded");
|
|
146
|
+
}
|
|
147
|
+
// The seeded text IS the on-disk state — the initial clean baseline.
|
|
148
|
+
room.baseline = source;
|
|
149
|
+
doc.transact(() => {
|
|
150
|
+
sourceText(doc).insert(0, source);
|
|
151
|
+
}, "seed");
|
|
152
|
+
})();
|
|
138
153
|
doc.on("update", (update: Uint8Array, origin: unknown) => {
|
|
139
154
|
const current = rooms.get(path);
|
|
140
155
|
if (!current || current.doc !== doc) {
|
|
@@ -148,6 +163,15 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
148
163
|
origin instanceof Object && "socket" in origin ? (origin as InternalConnection) : undefined,
|
|
149
164
|
);
|
|
150
165
|
options.onSourceChange?.(path);
|
|
166
|
+
// Room-level dirty: broadcast on transition so every peer's Save affordance stays in sync.
|
|
167
|
+
const nowDirty = sourceText(current.doc).toString() !== current.baseline;
|
|
168
|
+
if (nowDirty !== current.dirty) {
|
|
169
|
+
current.dirty = nowDirty;
|
|
170
|
+
broadcastToRoom(current, {
|
|
171
|
+
message: { dirty: nowDirty, path, type: "doc-dirty" },
|
|
172
|
+
type: "control",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
151
175
|
});
|
|
152
176
|
rooms.set(path, room);
|
|
153
177
|
return room;
|
|
@@ -178,6 +202,8 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
178
202
|
room.subscribers.add(conn);
|
|
179
203
|
conn.subscribed.add(path);
|
|
180
204
|
sendControl(conn, { epoch: room.epoch, path, type: "opened" });
|
|
205
|
+
// The joiner learns the room's current unsaved state (it may join a room others have dirtied).
|
|
206
|
+
sendControl(conn, { dirty: room.dirty, path, type: "doc-dirty" });
|
|
181
207
|
// Server-initiated step1: learn the client's state (it replies step2 + its own step1).
|
|
182
208
|
const body = encoding.createEncoder();
|
|
183
209
|
syncProtocol.writeSyncStep1(body, room.doc);
|
|
@@ -394,6 +420,21 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
394
420
|
}
|
|
395
421
|
},
|
|
396
422
|
|
|
423
|
+
markPersisted(path) {
|
|
424
|
+
const room = rooms.get(path);
|
|
425
|
+
if (!room) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
room.baseline = sourceText(room.doc).toString();
|
|
429
|
+
if (room.dirty) {
|
|
430
|
+
room.dirty = false;
|
|
431
|
+
broadcastToRoom(room, {
|
|
432
|
+
message: { dirty: false, path, type: "doc-dirty" },
|
|
433
|
+
type: "control",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
|
|
397
438
|
resetDoc(path) {
|
|
398
439
|
const nextEpoch = (epochs.get(path) ?? 0) + 1;
|
|
399
440
|
epochs.set(path, nextEpoch);
|