@jxsuite/collab 0.6.0 → 0.7.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/package.json +3 -2
- package/src/awareness-types.ts +8 -2
- package/src/envelope.ts +6 -0
- package/src/negotiate.ts +131 -0
- package/src/ws-client.ts +19 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./op-bridge": "./src/op-bridge.ts",
|
|
20
20
|
"./diff": "./src/diff.ts",
|
|
21
21
|
"./envelope": "./src/envelope.ts",
|
|
22
|
+
"./negotiate": "./src/negotiate.ts",
|
|
22
23
|
"./awareness-types": "./src/awareness-types.ts",
|
|
23
24
|
"./provider": "./src/provider.ts",
|
|
24
25
|
"./client": "./src/ws-client.ts",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"test:coverage": "bun test --isolate --coverage"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@jxsuite/schema": "^1.
|
|
36
|
+
"@jxsuite/schema": "^1.6.0",
|
|
36
37
|
"lib0": "^0.2.99",
|
|
37
38
|
"y-protocols": "^1.0.6",
|
|
38
39
|
"yjs": "13.6.27"
|
package/src/awareness-types.ts
CHANGED
|
@@ -21,8 +21,14 @@ export interface CollabAwarenessState {
|
|
|
21
21
|
focusedPath: string | null;
|
|
22
22
|
/** Which representation the client is editing (drives the Phase-B canonical lock). */
|
|
23
23
|
mode?: "structure" | "source";
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The client's structural (canvas) selection in the focused document — the whole SET.
|
|
26
|
+
*
|
|
27
|
+
* A list because `session.selection` is one (studio §6.5): a peer who has selected six cards
|
|
28
|
+
* publishes six paths, and the remote canvas draws a presence box for each. A peer with one node
|
|
29
|
+
* selected publishes a one-entry list, which draws exactly the one box it always did.
|
|
30
|
+
*/
|
|
31
|
+
structuralSelection?: JxPath[] | null;
|
|
26
32
|
/**
|
|
27
33
|
* RESERVED for y-monaco: the in-buffer text cursor as {anchor, head} Y.RelativePosition JSON.
|
|
28
34
|
* MonacoBinding owns this field via setLocalStateField("selection", …) while a code view is
|
package/src/envelope.ts
CHANGED
|
@@ -14,11 +14,17 @@
|
|
|
14
14
|
* are dropped and answered with a `doc-reset` control message, upon which the client destroys its
|
|
15
15
|
* Y.Doc and re-opens. The invariant "Y history is never deleted or replaced without an epoch bump"
|
|
16
16
|
* is what prevents divergent-history duplicate-content merges.
|
|
17
|
+
*
|
|
18
|
+
* The layout above is what the `jx.collab.v1` WebSocket subprotocol names. A change that would make
|
|
19
|
+
* a peer mis-parse a frame is a new token, not a silent edit here — see `negotiate.ts` for the bump
|
|
20
|
+
* rule and for why the capability probe, not the socket, is where the two sides agree.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import * as decoding from "lib0/decoding";
|
|
20
24
|
import * as encoding from "lib0/encoding";
|
|
21
25
|
|
|
26
|
+
export { COLLAB_SUBPROTOCOL } from "./negotiate.ts";
|
|
27
|
+
|
|
22
28
|
export const FRAME_DOC_SYNC = 0;
|
|
23
29
|
export const FRAME_AWARENESS = 1;
|
|
24
30
|
export const FRAME_DOC_CLOSE = 2;
|
package/src/negotiate.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket subprotocol negotiation for the collab socket (RFC 6455 §1.9, §4.2.2).
|
|
3
|
+
*
|
|
4
|
+
* **The token names the wire envelope, not the package.** `jx.collab.v1` means "I speak the frame
|
|
5
|
+
* layout `envelope.ts` documents". There is one token per envelope **major**, and the rule for
|
|
6
|
+
* bumping it is narrow on purpose: bump when {@link decodeFrame} would _mis-parse_ a peer's frame —
|
|
7
|
+
* a field reordered, a type widened, a length prefix changed. **Do not bump for a new frame type.**
|
|
8
|
+
* Both halves already skip a frame type they do not know, so a v1 peer and a v1-plus-one-frame peer
|
|
9
|
+
* interoperate; bumping there would refuse a room that would have worked.
|
|
10
|
+
*
|
|
11
|
+
* **Why the capability probe does the negotiating.** RFC 6455 §4.1 requires a client that offered
|
|
12
|
+
* subprotocols and received no echo to _fail the connection_, and browsers enforce it. So a new
|
|
13
|
+
* Studio that unconditionally offered `jx.collab.v1` would lose collab entirely against an older
|
|
14
|
+
* dev server — a regression caused by the fix. The probe already exists, already returns a version
|
|
15
|
+
* the client discards, and runs before the socket: it is the one place the client can learn what
|
|
16
|
+
* the server speaks without risking the handshake. Hence {@link negotiateCollab}, which turns a
|
|
17
|
+
* probe body into the list to offer — empty against a server that advertises nothing.
|
|
18
|
+
*
|
|
19
|
+
* **Nothing here may go in the `hello` control message.** That arrives after the socket is up and
|
|
20
|
+
* after frames may already have been exchanged, which is too late to prevent the divergent-history
|
|
21
|
+
* merge `schema.ts` warns about. Negotiation has to conclude before a byte of document state
|
|
22
|
+
* moves.
|
|
23
|
+
*
|
|
24
|
+
* This module imports nothing, deliberately: the Studio platform adapters read it eagerly, before
|
|
25
|
+
* the dynamic import that pulls in Yjs, so it must not drag the CRDT into the initial bundle.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The subprotocol token for the envelope in `envelope.ts`. One token per envelope major. */
|
|
29
|
+
export const COLLAB_SUBPROTOCOL = "jx.collab.v1";
|
|
30
|
+
|
|
31
|
+
/** What the client should do with the socket, given what the server advertised. */
|
|
32
|
+
export interface CollabNegotiation {
|
|
33
|
+
/**
|
|
34
|
+
* The subprotocols to offer as `Sec-WebSocket-Protocol`. **Empty means offer none**, which is the
|
|
35
|
+
* correct answer against a server that advertises none: an offer it cannot echo would fail the
|
|
36
|
+
* handshake outright, where offering nothing connects exactly as it does today.
|
|
37
|
+
*/
|
|
38
|
+
offer: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Non-null when the peers cannot interoperate: the server advertised subprotocols and none of
|
|
41
|
+
* them is one we speak. The client must not connect — the string says which side speaks what, so
|
|
42
|
+
* the degradation is legible rather than a silent solo session.
|
|
43
|
+
*/
|
|
44
|
+
refused: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decide what to offer, from the body of a `GET /__studio/collab` capability probe.
|
|
49
|
+
*
|
|
50
|
+
* @param {unknown} probe The parsed probe body, or null if it did not parse as JSON.
|
|
51
|
+
* @returns {CollabNegotiation}
|
|
52
|
+
*/
|
|
53
|
+
export function negotiateCollab(probe: unknown): CollabNegotiation {
|
|
54
|
+
if (probe === null || typeof probe !== "object") {
|
|
55
|
+
// A 200 that is not a JSON object: pre-negotiation server. Connect as before, offering nothing.
|
|
56
|
+
return { offer: [], refused: null };
|
|
57
|
+
}
|
|
58
|
+
const body = probe as { collab?: unknown; protocols?: unknown };
|
|
59
|
+
if (body.collab === false) {
|
|
60
|
+
return { offer: [], refused: "This server has collaboration disabled." };
|
|
61
|
+
}
|
|
62
|
+
const { protocols } = body;
|
|
63
|
+
if (!Array.isArray(protocols) || protocols.length === 0) {
|
|
64
|
+
// A server that predates negotiation. It would echo nothing, so we must offer nothing.
|
|
65
|
+
return { offer: [], refused: null };
|
|
66
|
+
}
|
|
67
|
+
if (protocols.includes(COLLAB_SUBPROTOCOL)) {
|
|
68
|
+
return { offer: [COLLAB_SUBPROTOCOL], refused: null };
|
|
69
|
+
}
|
|
70
|
+
const theirs = protocols.filter((p) => typeof p === "string").join(", ") || "none";
|
|
71
|
+
return {
|
|
72
|
+
offer: [],
|
|
73
|
+
refused: `This server speaks collab envelope ${theirs}; this editor speaks ${COLLAB_SUBPROTOCOL}.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The subprotocols a client offered, from its `Sec-WebSocket-Protocol` request header.
|
|
79
|
+
*
|
|
80
|
+
* RFC 6455 §4.1 allows the field to be split across several header lines and comma-separated within
|
|
81
|
+
* one; `Headers.get` already joins repeats with `, `, so one split covers both spellings.
|
|
82
|
+
*
|
|
83
|
+
* @param {string | null} header
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function offeredSubprotocols(header: string | null): string[] {
|
|
87
|
+
if (!header) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return header
|
|
91
|
+
.split(",")
|
|
92
|
+
.map((token) => token.trim())
|
|
93
|
+
.filter((token) => token !== "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the server should do with an upgrade, given the client's offer. */
|
|
97
|
+
export interface SubprotocolAnswer {
|
|
98
|
+
/** The token to echo in the 101 response, or null to echo nothing. */
|
|
99
|
+
echo: string | null;
|
|
100
|
+
/** Non-null when the handshake must be refused instead of upgraded. */
|
|
101
|
+
reject: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Choose the subprotocol to echo (RFC 6455 §4.2.2 step 4).
|
|
106
|
+
*
|
|
107
|
+
* Three cases, and the middle one is the one that is easy to get wrong:
|
|
108
|
+
*
|
|
109
|
+
* - **No offer** — an older client. Upgrade and echo nothing; it is not expecting an echo, and
|
|
110
|
+
* refusing it would break every Studio built before this shipped.
|
|
111
|
+
* - **An offer we speak** — echo exactly that token. Echoing a token the client did not offer is
|
|
112
|
+
* itself a protocol violation, so the echo is always a member of the offer.
|
|
113
|
+
* - **An offer we do not speak** — refuse. This is the whole point: two peers whose envelopes
|
|
114
|
+
* disagree about merge granularity (see `collab.md` §3.1) must not end up in one room, and a
|
|
115
|
+
* refused handshake is a far better outcome than a divergent-history merge.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]} offered
|
|
118
|
+
* @returns {SubprotocolAnswer}
|
|
119
|
+
*/
|
|
120
|
+
export function selectSubprotocol(offered: string[]): SubprotocolAnswer {
|
|
121
|
+
if (offered.length === 0) {
|
|
122
|
+
return { echo: null, reject: null };
|
|
123
|
+
}
|
|
124
|
+
if (offered.includes(COLLAB_SUBPROTOCOL)) {
|
|
125
|
+
return { echo: COLLAB_SUBPROTOCOL, reject: null };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
echo: null,
|
|
129
|
+
reject: `No supported collab subprotocol offered (this server speaks ${COLLAB_SUBPROTOCOL}).`,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/ws-client.ts
CHANGED
|
@@ -34,8 +34,17 @@ export interface WsLike {
|
|
|
34
34
|
export interface WsCollabConnectionOptions {
|
|
35
35
|
/** The ws(s):// endpoint for this project's collab socket. */
|
|
36
36
|
url: string;
|
|
37
|
+
/**
|
|
38
|
+
* Subprotocols to offer (`Sec-WebSocket-Protocol`), normally `negotiateCollab`'s `offer`.
|
|
39
|
+
*
|
|
40
|
+
* Left empty or undefined deliberately when the server advertised none: RFC 6455 §4.1 makes a
|
|
41
|
+
* client that offers a subprotocol and receives no echo fail the connection, so an unconditional
|
|
42
|
+
* offer would break collab against every server built before negotiation shipped. The caller
|
|
43
|
+
* decides, from the capability probe, and `negotiate.ts` holds the reasoning.
|
|
44
|
+
*/
|
|
45
|
+
protocols?: string[];
|
|
37
46
|
/** WebSocket constructor override (tests, non-browser runtimes). */
|
|
38
|
-
webSocketImpl?: new (url: string) => WsLike;
|
|
47
|
+
webSocketImpl?: new (url: string, protocols?: string[]) => WsLike;
|
|
39
48
|
/**
|
|
40
49
|
* Called when the server refuses a doc with content-not-loaded: hydrate the file over HTTP (which
|
|
41
50
|
* caches it server-side) so the retry can seed. One retry per open.
|
|
@@ -74,7 +83,8 @@ interface DocEntry {
|
|
|
74
83
|
|
|
75
84
|
export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
|
|
76
85
|
const WebSocketCtor =
|
|
77
|
-
options.webSocketImpl ??
|
|
86
|
+
options.webSocketImpl ??
|
|
87
|
+
(globalThis.WebSocket as unknown as new (url: string, protocols?: string[]) => WsLike);
|
|
78
88
|
const openTimeoutMs = options.openTimeoutMs ?? 10_000;
|
|
79
89
|
const docs = new Map<string, DocEntry>();
|
|
80
90
|
const statusCbs = new Set<(status: CollabStatus) => void>();
|
|
@@ -257,7 +267,13 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
257
267
|
return;
|
|
258
268
|
}
|
|
259
269
|
setStatus("connecting");
|
|
260
|
-
|
|
270
|
+
/* Re-offered on every reconnect: a socket that comes back after the server was restarted must
|
|
271
|
+
negotiate again, and the offer is a property of this client's envelope, not of one socket. */
|
|
272
|
+
const offer = options.protocols;
|
|
273
|
+
const ws =
|
|
274
|
+
offer === undefined || offer.length === 0
|
|
275
|
+
? new WebSocketCtor(options.url)
|
|
276
|
+
: new WebSocketCtor(options.url, offer);
|
|
261
277
|
socket = ws;
|
|
262
278
|
ws.binaryType = "arraybuffer";
|
|
263
279
|
/* Each connect() builds a FRESH socket; single-assignment handlers are the point (and keep
|