@epicenter/sync 0.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 +666 -0
- package/README.md +107 -0
- package/package.json +43 -0
- package/src/index.ts +39 -0
- package/src/protocol.test.ts +776 -0
- package/src/protocol.ts +612 -0
- package/src/rpc-errors.ts +89 -0
- package/tsconfig.json +8 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yjs WebSocket Protocol Encoding/Decoding Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for encoding and decoding y-websocket protocol messages.
|
|
5
|
+
* Separates protocol handling from transport (WebSocket handling).
|
|
6
|
+
*
|
|
7
|
+
* All sync payloads use Yjs V2 encoding for ~40% smaller wire size.
|
|
8
|
+
* State vectors are version-independent (same format for V1 and V2).
|
|
9
|
+
*
|
|
10
|
+
* Based on patterns from y-redis protocol.js:
|
|
11
|
+
* - Message type constants as first-class exports
|
|
12
|
+
* - Pure encoder/decoder functions
|
|
13
|
+
* - Single responsibility: protocol only, no transport logic
|
|
14
|
+
*
|
|
15
|
+
* @see https://github.com/yjs/y-redis/blob/main/src/protocol.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as decoding from 'lib0/decoding';
|
|
19
|
+
import * as encoding from 'lib0/encoding';
|
|
20
|
+
import { type Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness';
|
|
21
|
+
import * as Y from 'yjs';
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Top-Level Message Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Top-level message types in the y-websocket protocol.
|
|
29
|
+
* The first varint in any message identifies its type.
|
|
30
|
+
*
|
|
31
|
+
* Standard y-protocols: 0–1. y-websocket conventions: 2–3.
|
|
32
|
+
* Reserved 4–99 (buffer for future upstream additions).
|
|
33
|
+
* Epicenter extensions: 100+.
|
|
34
|
+
*/
|
|
35
|
+
export const MESSAGE_TYPE = {
|
|
36
|
+
/** Document synchronization messages (sync step 1, 2, or update) */
|
|
37
|
+
SYNC: 0,
|
|
38
|
+
/** User presence/cursor information */
|
|
39
|
+
AWARENESS: 1,
|
|
40
|
+
/** Authentication (reserved for future use) */
|
|
41
|
+
AUTH: 2,
|
|
42
|
+
/** Request current awareness states from server */
|
|
43
|
+
QUERY_AWARENESS: 3,
|
|
44
|
+
/**
|
|
45
|
+
* Version tracking for “Saving…”/“Saved” UX.
|
|
46
|
+
*
|
|
47
|
+
* NOT a heartbeat—liveness uses text ping/pong.
|
|
48
|
+
*
|
|
49
|
+
* ## How it works
|
|
50
|
+
*
|
|
51
|
+
* 1. Client increments a monotonic `localVersion` on every doc update.
|
|
52
|
+
* 2. Client sends `[100, varuint(localVersion)]` to the server.
|
|
53
|
+
* 3. Server echoes the raw payload back unchanged (zero parsing cost).
|
|
54
|
+
* 4. Client compares `ackedVersion` (from echo) to `localVersion`:
|
|
55
|
+
* - `ackedVersion < localVersion` → `hasLocalChanges = true` (“Saving…”)
|
|
56
|
+
* - `ackedVersion >= localVersion` → `hasLocalChanges = false` (“Saved”)
|
|
57
|
+
*
|
|
58
|
+
* ## Design (inspired by y-sweet)
|
|
59
|
+
*
|
|
60
|
+
* The server is intentionally dumb—it never parses the payload. This means
|
|
61
|
+
* the version semantics can evolve client-side without server changes.
|
|
62
|
+
*
|
|
63
|
+
* Wire format: `[varuint: 100] [varuint: localVersion]`
|
|
64
|
+
*/
|
|
65
|
+
SYNC_STATUS: 100,
|
|
66
|
+
/**
|
|
67
|
+
* Remote procedure call between peers, routed through the DO.
|
|
68
|
+
* Uses REQUEST/RESPONSE sub-types.
|
|
69
|
+
*
|
|
70
|
+
* Wire format (REQUEST):
|
|
71
|
+
* `[varuint: 101] [varuint: 0] [varuint: requestId] [varuint: targetClientId] [varuint: requesterClientId] [varString: action] [varUint8Array: JSON input]`
|
|
72
|
+
*
|
|
73
|
+
* Wire format (RESPONSE):
|
|
74
|
+
* `[varuint: 101] [varuint: 1] [varuint: requestId] [varuint: requesterClientId] [varUint8Array: JSON { data, error }]`
|
|
75
|
+
*/
|
|
76
|
+
RPC: 101,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export type MessageType = (typeof MESSAGE_TYPE)[keyof typeof MESSAGE_TYPE];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decodes the top-level message type from raw message data.
|
|
83
|
+
*
|
|
84
|
+
* The first varint in any y-websocket message is the message type:
|
|
85
|
+
* - 0: MESSAGE_SYNC (document sync)
|
|
86
|
+
* - 1: MESSAGE_AWARENESS (user presence)
|
|
87
|
+
* - 2: MESSAGE_AUTH (authentication, reserved)
|
|
88
|
+
* - 3: MESSAGE_QUERY_AWARENESS (request awareness states)
|
|
89
|
+
*
|
|
90
|
+
* Useful for quickly determining message type before full parsing.
|
|
91
|
+
*
|
|
92
|
+
* @param data - Raw message bytes
|
|
93
|
+
* @returns The message type constant (0=SYNC, 1=AWARENESS, etc.)
|
|
94
|
+
*/
|
|
95
|
+
export function decodeMessageType(data: Uint8Array): number {
|
|
96
|
+
const decoder = decoding.createDecoder(data);
|
|
97
|
+
return decoding.readVarUint(decoder);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Sync Protocol (V2 encoding)
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sub-message types within SYNC messages.
|
|
106
|
+
* Derived from y-protocols/sync constants for consistency.
|
|
107
|
+
*
|
|
108
|
+
* These are the second varint in a SYNC message, after MESSAGE_TYPE.SYNC.
|
|
109
|
+
*/
|
|
110
|
+
export const SYNC_MESSAGE_TYPE = {
|
|
111
|
+
/** Initial handshake: "here's my state vector, what am I missing?" */
|
|
112
|
+
STEP1: 0,
|
|
113
|
+
/** Response to STEP1: "here are the updates you're missing" */
|
|
114
|
+
STEP2: 1,
|
|
115
|
+
/** Incremental document update broadcast */
|
|
116
|
+
UPDATE: 2,
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
export type SyncMessageType =
|
|
120
|
+
(typeof SYNC_MESSAGE_TYPE)[keyof typeof SYNC_MESSAGE_TYPE];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decoded sync message - discriminated union of the three sync sub-types.
|
|
124
|
+
* Update payloads are V2-encoded.
|
|
125
|
+
*/
|
|
126
|
+
export type DecodedSyncMessage =
|
|
127
|
+
| { type: 'step1'; stateVector: Uint8Array }
|
|
128
|
+
| { type: 'step2'; update: Uint8Array }
|
|
129
|
+
| { type: 'update'; update: Uint8Array };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Encodes a sync step 1 message containing the document's state vector.
|
|
133
|
+
*
|
|
134
|
+
* This is the first message in the Yjs sync protocol handshake. The server
|
|
135
|
+
* sends its state vector to the client, asking "what updates do you have
|
|
136
|
+
* that I'm missing?" The client responds with sync step 2 containing any
|
|
137
|
+
* updates the server doesn't have.
|
|
138
|
+
*
|
|
139
|
+
* State vector encoding is version-independent (same for V1 and V2).
|
|
140
|
+
*
|
|
141
|
+
* @param options.doc - The Yjs document to get the state vector from
|
|
142
|
+
* @returns Encoded message ready to send over WebSocket
|
|
143
|
+
*/
|
|
144
|
+
export function encodeSyncStep1({ doc }: { doc: Y.Doc }): Uint8Array {
|
|
145
|
+
return encoding.encode((encoder) => {
|
|
146
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
147
|
+
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP1);
|
|
148
|
+
encoding.writeVarUint8Array(encoder, Y.encodeStateVector(doc));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encodes a sync step 2 message containing the FULL document state (V2).
|
|
154
|
+
*
|
|
155
|
+
* Unlike the step 2 response generated by {@link handleSyncPayload} (which
|
|
156
|
+
* computes a diff against the remote's state vector), this encodes the
|
|
157
|
+
* entire document with no diffing. Useful for bootstrapping a fresh client
|
|
158
|
+
* that has no prior state.
|
|
159
|
+
*
|
|
160
|
+
* @param options.doc - The Yjs document to encode in full
|
|
161
|
+
* @returns Encoded MESSAGE_SYNC + STEP2 message with full doc state
|
|
162
|
+
*/
|
|
163
|
+
export function encodeSyncStep2({ doc }: { doc: Y.Doc }): Uint8Array {
|
|
164
|
+
return encoding.encode((encoder) => {
|
|
165
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
166
|
+
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP2);
|
|
167
|
+
encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdateV2(doc));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Encodes a document update message for broadcasting to clients.
|
|
173
|
+
*
|
|
174
|
+
* After initial sync, any changes to the document are broadcast as update
|
|
175
|
+
* messages. These are incremental and can be applied in any order due to
|
|
176
|
+
* Yjs's CRDT properties.
|
|
177
|
+
*
|
|
178
|
+
* @param options.update - V2-encoded Yjs update bytes (from doc.on('updateV2'))
|
|
179
|
+
* @returns Encoded message ready to send over WebSocket
|
|
180
|
+
*/
|
|
181
|
+
export function encodeSyncUpdate({
|
|
182
|
+
update,
|
|
183
|
+
}: {
|
|
184
|
+
update: Uint8Array;
|
|
185
|
+
}): Uint8Array {
|
|
186
|
+
return encoding.encode((encoder) => {
|
|
187
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
188
|
+
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.UPDATE);
|
|
189
|
+
encoding.writeVarUint8Array(encoder, update);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Decodes a sync protocol message into its components.
|
|
195
|
+
*
|
|
196
|
+
* Pure decoder that returns the message type and payload without side effects.
|
|
197
|
+
* Useful for testing, logging, and protocol inspection. Update payloads are
|
|
198
|
+
* V2-encoded.
|
|
199
|
+
*
|
|
200
|
+
* @param data - Raw message bytes
|
|
201
|
+
* @returns Decoded message with type discriminator and payload
|
|
202
|
+
* @throws Error if message is not a valid SYNC message or has unknown sync type
|
|
203
|
+
*/
|
|
204
|
+
export function decodeSyncMessage(data: Uint8Array): DecodedSyncMessage {
|
|
205
|
+
const decoder = decoding.createDecoder(data);
|
|
206
|
+
const messageType = decoding.readVarUint(decoder);
|
|
207
|
+
if (messageType !== MESSAGE_TYPE.SYNC) {
|
|
208
|
+
throw new Error(`Expected SYNC message (0), got ${messageType}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const syncType = decoding.readVarUint(decoder);
|
|
212
|
+
const payload = decoding.readVarUint8Array(decoder);
|
|
213
|
+
|
|
214
|
+
switch (syncType) {
|
|
215
|
+
case SYNC_MESSAGE_TYPE.STEP1:
|
|
216
|
+
return { type: 'step1', stateVector: payload };
|
|
217
|
+
case SYNC_MESSAGE_TYPE.STEP2:
|
|
218
|
+
return { type: 'step2', update: payload };
|
|
219
|
+
case SYNC_MESSAGE_TYPE.UPDATE:
|
|
220
|
+
return { type: 'update', update: payload };
|
|
221
|
+
default:
|
|
222
|
+
throw new Error(`Unknown sync type: ${syncType}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle a decoded sync sub-message and return a response if needed.
|
|
228
|
+
*
|
|
229
|
+
* Pre-decoded alternative to y-protocols' `readSyncMessage` — accepts already-
|
|
230
|
+
* decoded `syncType` and `payload` instead of a mutable lib0 decoder. The
|
|
231
|
+
* caller reads these two fields from the decoder inline (consistent with how
|
|
232
|
+
* AWARENESS and SYNC_STATUS cases are already handled at every call site).
|
|
233
|
+
*
|
|
234
|
+
* Dispatches on the three sync sub-types (all V2 encoded):
|
|
235
|
+
* - STEP1: `payload` is a state vector → responds with a V2 diff (STEP2)
|
|
236
|
+
* - STEP2: `payload` is a V2 update → applied to doc, no response
|
|
237
|
+
* - UPDATE: `payload` is a V2 update → applied to doc, no response
|
|
238
|
+
*
|
|
239
|
+
* @param options.syncType - Which sync sub-message (STEP1, STEP2, or UPDATE)
|
|
240
|
+
* @param options.payload - The sub-message bytes (state vector for STEP1, V2 update for STEP2/UPDATE)
|
|
241
|
+
* @param options.doc - The Yjs document to sync. Mutated for STEP2/UPDATE via applyUpdateV2.
|
|
242
|
+
* @param options.origin - Transaction origin passed to applyUpdateV2 (typically the connection, used to prevent echo)
|
|
243
|
+
* @returns Encoded response message for STEP1, null otherwise
|
|
244
|
+
*/
|
|
245
|
+
export function handleSyncPayload({
|
|
246
|
+
syncType,
|
|
247
|
+
payload,
|
|
248
|
+
doc,
|
|
249
|
+
origin,
|
|
250
|
+
}: {
|
|
251
|
+
syncType: SyncMessageType;
|
|
252
|
+
payload: Uint8Array;
|
|
253
|
+
doc: Y.Doc;
|
|
254
|
+
origin: unknown;
|
|
255
|
+
}): Uint8Array | null {
|
|
256
|
+
switch (syncType) {
|
|
257
|
+
case SYNC_MESSAGE_TYPE.STEP1: {
|
|
258
|
+
const diff = Y.encodeStateAsUpdateV2(doc, payload);
|
|
259
|
+
return encoding.encode((encoder) => {
|
|
260
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
261
|
+
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP2);
|
|
262
|
+
encoding.writeVarUint8Array(encoder, diff);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
case SYNC_MESSAGE_TYPE.STEP2:
|
|
266
|
+
case SYNC_MESSAGE_TYPE.UPDATE: {
|
|
267
|
+
Y.applyUpdateV2(doc, payload, origin);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
default:
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Awareness Protocol
|
|
277
|
+
// ============================================================================
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Encodes an awareness update message from raw awareness bytes.
|
|
281
|
+
*
|
|
282
|
+
* Awareness is used for ephemeral user presence data like cursor positions,
|
|
283
|
+
* user names, and online status. Unlike document updates, awareness state
|
|
284
|
+
* is not persisted and is cleared when users disconnect.
|
|
285
|
+
*
|
|
286
|
+
* @param options.update - Raw awareness update bytes (from encodeAwarenessUpdate)
|
|
287
|
+
* @returns Encoded message ready to send over WebSocket
|
|
288
|
+
*/
|
|
289
|
+
export function encodeAwareness({
|
|
290
|
+
update,
|
|
291
|
+
}: {
|
|
292
|
+
update: Uint8Array;
|
|
293
|
+
}): Uint8Array {
|
|
294
|
+
return encoding.encode((encoder) => {
|
|
295
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.AWARENESS);
|
|
296
|
+
encoding.writeVarUint8Array(encoder, update);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Encodes awareness states for specified clients.
|
|
302
|
+
*
|
|
303
|
+
* Convenience function that combines awareness encoding with message wrapping.
|
|
304
|
+
* Typically used to send current awareness states to newly connected clients.
|
|
305
|
+
*
|
|
306
|
+
* @param options.awareness - The awareness instance containing client states
|
|
307
|
+
* @param options.clients - Array of client IDs whose states should be encoded
|
|
308
|
+
* @returns Encoded message ready to send over WebSocket
|
|
309
|
+
*/
|
|
310
|
+
export function encodeAwarenessStates({
|
|
311
|
+
awareness,
|
|
312
|
+
clients,
|
|
313
|
+
}: {
|
|
314
|
+
awareness: Awareness;
|
|
315
|
+
clients: number[];
|
|
316
|
+
}): Uint8Array {
|
|
317
|
+
return encodeAwareness({
|
|
318
|
+
update: encodeAwarenessUpdate(awareness, clients),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Encodes a query awareness message.
|
|
324
|
+
*
|
|
325
|
+
* This message requests all current awareness states from the server.
|
|
326
|
+
* Typically sent by clients that need to refresh their view of other users.
|
|
327
|
+
*
|
|
328
|
+
* @returns Encoded message ready to send over WebSocket
|
|
329
|
+
*/
|
|
330
|
+
export function encodeQueryAwareness(): Uint8Array {
|
|
331
|
+
return encoding.encode((encoder) => {
|
|
332
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.QUERY_AWARENESS);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// HTTP Sync Request Encoding (binary frame format for POST body)
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Encode a single-round-trip HTTP sync request body.
|
|
342
|
+
*
|
|
343
|
+
* Collapses the WebSocket 3-message handshake (step1 → step2 → step2) into
|
|
344
|
+
* one HTTP POST/response. The client bundles its state vector and an optional
|
|
345
|
+
* update together:
|
|
346
|
+
*
|
|
347
|
+
* Client POST: [stateVector, update?]
|
|
348
|
+
* Server response: V2 diff the client is missing (or 304 if already in sync)
|
|
349
|
+
*
|
|
350
|
+
* The state vector tells the server "what I already have." The update (if
|
|
351
|
+
* present) pushes local changes the server is missing. The server applies the
|
|
352
|
+
* update, then diffs against the client's state vector to produce the response.
|
|
353
|
+
*
|
|
354
|
+
* Wire format: two length-prefixed frames (lib0 varint encoding).
|
|
355
|
+
* Frame 1: stateVector (always present)
|
|
356
|
+
* Frame 2: update (zero-length Uint8Array when absent)
|
|
357
|
+
*
|
|
358
|
+
* @param stateVector - Client's Yjs state vector (tells server what client has)
|
|
359
|
+
* @param update - Optional V2 Yjs update to push to the server
|
|
360
|
+
* @returns Encoded binary request body
|
|
361
|
+
*/
|
|
362
|
+
export function encodeSyncRequest(
|
|
363
|
+
stateVector: Uint8Array,
|
|
364
|
+
update?: Uint8Array,
|
|
365
|
+
): Uint8Array {
|
|
366
|
+
return encoding.encode((encoder) => {
|
|
367
|
+
encoding.writeVarUint8Array(encoder, stateVector);
|
|
368
|
+
encoding.writeVarUint8Array(encoder, update ?? new Uint8Array(0));
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Decode a single-round-trip HTTP sync request body.
|
|
374
|
+
*
|
|
375
|
+
* Parses the two length-prefixed frames from {@link encodeSyncRequest}.
|
|
376
|
+
* The update field will be an empty Uint8Array (byteLength === 0) if
|
|
377
|
+
* the client had nothing to push.
|
|
378
|
+
*
|
|
379
|
+
* @param data - Raw sync request body bytes
|
|
380
|
+
* @returns Parsed state vector and update
|
|
381
|
+
* @throws Error if data is malformed or truncated
|
|
382
|
+
*/
|
|
383
|
+
export function decodeSyncRequest(data: Uint8Array): {
|
|
384
|
+
stateVector: Uint8Array;
|
|
385
|
+
update: Uint8Array;
|
|
386
|
+
} {
|
|
387
|
+
const decoder = decoding.createDecoder(data);
|
|
388
|
+
const stateVector = decoding.readVarUint8Array(decoder);
|
|
389
|
+
const update = decoding.readVarUint8Array(decoder);
|
|
390
|
+
return { stateVector, update };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// State Vector Utilities
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
/** Compare two state vectors for byte-level equality. */
|
|
398
|
+
export function stateVectorsEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
399
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
400
|
+
for (let i = 0; i < a.byteLength; i++) {
|
|
401
|
+
if (a[i] !== b[i]) return false;
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// SYNC_STATUS Protocol (100)
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Encode a SYNC_STATUS message with the given local version.
|
|
412
|
+
*
|
|
413
|
+
* Wire format: `[varuint: 100] [varuint: localVersion]`
|
|
414
|
+
*
|
|
415
|
+
* The server echoes this back unchanged. The client compares the echoed
|
|
416
|
+
* version to its local counter to determine save state.
|
|
417
|
+
*
|
|
418
|
+
* @param localVersion - Monotonic counter incremented on each doc update
|
|
419
|
+
* @returns Encoded SYNC_STATUS message ready to send
|
|
420
|
+
*/
|
|
421
|
+
export function encodeSyncStatus(localVersion: number): Uint8Array {
|
|
422
|
+
return encoding.encode((encoder) => {
|
|
423
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC_STATUS);
|
|
424
|
+
encoding.writeVarUint(encoder, localVersion);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Decode a SYNC_STATUS message, returning the local version.
|
|
430
|
+
*
|
|
431
|
+
* Reads past the message type varuint (100) and returns the localVersion
|
|
432
|
+
* varuint. Expects the full message bytes (including the type prefix).
|
|
433
|
+
*
|
|
434
|
+
* @param data - Raw SYNC_STATUS message bytes
|
|
435
|
+
* @returns The localVersion number from the message
|
|
436
|
+
*/
|
|
437
|
+
export function decodeSyncStatus(data: Uint8Array): number {
|
|
438
|
+
const decoder = decoding.createDecoder(data);
|
|
439
|
+
const messageType = decoding.readVarUint(decoder);
|
|
440
|
+
if (messageType !== MESSAGE_TYPE.SYNC_STATUS) {
|
|
441
|
+
throw new Error(`Expected SYNC_STATUS message (${MESSAGE_TYPE.SYNC_STATUS}), got ${messageType}`);
|
|
442
|
+
}
|
|
443
|
+
return decoding.readVarUint(decoder);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ============================================================================
|
|
447
|
+
// RPC Protocol (101)
|
|
448
|
+
// ============================================================================
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* RPC sub-types within an RPC message.
|
|
452
|
+
* The second varuint after MESSAGE_TYPE.RPC identifies the sub-type.
|
|
453
|
+
*/
|
|
454
|
+
export const RPC_TYPE = {
|
|
455
|
+
/** Client → DO → target peer: invoke an action */
|
|
456
|
+
REQUEST: 0,
|
|
457
|
+
/** Target peer → DO → requester: action result */
|
|
458
|
+
RESPONSE: 1,
|
|
459
|
+
} as const;
|
|
460
|
+
|
|
461
|
+
export type RpcType = (typeof RPC_TYPE)[keyof typeof RPC_TYPE];
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Decoded RPC message — discriminated union of REQUEST and RESPONSE.
|
|
465
|
+
*/
|
|
466
|
+
export type DecodedRpcMessage =
|
|
467
|
+
| {
|
|
468
|
+
type: 'request';
|
|
469
|
+
requestId: number;
|
|
470
|
+
targetClientId: number;
|
|
471
|
+
requesterClientId: number;
|
|
472
|
+
action: string;
|
|
473
|
+
input: unknown;
|
|
474
|
+
}
|
|
475
|
+
| {
|
|
476
|
+
type: 'response';
|
|
477
|
+
requestId: number;
|
|
478
|
+
requesterClientId: number;
|
|
479
|
+
result: { data: unknown; error: unknown };
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Encode an RPC REQUEST message.
|
|
484
|
+
*
|
|
485
|
+
* Wire format:
|
|
486
|
+
* `[varuint: 101] [varuint: 0=REQUEST] [varuint: requestId] [varuint: targetClientId] [varuint: requesterClientId] [varString: action] [varUint8Array: JSON input]`
|
|
487
|
+
*
|
|
488
|
+
* @param options.requestId - Monotonic counter scoped to the connection
|
|
489
|
+
* @param options.targetClientId - Awareness clientId of the target peer
|
|
490
|
+
* @param options.requesterClientId - Awareness clientId of the sender (for response routing)
|
|
491
|
+
* @param options.action - Dot-path action name (e.g. 'tabs.close')
|
|
492
|
+
* @param options.input - Action input (serialized as JSON)
|
|
493
|
+
* @returns Encoded RPC REQUEST message
|
|
494
|
+
*/
|
|
495
|
+
export function encodeRpcRequest({
|
|
496
|
+
requestId,
|
|
497
|
+
targetClientId,
|
|
498
|
+
requesterClientId,
|
|
499
|
+
action,
|
|
500
|
+
input,
|
|
501
|
+
}: {
|
|
502
|
+
requestId: number;
|
|
503
|
+
targetClientId: number;
|
|
504
|
+
requesterClientId: number;
|
|
505
|
+
action: string;
|
|
506
|
+
input?: unknown;
|
|
507
|
+
}): Uint8Array {
|
|
508
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(input ?? null));
|
|
509
|
+
return encoding.encode((encoder) => {
|
|
510
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.RPC);
|
|
511
|
+
encoding.writeVarUint(encoder, RPC_TYPE.REQUEST);
|
|
512
|
+
encoding.writeVarUint(encoder, requestId);
|
|
513
|
+
encoding.writeVarUint(encoder, targetClientId);
|
|
514
|
+
encoding.writeVarUint(encoder, requesterClientId);
|
|
515
|
+
encoding.writeVarString(encoder, action);
|
|
516
|
+
encoding.writeVarUint8Array(encoder, jsonBytes);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Encode an RPC RESPONSE message.
|
|
522
|
+
*
|
|
523
|
+
* Wire format:
|
|
524
|
+
* `[varuint: 101] [varuint: 1=RESPONSE] [varuint: requestId] [varuint: requesterClientId] [varUint8Array: JSON { data, error }]`
|
|
525
|
+
*
|
|
526
|
+
* @param options.requestId - Echo of the request's requestId
|
|
527
|
+
* @param options.requesterClientId - Awareness clientId of the original requester (for DO routing)
|
|
528
|
+
* @param options.result - The result envelope: `{ data, error }`
|
|
529
|
+
* @returns Encoded RPC RESPONSE message
|
|
530
|
+
*/
|
|
531
|
+
export function encodeRpcResponse({
|
|
532
|
+
requestId,
|
|
533
|
+
requesterClientId,
|
|
534
|
+
result,
|
|
535
|
+
}: {
|
|
536
|
+
requestId: number;
|
|
537
|
+
requesterClientId: number;
|
|
538
|
+
result: { data: unknown; error: unknown };
|
|
539
|
+
}): Uint8Array {
|
|
540
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(result));
|
|
541
|
+
return encoding.encode((encoder) => {
|
|
542
|
+
encoding.writeVarUint(encoder, MESSAGE_TYPE.RPC);
|
|
543
|
+
encoding.writeVarUint(encoder, RPC_TYPE.RESPONSE);
|
|
544
|
+
encoding.writeVarUint(encoder, requestId);
|
|
545
|
+
encoding.writeVarUint(encoder, requesterClientId);
|
|
546
|
+
encoding.writeVarUint8Array(encoder, jsonBytes);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Decode an RPC message into its typed components.
|
|
552
|
+
*
|
|
553
|
+
* Reads the full message bytes (including the MESSAGE_TYPE.RPC prefix).
|
|
554
|
+
* Returns a discriminated union of REQUEST or RESPONSE.
|
|
555
|
+
*
|
|
556
|
+
* Use this when you have the raw wire bytes. If the transport has already
|
|
557
|
+
* consumed the message-type varint, use {@link decodeRpcPayload} instead
|
|
558
|
+
* to avoid re-parsing the prefix.
|
|
559
|
+
*
|
|
560
|
+
* @param data - Raw RPC message bytes (starting with MESSAGE_TYPE.RPC prefix)
|
|
561
|
+
* @returns Decoded RPC message with type discriminator
|
|
562
|
+
*/
|
|
563
|
+
export function decodeRpcMessage(data: Uint8Array): DecodedRpcMessage {
|
|
564
|
+
const decoder = decoding.createDecoder(data);
|
|
565
|
+
const messageType = decoding.readVarUint(decoder);
|
|
566
|
+
if (messageType !== MESSAGE_TYPE.RPC) {
|
|
567
|
+
throw new Error(`Expected RPC message (${MESSAGE_TYPE.RPC}), got ${messageType}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return decodeRpcPayload(decoder);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Decode an RPC payload after the message-type varint has already been consumed.
|
|
575
|
+
*
|
|
576
|
+
* Use this when registering a message handler for {@link MESSAGE_TYPE.RPC}—the
|
|
577
|
+
* transport reads the message-type varint and passes the positioned decoder to
|
|
578
|
+
* the handler, which calls this to decode the RPC sub-type and fields.
|
|
579
|
+
*
|
|
580
|
+
* @param decoder - A lib0 decoder positioned after the message-type varint
|
|
581
|
+
* @returns Decoded RPC message with type discriminator
|
|
582
|
+
*/
|
|
583
|
+
export function decodeRpcPayload(
|
|
584
|
+
decoder: decoding.Decoder,
|
|
585
|
+
): DecodedRpcMessage {
|
|
586
|
+
const rpcType = decoding.readVarUint(decoder);
|
|
587
|
+
|
|
588
|
+
switch (rpcType) {
|
|
589
|
+
case RPC_TYPE.REQUEST: {
|
|
590
|
+
const requestId = decoding.readVarUint(decoder);
|
|
591
|
+
const targetClientId = decoding.readVarUint(decoder);
|
|
592
|
+
const requesterClientId = decoding.readVarUint(decoder);
|
|
593
|
+
const action = decoding.readVarString(decoder);
|
|
594
|
+
const jsonBytes = decoding.readVarUint8Array(decoder);
|
|
595
|
+
const input = JSON.parse(new TextDecoder().decode(jsonBytes));
|
|
596
|
+
return { type: 'request', requestId, targetClientId, requesterClientId, action, input };
|
|
597
|
+
}
|
|
598
|
+
case RPC_TYPE.RESPONSE: {
|
|
599
|
+
const requestId = decoding.readVarUint(decoder);
|
|
600
|
+
const requesterClientId = decoding.readVarUint(decoder);
|
|
601
|
+
const jsonBytes = decoding.readVarUint8Array(decoder);
|
|
602
|
+
const raw = JSON.parse(new TextDecoder().decode(jsonBytes));
|
|
603
|
+
if (typeof raw !== 'object' || raw === null || !('data' in raw)) {
|
|
604
|
+
throw new Error('Malformed RPC response: expected { data, error }');
|
|
605
|
+
}
|
|
606
|
+
const result = raw as { data: unknown; error: unknown };
|
|
607
|
+
return { type: 'response', requestId, requesterClientId, result };
|
|
608
|
+
}
|
|
609
|
+
default:
|
|
610
|
+
throw new Error(`Unknown RPC sub-type: ${rpcType}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineErrors,
|
|
3
|
+
extractErrorMessage,
|
|
4
|
+
type InferErrors,
|
|
5
|
+
} from 'wellcrafted/error';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RPC error variants for remote action invocation over the sync protocol.
|
|
9
|
+
*
|
|
10
|
+
* These errors cover all failure modes in the RPC flow:
|
|
11
|
+
* - Infrastructure errors (PeerOffline, Timeout) from the transport layer
|
|
12
|
+
* - Application errors (ActionNotFound, ActionFailed) from the target peer
|
|
13
|
+
*
|
|
14
|
+
* Defined in `@epicenter/sync` because they describe wire-protocol failure
|
|
15
|
+
* modes—both the server (Durable Object) and client construct these errors
|
|
16
|
+
* at the sync boundary.
|
|
17
|
+
*
|
|
18
|
+
* All errors include a `name` discriminant for switch-based handling:
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const { data, error } = await workspace.extensions.sync.rpc(clientId, 'tabs.close', { tabIds: [1] });
|
|
23
|
+
* if (error) {
|
|
24
|
+
* switch (error.name) {
|
|
25
|
+
* case 'PeerOffline': // target not connected
|
|
26
|
+
* case 'Timeout': // no response in time
|
|
27
|
+
* case 'ActionNotFound': // bad action path
|
|
28
|
+
* case 'ActionFailed': // handler error
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const RpcError = defineErrors({
|
|
34
|
+
PeerOffline: () => ({
|
|
35
|
+
message: 'Target peer is not connected',
|
|
36
|
+
}),
|
|
37
|
+
Timeout: ({ ms }: { ms: number }) => ({
|
|
38
|
+
message: `RPC call timed out after ${ms}ms`,
|
|
39
|
+
ms,
|
|
40
|
+
}),
|
|
41
|
+
ActionNotFound: ({ action }: { action: string }) => ({
|
|
42
|
+
message: `Target has no handler for '${action}'`,
|
|
43
|
+
action,
|
|
44
|
+
}),
|
|
45
|
+
ActionFailed: ({ action, cause }: { action: string; cause: unknown }) => ({
|
|
46
|
+
message: `Action '${action}' failed: ${extractErrorMessage(cause)}`,
|
|
47
|
+
action,
|
|
48
|
+
cause,
|
|
49
|
+
}),
|
|
50
|
+
Disconnected: () => ({
|
|
51
|
+
message: 'Connection lost before RPC response arrived',
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
export type RpcError = InferErrors<typeof RpcError>;
|
|
55
|
+
|
|
56
|
+
const RPC_ERROR_NAMES = new Set<string>([
|
|
57
|
+
'PeerOffline',
|
|
58
|
+
'Timeout',
|
|
59
|
+
'ActionNotFound',
|
|
60
|
+
'ActionFailed',
|
|
61
|
+
'Disconnected',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Type guard that narrows an unknown wire value to a known {@link RpcError} variant.
|
|
66
|
+
*
|
|
67
|
+
* Use this at the deserialization boundary instead of `as RpcError` casts.
|
|
68
|
+
* Validates that the value is an object with a `name` field matching one of
|
|
69
|
+
* the known RPC error variant names.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* if (isRpcError(result.error)) {
|
|
74
|
+
* switch (result.error.name) {
|
|
75
|
+
* case 'PeerOffline': // TypeScript knows the full shape
|
|
76
|
+
* case 'Timeout': // No cast needed
|
|
77
|
+
* }
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function isRpcError(value: unknown): value is RpcError {
|
|
82
|
+
return (
|
|
83
|
+
value != null &&
|
|
84
|
+
typeof value === 'object' &&
|
|
85
|
+
'name' in value &&
|
|
86
|
+
typeof value.name === 'string' &&
|
|
87
|
+
RPC_ERROR_NAMES.has(value.name)
|
|
88
|
+
);
|
|
89
|
+
}
|