@pylonsync/sync 0.3.202 → 0.3.203

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,140 @@
1
+ // Server-Sent Events transport. One-way GET stream of change events
2
+ // from a dedicated `/events` endpoint on baseUrl-port+2.
3
+ //
4
+ // SSE has no uplink, so `send()` is a no-op — the engine continues to
5
+ // dispatch subscribe / presence / topic frames through this method but
6
+ // they don't reach the wire on this transport. Apps that need
7
+ // bidirectional should pick WebSocket; SSE is for read-only mirror
8
+ // clients (dashboards, monitors) that don't subscribe to anything
9
+ // individually.
10
+ //
11
+ // Reconnect: SSE has its own browser-level reconnect, but it can't see
12
+ // our auth token (no header on the GET retry), so we own the reconnect
13
+ // loop here too — close on error, jittered backoff, pull-on-reconnect.
14
+
15
+ import type { ChangeEvent } from "../types";
16
+ import { ReconnectBackoff } from "./reconnect";
17
+ import type { Transport, TransportHost } from "./types";
18
+
19
+ export class SseTransport implements Transport {
20
+ private readonly host: TransportHost;
21
+ private es: EventSource | null = null;
22
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
23
+ private readonly backoff: ReconnectBackoff;
24
+
25
+ constructor(host: TransportHost) {
26
+ this.host = host;
27
+ this.backoff = new ReconnectBackoff(host.reconnectDelayMs);
28
+ }
29
+
30
+ start(): void {
31
+ if (!this.host.isRunning()) return;
32
+ if (!this.host.isLeader()) return;
33
+ if (this.es) return;
34
+
35
+ const sseUrl = this.deriveSseUrl();
36
+ try {
37
+ const es = new EventSource(sseUrl);
38
+ this.es = es;
39
+ es.onopen = () => {
40
+ this.host.setStatus("connected");
41
+ this.backoff.reset();
42
+ this.host.onConnected();
43
+ };
44
+ es.onmessage = (event) => {
45
+ try {
46
+ const msg = JSON.parse(event.data) as Record<string, unknown>;
47
+ if (
48
+ typeof msg.seq === "number" &&
49
+ msg.seq > 0 &&
50
+ typeof msg.entity === "string" &&
51
+ typeof msg.kind === "string"
52
+ ) {
53
+ this.host.onChangeEvent(msg as unknown as ChangeEvent);
54
+ return;
55
+ }
56
+ // Non-change envelopes are rare on SSE (no reactive queries,
57
+ // no row-revoked typically) but route through anyway so
58
+ // future protocol additions don't get silently dropped.
59
+ this.host.onJsonMessage(msg);
60
+ } catch {
61
+ // Ignore malformed events.
62
+ }
63
+ };
64
+ es.onerror = () => {
65
+ try {
66
+ es.close();
67
+ } catch {
68
+ /* may already be closed */
69
+ }
70
+ if (this.es === es) this.es = null;
71
+ if (!this.host.isRunning()) return;
72
+ this.host.setStatus("reconnecting");
73
+ this.host.onDisconnected();
74
+ this.scheduleReconnect();
75
+ };
76
+ } catch {
77
+ // EventSource constructor threw at runtime — rare, but a buggy
78
+ // polyfill or a malformed URL can land here. The `transport: "sse"`
79
+ // → polling fallback for the "EventSource undefined" case lives in
80
+ // `createTransport`; this catch covers the narrower "exists but
81
+ // failed to construct" case. Schedule a reconnect so we retry
82
+ // rather than silently giving up.
83
+ this.scheduleReconnect();
84
+ }
85
+ }
86
+
87
+ stop(): void {
88
+ if (this.es) {
89
+ try {
90
+ this.es.close();
91
+ } catch {
92
+ /* may already be closed */
93
+ }
94
+ this.es = null;
95
+ }
96
+ if (this.reconnectTimer) {
97
+ clearTimeout(this.reconnectTimer);
98
+ this.reconnectTimer = null;
99
+ }
100
+ }
101
+
102
+ /** SSE is one-way. The engine still calls send() for subscribe /
103
+ * presence / topic / ping, but those frames have nowhere to go.
104
+ * Silent no-op rather than throw: a no-op keeps app code identical
105
+ * across transports. */
106
+ send(_msg: unknown): void {
107
+ /* no uplink on SSE */
108
+ }
109
+
110
+ /** SSE is always "open enough" to receive once the EventSource is
111
+ * attached and not in CLOSED. We expose this for the engine's
112
+ * `connected` getter so UI status matches reality. */
113
+ isOpen(): boolean {
114
+ return (
115
+ this.es !== null && this.es.readyState !== EventSource.CLOSED
116
+ );
117
+ }
118
+
119
+ bumpReconnect(by = 1): void {
120
+ this.backoff.bump(by);
121
+ }
122
+
123
+ // ---- internals --------------------------------------------------------
124
+
125
+ private scheduleReconnect(): void {
126
+ if (!this.host.isRunning()) return;
127
+ this.backoff.bump();
128
+ const delay = this.backoff.nextDelayMs();
129
+ this.reconnectTimer = setTimeout(() => {
130
+ this.reconnectTimer = null;
131
+ void this.host.performReconnectPull().then(() => this.start());
132
+ }, delay);
133
+ }
134
+
135
+ private deriveSseUrl(): string {
136
+ const url = new URL(this.host.baseUrl);
137
+ const port = parseInt(url.port || "4321", 10);
138
+ return `http://${url.hostname}:${port + 2}/events`;
139
+ }
140
+ }
@@ -0,0 +1,116 @@
1
+ // Common interface every real-time transport implements + the host
2
+ // contract the engine offers each transport.
3
+ //
4
+ // Why this exists: WebSocket, SSE, and polling each used to live as
5
+ // large parallel methods on the engine, sharing state through engine
6
+ // fields (`this.ws`, `this.pingTimer`, `this.reconnectAttempts`,
7
+ // `this.pollTimer`). That coupling made the three modes hard to
8
+ // test, hard to swap, and a graveyard for state bugs (timer leaks on
9
+ // promotion, stale-socket sends after reconnect, ping racing close).
10
+ // The interface here gives each one a single owner of its own state
11
+ // + a tight contract with the engine for dispatch and lifecycle.
12
+
13
+ import type { ChangeEvent, SyncConnectionStatus } from "../types";
14
+
15
+ /** Real-time transport implementations all conform to this shape. The
16
+ * engine holds exactly one. The interface intentionally hides the
17
+ * underlying mechanism (WebSocket vs EventSource vs setInterval) so
18
+ * the engine can be told "go connect" without caring which one. */
19
+ export interface Transport {
20
+ /** Connect / start the transport. Idempotent — calling twice is a
21
+ * no-op for an already-running transport. */
22
+ start(): void;
23
+ /** Disconnect / stop the transport, clearing every timer and
24
+ * releasing the socket. Idempotent. After stop() the transport is
25
+ * re-startable. */
26
+ stop(): void;
27
+ /** Send a JSON message over the transport. No-op for non-bidirectional
28
+ * transports (SSE, polling) — the engine still sends subscribe
29
+ * frames + presence + topic + ping through this entry point, and
30
+ * those frames simply never reach the wire when the transport
31
+ * doesn't support uplink. */
32
+ send(msg: unknown): void;
33
+ /** True when the underlying connection is currently open for sending.
34
+ * Used by the engine's `connected` getter and by paths that need to
35
+ * short-circuit when no socket exists (e.g., avoid throwing on a
36
+ * send-without-WS). */
37
+ isOpen(): boolean;
38
+ /** Bump the reconnect backoff counter by N attempts. Called by the
39
+ * engine when it observes a hint that the NEXT reconnect should
40
+ * wait longer — typically a 429 on pull, which would otherwise
41
+ * drive a tight 429 / reconnect / 429 loop. Transports without a
42
+ * backoff (polling) implement this as a no-op. */
43
+ bumpReconnect(by?: number): void;
44
+ }
45
+
46
+ /** Engine-side surface the transport calls into. Transport is the
47
+ * thing that knows about sockets and timers; the host (engine) is the
48
+ * thing that knows what to DO with inbound frames and lifecycle
49
+ * events. Keeping this small + explicit makes the boundary clear:
50
+ * if a transport needs a new engine capability it gets added here,
51
+ * not via reaching back into the engine instance. */
52
+ export interface TransportHost {
53
+ /** Base URL of the Pylon HTTP API (e.g. `https://api.example.com`).
54
+ * Transports derive their own URLs from this (ws upgrade path, SSE
55
+ * endpoint, poll path). */
56
+ readonly baseUrl: string;
57
+ /** Optional explicit WS URL — overrides the derived one. */
58
+ readonly wsUrl?: string;
59
+ /** WS keepalive interval. Default is implementation-defined; the
60
+ * engine accepts `pingIntervalMs` from config to tune broadcast
61
+ * latency on single-threaded server fallbacks. */
62
+ readonly pingIntervalMs?: number;
63
+ /** Base delay for reconnect backoff (full-jitter). */
64
+ readonly reconnectDelayMs?: number;
65
+ /** Polling cadence (polling transport only). */
66
+ readonly pollIntervalMs?: number;
67
+
68
+ /** Current bearer token, or undefined when anonymous. The transport
69
+ * must call this fresh on each connect / reconnect so token rotation
70
+ * is picked up automatically. */
71
+ getToken(): string | undefined;
72
+
73
+ /** Is this tab the multi-tab leader. Followers don't open their own
74
+ * transport — they mirror the leader's broadcasts. Transports check
75
+ * this defensively at start() to avoid wasting socket budget. */
76
+ isLeader(): boolean;
77
+ /** Is the engine currently running. Set false on stop(); transports
78
+ * bail out of reconnect timers when this flips. */
79
+ isRunning(): boolean;
80
+
81
+ /** Inbound: a typed change event from the server's change log.
82
+ * Engine routes through the apply queue so order is preserved. */
83
+ onChangeEvent(ev: ChangeEvent): void;
84
+ /** Inbound: a typed JSON envelope (not a ChangeEvent). The transport
85
+ * doesn't try to interpret the envelope — engine has the dispatch
86
+ * table (session-changed, reactive-result, row-revoked, presence,
87
+ * pong, etc). */
88
+ onJsonMessage(msg: Record<string, unknown>): void;
89
+ /** Inbound: a binary frame. CRDT broadcast layer ships these for
90
+ * every Loro-mode write; the engine routes via its binaryHandlers
91
+ * + multi-tab forwarders. */
92
+ onBinaryFrame(bytes: Uint8Array): void;
93
+
94
+ /** Lifecycle: the transport just connected. Engine uses this to
95
+ * replay active subscriptions, fire a catch-up pull + reconcile,
96
+ * and flip the public connection status. */
97
+ onConnected(): void;
98
+ /** Lifecycle: the transport just disconnected. Engine flips status
99
+ * to `reconnecting` while the transport's own backoff timer is
100
+ * pending; or to `offline` when `isRunning()` is false. */
101
+ onDisconnected(): void;
102
+ /** Flip the engine's public `connectionStatus`. Transports drive
103
+ * this so the UI sees `connecting` / `connected` / `reconnecting`
104
+ * / `offline` at the right moments. */
105
+ setStatus(s: SyncConnectionStatus): void;
106
+
107
+ /** Polling transport only: one iteration of the push→pull cycle.
108
+ * Implementations of polling do nothing else; they just call this
109
+ * on a timer. */
110
+ performPollTick(): Promise<void>;
111
+ /** Reconnect-aware transports (WS, SSE) need to do a catch-up pull
112
+ * before the next connect attempt so they don't open a fresh socket
113
+ * and immediately discover their cursor is stale. The engine owns
114
+ * the pull queue + reconcile path; the transport invokes this. */
115
+ performReconnectPull(): Promise<void>;
116
+ }
@@ -0,0 +1,310 @@
1
+ // Unit tests for the WebSocketTransport. Uses a fake WebSocket
2
+ // implementation so the test runs without a real server. Pins:
3
+ // - start() opens a socket and wires onopen/onmessage/onclose/onerror
4
+ // - onopen flips status to "connected", replay fires via host.onConnected
5
+ // - binary frames route through host.onBinaryFrame
6
+ // - ChangeEvent JSON routes through host.onChangeEvent
7
+ // - Other JSON routes through host.onJsonMessage
8
+ // - Disconnect triggers reconnect after a backoff window
9
+ // - stop() suppresses the scheduled reconnect
10
+
11
+ import {
12
+ afterEach,
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ test,
18
+ } from "bun:test";
19
+
20
+ import type { ChangeEvent, SyncConnectionStatus } from "../types";
21
+ import type { TransportHost } from "./types";
22
+ import { WebSocketTransport } from "./websocket";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Fake WebSocket. Captures every constructor call so tests can poke at the
26
+ // returned socket's onopen/onmessage/onclose/onerror handlers. The real
27
+ // browser API doesn't let us do that — but we control what `new WebSocket`
28
+ // returns in test land.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface FakeWebSocketInstance {
32
+ url: string;
33
+ protocols?: string | string[];
34
+ readyState: number;
35
+ binaryType: string;
36
+ sent: string[];
37
+ onopen: ((ev: Event) => void) | null;
38
+ onmessage: ((ev: MessageEvent) => void) | null;
39
+ onclose: ((ev: CloseEvent) => void) | null;
40
+ onerror: ((ev: Event) => void) | null;
41
+ send(data: string): void;
42
+ close(): void;
43
+ /** Test helper: simulate the server pushing a frame. */
44
+ simulateMessage(data: string | ArrayBuffer): void;
45
+ /** Test helper: simulate the open handshake completing. */
46
+ simulateOpen(): void;
47
+ /** Test helper: simulate an unexpected close. */
48
+ simulateClose(): void;
49
+ }
50
+
51
+ const fakeSockets: FakeWebSocketInstance[] = [];
52
+
53
+ class FakeWebSocket implements FakeWebSocketInstance {
54
+ url: string;
55
+ protocols?: string | string[];
56
+ readyState = 0; // CONNECTING
57
+ binaryType = "blob";
58
+ sent: string[] = [];
59
+ onopen: ((ev: Event) => void) | null = null;
60
+ onmessage: ((ev: MessageEvent) => void) | null = null;
61
+ onclose: ((ev: CloseEvent) => void) | null = null;
62
+ onerror: ((ev: Event) => void) | null = null;
63
+
64
+ constructor(url: string, protocols?: string | string[]) {
65
+ this.url = url;
66
+ this.protocols = protocols;
67
+ fakeSockets.push(this);
68
+ }
69
+
70
+ send(data: string): void {
71
+ this.sent.push(data);
72
+ }
73
+
74
+ close(): void {
75
+ this.readyState = 3; // CLOSED
76
+ // Bun's tests run synchronously enough that we don't fire onclose
77
+ // here — the production code's stop() nulls onclose before this
78
+ // is called, and the test's simulateClose covers the other path.
79
+ }
80
+
81
+ simulateOpen(): void {
82
+ this.readyState = 1; // OPEN
83
+ this.onopen?.(new Event("open"));
84
+ }
85
+
86
+ simulateMessage(data: string | ArrayBuffer): void {
87
+ this.onmessage?.({ data } as MessageEvent);
88
+ }
89
+
90
+ simulateClose(): void {
91
+ this.readyState = 3; // CLOSED
92
+ this.onclose?.(new CloseEvent("close"));
93
+ }
94
+ }
95
+
96
+ // Patch globals once for this file. We intentionally don't restore —
97
+ // the test runner gives each file its own module evaluation, and other
98
+ // test files that need a real WebSocket already supply their own stub
99
+ // (e.g., the scenario harness installs its own). Restoring after each
100
+ // test caused us to clobber the FakeWebSocket mid-suite and break
101
+ // subsequent tests in this file.
102
+ beforeAll(() => {
103
+ Object.defineProperty(globalThis, "WebSocket", {
104
+ value: FakeWebSocket,
105
+ writable: true,
106
+ configurable: true,
107
+ });
108
+ // The transport checks `this.ws.readyState === WebSocket.OPEN`, so
109
+ // the static .OPEN constant has to exist on whatever class we
110
+ // install. Mirrors the browser's WebSocket.OPEN === 1.
111
+ Object.defineProperty(FakeWebSocket, "OPEN", {
112
+ value: 1,
113
+ writable: false,
114
+ configurable: true,
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+
120
+ interface TestState {
121
+ statuses: SyncConnectionStatus[];
122
+ changes: ChangeEvent[];
123
+ jsonMessages: Record<string, unknown>[];
124
+ binaries: Uint8Array[];
125
+ connectedCount: number;
126
+ reconnectPulls: number;
127
+ }
128
+
129
+ /** Build a TransportHost wired to a TestState recorder. Recorder is
130
+ * returned via `state` so tests can read counters after the transport
131
+ * has called back into the host. Spreading state into host would
132
+ * copy the counter values once at construction — the recorder needs
133
+ * shared mutable state. */
134
+ function makeHost(
135
+ overrides: Partial<TransportHost> = {},
136
+ ): { host: TransportHost; state: TestState } {
137
+ const state: TestState = {
138
+ statuses: [],
139
+ changes: [],
140
+ jsonMessages: [],
141
+ binaries: [],
142
+ connectedCount: 0,
143
+ reconnectPulls: 0,
144
+ };
145
+ const host: TransportHost = {
146
+ baseUrl: "http://stub.invalid",
147
+ reconnectDelayMs: 10,
148
+ getToken: () => undefined,
149
+ isLeader: () => true,
150
+ isRunning: () => true,
151
+ onChangeEvent: (ev) => state.changes.push(ev),
152
+ onJsonMessage: (msg) => state.jsonMessages.push(msg),
153
+ onBinaryFrame: (bytes) => state.binaries.push(bytes),
154
+ onConnected: () => {
155
+ state.connectedCount += 1;
156
+ },
157
+ onDisconnected: () => {},
158
+ setStatus: (s) => state.statuses.push(s),
159
+ performPollTick: async () => {},
160
+ performReconnectPull: async () => {
161
+ state.reconnectPulls += 1;
162
+ },
163
+ ...overrides,
164
+ };
165
+ return { host, state };
166
+ }
167
+
168
+ describe("WebSocketTransport", () => {
169
+ let t: WebSocketTransport | null = null;
170
+
171
+ beforeEach(() => {
172
+ fakeSockets.length = 0;
173
+ t = null;
174
+ });
175
+
176
+ afterEach(() => {
177
+ t?.stop();
178
+ t = null;
179
+ });
180
+
181
+ test("start opens a socket with the derived URL", () => {
182
+ const { host, state } = makeHost();
183
+ t = new WebSocketTransport(host);
184
+ t.start();
185
+ expect(fakeSockets.length).toBe(1);
186
+ // baseUrl is http → ws scheme; multiplexed on /api/sync/ws.
187
+ expect(fakeSockets[0].url).toBe("ws://stub.invalid/api/sync/ws");
188
+ });
189
+
190
+ test("start passes the bearer-subprotocol when host has a token", () => {
191
+ const { host } = makeHost({ getToken: () => "tok_abc" });
192
+ t = new WebSocketTransport(host);
193
+ t.start();
194
+ expect(fakeSockets[0].protocols).toBe("bearer.tok_abc");
195
+ });
196
+
197
+ test("onopen flips status to connected and fires host.onConnected", () => {
198
+ const { host, state } = makeHost();
199
+ t = new WebSocketTransport(host);
200
+ t.start();
201
+ fakeSockets[0].simulateOpen();
202
+ expect(state.statuses).toContain("connected");
203
+ expect(state.connectedCount).toBe(1);
204
+ });
205
+
206
+ test("ChangeEvent frames route through onChangeEvent", () => {
207
+ const { host, state } = makeHost();
208
+ t = new WebSocketTransport(host);
209
+ t.start();
210
+ fakeSockets[0].simulateOpen();
211
+ const ev = {
212
+ seq: 5,
213
+ entity: "Todo",
214
+ row_id: "r1",
215
+ kind: "insert",
216
+ data: { id: "r1" },
217
+ timestamp: "",
218
+ };
219
+ fakeSockets[0].simulateMessage(JSON.stringify(ev));
220
+ expect(state.changes.length).toBe(1);
221
+ expect(state.changes[0].seq).toBe(5);
222
+ });
223
+
224
+ test("non-change JSON routes through onJsonMessage", () => {
225
+ const { host, state } = makeHost();
226
+ t = new WebSocketTransport(host);
227
+ t.start();
228
+ fakeSockets[0].simulateOpen();
229
+ fakeSockets[0].simulateMessage(
230
+ JSON.stringify({ type: "session-changed" }),
231
+ );
232
+ expect(state.jsonMessages.length).toBe(1);
233
+ expect(state.jsonMessages[0].type).toBe("session-changed");
234
+ expect(state.changes.length).toBe(0);
235
+ });
236
+
237
+ test("binary frames route through onBinaryFrame", () => {
238
+ const { host, state } = makeHost();
239
+ t = new WebSocketTransport(host);
240
+ t.start();
241
+ fakeSockets[0].simulateOpen();
242
+ const bytes = new Uint8Array([1, 2, 3, 4]).buffer;
243
+ fakeSockets[0].simulateMessage(bytes);
244
+ expect(state.binaries.length).toBe(1);
245
+ expect(state.binaries[0].length).toBe(4);
246
+ });
247
+
248
+ test("send writes a JSON string to the socket when open", () => {
249
+ const { host, state } = makeHost();
250
+ t = new WebSocketTransport(host);
251
+ t.start();
252
+ fakeSockets[0].simulateOpen();
253
+ t.send({ type: "presence", x: 1 });
254
+ expect(fakeSockets[0].sent).toContain('{"type":"presence","x":1}');
255
+ });
256
+
257
+ test("send is a no-op when the socket isn't open", () => {
258
+ const { host, state } = makeHost();
259
+ t = new WebSocketTransport(host);
260
+ t.start();
261
+ // Don't simulateOpen — readyState stays at CONNECTING.
262
+ t.send({ type: "presence" });
263
+ expect(fakeSockets[0].sent.length).toBe(0);
264
+ });
265
+
266
+ test("unexpected close triggers reconnect via performReconnectPull", async () => {
267
+ const { host, state } = makeHost({ reconnectDelayMs: 5 });
268
+ t = new WebSocketTransport(host);
269
+ t.start();
270
+ fakeSockets[0].simulateOpen();
271
+ fakeSockets[0].simulateClose();
272
+ // Wait long enough for the backoff timer + reconnect to fire.
273
+ await new Promise((r) => setTimeout(r, 80));
274
+ expect(state.reconnectPulls).toBeGreaterThanOrEqual(1);
275
+ // A second socket should have been opened.
276
+ expect(fakeSockets.length).toBeGreaterThanOrEqual(2);
277
+ });
278
+
279
+ test("stop suppresses the scheduled reconnect", async () => {
280
+ const { host, state } = makeHost({ reconnectDelayMs: 5 });
281
+ t = new WebSocketTransport(host);
282
+ t.start();
283
+ fakeSockets[0].simulateOpen();
284
+ fakeSockets[0].simulateClose();
285
+ t.stop();
286
+ await new Promise((r) => setTimeout(r, 80));
287
+ // No reconnect pull should have fired — stop cancelled the timer.
288
+ expect(state.reconnectPulls).toBe(0);
289
+ });
290
+
291
+ test("does not open a socket when host is not leader", () => {
292
+ const { host } = makeHost({ isLeader: () => false });
293
+ t = new WebSocketTransport(host);
294
+ t.start();
295
+ expect(fakeSockets.length).toBe(0);
296
+ });
297
+
298
+ test("isOpen reflects the underlying readyState", () => {
299
+ const { host, state } = makeHost();
300
+ t = new WebSocketTransport(host);
301
+ expect(t.isOpen()).toBe(false);
302
+ t.start();
303
+ expect(t.isOpen()).toBe(false); // CONNECTING
304
+ fakeSockets[0].simulateOpen();
305
+ expect(t.isOpen()).toBe(true); // OPEN
306
+ fakeSockets[0].simulateClose();
307
+ expect(t.isOpen()).toBe(false); // CLOSED
308
+ });
309
+ });
310
+