@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.ts
CHANGED
|
@@ -1,111 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Yjs
|
|
2
|
+
* Yjs Sync Protocol Encoding/Decoding Utilities
|
|
3
3
|
*
|
|
4
|
-
* Pure functions for encoding and decoding
|
|
5
|
-
*
|
|
4
|
+
* Pure functions for encoding and decoding the binary WebSocket channel.
|
|
5
|
+
*
|
|
6
|
+
* The binary channel carries exactly one message family: Yjs document
|
|
7
|
+
* sync. A binary frame *is* a sync frame, so there is no top-level
|
|
8
|
+
* message-type varint; the first varint is the sync sub-type (STEP1,
|
|
9
|
+
* STEP2, or UPDATE). This is byte-identical to raw y-protocols/sync
|
|
10
|
+
* framing. Wire-format versioning, if ever needed, rides the WebSocket
|
|
11
|
+
* subprotocol (`MAIN_SUBPROTOCOL`), not an in-band discriminator.
|
|
12
|
+
*
|
|
13
|
+
* Dispatch and presence ride WebSocket *text* frames, not this channel.
|
|
6
14
|
*
|
|
7
15
|
* All sync payloads use Yjs V2 encoding for ~40% smaller wire size.
|
|
8
16
|
* State vectors are version-independent (same format for V1 and V2).
|
|
9
17
|
*
|
|
10
|
-
*
|
|
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
|
|
18
|
+
* Pure encoder/decoder functions: protocol only, no transport logic.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import * as decoding from 'lib0/decoding';
|
|
19
22
|
import * as encoding from 'lib0/encoding';
|
|
20
|
-
import { type Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness';
|
|
21
23
|
import * as Y from 'yjs';
|
|
22
24
|
|
|
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
25
|
// ============================================================================
|
|
101
26
|
// Sync Protocol (V2 encoding)
|
|
102
27
|
// ============================================================================
|
|
103
28
|
|
|
104
29
|
/**
|
|
105
|
-
* Sub-message types within
|
|
30
|
+
* Sub-message types within sync frames.
|
|
106
31
|
* Derived from y-protocols/sync constants for consistency.
|
|
107
32
|
*
|
|
108
|
-
*
|
|
33
|
+
* This is the first (and only) varint preceding the payload in a binary
|
|
34
|
+
* WebSocket frame.
|
|
109
35
|
*/
|
|
110
36
|
export const SYNC_MESSAGE_TYPE = {
|
|
111
37
|
/** Initial handshake: "here's my state vector, what am I missing?" */
|
|
@@ -119,15 +45,6 @@ export const SYNC_MESSAGE_TYPE = {
|
|
|
119
45
|
export type SyncMessageType =
|
|
120
46
|
(typeof SYNC_MESSAGE_TYPE)[keyof typeof SYNC_MESSAGE_TYPE];
|
|
121
47
|
|
|
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
48
|
/**
|
|
132
49
|
* Encodes a sync step 1 message containing the document's state vector.
|
|
133
50
|
*
|
|
@@ -143,31 +60,11 @@ export type DecodedSyncMessage =
|
|
|
143
60
|
*/
|
|
144
61
|
export function encodeSyncStep1({ doc }: { doc: Y.Doc }): Uint8Array {
|
|
145
62
|
return encoding.encode((encoder) => {
|
|
146
|
-
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
147
63
|
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP1);
|
|
148
64
|
encoding.writeVarUint8Array(encoder, Y.encodeStateVector(doc));
|
|
149
65
|
});
|
|
150
66
|
}
|
|
151
67
|
|
|
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
68
|
/**
|
|
172
69
|
* Encodes a document update message for broadcasting to clients.
|
|
173
70
|
*
|
|
@@ -184,57 +81,22 @@ export function encodeSyncUpdate({
|
|
|
184
81
|
update: Uint8Array;
|
|
185
82
|
}): Uint8Array {
|
|
186
83
|
return encoding.encode((encoder) => {
|
|
187
|
-
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
188
84
|
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.UPDATE);
|
|
189
85
|
encoding.writeVarUint8Array(encoder, update);
|
|
190
86
|
});
|
|
191
87
|
}
|
|
192
88
|
|
|
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
89
|
/**
|
|
227
90
|
* Handle a decoded sync sub-message and return a response if needed.
|
|
228
91
|
*
|
|
229
|
-
* Pre-decoded alternative to y-protocols' `readSyncMessage
|
|
92
|
+
* Pre-decoded alternative to y-protocols' `readSyncMessage`: accepts already-
|
|
230
93
|
* decoded `syncType` and `payload` instead of a mutable lib0 decoder. The
|
|
231
|
-
* caller reads these two fields from the decoder inline
|
|
232
|
-
* AWARENESS and SYNC_STATUS cases are already handled at every call site).
|
|
94
|
+
* caller reads these two fields from the decoder inline.
|
|
233
95
|
*
|
|
234
96
|
* Dispatches on the three sync sub-types (all V2 encoded):
|
|
235
|
-
* - STEP1: `payload` is a state vector
|
|
236
|
-
* - STEP2: `payload` is a V2 update
|
|
237
|
-
* - UPDATE: `payload` is a V2 update
|
|
97
|
+
* - STEP1: `payload` is a state vector, responds with a V2 diff (STEP2)
|
|
98
|
+
* - STEP2: `payload` is a V2 update, applied to doc, no response
|
|
99
|
+
* - UPDATE: `payload` is a V2 update, applied to doc, no response
|
|
238
100
|
*
|
|
239
101
|
* @param options.syncType - Which sync sub-message (STEP1, STEP2, or UPDATE)
|
|
240
102
|
* @param options.payload - The sub-message bytes (state vector for STEP1, V2 update for STEP2/UPDATE)
|
|
@@ -257,7 +119,6 @@ export function handleSyncPayload({
|
|
|
257
119
|
case SYNC_MESSAGE_TYPE.STEP1: {
|
|
258
120
|
const diff = Y.encodeStateAsUpdateV2(doc, payload);
|
|
259
121
|
return encoding.encode((encoder) => {
|
|
260
|
-
encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC);
|
|
261
122
|
encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP2);
|
|
262
123
|
encoding.writeVarUint8Array(encoder, diff);
|
|
263
124
|
});
|
|
@@ -272,67 +133,6 @@ export function handleSyncPayload({
|
|
|
272
133
|
}
|
|
273
134
|
}
|
|
274
135
|
|
|
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
136
|
// ============================================================================
|
|
337
137
|
// HTTP Sync Request Encoding (binary frame format for POST body)
|
|
338
138
|
// ============================================================================
|
|
@@ -340,12 +140,12 @@ export function encodeQueryAwareness(): Uint8Array {
|
|
|
340
140
|
/**
|
|
341
141
|
* Encode a single-round-trip HTTP sync request body.
|
|
342
142
|
*
|
|
343
|
-
* Collapses the WebSocket 3-message handshake (step1
|
|
143
|
+
* Collapses the WebSocket 3-message handshake (step1 -> step2 -> step2) into
|
|
344
144
|
* one HTTP POST/response. The client bundles its state vector and an optional
|
|
345
145
|
* update together:
|
|
346
146
|
*
|
|
347
147
|
* Client POST: [stateVector, update?]
|
|
348
|
-
* Server response: V2 diff the client is missing (or
|
|
148
|
+
* Server response: V2 diff the client is missing (or 204 if already in sync)
|
|
349
149
|
*
|
|
350
150
|
* The state vector tells the server "what I already have." The update (if
|
|
351
151
|
* present) pushes local changes the server is missing. The server applies the
|
|
@@ -402,211 +202,3 @@ export function stateVectorsEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
402
202
|
}
|
|
403
203
|
return true;
|
|
404
204
|
}
|
|
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,19 @@
|
|
|
1
|
+
import type { OwnerId } from '@epicenter/identity';
|
|
2
|
+
|
|
3
|
+
const stripTrailing = (s: string) => s.replace(/\/+$/, '');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wire route for a workspace sync room: `/api/owners/:ownerId/rooms/:roomId`.
|
|
7
|
+
*
|
|
8
|
+
* Single source of truth shared by the workspace client (which builds the URL
|
|
9
|
+
* in transport.ts) and the sync server (which registers the pattern). It is
|
|
10
|
+
* part of the sync wire contract, so it lives in `@epicenter/sync` alongside
|
|
11
|
+
* the message protocol and the auth subprotocol. The URL string is durable:
|
|
12
|
+
* production clients hit it today, so the shape must not change.
|
|
13
|
+
*/
|
|
14
|
+
export const ROOM_ROUTE = {
|
|
15
|
+
pattern: '/api/owners/:ownerId/rooms/:roomId',
|
|
16
|
+
prefixPattern: '/api/owners/:ownerId/rooms/*',
|
|
17
|
+
url: (baseURL: string, ownerId: OwnerId, roomId: string) =>
|
|
18
|
+
`${stripTrailing(baseURL)}/api/owners/${encodeURIComponent(ownerId)}/rooms/${encodeURIComponent(roomId)}`,
|
|
19
|
+
} as const;
|
package/tsconfig.json
CHANGED
package/src/rpc-errors.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
}
|