@syncular/transport-ws 0.0.1-60

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,163 @@
1
+ /**
2
+ * @syncular/transport-ws - WebSocket transport for sync realtime wake-ups
3
+ *
4
+ * Extends the HTTP transport with WebSocket-based realtime notifications.
5
+ * WebSockets are only used as a "wake up" mechanism; clients must still pull.
6
+ *
7
+ * Auth notes:
8
+ * - Browsers' `WebSocket` cannot attach custom headers.
9
+ * - Use cookie auth (same-origin) or a query-param token for the realtime URL.
10
+ */
11
+ import type { SyncPushRequest, SyncPushResponse, SyncTransport } from '@syncular/core';
12
+ import { type ClientOptions } from '@syncular/transport-http';
13
+ /**
14
+ * WebSocket connection state
15
+ */
16
+ export type WebSocketConnectionState = 'disconnected' | 'connecting' | 'connected';
17
+ /**
18
+ * Presence event data
19
+ */
20
+ export interface PresenceEventData {
21
+ action: 'join' | 'leave' | 'update' | 'snapshot';
22
+ scopeKey: string;
23
+ clientId?: string;
24
+ actorId?: string;
25
+ metadata?: Record<string, unknown>;
26
+ entries?: Array<{
27
+ clientId: string;
28
+ actorId: string;
29
+ joinedAt: number;
30
+ metadata?: Record<string, unknown>;
31
+ }>;
32
+ }
33
+ /**
34
+ * Push response data received from the server over WS
35
+ */
36
+ export interface WsPushResponseData {
37
+ requestId: string;
38
+ ok: boolean;
39
+ status: string;
40
+ commitSeq?: number;
41
+ results: Array<{
42
+ opIndex: number;
43
+ status: string;
44
+ [k: string]: unknown;
45
+ }>;
46
+ timestamp: number;
47
+ }
48
+ /**
49
+ * WebSocket event from the server
50
+ */
51
+ export interface WebSocketEvent {
52
+ event: 'sync' | 'heartbeat' | 'error' | 'presence' | 'push-response';
53
+ data: {
54
+ cursor?: number;
55
+ /** Inline change data for small payloads (WS data delivery) */
56
+ changes?: unknown[];
57
+ error?: string;
58
+ presence?: PresenceEventData;
59
+ /** Push response fields (for push-response events) */
60
+ requestId?: string;
61
+ ok?: boolean;
62
+ status?: string;
63
+ commitSeq?: number;
64
+ results?: Array<{
65
+ opIndex: number;
66
+ status: string;
67
+ [k: string]: unknown;
68
+ }>;
69
+ timestamp: number;
70
+ };
71
+ }
72
+ /**
73
+ * Callback for realtime events
74
+ */
75
+ export type WebSocketEventCallback = (event: WebSocketEvent) => void;
76
+ /**
77
+ * Callback for connection state changes
78
+ */
79
+ export type WebSocketStateCallback = (state: WebSocketConnectionState) => void;
80
+ export interface WebSocketTransportOptions extends ClientOptions {
81
+ /**
82
+ * WebSocket endpoint URL. If not provided, uses `${baseUrl}/realtime` with
83
+ * `http(s)` -> `ws(s)` conversion when possible.
84
+ */
85
+ wsUrl?: string;
86
+ /**
87
+ * Additional query params for the realtime URL (e.g. `{ token }`).
88
+ *
89
+ * ⚠️ SECURITY WARNING: Query parameters may be logged by proxies, CDNs, and
90
+ * browser history. Do NOT pass sensitive tokens here. Use cookie-based auth
91
+ * or the `authToken` option with a server that supports first-message auth.
92
+ */
93
+ getRealtimeParams?: (args: {
94
+ clientId: string;
95
+ }) => Record<string, string> | Promise<Record<string, string>>;
96
+ /**
97
+ * Auth token sent in the first WebSocket message after connection.
98
+ * More secure than query parameters as it won't appear in URLs.
99
+ * Requires server support for first-message auth.
100
+ */
101
+ authToken?: string | (() => string | Promise<string>);
102
+ /**
103
+ * Initial reconnection delay in ms.
104
+ * Default: 1000 (1 second)
105
+ */
106
+ initialReconnectDelay?: number;
107
+ /**
108
+ * Maximum reconnection delay in ms.
109
+ * Default: 30000 (30 seconds)
110
+ */
111
+ maxReconnectDelay?: number;
112
+ /**
113
+ * Backoff factor for reconnection delay.
114
+ * Default: 2
115
+ */
116
+ reconnectBackoffFactor?: number;
117
+ /**
118
+ * Jitter factor for reconnection delay (0-1).
119
+ * Adds randomness to prevent thundering herd on server restart.
120
+ * Default: 0.3 (30% randomization)
121
+ */
122
+ reconnectJitter?: number;
123
+ /**
124
+ * Heartbeat timeout in ms. If no message is received within this time,
125
+ * the connection is considered dead.
126
+ * Default: 60000 (60 seconds)
127
+ */
128
+ heartbeatTimeout?: number;
129
+ /**
130
+ * Optional WebSocket implementation override (useful for non-browser runtimes).
131
+ */
132
+ WebSocketImpl?: typeof WebSocket;
133
+ /**
134
+ * Transport path telemetry sent to the server for push/pull and realtime.
135
+ * Defaults to 'relay' for this transport.
136
+ */
137
+ transportPath?: 'direct' | 'relay';
138
+ }
139
+ /**
140
+ * Callback for presence events from the server
141
+ */
142
+ export type PresenceEventCallback = (event: PresenceEventData) => void;
143
+ /**
144
+ * Extended sync transport with WebSocket subscription support.
145
+ */
146
+ export interface WebSocketTransport extends SyncTransport {
147
+ connect(args: {
148
+ clientId: string;
149
+ }, onEvent: WebSocketEventCallback, onStateChange?: WebSocketStateCallback): () => void;
150
+ getConnectionState(): WebSocketConnectionState;
151
+ reconnect(): void;
152
+ sendPresenceJoin(scopeKey: string, metadata?: Record<string, unknown>): void;
153
+ sendPresenceLeave(scopeKey: string): void;
154
+ sendPresenceUpdate(scopeKey: string, metadata: Record<string, unknown>): void;
155
+ onPresenceEvent(callback: PresenceEventCallback): () => void;
156
+ /**
157
+ * Push a commit via WebSocket (bypasses HTTP).
158
+ * Returns `null` if WS is not connected or times out (caller should fall back to HTTP).
159
+ */
160
+ pushViaWs(request: SyncPushRequest): Promise<SyncPushResponse | null>;
161
+ }
162
+ export declare function createWebSocketTransport(options: WebSocketTransportOptions): WebSocketTransport;
163
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @syncular/transport-ws - WebSocket transport for sync realtime wake-ups
3
+ *
4
+ * Extends the HTTP transport with WebSocket-based realtime notifications.
5
+ * WebSockets are only used as a "wake up" mechanism; clients must still pull.
6
+ *
7
+ * Auth notes:
8
+ * - Browsers' `WebSocket` cannot attach custom headers.
9
+ * - Use cookie auth (same-origin) or a query-param token for the realtime URL.
10
+ */
11
+ import { createHttpTransport, } from '@syncular/transport-http';
12
+ function defaultWsUrl(baseUrl) {
13
+ try {
14
+ const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(baseUrl);
15
+ const resolved = isAbsolute || typeof location === 'undefined'
16
+ ? new URL(baseUrl)
17
+ : new URL(baseUrl, location.origin);
18
+ resolved.protocol = resolved.protocol === 'https:' ? 'wss:' : 'ws:';
19
+ resolved.pathname = `${resolved.pathname.replace(/\/$/, '')}/realtime`;
20
+ return resolved.toString();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function createWebSocketTransport(options) {
27
+ const telemetryTransportPath = options.transportPath ?? 'relay';
28
+ const httpTransport = createHttpTransport({
29
+ baseUrl: options.baseUrl,
30
+ getHeaders: options.getHeaders,
31
+ fetch: options.fetch,
32
+ transportPath: telemetryTransportPath,
33
+ });
34
+ const { baseUrl, wsUrl = options.wsUrl ?? defaultWsUrl(baseUrl), getRealtimeParams, authToken, initialReconnectDelay = 1000, maxReconnectDelay = 30_000, reconnectBackoffFactor = 2, heartbeatTimeout = 60_000, WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : undefined, } = options;
35
+ // Warn about security risk of using getRealtimeParams with sensitive data
36
+ if (getRealtimeParams && !authToken) {
37
+ console.warn('[transport-ws] getRealtimeParams sends data in URL query parameters, ' +
38
+ 'which may be logged by proxies and CDNs. Consider using authToken instead.');
39
+ }
40
+ if (!wsUrl) {
41
+ throw new Error('@syncular/transport-ws: wsUrl is required when baseUrl cannot be converted');
42
+ }
43
+ if (!WebSocketImpl) {
44
+ throw new Error('@syncular/transport-ws: WebSocket is not available in this runtime');
45
+ }
46
+ let ws = null;
47
+ let connectionState = 'disconnected';
48
+ let reconnectAttempts = 0;
49
+ let reconnectTimer = null;
50
+ let heartbeatTimer = null;
51
+ let currentEventCallback = null;
52
+ let currentStateCallback = null;
53
+ let isManuallyDisconnected = false;
54
+ let currentClientId = null;
55
+ let connectNonce = 0;
56
+ // Presence state
57
+ const activePresenceScopes = new Map();
58
+ const presenceCallbacks = new Set();
59
+ // Pending WS push requests (requestId -> resolver)
60
+ const pendingPushRequests = new Map();
61
+ const WS_PUSH_TIMEOUT_MS = 10_000;
62
+ function setConnectionState(state) {
63
+ if (connectionState === state)
64
+ return;
65
+ connectionState = state;
66
+ currentStateCallback?.(state);
67
+ }
68
+ function calculateReconnectDelay() {
69
+ const baseDelay = Math.min(initialReconnectDelay * reconnectBackoffFactor ** reconnectAttempts, maxReconnectDelay);
70
+ // Add jitter to prevent thundering herd (multiple clients reconnecting simultaneously)
71
+ const jitterFactor = options.reconnectJitter ?? 0.3;
72
+ const jitter = baseDelay * jitterFactor * (Math.random() * 2 - 1); // +/- jitterFactor
73
+ return Math.max(0, Math.round(baseDelay + jitter));
74
+ }
75
+ function clearHeartbeatTimer() {
76
+ if (!heartbeatTimer)
77
+ return;
78
+ clearTimeout(heartbeatTimer);
79
+ heartbeatTimer = null;
80
+ }
81
+ function resetHeartbeatTimer() {
82
+ clearHeartbeatTimer();
83
+ if (heartbeatTimeout <= 0)
84
+ return;
85
+ heartbeatTimer = setTimeout(() => {
86
+ doDisconnect();
87
+ scheduleReconnect();
88
+ }, heartbeatTimeout);
89
+ }
90
+ function clearReconnectTimer() {
91
+ if (!reconnectTimer)
92
+ return;
93
+ clearTimeout(reconnectTimer);
94
+ reconnectTimer = null;
95
+ }
96
+ function scheduleReconnect() {
97
+ if (isManuallyDisconnected)
98
+ return;
99
+ clearReconnectTimer();
100
+ const delay = calculateReconnectDelay();
101
+ reconnectAttempts += 1;
102
+ reconnectTimer = setTimeout(() => {
103
+ void doConnect();
104
+ }, delay);
105
+ }
106
+ function doDisconnect() {
107
+ clearHeartbeatTimer();
108
+ clearReconnectTimer();
109
+ // Resolve all pending WS push requests as null (triggers HTTP fallback)
110
+ for (const [, pending] of pendingPushRequests) {
111
+ clearTimeout(pending.timer);
112
+ pending.resolve(null);
113
+ }
114
+ pendingPushRequests.clear();
115
+ if (ws) {
116
+ try {
117
+ ws.onopen = null;
118
+ ws.onmessage = null;
119
+ ws.onerror = null;
120
+ ws.onclose = null;
121
+ ws.close();
122
+ }
123
+ catch {
124
+ // ignore
125
+ }
126
+ ws = null;
127
+ }
128
+ setConnectionState('disconnected');
129
+ }
130
+ function dispatchEvent(raw) {
131
+ resetHeartbeatTimer();
132
+ if (!raw || typeof raw !== 'object')
133
+ return;
134
+ if (!('event' in raw) || !('data' in raw))
135
+ return;
136
+ const event = raw.event;
137
+ const data = raw.data;
138
+ if (!data || typeof data !== 'object')
139
+ return;
140
+ // Route push-response events to pending request resolvers
141
+ if (event === 'push-response') {
142
+ const d = data;
143
+ const requestId = typeof d.requestId === 'string' ? d.requestId : '';
144
+ const pending = pendingPushRequests.get(requestId);
145
+ if (pending) {
146
+ pendingPushRequests.delete(requestId);
147
+ clearTimeout(pending.timer);
148
+ pending.resolve({
149
+ ok: true,
150
+ status: d.status ?? 'rejected',
151
+ commitSeq: typeof d.commitSeq === 'number' ? d.commitSeq : undefined,
152
+ results: Array.isArray(d.results)
153
+ ? d.results
154
+ : [],
155
+ });
156
+ }
157
+ return;
158
+ }
159
+ // Route presence events to dedicated callbacks
160
+ if (event === 'presence') {
161
+ const presenceData = data.presence;
162
+ if (presenceData && typeof presenceData === 'object') {
163
+ for (const cb of presenceCallbacks) {
164
+ cb(presenceData);
165
+ }
166
+ }
167
+ // Also forward to main event callback
168
+ if (currentEventCallback) {
169
+ currentEventCallback({ event, data });
170
+ }
171
+ return;
172
+ }
173
+ if (event !== 'sync' && event !== 'heartbeat' && event !== 'error')
174
+ return;
175
+ currentEventCallback?.({ event, data });
176
+ }
177
+ function sendPresenceMessage(msg) {
178
+ if (!ws || ws.readyState !== WebSocketImpl.OPEN)
179
+ return;
180
+ ws.send(JSON.stringify({ type: 'presence', ...msg }));
181
+ }
182
+ async function buildUrl(clientId) {
183
+ if (!wsUrl)
184
+ throw new Error('wsUrl is required');
185
+ // Handle relative URLs by using location.origin as base
186
+ const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(wsUrl);
187
+ const url = isAbsolute || typeof location === 'undefined'
188
+ ? new URL(wsUrl)
189
+ : new URL(wsUrl, location.origin);
190
+ // Convert http(s) to ws(s) if needed
191
+ if (url.protocol === 'https:')
192
+ url.protocol = 'wss:';
193
+ if (url.protocol === 'http:')
194
+ url.protocol = 'ws:';
195
+ url.searchParams.set('clientId', clientId);
196
+ if (getRealtimeParams) {
197
+ try {
198
+ const params = await getRealtimeParams({ clientId });
199
+ for (const [k, v] of Object.entries(params ?? {})) {
200
+ if (typeof v !== 'string')
201
+ continue;
202
+ if (!v)
203
+ continue;
204
+ url.searchParams.set(k, v);
205
+ }
206
+ }
207
+ catch {
208
+ // ignore; realtime is best-effort
209
+ }
210
+ }
211
+ url.searchParams.set('transportPath', telemetryTransportPath);
212
+ return url.toString();
213
+ }
214
+ async function doConnect() {
215
+ if (!currentClientId)
216
+ return;
217
+ if (isManuallyDisconnected)
218
+ return;
219
+ const nonce = ++connectNonce;
220
+ setConnectionState('connecting');
221
+ const url = await buildUrl(currentClientId);
222
+ if (nonce !== connectNonce)
223
+ return;
224
+ if (!WebSocketImpl)
225
+ throw new Error('WebSocketImpl is required');
226
+ try {
227
+ ws = new WebSocketImpl(url);
228
+ }
229
+ catch {
230
+ doDisconnect();
231
+ scheduleReconnect();
232
+ return;
233
+ }
234
+ ws.onopen = async () => {
235
+ if (nonce !== connectNonce)
236
+ return;
237
+ // Send auth token if provided (more secure than query params)
238
+ if (authToken && ws) {
239
+ try {
240
+ const token = typeof authToken === 'function' ? await authToken() : authToken;
241
+ if (token && nonce === connectNonce) {
242
+ ws.send(JSON.stringify({ type: 'auth', token }));
243
+ }
244
+ }
245
+ catch {
246
+ // Auth token failed, but connection is still open
247
+ // Server will handle unauthenticated connection appropriately
248
+ }
249
+ }
250
+ if (nonce !== connectNonce)
251
+ return;
252
+ setConnectionState('connected');
253
+ reconnectAttempts = 0;
254
+ resetHeartbeatTimer();
255
+ // Re-join all active presence scopes on reconnect
256
+ for (const [scopeKey, metadata] of activePresenceScopes) {
257
+ sendPresenceMessage({ action: 'join', scopeKey, metadata });
258
+ }
259
+ };
260
+ ws.onmessage = (evt) => {
261
+ if (nonce !== connectNonce)
262
+ return;
263
+ resetHeartbeatTimer();
264
+ if (typeof evt.data === 'string') {
265
+ try {
266
+ dispatchEvent(JSON.parse(evt.data));
267
+ }
268
+ catch {
269
+ // ignore malformed messages
270
+ }
271
+ }
272
+ };
273
+ ws.onerror = () => {
274
+ if (nonce !== connectNonce)
275
+ return;
276
+ doDisconnect();
277
+ scheduleReconnect();
278
+ };
279
+ ws.onclose = () => {
280
+ if (nonce !== connectNonce)
281
+ return;
282
+ doDisconnect();
283
+ scheduleReconnect();
284
+ };
285
+ }
286
+ return {
287
+ ...httpTransport,
288
+ connect(args, onEvent, onStateChange) {
289
+ currentClientId = args.clientId;
290
+ currentEventCallback = onEvent;
291
+ currentStateCallback = onStateChange ?? null;
292
+ isManuallyDisconnected = false;
293
+ reconnectAttempts = 0;
294
+ void doConnect();
295
+ return () => {
296
+ isManuallyDisconnected = true;
297
+ currentEventCallback = null;
298
+ currentStateCallback = null;
299
+ currentClientId = null;
300
+ connectNonce += 1;
301
+ doDisconnect();
302
+ };
303
+ },
304
+ getConnectionState() {
305
+ return connectionState;
306
+ },
307
+ reconnect() {
308
+ if (!currentClientId)
309
+ return;
310
+ if (isManuallyDisconnected)
311
+ return;
312
+ connectNonce += 1;
313
+ doDisconnect();
314
+ void doConnect();
315
+ },
316
+ sendPresenceJoin(scopeKey, metadata) {
317
+ activePresenceScopes.set(scopeKey, metadata);
318
+ sendPresenceMessage({ action: 'join', scopeKey, metadata });
319
+ },
320
+ sendPresenceLeave(scopeKey) {
321
+ activePresenceScopes.delete(scopeKey);
322
+ sendPresenceMessage({ action: 'leave', scopeKey });
323
+ },
324
+ sendPresenceUpdate(scopeKey, metadata) {
325
+ activePresenceScopes.set(scopeKey, metadata);
326
+ sendPresenceMessage({ action: 'update', scopeKey, metadata });
327
+ },
328
+ onPresenceEvent(callback) {
329
+ presenceCallbacks.add(callback);
330
+ return () => {
331
+ presenceCallbacks.delete(callback);
332
+ };
333
+ },
334
+ pushViaWs(request) {
335
+ if (!ws || ws.readyState !== WebSocketImpl.OPEN) {
336
+ return Promise.resolve(null);
337
+ }
338
+ const requestId = crypto.randomUUID();
339
+ return new Promise((resolve) => {
340
+ const timer = setTimeout(() => {
341
+ pendingPushRequests.delete(requestId);
342
+ resolve(null);
343
+ }, WS_PUSH_TIMEOUT_MS);
344
+ pendingPushRequests.set(requestId, { resolve, timer });
345
+ try {
346
+ ws.send(JSON.stringify({
347
+ type: 'push',
348
+ requestId,
349
+ clientCommitId: request.clientCommitId,
350
+ operations: request.operations,
351
+ schemaVersion: request.schemaVersion,
352
+ }));
353
+ }
354
+ catch {
355
+ pendingPushRequests.delete(requestId);
356
+ clearTimeout(timer);
357
+ resolve(null);
358
+ }
359
+ });
360
+ },
361
+ };
362
+ }
363
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@syncular/transport-ws",
3
+ "version": "0.0.1-60",
4
+ "description": "WebSocket transport for Syncular real-time sync",
5
+ "license": "MIT",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/transport-ws"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript",
22
+ "websocket",
23
+ "realtime"
24
+ ],
25
+ "private": false,
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "import": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ }
37
+ }
38
+ },
39
+ "scripts": {
40
+ "tsgo": "tsgo --noEmit",
41
+ "build": "rm -rf dist && tsgo",
42
+ "release": "bun pm pack --destination . && npm publish ./*.tgz --tag latest && rm -f ./*.tgz"
43
+ },
44
+ "dependencies": {
45
+ "@syncular/core": "0.0.1",
46
+ "@syncular/transport-http": "0.0.1"
47
+ },
48
+ "devDependencies": {
49
+ "@syncular/config": "0.0.0"
50
+ },
51
+ "files": [
52
+ "dist",
53
+ "src"
54
+ ]
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * @syncular/transport-ws - WebSocket transport for sync realtime wake-ups
3
+ *
4
+ * Extends the HTTP transport with WebSocket-based realtime notifications.
5
+ * WebSockets are only used as a "wake up" mechanism; clients must still pull.
6
+ *
7
+ * Auth notes:
8
+ * - Browsers' `WebSocket` cannot attach custom headers.
9
+ * - Use cookie auth (same-origin) or a query-param token for the realtime URL.
10
+ */
11
+
12
+ import type {
13
+ SyncPushRequest,
14
+ SyncPushResponse,
15
+ SyncTransport,
16
+ } from '@syncular/core';
17
+ import {
18
+ type ClientOptions,
19
+ createHttpTransport,
20
+ } from '@syncular/transport-http';
21
+
22
+ /**
23
+ * WebSocket connection state
24
+ */
25
+ export type WebSocketConnectionState =
26
+ | 'disconnected'
27
+ | 'connecting'
28
+ | 'connected';
29
+
30
+ /**
31
+ * Presence event data
32
+ */
33
+ export interface PresenceEventData {
34
+ action: 'join' | 'leave' | 'update' | 'snapshot';
35
+ scopeKey: string;
36
+ clientId?: string;
37
+ actorId?: string;
38
+ metadata?: Record<string, unknown>;
39
+ entries?: Array<{
40
+ clientId: string;
41
+ actorId: string;
42
+ joinedAt: number;
43
+ metadata?: Record<string, unknown>;
44
+ }>;
45
+ }
46
+
47
+ /**
48
+ * Push response data received from the server over WS
49
+ */
50
+ export interface WsPushResponseData {
51
+ requestId: string;
52
+ ok: boolean;
53
+ status: string;
54
+ commitSeq?: number;
55
+ results: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
56
+ timestamp: number;
57
+ }
58
+
59
+ /**
60
+ * WebSocket event from the server
61
+ */
62
+ export interface WebSocketEvent {
63
+ event: 'sync' | 'heartbeat' | 'error' | 'presence' | 'push-response';
64
+ data: {
65
+ cursor?: number;
66
+ /** Inline change data for small payloads (WS data delivery) */
67
+ changes?: unknown[];
68
+ error?: string;
69
+ presence?: PresenceEventData;
70
+ /** Push response fields (for push-response events) */
71
+ requestId?: string;
72
+ ok?: boolean;
73
+ status?: string;
74
+ commitSeq?: number;
75
+ results?: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
76
+ timestamp: number;
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Callback for realtime events
82
+ */
83
+ export type WebSocketEventCallback = (event: WebSocketEvent) => void;
84
+
85
+ /**
86
+ * Callback for connection state changes
87
+ */
88
+ export type WebSocketStateCallback = (state: WebSocketConnectionState) => void;
89
+
90
+ export interface WebSocketTransportOptions extends ClientOptions {
91
+ /**
92
+ * WebSocket endpoint URL. If not provided, uses `${baseUrl}/realtime` with
93
+ * `http(s)` -> `ws(s)` conversion when possible.
94
+ */
95
+ wsUrl?: string;
96
+ /**
97
+ * Additional query params for the realtime URL (e.g. `{ token }`).
98
+ *
99
+ * ⚠️ SECURITY WARNING: Query parameters may be logged by proxies, CDNs, and
100
+ * browser history. Do NOT pass sensitive tokens here. Use cookie-based auth
101
+ * or the `authToken` option with a server that supports first-message auth.
102
+ */
103
+ getRealtimeParams?: (args: {
104
+ clientId: string;
105
+ }) => Record<string, string> | Promise<Record<string, string>>;
106
+ /**
107
+ * Auth token sent in the first WebSocket message after connection.
108
+ * More secure than query parameters as it won't appear in URLs.
109
+ * Requires server support for first-message auth.
110
+ */
111
+ authToken?: string | (() => string | Promise<string>);
112
+ /**
113
+ * Initial reconnection delay in ms.
114
+ * Default: 1000 (1 second)
115
+ */
116
+ initialReconnectDelay?: number;
117
+ /**
118
+ * Maximum reconnection delay in ms.
119
+ * Default: 30000 (30 seconds)
120
+ */
121
+ maxReconnectDelay?: number;
122
+ /**
123
+ * Backoff factor for reconnection delay.
124
+ * Default: 2
125
+ */
126
+ reconnectBackoffFactor?: number;
127
+ /**
128
+ * Jitter factor for reconnection delay (0-1).
129
+ * Adds randomness to prevent thundering herd on server restart.
130
+ * Default: 0.3 (30% randomization)
131
+ */
132
+ reconnectJitter?: number;
133
+ /**
134
+ * Heartbeat timeout in ms. If no message is received within this time,
135
+ * the connection is considered dead.
136
+ * Default: 60000 (60 seconds)
137
+ */
138
+ heartbeatTimeout?: number;
139
+ /**
140
+ * Optional WebSocket implementation override (useful for non-browser runtimes).
141
+ */
142
+ WebSocketImpl?: typeof WebSocket;
143
+ /**
144
+ * Transport path telemetry sent to the server for push/pull and realtime.
145
+ * Defaults to 'relay' for this transport.
146
+ */
147
+ transportPath?: 'direct' | 'relay';
148
+ }
149
+
150
+ /**
151
+ * Callback for presence events from the server
152
+ */
153
+ export type PresenceEventCallback = (event: PresenceEventData) => void;
154
+
155
+ /**
156
+ * Extended sync transport with WebSocket subscription support.
157
+ */
158
+ export interface WebSocketTransport extends SyncTransport {
159
+ connect(
160
+ args: { clientId: string },
161
+ onEvent: WebSocketEventCallback,
162
+ onStateChange?: WebSocketStateCallback
163
+ ): () => void;
164
+ getConnectionState(): WebSocketConnectionState;
165
+ reconnect(): void;
166
+ sendPresenceJoin(scopeKey: string, metadata?: Record<string, unknown>): void;
167
+ sendPresenceLeave(scopeKey: string): void;
168
+ sendPresenceUpdate(scopeKey: string, metadata: Record<string, unknown>): void;
169
+ onPresenceEvent(callback: PresenceEventCallback): () => void;
170
+ /**
171
+ * Push a commit via WebSocket (bypasses HTTP).
172
+ * Returns `null` if WS is not connected or times out (caller should fall back to HTTP).
173
+ */
174
+ pushViaWs(request: SyncPushRequest): Promise<SyncPushResponse | null>;
175
+ }
176
+
177
+ function defaultWsUrl(baseUrl: string): string | null {
178
+ try {
179
+ const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(baseUrl);
180
+ const resolved =
181
+ isAbsolute || typeof location === 'undefined'
182
+ ? new URL(baseUrl)
183
+ : new URL(baseUrl, location.origin);
184
+
185
+ resolved.protocol = resolved.protocol === 'https:' ? 'wss:' : 'ws:';
186
+ resolved.pathname = `${resolved.pathname.replace(/\/$/, '')}/realtime`;
187
+ return resolved.toString();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ export function createWebSocketTransport(
194
+ options: WebSocketTransportOptions
195
+ ): WebSocketTransport {
196
+ const telemetryTransportPath = options.transportPath ?? 'relay';
197
+ const httpTransport = createHttpTransport({
198
+ baseUrl: options.baseUrl,
199
+ getHeaders: options.getHeaders,
200
+ fetch: options.fetch,
201
+ transportPath: telemetryTransportPath,
202
+ });
203
+
204
+ const {
205
+ baseUrl,
206
+ wsUrl = options.wsUrl ?? defaultWsUrl(baseUrl),
207
+ getRealtimeParams,
208
+ authToken,
209
+ initialReconnectDelay = 1000,
210
+ maxReconnectDelay = 30_000,
211
+ reconnectBackoffFactor = 2,
212
+ heartbeatTimeout = 60_000,
213
+ WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : undefined,
214
+ } = options;
215
+
216
+ // Warn about security risk of using getRealtimeParams with sensitive data
217
+ if (getRealtimeParams && !authToken) {
218
+ console.warn(
219
+ '[transport-ws] getRealtimeParams sends data in URL query parameters, ' +
220
+ 'which may be logged by proxies and CDNs. Consider using authToken instead.'
221
+ );
222
+ }
223
+
224
+ if (!wsUrl) {
225
+ throw new Error(
226
+ '@syncular/transport-ws: wsUrl is required when baseUrl cannot be converted'
227
+ );
228
+ }
229
+
230
+ if (!WebSocketImpl) {
231
+ throw new Error(
232
+ '@syncular/transport-ws: WebSocket is not available in this runtime'
233
+ );
234
+ }
235
+
236
+ let ws: WebSocket | null = null;
237
+ let connectionState: WebSocketConnectionState = 'disconnected';
238
+ let reconnectAttempts = 0;
239
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
240
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
241
+
242
+ let currentEventCallback: WebSocketEventCallback | null = null;
243
+ let currentStateCallback: WebSocketStateCallback | null = null;
244
+ let isManuallyDisconnected = false;
245
+ let currentClientId: string | null = null;
246
+ let connectNonce = 0;
247
+
248
+ // Presence state
249
+ const activePresenceScopes = new Map<
250
+ string,
251
+ Record<string, unknown> | undefined
252
+ >();
253
+ const presenceCallbacks = new Set<PresenceEventCallback>();
254
+
255
+ // Pending WS push requests (requestId -> resolver)
256
+ const pendingPushRequests = new Map<
257
+ string,
258
+ {
259
+ resolve: (value: SyncPushResponse | null) => void;
260
+ timer: ReturnType<typeof setTimeout>;
261
+ }
262
+ >();
263
+ const WS_PUSH_TIMEOUT_MS = 10_000;
264
+
265
+ function setConnectionState(state: WebSocketConnectionState): void {
266
+ if (connectionState === state) return;
267
+ connectionState = state;
268
+ currentStateCallback?.(state);
269
+ }
270
+
271
+ function calculateReconnectDelay(): number {
272
+ const baseDelay = Math.min(
273
+ initialReconnectDelay * reconnectBackoffFactor ** reconnectAttempts,
274
+ maxReconnectDelay
275
+ );
276
+ // Add jitter to prevent thundering herd (multiple clients reconnecting simultaneously)
277
+ const jitterFactor = options.reconnectJitter ?? 0.3;
278
+ const jitter = baseDelay * jitterFactor * (Math.random() * 2 - 1); // +/- jitterFactor
279
+ return Math.max(0, Math.round(baseDelay + jitter));
280
+ }
281
+
282
+ function clearHeartbeatTimer(): void {
283
+ if (!heartbeatTimer) return;
284
+ clearTimeout(heartbeatTimer);
285
+ heartbeatTimer = null;
286
+ }
287
+
288
+ function resetHeartbeatTimer(): void {
289
+ clearHeartbeatTimer();
290
+ if (heartbeatTimeout <= 0) return;
291
+
292
+ heartbeatTimer = setTimeout(() => {
293
+ doDisconnect();
294
+ scheduleReconnect();
295
+ }, heartbeatTimeout);
296
+ }
297
+
298
+ function clearReconnectTimer(): void {
299
+ if (!reconnectTimer) return;
300
+ clearTimeout(reconnectTimer);
301
+ reconnectTimer = null;
302
+ }
303
+
304
+ function scheduleReconnect(): void {
305
+ if (isManuallyDisconnected) return;
306
+
307
+ clearReconnectTimer();
308
+ const delay = calculateReconnectDelay();
309
+ reconnectAttempts += 1;
310
+
311
+ reconnectTimer = setTimeout(() => {
312
+ void doConnect();
313
+ }, delay);
314
+ }
315
+
316
+ function doDisconnect(): void {
317
+ clearHeartbeatTimer();
318
+ clearReconnectTimer();
319
+
320
+ // Resolve all pending WS push requests as null (triggers HTTP fallback)
321
+ for (const [, pending] of pendingPushRequests) {
322
+ clearTimeout(pending.timer);
323
+ pending.resolve(null);
324
+ }
325
+ pendingPushRequests.clear();
326
+
327
+ if (ws) {
328
+ try {
329
+ ws.onopen = null;
330
+ ws.onmessage = null;
331
+ ws.onerror = null;
332
+ ws.onclose = null;
333
+ ws.close();
334
+ } catch {
335
+ // ignore
336
+ }
337
+ ws = null;
338
+ }
339
+
340
+ setConnectionState('disconnected');
341
+ }
342
+
343
+ function dispatchEvent(raw: unknown): void {
344
+ resetHeartbeatTimer();
345
+
346
+ if (!raw || typeof raw !== 'object') return;
347
+ if (!('event' in raw) || !('data' in raw)) return;
348
+ const event = (raw as { event: unknown }).event;
349
+ const data = (raw as { data: unknown }).data;
350
+ if (!data || typeof data !== 'object') return;
351
+
352
+ // Route push-response events to pending request resolvers
353
+ if (event === 'push-response') {
354
+ const d = data as Record<string, unknown>;
355
+ const requestId = typeof d.requestId === 'string' ? d.requestId : '';
356
+ const pending = pendingPushRequests.get(requestId);
357
+ if (pending) {
358
+ pendingPushRequests.delete(requestId);
359
+ clearTimeout(pending.timer);
360
+ pending.resolve({
361
+ ok: true as const,
362
+ status: (d.status as 'applied' | 'cached' | 'rejected') ?? 'rejected',
363
+ commitSeq: typeof d.commitSeq === 'number' ? d.commitSeq : undefined,
364
+ results: Array.isArray(d.results)
365
+ ? (d.results as SyncPushResponse['results'])
366
+ : [],
367
+ });
368
+ }
369
+ return;
370
+ }
371
+
372
+ // Route presence events to dedicated callbacks
373
+ if (event === 'presence') {
374
+ const presenceData = (data as { presence?: unknown }).presence;
375
+ if (presenceData && typeof presenceData === 'object') {
376
+ for (const cb of presenceCallbacks) {
377
+ cb(presenceData as PresenceEventData);
378
+ }
379
+ }
380
+ // Also forward to main event callback
381
+ if (currentEventCallback) {
382
+ currentEventCallback({ event, data } as WebSocketEvent);
383
+ }
384
+ return;
385
+ }
386
+
387
+ if (event !== 'sync' && event !== 'heartbeat' && event !== 'error') return;
388
+ currentEventCallback?.({ event, data } as WebSocketEvent);
389
+ }
390
+
391
+ function sendPresenceMessage(msg: Record<string, unknown>): void {
392
+ if (!ws || ws.readyState !== WebSocketImpl!.OPEN) return;
393
+ ws.send(JSON.stringify({ type: 'presence', ...msg }));
394
+ }
395
+
396
+ async function buildUrl(clientId: string): Promise<string> {
397
+ if (!wsUrl) throw new Error('wsUrl is required');
398
+ // Handle relative URLs by using location.origin as base
399
+ const isAbsolute = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(wsUrl);
400
+ const url =
401
+ isAbsolute || typeof location === 'undefined'
402
+ ? new URL(wsUrl)
403
+ : new URL(wsUrl, location.origin);
404
+ // Convert http(s) to ws(s) if needed
405
+ if (url.protocol === 'https:') url.protocol = 'wss:';
406
+ if (url.protocol === 'http:') url.protocol = 'ws:';
407
+ url.searchParams.set('clientId', clientId);
408
+
409
+ if (getRealtimeParams) {
410
+ try {
411
+ const params = await getRealtimeParams({ clientId });
412
+ for (const [k, v] of Object.entries(params ?? {})) {
413
+ if (typeof v !== 'string') continue;
414
+ if (!v) continue;
415
+ url.searchParams.set(k, v);
416
+ }
417
+ } catch {
418
+ // ignore; realtime is best-effort
419
+ }
420
+ }
421
+
422
+ url.searchParams.set('transportPath', telemetryTransportPath);
423
+
424
+ return url.toString();
425
+ }
426
+
427
+ async function doConnect(): Promise<void> {
428
+ if (!currentClientId) return;
429
+ if (isManuallyDisconnected) return;
430
+
431
+ const nonce = ++connectNonce;
432
+
433
+ setConnectionState('connecting');
434
+
435
+ const url = await buildUrl(currentClientId);
436
+ if (nonce !== connectNonce) return;
437
+
438
+ if (!WebSocketImpl) throw new Error('WebSocketImpl is required');
439
+
440
+ try {
441
+ ws = new WebSocketImpl(url);
442
+ } catch {
443
+ doDisconnect();
444
+ scheduleReconnect();
445
+ return;
446
+ }
447
+
448
+ ws.onopen = async () => {
449
+ if (nonce !== connectNonce) return;
450
+
451
+ // Send auth token if provided (more secure than query params)
452
+ if (authToken && ws) {
453
+ try {
454
+ const token =
455
+ typeof authToken === 'function' ? await authToken() : authToken;
456
+ if (token && nonce === connectNonce) {
457
+ ws.send(JSON.stringify({ type: 'auth', token }));
458
+ }
459
+ } catch {
460
+ // Auth token failed, but connection is still open
461
+ // Server will handle unauthenticated connection appropriately
462
+ }
463
+ }
464
+
465
+ if (nonce !== connectNonce) return;
466
+ setConnectionState('connected');
467
+ reconnectAttempts = 0;
468
+ resetHeartbeatTimer();
469
+
470
+ // Re-join all active presence scopes on reconnect
471
+ for (const [scopeKey, metadata] of activePresenceScopes) {
472
+ sendPresenceMessage({ action: 'join', scopeKey, metadata });
473
+ }
474
+ };
475
+
476
+ ws.onmessage = (evt) => {
477
+ if (nonce !== connectNonce) return;
478
+ resetHeartbeatTimer();
479
+
480
+ if (typeof evt.data === 'string') {
481
+ try {
482
+ dispatchEvent(JSON.parse(evt.data));
483
+ } catch {
484
+ // ignore malformed messages
485
+ }
486
+ }
487
+ };
488
+
489
+ ws.onerror = () => {
490
+ if (nonce !== connectNonce) return;
491
+ doDisconnect();
492
+ scheduleReconnect();
493
+ };
494
+
495
+ ws.onclose = () => {
496
+ if (nonce !== connectNonce) return;
497
+ doDisconnect();
498
+ scheduleReconnect();
499
+ };
500
+ }
501
+
502
+ return {
503
+ ...httpTransport,
504
+ connect(args, onEvent, onStateChange) {
505
+ currentClientId = args.clientId;
506
+ currentEventCallback = onEvent;
507
+ currentStateCallback = onStateChange ?? null;
508
+ isManuallyDisconnected = false;
509
+ reconnectAttempts = 0;
510
+ void doConnect();
511
+
512
+ return () => {
513
+ isManuallyDisconnected = true;
514
+ currentEventCallback = null;
515
+ currentStateCallback = null;
516
+ currentClientId = null;
517
+ connectNonce += 1;
518
+ doDisconnect();
519
+ };
520
+ },
521
+ getConnectionState() {
522
+ return connectionState;
523
+ },
524
+ reconnect() {
525
+ if (!currentClientId) return;
526
+ if (isManuallyDisconnected) return;
527
+ connectNonce += 1;
528
+ doDisconnect();
529
+ void doConnect();
530
+ },
531
+ sendPresenceJoin(scopeKey, metadata) {
532
+ activePresenceScopes.set(scopeKey, metadata);
533
+ sendPresenceMessage({ action: 'join', scopeKey, metadata });
534
+ },
535
+ sendPresenceLeave(scopeKey) {
536
+ activePresenceScopes.delete(scopeKey);
537
+ sendPresenceMessage({ action: 'leave', scopeKey });
538
+ },
539
+ sendPresenceUpdate(scopeKey, metadata) {
540
+ activePresenceScopes.set(scopeKey, metadata);
541
+ sendPresenceMessage({ action: 'update', scopeKey, metadata });
542
+ },
543
+ onPresenceEvent(callback) {
544
+ presenceCallbacks.add(callback);
545
+ return () => {
546
+ presenceCallbacks.delete(callback);
547
+ };
548
+ },
549
+ pushViaWs(request: SyncPushRequest): Promise<SyncPushResponse | null> {
550
+ if (!ws || ws.readyState !== WebSocketImpl!.OPEN) {
551
+ return Promise.resolve(null);
552
+ }
553
+
554
+ const requestId = crypto.randomUUID();
555
+
556
+ return new Promise<SyncPushResponse | null>((resolve) => {
557
+ const timer = setTimeout(() => {
558
+ pendingPushRequests.delete(requestId);
559
+ resolve(null);
560
+ }, WS_PUSH_TIMEOUT_MS);
561
+
562
+ pendingPushRequests.set(requestId, { resolve, timer });
563
+
564
+ try {
565
+ ws!.send(
566
+ JSON.stringify({
567
+ type: 'push',
568
+ requestId,
569
+ clientCommitId: request.clientCommitId,
570
+ operations: request.operations,
571
+ schemaVersion: request.schemaVersion,
572
+ })
573
+ );
574
+ } catch {
575
+ pendingPushRequests.delete(requestId);
576
+ clearTimeout(timer);
577
+ resolve(null);
578
+ }
579
+ });
580
+ },
581
+ };
582
+ }