@jxsuite/collab 0.6.0 → 0.8.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/README.md CHANGED
@@ -69,7 +69,7 @@ one `error{read-only}`.
69
69
 
70
70
  One project-level `Awareness` per connection. State shape:
71
71
  `{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, structuralSelection?, selection? }` —
72
- file-tree presence (the plain `selection` field is y-monaco's in-buffer cursor)
72
+ file-tree presence (the plain `selection` field is the code view's in-buffer cursor)
73
73
  and per-document cursor overlays both filter the same states. Identity comes from the server
74
74
  `hello`; colors are deterministic (`colorForKey`).
75
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {
@@ -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,9 +33,9 @@
32
33
  "test:coverage": "bun test --isolate --coverage"
33
34
  },
34
35
  "dependencies": {
35
- "@jxsuite/schema": "^1.5.0",
36
- "lib0": "^0.2.99",
37
- "y-protocols": "^1.0.6",
38
- "yjs": "13.6.27"
36
+ "@jxsuite/schema": "^1.8.0",
37
+ "lib0": "^0.2.117",
38
+ "y-protocols": "^1.0.7",
39
+ "yjs": "13.6.32"
39
40
  }
40
41
  }
@@ -21,12 +21,20 @@ 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
- /** The client's structural (canvas) selection in the focused document. */
25
- structuralSelection?: JxPath | null;
26
24
  /**
27
- * RESERVED for y-monaco: the in-buffer text cursor as {anchor, head} Y.RelativePosition JSON.
28
- * MonacoBinding owns this field via setLocalStateField("selection", …) while a code view is
29
- * bound; nothing else may write it.
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;
32
+ /**
33
+ * RESERVED for the code view: the in-buffer text cursor as {anchor, head} Y.RelativePosition
34
+ * JSON. `studio/src/collab/monaco-binding.ts`'s bindMonacoToYText owns this field via
35
+ * setLocalStateField("selection", …) while a code view is bound, and sets it back to null when it
36
+ * unbinds; nothing else may write it. The shape is y-monaco's, deliberately — a peer on an older
37
+ * Studio publishes and reads exactly this.
30
38
  */
31
39
  selection?: { anchor: unknown; head: unknown } | null;
32
40
  /**
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/index.ts CHANGED
@@ -18,8 +18,18 @@ export {
18
18
  yDocToJson,
19
19
  yValueToJson,
20
20
  } from "./schema.ts";
21
- // Re-exported so consumers keep a single yjs import point (avoids dual-instance hazards).
22
- export { Doc as YDoc, UndoManager } from "yjs";
21
+ /* Re-exported so consumers keep a single yjs import point (avoids dual-instance hazards).
22
+
23
+ The two position helpers are here for exactly that reason. `studio/src/collab/monaco-binding.ts`
24
+ mints a RelativePosition for every saved caret and resolves peers' back to offsets, and a
25
+ RelativePosition minted by a SECOND yjs instance resolves to null against this doc — the failure
26
+ is a cursor that silently stops moving, not an error. One import point, one instance. */
27
+ export {
28
+ createAbsolutePositionFromRelativePosition,
29
+ createRelativePositionFromTypeIndex,
30
+ Doc as YDoc,
31
+ UndoManager,
32
+ } from "yjs";
23
33
  export {
24
34
  applyDocOpsToY,
25
35
  CollabPathError,
@@ -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 ?? (globalThis.WebSocket as unknown as new (url: string) => WsLike);
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
- const ws = new WebSocketCtor(options.url);
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