@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.
- package/dist/index.d.ts +163 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +363 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +582 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|