@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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @epicenter/sync
2
2
 
3
- `@epicenter/sync` is the wire-format package for Epicenter sync. It owns the binary framing for Yjs sync, awareness, sync-status, and peer-to-peer RPC messages so the transport layer can stay dumb. `@epicenter/workspace` and `apps/api` use it when they need to turn a `Y.Doc` change into bytes—or turn bytes back into something the app can reason about.
3
+ `@epicenter/sync` is the wire-format package for Epicenter sync. It owns the binary framing for Yjs sync, awareness, sync-status, and peer-to-peer RPC messages so the transport layer can stay dumb. `@epicenter/workspace` and `apps/api` use it when they need to turn a `Y.Doc` change into bytes. Or turn bytes back into something the app can reason about.
4
4
 
5
5
  ## Installation
6
6
 
@@ -63,10 +63,9 @@ The design shows up in a few places:
63
63
 
64
64
  - `encodeSyncStep1`, `encodeSyncStep2`, and `encodeSyncUpdate` only deal with Yjs payloads.
65
65
  - `encodeSyncRequest` and `decodeSyncRequest` collapse the WebSocket handshake into a binary HTTP request/response format.
66
- - `encodeSyncStatus` uses an echoed version counter for save-state UX; the server can relay the payload unchanged.
67
66
  - RPC framing is separate from RPC behavior. The package defines request/response bytes and shared error variants, not the transport policy around retries or timeouts.
68
67
 
69
- If you want lifecycle helpers for a WebSocket server, this package is the protocol layer under them—not the server itself.
68
+ If you want lifecycle helpers for a WebSocket server, this package is the protocol layer under them. Not the server itself.
70
69
 
71
70
  ## API overview
72
71
 
@@ -76,7 +75,7 @@ Main exports from `src/index.ts`:
76
75
  - Sync encode/decode: `encodeSyncStep1`, `encodeSyncStep2`, `encodeSyncUpdate`, `decodeSyncMessage`, `handleSyncPayload`
77
76
  - Awareness helpers: `encodeAwareness`, `encodeAwarenessStates`, `encodeQueryAwareness`
78
77
  - HTTP sync helpers: `encodeSyncRequest`, `decodeSyncRequest`
79
- - Save-status helpers: `encodeSyncStatus`, `decodeSyncStatus`, `stateVectorsEqual`
78
+ - State helpers: `stateVectorsEqual`
80
79
  - RPC helpers: `encodeRpcRequest`, `encodeRpcResponse`, `decodeRpcMessage`, `decodeRpcPayload`
81
80
  - RPC types and guards: `DecodedRpcMessage`, `RpcError`, `isRpcError`
82
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicenter/sync",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sync protocol for Epicenter workspaces. Yjs-based encoding, WebSocket transport, and broadcast channel sync.",
5
5
  "keywords": [
6
6
  "local-first",
@@ -18,26 +18,24 @@
18
18
  "directory": "packages/sync"
19
19
  },
20
20
  "homepage": "https://epicenter.so",
21
- "main": "./src/index.ts",
22
- "types": "./src/index.ts",
23
21
  "exports": {
24
22
  ".": "./src/index.ts"
25
23
  },
26
- "license": "AGPL-3.0",
24
+ "license": "MIT",
27
25
  "scripts": {
28
26
  "typecheck": "tsc --noEmit"
29
27
  },
30
28
  "dependencies": {
31
- "lib0": "catalog:",
32
- "wellcrafted": "catalog:",
33
- "y-protocols": "catalog:"
29
+ "@epicenter/identity": "0.1.0",
30
+ "lib0": "^0.2.117",
31
+ "wellcrafted": "^0.42.0"
34
32
  },
35
33
  "peerDependencies": {
36
- "yjs": "catalog:"
34
+ "yjs": "^13.6.29"
37
35
  },
38
36
  "devDependencies": {
39
- "@types/bun": "catalog:",
40
- "typescript": "catalog:",
41
- "yjs": "catalog:"
37
+ "@types/bun": "latest",
38
+ "typescript": "^5.9.3",
39
+ "yjs": "^13.6.29"
42
40
  }
