@epicenter/sync 0.1.0 → 0.3.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -666
- package/README.md +3 -4
- package/package.json +9 -11
- package/src/auth-subprotocol.test.ts +26 -0
- package/src/auth-subprotocol.ts +37 -0
- package/src/index.ts +18 -23
- package/src/origins.ts +45 -0
- package/src/protocol.test.ts +122 -655
- package/src/protocol.ts +22 -430
- package/src/room-route.ts +19 -0
- package/tsconfig.json +2 -2
- package/src/rpc-errors.ts +0 -89
package/src/protocol.test.ts
CHANGED
|
@@ -1,212 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Protocol Unit Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests
|
|
5
|
-
*
|
|
6
|
-
* and end-to-end synchronization
|
|
4
|
+
* Tests the Yjs sync protocol helpers used by the server sync endpoint:
|
|
5
|
+
* the frame encoders (`encodeSyncStep1` / `encodeSyncUpdate`), the
|
|
6
|
+
* dispatcher (`handleSyncPayload`), and end-to-end synchronization.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - Handshake and incremental updates converge document state across peers.
|
|
8
|
+
* A binary frame is `[sync sub-type varint][payload]`; `readFrame` below
|
|
9
|
+
* is the test-side decoder (production decodes inline at the transport).
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import { describe, expect, test } from 'bun:test';
|
|
14
|
-
import
|
|
15
|
-
Awareness,
|
|
16
|
-
applyAwarenessUpdate,
|
|
17
|
-
encodeAwarenessUpdate,
|
|
18
|
-
} from 'y-protocols/awareness';
|
|
13
|
+
import * as decoding from 'lib0/decoding';
|
|
19
14
|
import * as Y from 'yjs';
|
|
20
|
-
import { RpcError } from './rpc-errors';
|
|
21
15
|
import {
|
|
22
|
-
decodeMessageType,
|
|
23
|
-
decodeRpcMessage,
|
|
24
|
-
decodeSyncMessage,
|
|
25
|
-
decodeSyncStatus,
|
|
26
|
-
encodeAwareness,
|
|
27
|
-
encodeAwarenessStates,
|
|
28
|
-
encodeQueryAwareness,
|
|
29
|
-
encodeRpcRequest,
|
|
30
|
-
encodeRpcResponse,
|
|
31
|
-
encodeSyncStatus,
|
|
32
16
|
encodeSyncStep1,
|
|
33
|
-
encodeSyncStep2,
|
|
34
17
|
encodeSyncUpdate,
|
|
35
18
|
handleSyncPayload,
|
|
36
|
-
MESSAGE_TYPE,
|
|
37
|
-
RPC_TYPE,
|
|
38
19
|
SYNC_MESSAGE_TYPE,
|
|
20
|
+
type SyncMessageType,
|
|
39
21
|
} from './protocol';
|
|
40
22
|
|
|
41
23
|
// ============================================================================
|
|
42
|
-
//
|
|
43
|
-
// ============================================================================
|
|
44
|
-
|
|
45
|
-
describe('MESSAGE_TYPE constants', () => {
|
|
46
|
-
test('match y-websocket protocol values', () => {
|
|
47
|
-
// These values are defined by y-websocket and must not change
|
|
48
|
-
expect(MESSAGE_TYPE.SYNC).toBe(0);
|
|
49
|
-
expect(MESSAGE_TYPE.AWARENESS).toBe(1);
|
|
50
|
-
expect(MESSAGE_TYPE.AUTH).toBe(2);
|
|
51
|
-
expect(MESSAGE_TYPE.QUERY_AWARENESS).toBe(3);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('SYNC_STATUS is 100 (custom extension for version tracking)', () => {
|
|
55
|
-
expect(MESSAGE_TYPE.SYNC_STATUS).toBe(100);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// ============================================================================
|
|
60
|
-
// SYNC_STATUS Encode/Decode Tests
|
|
61
|
-
// ============================================================================
|
|
62
|
-
|
|
63
|
-
describe('SYNC_STATUS encode/decode', () => {
|
|
64
|
-
test('encodeSyncStatus produces correct message type', () => {
|
|
65
|
-
const message = encodeSyncStatus(42);
|
|
66
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC_STATUS);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('round-trip: encode then decode preserves localVersion', () => {
|
|
70
|
-
const version = 12345;
|
|
71
|
-
const encoded = encodeSyncStatus(version);
|
|
72
|
-
const decoded = decodeSyncStatus(encoded);
|
|
73
|
-
expect(decoded).toBe(version);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('round-trip with version 0', () => {
|
|
77
|
-
const encoded = encodeSyncStatus(0);
|
|
78
|
-
const decoded = decodeSyncStatus(encoded);
|
|
79
|
-
expect(decoded).toBe(0);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('round-trip with large version number', () => {
|
|
83
|
-
const version = 1_000_000;
|
|
84
|
-
const encoded = encodeSyncStatus(version);
|
|
85
|
-
const decoded = decodeSyncStatus(encoded);
|
|
86
|
-
expect(decoded).toBe(version);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('decodeSyncStatus throws on non-SYNC_STATUS message', () => {
|
|
90
|
-
const doc = createDoc();
|
|
91
|
-
const syncMessage = encodeSyncStep1({ doc });
|
|
92
|
-
expect(() => decodeSyncStatus(syncMessage)).toThrow(
|
|
93
|
-
'Expected SYNC_STATUS message (100), got 0',
|
|
94
|
-
);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// ============================================================================
|
|
99
|
-
// RPC Encode/Decode Tests
|
|
24
|
+
// SYNC_MESSAGE_TYPE Constants
|
|
100
25
|
// ============================================================================
|
|
101
26
|
|
|
102
|
-
describe('RPC protocol', () => {
|
|
103
|
-
test('MESSAGE_TYPE.RPC is 101', () => {
|
|
104
|
-
expect(MESSAGE_TYPE.RPC).toBe(101);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('RPC_TYPE constants', () => {
|
|
108
|
-
expect(RPC_TYPE.REQUEST).toBe(0);
|
|
109
|
-
expect(RPC_TYPE.RESPONSE).toBe(1);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('round-trip: encode/decode RPC REQUEST', () => {
|
|
113
|
-
const encoded = encodeRpcRequest({
|
|
114
|
-
requestId: 42,
|
|
115
|
-
targetClientId: 100,
|
|
116
|
-
requesterClientId: 200,
|
|
117
|
-
action: 'tabs.close',
|
|
118
|
-
input: { tabIds: [1, 2, 3] },
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
|
|
122
|
-
|
|
123
|
-
const decoded = decodeRpcMessage(encoded);
|
|
124
|
-
expect(decoded.type).toBe('request');
|
|
125
|
-
if (decoded.type === 'request') {
|
|
126
|
-
expect(decoded.requestId).toBe(42);
|
|
127
|
-
expect(decoded.targetClientId).toBe(100);
|
|
128
|
-
expect(decoded.requesterClientId).toBe(200);
|
|
129
|
-
expect(decoded.action).toBe('tabs.close');
|
|
130
|
-
expect(decoded.input).toEqual({ tabIds: [1, 2, 3] });
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('round-trip: encode/decode RPC RESPONSE', () => {
|
|
135
|
-
const encoded = encodeRpcResponse({
|
|
136
|
-
requestId: 42,
|
|
137
|
-
requesterClientId: 200,
|
|
138
|
-
result: { data: { closedCount: 3 }, error: null },
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
|
|
142
|
-
|
|
143
|
-
const decoded = decodeRpcMessage(encoded);
|
|
144
|
-
expect(decoded.type).toBe('response');
|
|
145
|
-
if (decoded.type === 'response') {
|
|
146
|
-
expect(decoded.requestId).toBe(42);
|
|
147
|
-
expect(decoded.requesterClientId).toBe(200);
|
|
148
|
-
expect(decoded.result).toEqual({ data: { closedCount: 3 }, error: null });
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test('REQUEST with null input', () => {
|
|
153
|
-
const encoded = encodeRpcRequest({
|
|
154
|
-
requestId: 0,
|
|
155
|
-
targetClientId: 50,
|
|
156
|
-
requesterClientId: 60,
|
|
157
|
-
action: 'devices.list',
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const decoded = decodeRpcMessage(encoded);
|
|
161
|
-
expect(decoded.type).toBe('request');
|
|
162
|
-
if (decoded.type === 'request') {
|
|
163
|
-
expect(decoded.input).toBeNull();
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('RESPONSE with error', () => {
|
|
168
|
-
const encoded = encodeRpcResponse({
|
|
169
|
-
requestId: 7,
|
|
170
|
-
requesterClientId: 300,
|
|
171
|
-
result: RpcError.PeerOffline(),
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const decoded = decodeRpcMessage(encoded);
|
|
175
|
-
expect(decoded.type).toBe('response');
|
|
176
|
-
if (decoded.type === 'response') {
|
|
177
|
-
expect(decoded.result.data).toBeNull();
|
|
178
|
-
expect(decoded.result.error).toMatchObject({
|
|
179
|
-
name: 'PeerOffline',
|
|
180
|
-
message: 'Target peer is not connected',
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('decodeRpcMessage discriminates REQUEST vs RESPONSE', () => {
|
|
186
|
-
const request = encodeRpcRequest({
|
|
187
|
-
requestId: 1,
|
|
188
|
-
targetClientId: 10,
|
|
189
|
-
requesterClientId: 20,
|
|
190
|
-
action: 'test',
|
|
191
|
-
});
|
|
192
|
-
const response = encodeRpcResponse({
|
|
193
|
-
requestId: 1,
|
|
194
|
-
requesterClientId: 20,
|
|
195
|
-
result: { data: 'ok', error: null },
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(decodeRpcMessage(request).type).toBe('request');
|
|
199
|
-
expect(decodeRpcMessage(response).type).toBe('response');
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test('decodeRpcMessage throws on non-RPC message', () => {
|
|
203
|
-
const syncMessage = encodeSyncStep1({ doc: new Y.Doc() });
|
|
204
|
-
expect(() => decodeRpcMessage(syncMessage)).toThrow(
|
|
205
|
-
'Expected RPC message (101), got 0',
|
|
206
|
-
);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
27
|
describe('SYNC_MESSAGE_TYPE constants', () => {
|
|
211
28
|
test('have expected numeric values', () => {
|
|
212
29
|
expect(SYNC_MESSAGE_TYPE.STEP1).toBe(0);
|
|
@@ -216,416 +33,124 @@ describe('SYNC_MESSAGE_TYPE constants', () => {
|
|
|
216
33
|
});
|
|
217
34
|
|
|
218
35
|
// ============================================================================
|
|
219
|
-
//
|
|
36
|
+
// Frame Encoders
|
|
220
37
|
// ============================================================================
|
|
221
38
|
|
|
222
|
-
describe('
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const message = encodeSyncStep1({ doc });
|
|
227
|
-
const decoded = decodeSyncMessage(message);
|
|
228
|
-
|
|
229
|
-
expect(decoded.type).toBe('step1');
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test('encodes document with content', () => {
|
|
233
|
-
const doc = createDoc((d) => {
|
|
234
|
-
d.getMap('data').set('key', 'value');
|
|
235
|
-
});
|
|
236
|
-
const message = encodeSyncStep1({ doc });
|
|
237
|
-
const decoded = decodeSyncMessage(message);
|
|
238
|
-
|
|
239
|
-
expect(decoded.type).toBe('step1');
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test('state vector changes after modification', () => {
|
|
243
|
-
const doc = createDoc();
|
|
244
|
-
const message1 = encodeSyncStep1({ doc });
|
|
245
|
-
|
|
246
|
-
doc.getMap('data').set('key', 'value');
|
|
247
|
-
const message2 = encodeSyncStep1({ doc });
|
|
248
|
-
|
|
249
|
-
// Different state vectors = different messages
|
|
250
|
-
expect(message1).not.toEqual(message2);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('can be decoded by y-protocols', () => {
|
|
254
|
-
const doc = createDoc((d) => {
|
|
255
|
-
d.getMap('test').set('foo', 'bar');
|
|
256
|
-
});
|
|
257
|
-
const message = encodeSyncStep1({ doc });
|
|
258
|
-
const decoded = decodeSyncMessage(message);
|
|
259
|
-
|
|
260
|
-
expect(decoded.type).toBe('step1');
|
|
261
|
-
if (decoded.type === 'step1') {
|
|
262
|
-
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
263
|
-
expect(decoded.stateVector.length).toBeGreaterThan(0);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe('encodeSyncStep2', () => {
|
|
269
|
-
test('encodes document diff', () => {
|
|
270
|
-
const doc = createDoc((d) => {
|
|
271
|
-
d.getMap('data').set('key', 'value');
|
|
272
|
-
});
|
|
273
|
-
const message = encodeSyncStep2({ doc });
|
|
274
|
-
const decoded = decodeSyncMessage(message);
|
|
275
|
-
|
|
276
|
-
expect(decoded.type).toBe('step2');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test('contains update data', () => {
|
|
280
|
-
const doc = createDoc((d) => {
|
|
281
|
-
d.getMap('data').set('key', 'value');
|
|
282
|
-
});
|
|
283
|
-
const message = encodeSyncStep2({ doc });
|
|
284
|
-
const decoded = decodeSyncMessage(message);
|
|
39
|
+
describe('encodeSyncStep1', () => {
|
|
40
|
+
test('produces a STEP1 frame carrying the document state vector', () => {
|
|
41
|
+
const doc = createDoc((d) => d.getMap('test').set('foo', 'bar'));
|
|
42
|
+
const { syncType, payload } = readFrame(encodeSyncStep1({ doc }));
|
|
285
43
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
expect(decoded.update.length).toBeGreaterThan(0);
|
|
289
|
-
}
|
|
290
|
-
});
|
|
44
|
+
expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP1);
|
|
45
|
+
expect(payload).toEqual(Y.encodeStateVector(doc));
|
|
291
46
|
});
|
|
47
|
+
});
|
|
292
48
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
capturedUpdate = update;
|
|
300
|
-
});
|
|
301
|
-
doc.getMap('data').set('key', 'value');
|
|
302
|
-
|
|
303
|
-
expect(capturedUpdate).not.toBeNull();
|
|
304
|
-
if (!capturedUpdate) {
|
|
305
|
-
throw new Error('Expected captured update after document mutation');
|
|
306
|
-
}
|
|
307
|
-
const message = encodeSyncUpdate({ update: capturedUpdate });
|
|
308
|
-
const decoded = decodeSyncMessage(message);
|
|
309
|
-
|
|
310
|
-
expect(decoded.type).toBe('update');
|
|
49
|
+
describe('encodeSyncUpdate', () => {
|
|
50
|
+
test('produces an UPDATE frame carrying the update bytes', () => {
|
|
51
|
+
const doc = createDoc();
|
|
52
|
+
let captured: Uint8Array | null = null;
|
|
53
|
+
doc.on('updateV2', (update: Uint8Array) => {
|
|
54
|
+
captured = update;
|
|
311
55
|
});
|
|
56
|
+
doc.getMap('data').set('key', 'value');
|
|
312
57
|
|
|
313
|
-
|
|
314
|
-
|
|
58
|
+
if (!captured) {
|
|
59
|
+
throw new Error('Expected a captured update after document mutation');
|
|
60
|
+
}
|
|
61
|
+
const { syncType, payload } = readFrame(
|
|
62
|
+
encodeSyncUpdate({ update: captured }),
|
|
63
|
+
);
|
|
315
64
|
|
|
316
|
-
|
|
317
|
-
|
|
65
|
+
expect(syncType).toBe(SYNC_MESSAGE_TYPE.UPDATE);
|
|
66
|
+
expect(payload).toEqual(captured);
|
|
318
67
|
});
|
|
319
68
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
});
|
|
325
|
-
const clientDoc = createDoc();
|
|
326
|
-
|
|
327
|
-
const response = handleSyncPayload({
|
|
328
|
-
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
329
|
-
payload: Y.encodeStateVector(clientDoc),
|
|
330
|
-
doc: serverDoc,
|
|
331
|
-
origin: 'test-client',
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
expect(response).not.toBeNull();
|
|
335
|
-
if (!response) {
|
|
336
|
-
throw new Error(
|
|
337
|
-
'Expected sync step 2 response for sync step 1 payload',
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
const decoded = decodeSyncMessage(response);
|
|
341
|
-
expect(decoded.type).toBe('step2');
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
test('returns null for sync step 2 (no response needed)', () => {
|
|
345
|
-
const serverDoc = createDoc();
|
|
346
|
-
const clientDoc = createDoc((d) => {
|
|
347
|
-
d.getMap('data').set('client', 'content');
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const response = handleSyncPayload({
|
|
351
|
-
syncType: SYNC_MESSAGE_TYPE.STEP2,
|
|
352
|
-
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
353
|
-
doc: serverDoc,
|
|
354
|
-
origin: 'test-client',
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
expect(response).toBeNull();
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test('returns null for sync update (no response needed)', () => {
|
|
361
|
-
const serverDoc = createDoc();
|
|
362
|
-
const updateV2 = Y.encodeStateAsUpdateV2(
|
|
363
|
-
createDoc((d) => d.getMap('data').set('key', 'value')),
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const response = handleSyncPayload({
|
|
367
|
-
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
368
|
-
payload: updateV2,
|
|
369
|
-
doc: serverDoc,
|
|
370
|
-
origin: 'test-client',
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
expect(response).toBeNull();
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test('applies update to document', () => {
|
|
377
|
-
const serverDoc = createDoc();
|
|
378
|
-
const clientDoc = createDoc((d) => {
|
|
379
|
-
d.getMap('data').set('key', 'value');
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
handleSyncPayload({
|
|
383
|
-
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
384
|
-
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
385
|
-
doc: serverDoc,
|
|
386
|
-
origin: 'test-client',
|
|
387
|
-
});
|
|
69
|
+
test('handles an empty update', () => {
|
|
70
|
+
const { syncType, payload } = readFrame(
|
|
71
|
+
encodeSyncUpdate({ update: new Uint8Array(0) }),
|
|
72
|
+
);
|
|
388
73
|
|
|
389
|
-
|
|
390
|
-
|
|
74
|
+
expect(syncType).toBe(SYNC_MESSAGE_TYPE.UPDATE);
|
|
75
|
+
expect(payload.length).toBe(0);
|
|
391
76
|
});
|
|
392
77
|
});
|
|
393
78
|
|
|
394
79
|
// ============================================================================
|
|
395
|
-
//
|
|
80
|
+
// handleSyncPayload
|
|
396
81
|
// ============================================================================
|
|
397
82
|
|
|
398
|
-
describe('
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const awareness = new Awareness(doc);
|
|
403
|
-
awareness.setLocalState({ name: 'User 1', cursor: { x: 10, y: 20 } });
|
|
404
|
-
|
|
405
|
-
const message = encodeAwarenessStates({
|
|
406
|
-
awareness,
|
|
407
|
-
clients: [awareness.clientID],
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
test('encodes complex nested state', () => {
|
|
414
|
-
const doc = createDoc();
|
|
415
|
-
const awareness = new Awareness(doc);
|
|
416
|
-
awareness.setLocalState({
|
|
417
|
-
user: { name: 'Test', color: '#ff0000' },
|
|
418
|
-
cursor: { position: { x: 100, y: 200 }, selection: [0, 10] },
|
|
419
|
-
metadata: { version: 1, flags: ['active'] },
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
const message = encodeAwarenessStates({
|
|
423
|
-
awareness,
|
|
424
|
-
clients: [awareness.clientID],
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
test('handles special characters in state', () => {
|
|
431
|
-
const doc = createDoc();
|
|
432
|
-
const awareness = new Awareness(doc);
|
|
433
|
-
awareness.setLocalState({
|
|
434
|
-
name: 'User with "quotes" and \'apostrophes\'',
|
|
435
|
-
emoji: '🎉🚀',
|
|
436
|
-
newlines: 'line1\nline2',
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
const message = encodeAwarenessStates({
|
|
440
|
-
awareness,
|
|
441
|
-
clients: [awareness.clientID],
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test('handles large awareness state', () => {
|
|
448
|
-
const doc = createDoc();
|
|
449
|
-
const awareness = new Awareness(doc);
|
|
450
|
-
awareness.setLocalState({
|
|
451
|
-
largeArray: Array(1000).fill('item'),
|
|
452
|
-
largeString: 'x'.repeat(10000),
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
const message = encodeAwarenessStates({
|
|
456
|
-
awareness,
|
|
457
|
-
clients: [awareness.clientID],
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
461
|
-
expect(message.length).toBeGreaterThan(10000);
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
describe('encodeAwareness', () => {
|
|
466
|
-
test('wraps raw awareness update', () => {
|
|
467
|
-
const doc = createDoc();
|
|
468
|
-
const awareness = new Awareness(doc);
|
|
469
|
-
awareness.setLocalState({ name: 'Test' });
|
|
470
|
-
|
|
471
|
-
const update = encodeAwarenessUpdate(awareness, [awareness.clientID]);
|
|
472
|
-
const message = encodeAwareness({ update });
|
|
473
|
-
|
|
474
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
describe('awareness protocol compatibility', () => {
|
|
479
|
-
test('encoded awareness can be applied to another instance', () => {
|
|
480
|
-
const doc1 = createDoc();
|
|
481
|
-
const awareness1 = new Awareness(doc1);
|
|
482
|
-
awareness1.setLocalState({ name: 'User 1' });
|
|
483
|
-
|
|
484
|
-
const doc2 = createDoc();
|
|
485
|
-
const awareness2 = new Awareness(doc2);
|
|
486
|
-
|
|
487
|
-
// Encode from awareness1
|
|
488
|
-
const update = encodeAwarenessUpdate(awareness1, [awareness1.clientID]);
|
|
489
|
-
|
|
490
|
-
// Apply to awareness2
|
|
491
|
-
applyAwarenessUpdate(awareness2, update, 'remote');
|
|
492
|
-
|
|
493
|
-
// awareness2 should have awareness1's state
|
|
494
|
-
const states = awareness2.getStates();
|
|
495
|
-
expect(states.has(awareness1.clientID)).toBe(true);
|
|
496
|
-
expect(states.get(awareness1.clientID)).toEqual({ name: 'User 1' });
|
|
83
|
+
describe('handleSyncPayload', () => {
|
|
84
|
+
test('responds to STEP1 with a STEP2 frame the client can apply', () => {
|
|
85
|
+
const serverDoc = createDoc((d) => {
|
|
86
|
+
d.getMap('data').set('server', 'content');
|
|
497
87
|
});
|
|
88
|
+
const clientDoc = createDoc();
|
|
498
89
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
expect(awareness.getStates().has(awareness.clientID)).toBe(true);
|
|
505
|
-
|
|
506
|
-
// Setting null removes the state
|
|
507
|
-
awareness.setLocalState(null);
|
|
508
|
-
|
|
509
|
-
expect(awareness.getStates().has(awareness.clientID)).toBe(false);
|
|
90
|
+
const response = handleSyncPayload({
|
|
91
|
+
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
92
|
+
payload: Y.encodeStateVector(clientDoc),
|
|
93
|
+
doc: serverDoc,
|
|
94
|
+
origin: 'test-client',
|
|
510
95
|
});
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
// ============================================================================
|
|
515
|
-
// MESSAGE_QUERY_AWARENESS Tests
|
|
516
|
-
// ============================================================================
|
|
517
|
-
|
|
518
|
-
describe('MESSAGE_QUERY_AWARENESS', () => {
|
|
519
|
-
test('query awareness message is single byte', () => {
|
|
520
|
-
const message = encodeQueryAwareness();
|
|
521
96
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
});
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
// ============================================================================
|
|
528
|
-
// Decoder Tests
|
|
529
|
-
// ============================================================================
|
|
530
|
-
|
|
531
|
-
describe('decodeSyncMessage', () => {
|
|
532
|
-
test('decodes sync step 1 message', () => {
|
|
533
|
-
const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
|
|
534
|
-
const encoded = encodeSyncStep1({ doc });
|
|
535
|
-
const decoded = decodeSyncMessage(encoded);
|
|
536
|
-
|
|
537
|
-
expect(decoded.type).toBe('step1');
|
|
538
|
-
if (decoded.type === 'step1') {
|
|
539
|
-
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
540
|
-
expect(decoded.stateVector.length).toBeGreaterThan(0);
|
|
97
|
+
if (!response) {
|
|
98
|
+
throw new Error('Expected a STEP2 response for a STEP1 payload');
|
|
541
99
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
test('decodes sync step 2 message', () => {
|
|
545
|
-
const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
|
|
546
|
-
const encoded = encodeSyncStep2({ doc });
|
|
547
|
-
const decoded = decodeSyncMessage(encoded);
|
|
100
|
+
const { syncType, payload } = readFrame(response);
|
|
101
|
+
expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP2);
|
|
548
102
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
expect(decoded.update.length).toBeGreaterThan(0);
|
|
553
|
-
}
|
|
103
|
+
// The client applies the STEP2 payload and converges on server content.
|
|
104
|
+
Y.applyUpdateV2(clientDoc, payload);
|
|
105
|
+
expect(clientDoc.getMap('data').get('server')).toBe('content');
|
|
554
106
|
});
|
|
555
107
|
|
|
556
|
-
test('
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
capturedUpdate = update;
|
|
108
|
+
test('returns null for sync step 2 (no response needed)', () => {
|
|
109
|
+
const serverDoc = createDoc();
|
|
110
|
+
const clientDoc = createDoc((d) => {
|
|
111
|
+
d.getMap('data').set('client', 'content');
|
|
561
112
|
});
|
|
562
|
-
doc.getMap('test').set('key', 'value');
|
|
563
113
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
114
|
+
const response = handleSyncPayload({
|
|
115
|
+
syncType: SYNC_MESSAGE_TYPE.STEP2,
|
|
116
|
+
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
117
|
+
doc: serverDoc,
|
|
118
|
+
origin: 'test-client',
|
|
119
|
+
});
|
|
569
120
|
|
|
570
|
-
expect(
|
|
571
|
-
if (decoded.type === 'update') {
|
|
572
|
-
expect(decoded.update).toBeInstanceOf(Uint8Array);
|
|
573
|
-
}
|
|
121
|
+
expect(response).toBeNull();
|
|
574
122
|
});
|
|
575
123
|
|
|
576
|
-
test('
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
const awarenessMessage = encodeAwarenessStates({
|
|
581
|
-
awareness,
|
|
582
|
-
clients: [awareness.clientID],
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
expect(() => decodeSyncMessage(awarenessMessage)).toThrow(
|
|
586
|
-
'Expected SYNC message (0), got 1',
|
|
124
|
+
test('returns null for sync update (no response needed)', () => {
|
|
125
|
+
const serverDoc = createDoc();
|
|
126
|
+
const updateV2 = Y.encodeStateAsUpdateV2(
|
|
127
|
+
createDoc((d) => d.getMap('data').set('key', 'value')),
|
|
587
128
|
);
|
|
588
|
-
});
|
|
589
129
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
130
|
+
const response = handleSyncPayload({
|
|
131
|
+
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
132
|
+
payload: updateV2,
|
|
133
|
+
doc: serverDoc,
|
|
134
|
+
origin: 'test-client',
|
|
594
135
|
});
|
|
595
136
|
|
|
596
|
-
|
|
597
|
-
const step1 = encodeSyncStep1({ doc });
|
|
598
|
-
const decodedStep1 = decodeSyncMessage(step1);
|
|
599
|
-
expect(decodedStep1.type).toBe('step1');
|
|
600
|
-
|
|
601
|
-
// Test step 2 roundtrip
|
|
602
|
-
const step2 = encodeSyncStep2({ doc });
|
|
603
|
-
const decodedStep2 = decodeSyncMessage(step2);
|
|
604
|
-
expect(decodedStep2.type).toBe('step2');
|
|
137
|
+
expect(response).toBeNull();
|
|
605
138
|
});
|
|
606
|
-
});
|
|
607
139
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
});
|
|
140
|
+
test('applies update to document', () => {
|
|
141
|
+
const serverDoc = createDoc();
|
|
142
|
+
const clientDoc = createDoc((d) => {
|
|
143
|
+
d.getMap('data').set('key', 'value');
|
|
144
|
+
});
|
|
614
145
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
awareness,
|
|
621
|
-
clients: [awareness.clientID],
|
|
146
|
+
handleSyncPayload({
|
|
147
|
+
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
148
|
+
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
149
|
+
doc: serverDoc,
|
|
150
|
+
origin: 'test-client',
|
|
622
151
|
});
|
|
623
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
624
|
-
});
|
|
625
152
|
|
|
626
|
-
|
|
627
|
-
const message = encodeQueryAwareness();
|
|
628
|
-
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
|
|
153
|
+
expect(serverDoc.getMap('data').get('key')).toBe('value');
|
|
629
154
|
});
|
|
630
155
|
});
|
|
631
156
|
|
|
@@ -640,67 +165,24 @@ describe('full sync protocol', () => {
|
|
|
640
165
|
});
|
|
641
166
|
const clientDoc = createDoc();
|
|
642
167
|
|
|
643
|
-
// Server handles client's state vector and responds with
|
|
168
|
+
// Server handles the client's state vector and responds with STEP2.
|
|
644
169
|
const serverResponse = handleSyncPayload({
|
|
645
170
|
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
646
171
|
payload: Y.encodeStateVector(clientDoc),
|
|
647
172
|
doc: serverDoc,
|
|
648
173
|
origin: 'client',
|
|
649
174
|
});
|
|
650
|
-
|
|
651
|
-
expect(serverResponse).not.toBeNull();
|
|
652
175
|
if (!serverResponse) {
|
|
653
|
-
throw new Error('Expected server sync response during handshake');
|
|
176
|
+
throw new Error('Expected a server sync response during handshake');
|
|
654
177
|
}
|
|
655
178
|
|
|
656
|
-
// Client applies
|
|
657
|
-
const
|
|
658
|
-
expect(
|
|
659
|
-
|
|
660
|
-
Y.applyUpdateV2(clientDoc, decoded.update, 'server');
|
|
661
|
-
}
|
|
179
|
+
// Client decodes the STEP2 frame and applies it through the same path.
|
|
180
|
+
const { syncType, payload } = readFrame(serverResponse);
|
|
181
|
+
expect(syncType).toBe(SYNC_MESSAGE_TYPE.STEP2);
|
|
182
|
+
handleSyncPayload({ syncType, payload, doc: clientDoc, origin: 'server' });
|
|
662
183
|
|
|
663
|
-
// Client should have server's content
|
|
664
184
|
expect(clientDoc.getMap('notes').get('note1')).toBe('Hello from server');
|
|
665
185
|
});
|
|
666
|
-
|
|
667
|
-
test('bidirectional sync merges both documents', () => {
|
|
668
|
-
const doc1 = createDoc((d) => d.getMap('data').set('from1', 'value1'));
|
|
669
|
-
const doc2 = createDoc((d) => d.getMap('data').set('from2', 'value2'));
|
|
670
|
-
|
|
671
|
-
// Full bidirectional sync using Yjs V2 pattern
|
|
672
|
-
syncDocs(doc1, doc2);
|
|
673
|
-
|
|
674
|
-
expect(doc1.getMap('data').get('from1')).toBe('value1');
|
|
675
|
-
expect(doc1.getMap('data').get('from2')).toBe('value2');
|
|
676
|
-
expect(doc2.getMap('data').get('from1')).toBe('value1');
|
|
677
|
-
expect(doc2.getMap('data').get('from2')).toBe('value2');
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
test('incremental updates are applied correctly', () => {
|
|
681
|
-
const doc1 = createDoc();
|
|
682
|
-
const doc2 = createDoc();
|
|
683
|
-
|
|
684
|
-
// Capture V2 updates from doc1
|
|
685
|
-
const updates: Uint8Array[] = [];
|
|
686
|
-
doc1.on('updateV2', (update: Uint8Array) => {
|
|
687
|
-
updates.push(update);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
// Make changes
|
|
691
|
-
doc1.getMap('data').set('key1', 'value1');
|
|
692
|
-
doc1.getMap('data').set('key2', 'value2');
|
|
693
|
-
doc1.getArray('list').push(['item1', 'item2']);
|
|
694
|
-
|
|
695
|
-
// Apply V2 updates to doc2
|
|
696
|
-
for (const update of updates) {
|
|
697
|
-
Y.applyUpdateV2(doc2, update);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
expect(doc2.getMap('data').get('key1')).toBe('value1');
|
|
701
|
-
expect(doc2.getMap('data').get('key2')).toBe('value2');
|
|
702
|
-
expect(doc2.getArray('list').toArray()).toEqual(['item1', 'item2']);
|
|
703
|
-
});
|
|
704
186
|
});
|
|
705
187
|
|
|
706
188
|
// ============================================================================
|
|
@@ -708,51 +190,28 @@ describe('full sync protocol', () => {
|
|
|
708
190
|
// ============================================================================
|
|
709
191
|
|
|
710
192
|
describe('edge cases', () => {
|
|
711
|
-
test('
|
|
712
|
-
const
|
|
193
|
+
test('handshake syncs a large document (1000+ operations)', () => {
|
|
194
|
+
const serverDoc = createDoc((d) => {
|
|
713
195
|
const arr = d.getArray<string>('items');
|
|
714
196
|
for (let i = 0; i < 1000; i++) {
|
|
715
197
|
arr.push([`item-${i}`]);
|
|
716
198
|
}
|
|
717
199
|
});
|
|
200
|
+
const clientDoc = createDoc();
|
|
718
201
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
test('handles concurrent modifications (CRDT merge)', () => {
|
|
730
|
-
const doc1 = createDoc();
|
|
731
|
-
const doc2 = createDoc();
|
|
732
|
-
|
|
733
|
-
// Both modify same key concurrently
|
|
734
|
-
doc1.getMap('data').set('key', 'value1');
|
|
735
|
-
doc2.getMap('data').set('key', 'value2');
|
|
736
|
-
|
|
737
|
-
// Sync should resolve deterministically
|
|
738
|
-
syncDocs(doc1, doc2);
|
|
739
|
-
|
|
740
|
-
// Both should have same value (CRDT resolution)
|
|
741
|
-
const val1 = doc1.getMap('data').get('key');
|
|
742
|
-
const val2 = doc2.getMap('data').get('key');
|
|
743
|
-
expect(val1).toBe(val2);
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
test('empty document produces valid sync step 1', () => {
|
|
747
|
-
const doc = createDoc();
|
|
748
|
-
const message = encodeSyncStep1({ doc });
|
|
749
|
-
const decoded = decodeSyncMessage(message);
|
|
750
|
-
|
|
751
|
-
expect(decoded.type).toBe('step1');
|
|
752
|
-
if (decoded.type === 'step1') {
|
|
753
|
-
// Even empty docs have a state vector (contains clientID info)
|
|
754
|
-
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
202
|
+
const response = handleSyncPayload({
|
|
203
|
+
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
204
|
+
payload: Y.encodeStateVector(clientDoc),
|
|
205
|
+
doc: serverDoc,
|
|
206
|
+
origin: 'client',
|
|
207
|
+
});
|
|
208
|
+
if (!response) {
|
|
209
|
+
throw new Error('Expected a STEP2 response for the handshake');
|
|
755
210
|
}
|
|
211
|
+
const { syncType, payload } = readFrame(response);
|
|
212
|
+
handleSyncPayload({ syncType, payload, doc: clientDoc, origin: 'server' });
|
|
213
|
+
|
|
214
|
+
expect(clientDoc.getArray('items').length).toBe(1000);
|
|
756
215
|
});
|
|
757
216
|
});
|
|
758
217
|
|
|
@@ -767,10 +226,18 @@ function createDoc(init?: (doc: Y.Doc) => void): Y.Doc {
|
|
|
767
226
|
return doc;
|
|
768
227
|
}
|
|
769
228
|
|
|
770
|
-
/**
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Decode a binary sync frame into its sub-type and payload. Mirrors the
|
|
231
|
+
* production transport decode (`Room.webSocketMessage` / `sync-supervisor`),
|
|
232
|
+
* which asserts the sub-type varint as `SyncMessageType`.
|
|
233
|
+
*/
|
|
234
|
+
function readFrame(data: Uint8Array): {
|
|
235
|
+
syncType: SyncMessageType;
|
|
236
|
+
payload: Uint8Array;
|
|
237
|
+
} {
|
|
238
|
+
const decoder = decoding.createDecoder(data);
|
|
239
|
+
return {
|
|
240
|
+
syncType: decoding.readVarUint(decoder) as SyncMessageType,
|
|
241
|
+
payload: decoding.readVarUint8Array(decoder),
|
|
242
|
+
};
|
|
776
243
|
}
|