@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,222 @@
1
+ // WebSocket transport. Owns the active socket, the keepalive ping
2
+ // timer, the stable-connection timer (5s window before backoff resets),
3
+ // and reconnect scheduling.
4
+ //
5
+ // Frame routing: this transport only does the bare minimum — recognize
6
+ // binary vs JSON and call back into the host (engine) for actual
7
+ // dispatch. The host has the typed envelope table (change events,
8
+ // reactive results, row revocations, session-changed, pong, presence).
9
+ //
10
+ // Token handling: browser WebSocket has no header API, so we pass the
11
+ // bearer token as a `bearer.<percent-encoded>` subprotocol per
12
+ // RFC 6455 §1.9. Native clients can still use Authorization: Bearer.
13
+
14
+ import type { ChangeEvent } from "../types";
15
+ import { ReconnectBackoff } from "./reconnect";
16
+ import type { Transport, TransportHost } from "./types";
17
+
18
+ const STABLE_CONNECTION_MS = 5_000;
19
+ const DEFAULT_PING_INTERVAL_MS = 25_000;
20
+
21
+ export class WebSocketTransport implements Transport {
22
+ private readonly host: TransportHost;
23
+ private ws: WebSocket | null = null;
24
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
25
+ private stableTimer: ReturnType<typeof setTimeout> | null = null;
26
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
27
+ private readonly backoff: ReconnectBackoff;
28
+
29
+ constructor(host: TransportHost) {
30
+ this.host = host;
31
+ this.backoff = new ReconnectBackoff(host.reconnectDelayMs);
32
+ }
33
+
34
+ start(): void {
35
+ if (!this.host.isRunning()) return;
36
+ // Followers never open a WS — the leader's fanout is mirrored over
37
+ // the BroadcastChannel.
38
+ if (!this.host.isLeader()) return;
39
+ // Already connected / connecting? Don't stack a second socket.
40
+ if (this.ws) return;
41
+
42
+ const wsUrl = this.host.wsUrl ?? this.deriveWsUrl();
43
+ const token = this.host.getToken();
44
+ try {
45
+ if (token) {
46
+ const proto = `bearer.${encodeURIComponent(token)}`;
47
+ this.ws = new WebSocket(wsUrl, proto);
48
+ } else {
49
+ this.ws = new WebSocket(wsUrl);
50
+ }
51
+ } catch {
52
+ this.scheduleReconnect();
53
+ return;
54
+ }
55
+
56
+ this.ws.binaryType = "arraybuffer";
57
+
58
+ this.ws.onopen = () => {
59
+ // Flip to "connected" immediately so UI consumers can clear the
60
+ // "reconnecting" indicator the moment data starts flowing again.
61
+ // The stable-window timer below decides when to RESET the
62
+ // backoff counter — a socket that opens then closes inside a
63
+ // few seconds (auth failure, server 1008) would otherwise let
64
+ // the reconnect loop fire at ~2/sec forever.
65
+ this.host.setStatus("connected");
66
+ if (this.stableTimer) clearTimeout(this.stableTimer);
67
+ this.stableTimer = setTimeout(() => {
68
+ this.backoff.reset();
69
+ this.stableTimer = null;
70
+ }, STABLE_CONNECTION_MS);
71
+
72
+ // Client-side keepalive ping. Default 25s — pure liveness, since
73
+ // the dedicated :port+1 server uses a dual-thread design that
74
+ // wakes the writer instantly on every broadcast. The HTTP-
75
+ // multiplexed `/api/sync/ws` fallback path IS bounded by this
76
+ // interval, so apps that can't route to the :port+1 listener
77
+ // can pass `init({ pingIntervalMs: 200 })` to trade traffic for
78
+ // latency.
79
+ const pingIntervalMs = this.host.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
80
+ if (this.pingTimer) clearInterval(this.pingTimer);
81
+ this.pingTimer = setInterval(() => {
82
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
83
+ try {
84
+ this.ws.send('{"type":"ping"}');
85
+ } catch {
86
+ // ignore — onclose will trigger reconnect
87
+ }
88
+ }, pingIntervalMs);
89
+
90
+ this.host.onConnected();
91
+ };
92
+
93
+ this.ws.onmessage = (event) => {
94
+ // Binary frame: hand off to the host. Pylon ships every CRDT-mode
95
+ // write as a binary [type|entity|row_id|payload] frame; @pylonsync/
96
+ // loro is the intended decoder. The transport stays binary-agnostic
97
+ // so future binary use cases (file streaming, video chunks…) can
98
+ // register on the host side without churning this layer.
99
+ if (event.data instanceof ArrayBuffer) {
100
+ this.host.onBinaryFrame(new Uint8Array(event.data));
101
+ return;
102
+ }
103
+ try {
104
+ const msg = JSON.parse(event.data as string) as Record<string, unknown>;
105
+ // Change event sniff: `seq + entity + kind` is the canonical
106
+ // wire shape. Route through onChangeEvent so the host applies
107
+ // through its shared apply queue (preserves seq order with
108
+ // pull() output).
109
+ if (
110
+ typeof msg.seq === "number" &&
111
+ msg.seq > 0 &&
112
+ typeof msg.entity === "string" &&
113
+ typeof msg.kind === "string"
114
+ ) {
115
+ this.host.onChangeEvent(msg as unknown as ChangeEvent);
116
+ return;
117
+ }
118
+ this.host.onJsonMessage(msg);
119
+ } catch {
120
+ // Ignore malformed messages.
121
+ }
122
+ };
123
+
124
+ this.ws.onclose = () => {
125
+ this.ws = null;
126
+ // Socket closed before the stable-window timer fired — treat
127
+ // this as an unstable connection and DON'T reset the backoff.
128
+ // The growing delay protects the server from a tight loop.
129
+ if (this.stableTimer) {
130
+ clearTimeout(this.stableTimer);
131
+ this.stableTimer = null;
132
+ }
133
+ if (this.pingTimer) {
134
+ clearInterval(this.pingTimer);
135
+ this.pingTimer = null;
136
+ }
137
+ if (this.host.isRunning()) {
138
+ this.host.setStatus("reconnecting");
139
+ }
140
+ this.host.onDisconnected();
141
+ this.scheduleReconnect();
142
+ };
143
+
144
+ this.ws.onerror = () => {
145
+ // onclose will fire after this — no work to do here.
146
+ };
147
+ }
148
+
149
+ stop(): void {
150
+ if (this.ws) {
151
+ // Null-out onclose so the close we're triggering doesn't kick
152
+ // off another reconnect cycle.
153
+ this.ws.onclose = null;
154
+ try {
155
+ this.ws.close();
156
+ } catch {
157
+ /* socket may already be torn down */
158
+ }
159
+ this.ws = null;
160
+ }
161
+ if (this.reconnectTimer) {
162
+ clearTimeout(this.reconnectTimer);
163
+ this.reconnectTimer = null;
164
+ }
165
+ if (this.stableTimer) {
166
+ clearTimeout(this.stableTimer);
167
+ this.stableTimer = null;
168
+ }
169
+ if (this.pingTimer) {
170
+ clearInterval(this.pingTimer);
171
+ this.pingTimer = null;
172
+ }
173
+ }
174
+
175
+ send(msg: unknown): void {
176
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
177
+ this.ws.send(JSON.stringify(msg));
178
+ }
179
+ }
180
+
181
+ isOpen(): boolean {
182
+ return this.ws?.readyState === WebSocket.OPEN;
183
+ }
184
+
185
+ /** Bump the reconnect counter explicitly. Used for the 429 RESYNC
186
+ * case where the engine wants the next delay to be 3 steps further
187
+ * out — protects the server from a 429-loop where a 429 on pull
188
+ * triggers onclose → reconnect → pull → 429 in a tight cycle. */
189
+ bumpReconnect(by = 1): void {
190
+ this.backoff.bump(by);
191
+ }
192
+
193
+ // ---- internals --------------------------------------------------------
194
+
195
+ private scheduleReconnect(): void {
196
+ if (!this.host.isRunning()) return;
197
+ this.backoff.bump();
198
+ const delay = this.backoff.nextDelayMs();
199
+ this.reconnectTimer = setTimeout(() => {
200
+ this.reconnectTimer = null;
201
+ // Pull any missed changes before the next connect attempt, then
202
+ // reconnect. The pull is engine-side so retries / 410 handling
203
+ // / session resets stay centralized.
204
+ void this.host.performReconnectPull().then(() => this.start());
205
+ }, delay);
206
+ }
207
+
208
+ private deriveWsUrl(): string {
209
+ const url = new URL(this.host.baseUrl);
210
+ const scheme = url.protocol === "https:" ? "wss" : "ws";
211
+ // Always multiplex WS on the same origin via `/api/sync/ws`. The
212
+ // Pylon runtime accepts the Upgrade on its main HTTP port, so any
213
+ // reverse proxy that already forwards `/api/*` carries the
214
+ // WebSocket through too (Vite's `ws: true` proxy, Next.js
215
+ // rewrites, CDNs with WS support). The legacy port+1 fallback is
216
+ // still available on the runtime; we just don't derive it client-
217
+ // side anymore because any setup where the page origin wasn't
218
+ // equal to the API origin would compute ws://localhost:3001 — which
219
+ // doesn't exist and bypasses the dev-server proxy.
220
+ return `${scheme}://${url.host}/api/sync/ws`;
221
+ }
222
+ }