43
41
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * WebSocket Auth Subprotocol Tests
3
+ *
4
+ * Verifies the shared bearer subprotocol helpers used by auth clients and API
5
+ * middleware.
6
+ *
7
+ * Key behaviors:
8
+ * - Bearer prefix comes from the shared constants package
9
+ * - Subprotocol headers parse into their comma-separated token list
10
+ */
11
+
12
+ import { expect, test } from 'bun:test';
13
+ import {
14
+ BEARER_SUBPROTOCOL_PREFIX,
15
+ MAIN_SUBPROTOCOL,
16
+ parseSubprotocols,
17
+ } from './auth-subprotocol.js';
18
+
19
+ test('parseSubprotocols splits a comma-separated subprotocol header', () => {
20
+ const header = `${MAIN_SUBPROTOCOL}, ${BEARER_SUBPROTOCOL_PREFIX}token-1`;
21
+
22
+ expect(parseSubprotocols(header)).toEqual([
23
+ MAIN_SUBPROTOCOL,
24
+ `${BEARER_SUBPROTOCOL_PREFIX}token-1`,
25
+ ]);
26
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WebSocket subprotocol auth: shared client/server constants.
3
+ *
4
+ * Auth tokens travel inside the `Sec-WebSocket-Protocol` handshake header
5
+ * as `bearer.<token>`, not in the URL's query string. The real threat is
6
+ * server-side access logs (Cloudflare, Hono middleware, downstream APMs
7
+ * like Sentry/Datadog): full URLs including query strings are captured by
8
+ * default, so a `?token=` scheme leaks long-lived session tokens into any
9
+ * system with log access. Subprotocol headers aren't captured by default
10
+ * on those systems. The server extracts and consumes the bearer entry on
11
+ * upgrade; only the main protocol name (`epicenter`) is echoed back on
12
+ * the 101 response, so the token never round-trips.
13
+ *
14
+ * The `.` separator is required by RFC compliance: `Sec-WebSocket-Protocol`
15
+ * values are RFC 7230 `token` productions, where `:` is not a valid `tchar`
16
+ * but `.` is. Prior art for `<scheme>.<token>`: Phoenix channels
17
+ * (`phx_bearer.<token>`), Supabase Realtime, and Kubernetes
18
+ * (`base64url.bearer.authorization.k8s.io.<token>`).
19
+ */
20
+
21
+ /** Primary subprotocol name every Epicenter client negotiates. */
22
+ export const MAIN_SUBPROTOCOL = 'epicenter';
23
+
24
+ /** Prefix for OAuth bearer tokens carried through WebSocket subprotocols. */
25
+ export const BEARER_SUBPROTOCOL_PREFIX = 'bearer.';
26
+
27
+ /**
28
+ * Parse a `Sec-WebSocket-Protocol` header value into its list of tokens.
29
+ *
30
+ * RFC 6455 specifies the value as a comma-separated list of RFC 7230 tokens,
31
+ * with optional whitespace after commas. Returns an empty list if the header
32
+ * is absent.
33
+ */
34
+ export function parseSubprotocols(header: string | null): string[] {
35
+ if (!header) return [];
36
+ return header.split(',').map((s) => s.trim());
37
+ }
package/src/index.ts CHANGED
@@ -1,39 +1,34 @@
1
1
  /**
2
- * @epicenter/sync Yjs Sync Protocol Primitives
2
+ * @epicenter/sync: Yjs Sync Protocol Primitives
3
3
  *
4
- * Encode/decode functions for the y-websocket wire protocol, plus
5
- * RPC error variants shared by both server and client.
4
+ * Encode/decode functions for the sync wire protocol.
6
5
  *
7
- * For server-side WebSocket lifecycle handlers, import from
8
- * `@epicenter/sync/server` instead.
6
+ * The binary WebSocket channel carries a single message family: Yjs
7
+ * document sync. A binary frame is a sync frame, with no top-level
8
+ * message-type discriminator. Presence and dispatch ride text frames.
9
9
  */
10
10
 
11
+ // WebSocket subprotocol auth (shared client/server constants + helpers)
12
+ export {
13
+ BEARER_SUBPROTOCOL_PREFIX,
14
+ MAIN_SUBPROTOCOL,
15
+ parseSubprotocols,
16
+ } from './auth-subprotocol';
17
+ // Transport origin sentinels (shared across all sync layers)
18
+ export {
19
+ BC_ORIGIN,
20
+ isTransportOrigin,
21
+ SYNC_ORIGIN,
22
+ } from './origins';
11
23
  // Protocol (encode/decode for WS messages and HTTP sync requests)
12
24
  export {
13
- decodeMessageType,
14
- decodeRpcMessage,
15
- decodeRpcPayload,
16
- type DecodedRpcMessage,
17
- decodeSyncMessage,
18
25
  decodeSyncRequest,
19
- decodeSyncStatus,
20
- encodeAwareness,
21
- encodeAwarenessStates,
22
- encodeQueryAwareness,
23
- encodeRpcRequest,
24
- encodeRpcResponse,
25
26
  encodeSyncRequest,
26
- encodeSyncStatus,
27
27
  encodeSyncStep1,
28
- encodeSyncStep2,
29
28
  encodeSyncUpdate,
30
29
  handleSyncPayload,
31
- MESSAGE_TYPE,
32
- RPC_TYPE,
33
30
  SYNC_MESSAGE_TYPE,
34
31
  type SyncMessageType,
35
32
  stateVectorsEqual,
36
33
  } from './protocol';
37
-
38
- // RPC error variants and type guard (used by both server and client)
39
- export { isRpcError, RpcError } from './rpc-errors';
34
+ export { ROOM_ROUTE } from './room-route';
package/src/origins.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Transport origin sentinels for Yjs sync.
3
+ *
4
+ * Canonical definitions live here because every transport that touches a
5
+ * shared Y.Doc must agree on them. When the WebSocket handler applies a
6
+ * remote update, it tags it with `SYNC_ORIGIN`; the BroadcastChannel handler
7
+ * tags cross-tab updates with `BC_ORIGIN`. Handlers check origins to avoid
8
+ * echo loops (e.g., BC must not re-broadcast what it just received from WS,
9
+ * and vice versa).
10
+ *
11
+ * Historically each transport defined its own symbol locally, which meant
12
+ * two separate symbols for the same semantic "this update came from the
13
+ * server" concept: fine as long as only one transport existed per Y.Doc,
14
+ * risky once multiple layers could attach. These exports make the contract
15
+ * explicit and unambiguous.
16
+ *
17
+ * **Not here**: self-loop guards that never leave their defining module
18
+ * (`DEDUP_ORIGIN` in y-keyvalue-lww.ts, `REENCRYPT_ORIGIN` in
19
+ * y-keyvalue-lww-encrypted.ts). Those are genuinely private and don't
20
+ * benefit from sharing.
21
+ */
22
+
23
+ /** Origin for updates applied from the WebSocket sync transport. */
24
+ export const SYNC_ORIGIN = Symbol.for('@epicenter/sync/sync-origin');
25
+
26
+ /** Origin for updates applied from BroadcastChannel cross-tab sync. */
27
+ export const BC_ORIGIN = Symbol.for('@epicenter/sync/bc-origin');
28
+
29
+ /**
30
+ * Origins that mean a realtime transport already applied this update locally.
31
+ *
32
+ * Realtime transport listeners use this list to avoid forwarding an update
33
+ * back through another realtime transport.
34
+ */
35
+ const TRANSPORT_ORIGINS = [
36
+ SYNC_ORIGIN,
37
+ BC_ORIGIN,
38
+ ] as const satisfies readonly unknown[];
39
+
40
+ export function isTransportOrigin(origin: unknown): boolean {
41
+ return (
42
+ typeof origin === 'symbol' &&
43
+ TRANSPORT_ORIGINS.some((transportOrigin) => transportOrigin === origin)
44
+ );
45
+ }