@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.
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noUnusedLocals": true,
5
+ "noUnusedParameters": true,
6
+ "noPropertyAccessFromIndexSignature": false
7
+ }
8
+ }