@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/src/protocol.ts CHANGED
@@ -1,111 +1,37 @@
1
1
  /**
2
- * Yjs WebSocket Protocol Encoding/Decoding Utilities
2
+ * Yjs Sync Protocol Encoding/Decoding Utilities
3
3
  *
4
- * Pure functions for encoding and decoding y-websocket protocol messages.
5
- * Separates protocol handling from transport (WebSocket handling).
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
- * 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
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 SYNC messages.
30
+ * Sub-message types within sync frames.
106
31
  * Derived from y-protocols/sync constants for consistency.
107
32
  *
108
- * These are the second varint in a SYNC message, after MESSAGE_TYPE.SYNC.
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` accepts already-
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 (consistent with how
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 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
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 step2 step2) into
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 304 if already in sync)
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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
+ "types": ["bun"],
4
5
  "noUnusedLocals": true,
5
- "noUnusedParameters": true,
6
- "noPropertyAccessFromIndexSignature": false
6
+ "noUnusedParameters": true
7
7
  }
8
8
  }
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
- }