@voidhash/mimic 0.0.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +33 -0
- package/src/Document.ts +256 -0
- package/src/FractionalIndex.ts +1249 -0
- package/src/Operation.ts +59 -0
- package/src/OperationDefinition.ts +23 -0
- package/src/OperationPath.ts +197 -0
- package/src/Presence.ts +142 -0
- package/src/Primitive.ts +32 -0
- package/src/Proxy.ts +8 -0
- package/src/ProxyEnvironment.ts +52 -0
- package/src/Transaction.ts +72 -0
- package/src/Transform.ts +13 -0
- package/src/client/ClientDocument.ts +1163 -0
- package/src/client/Rebase.ts +309 -0
- package/src/client/StateMonitor.ts +307 -0
- package/src/client/Transport.ts +318 -0
- package/src/client/WebSocketTransport.ts +572 -0
- package/src/client/errors.ts +145 -0
- package/src/client/index.ts +61 -0
- package/src/index.ts +12 -0
- package/src/primitives/Array.ts +457 -0
- package/src/primitives/Boolean.ts +128 -0
- package/src/primitives/Lazy.ts +89 -0
- package/src/primitives/Literal.ts +128 -0
- package/src/primitives/Number.ts +169 -0
- package/src/primitives/String.ts +189 -0
- package/src/primitives/Struct.ts +348 -0
- package/src/primitives/Tree.ts +1120 -0
- package/src/primitives/TreeNode.ts +113 -0
- package/src/primitives/Union.ts +329 -0
- package/src/primitives/shared.ts +122 -0
- package/src/server/ServerDocument.ts +267 -0
- package/src/server/errors.ts +90 -0
- package/src/server/index.ts +40 -0
- package/tests/Document.test.ts +556 -0
- package/tests/FractionalIndex.test.ts +377 -0
- package/tests/OperationPath.test.ts +151 -0
- package/tests/Presence.test.ts +321 -0
- package/tests/Primitive.test.ts +381 -0
- package/tests/client/ClientDocument.test.ts +1398 -0
- package/tests/client/WebSocketTransport.test.ts +992 -0
- package/tests/primitives/Array.test.ts +418 -0
- package/tests/primitives/Boolean.test.ts +126 -0
- package/tests/primitives/Lazy.test.ts +143 -0
- package/tests/primitives/Literal.test.ts +122 -0
- package/tests/primitives/Number.test.ts +133 -0
- package/tests/primitives/String.test.ts +128 -0
- package/tests/primitives/Struct.test.ts +311 -0
- package/tests/primitives/Tree.test.ts +467 -0
- package/tests/primitives/TreeNode.test.ts +50 -0
- package/tests/primitives/Union.test.ts +210 -0
- package/tests/server/ServerDocument.test.ts +528 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- package/vitest.mts +11 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import * as Transaction from "../Transaction";
|
|
2
|
+
|
|
3
|
+
import * as Transport from "./Transport";
|
|
4
|
+
import { WebSocketError, AuthenticationError } from "./errors";
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// WebSocket Transport Options
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for creating a WebSocket transport.
|
|
12
|
+
*/
|
|
13
|
+
export interface WebSocketTransportOptions extends Transport.TransportOptions {
|
|
14
|
+
/** WebSocket URL (ws:// or wss://) - base URL without document path */
|
|
15
|
+
readonly url: string;
|
|
16
|
+
/** Document ID to connect to. Will be appended to URL as /doc/{documentId} */
|
|
17
|
+
readonly documentId?: string;
|
|
18
|
+
/** WebSocket subprotocols */
|
|
19
|
+
readonly protocols?: string[];
|
|
20
|
+
/** Authentication token or function that returns a token */
|
|
21
|
+
readonly authToken?: string | (() => string | Promise<string>);
|
|
22
|
+
/** Interval between heartbeat pings (ms). Default: 30000 */
|
|
23
|
+
readonly heartbeatInterval?: number;
|
|
24
|
+
/** Timeout to wait for pong response (ms). Default: 10000 */
|
|
25
|
+
readonly heartbeatTimeout?: number;
|
|
26
|
+
/** Maximum delay between reconnection attempts (ms). Default: 30000 */
|
|
27
|
+
readonly maxReconnectDelay?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Connection State
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
type ConnectionState =
|
|
35
|
+
| { type: "disconnected" }
|
|
36
|
+
| { type: "connecting" }
|
|
37
|
+
| { type: "authenticating" }
|
|
38
|
+
| { type: "connected" }
|
|
39
|
+
| { type: "reconnecting"; attempt: number };
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// WebSocket Transport Implementation
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a WebSocket-based transport for real-time server communication.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Build the WebSocket URL with optional document ID path.
|
|
50
|
+
*/
|
|
51
|
+
const buildWebSocketUrl = (baseUrl: string, documentId?: string): string => {
|
|
52
|
+
if (!documentId) {
|
|
53
|
+
return baseUrl;
|
|
54
|
+
}
|
|
55
|
+
// Remove trailing slash from base URL
|
|
56
|
+
const normalizedBase = baseUrl.replace(/\/+$/, "");
|
|
57
|
+
// Encode the document ID for URL safety
|
|
58
|
+
const encodedDocId = encodeURIComponent(documentId);
|
|
59
|
+
return `${normalizedBase}/doc/${encodedDocId}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const make = (options: WebSocketTransportOptions): Transport.Transport => {
|
|
63
|
+
const {
|
|
64
|
+
url: baseUrl,
|
|
65
|
+
documentId,
|
|
66
|
+
protocols,
|
|
67
|
+
authToken,
|
|
68
|
+
onEvent,
|
|
69
|
+
connectionTimeout = 10000,
|
|
70
|
+
autoReconnect = true,
|
|
71
|
+
maxReconnectAttempts = 10,
|
|
72
|
+
reconnectDelay = 1000,
|
|
73
|
+
maxReconnectDelay = 30000,
|
|
74
|
+
heartbeatInterval = 30000,
|
|
75
|
+
heartbeatTimeout = 10000,
|
|
76
|
+
} = options;
|
|
77
|
+
|
|
78
|
+
// Build the full URL with document ID if provided
|
|
79
|
+
const url = buildWebSocketUrl(baseUrl, documentId);
|
|
80
|
+
|
|
81
|
+
// ==========================================================================
|
|
82
|
+
// Internal State
|
|
83
|
+
// ==========================================================================
|
|
84
|
+
|
|
85
|
+
let _state: ConnectionState = { type: "disconnected" };
|
|
86
|
+
let _ws: WebSocket | null = null;
|
|
87
|
+
let _messageHandlers: Set<(message: Transport.ServerMessage) => void> = new Set();
|
|
88
|
+
|
|
89
|
+
// Timers
|
|
90
|
+
let _connectionTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
91
|
+
let _heartbeatIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
92
|
+
let _heartbeatTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
93
|
+
let _reconnectTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
94
|
+
|
|
95
|
+
// Message queue for messages sent while reconnecting
|
|
96
|
+
let _messageQueue: Transport.ClientMessage[] = [];
|
|
97
|
+
|
|
98
|
+
// Promise resolvers for connect()
|
|
99
|
+
let _connectResolver: (() => void) | null = null;
|
|
100
|
+
let _connectRejecter: ((error: Error) => void) | null = null;
|
|
101
|
+
|
|
102
|
+
// Track reconnection attempt count (persists through connecting state)
|
|
103
|
+
let _reconnectAttempt = 0;
|
|
104
|
+
|
|
105
|
+
// ==========================================================================
|
|
106
|
+
// Helper Functions
|
|
107
|
+
// ==========================================================================
|
|
108
|
+
|
|
109
|
+
const emit = (handler: Transport.TransportEventHandler | undefined, event: Parameters<Transport.TransportEventHandler>[0]) => {
|
|
110
|
+
handler?.(event);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Encodes a client message for network transport.
|
|
115
|
+
*/
|
|
116
|
+
const encodeClientMessage = (message: Transport.ClientMessage): Transport.EncodedClientMessage => {
|
|
117
|
+
if (message.type === "submit") {
|
|
118
|
+
return {
|
|
119
|
+
type: "submit",
|
|
120
|
+
transaction: Transaction.encode(message.transaction),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return message;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decodes a server message from network transport.
|
|
128
|
+
*/
|
|
129
|
+
const decodeServerMessage = (encoded: Transport.EncodedServerMessage): Transport.ServerMessage => {
|
|
130
|
+
if (encoded.type === "transaction") {
|
|
131
|
+
return {
|
|
132
|
+
type: "transaction",
|
|
133
|
+
transaction: Transaction.decode(encoded.transaction),
|
|
134
|
+
version: encoded.version,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return encoded;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sends a raw message over the WebSocket.
|
|
142
|
+
*/
|
|
143
|
+
const sendRaw = (message: Transport.ClientMessage): void => {
|
|
144
|
+
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
145
|
+
_ws.send(JSON.stringify(encodeClientMessage(message)));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Clears all active timers.
|
|
151
|
+
*/
|
|
152
|
+
const clearTimers = (): void => {
|
|
153
|
+
if (_connectionTimeoutHandle) {
|
|
154
|
+
clearTimeout(_connectionTimeoutHandle);
|
|
155
|
+
_connectionTimeoutHandle = null;
|
|
156
|
+
}
|
|
157
|
+
if (_heartbeatIntervalHandle) {
|
|
158
|
+
clearInterval(_heartbeatIntervalHandle);
|
|
159
|
+
_heartbeatIntervalHandle = null;
|
|
160
|
+
}
|
|
161
|
+
if (_heartbeatTimeoutHandle) {
|
|
162
|
+
clearTimeout(_heartbeatTimeoutHandle);
|
|
163
|
+
_heartbeatTimeoutHandle = null;
|
|
164
|
+
}
|
|
165
|
+
if (_reconnectTimeoutHandle) {
|
|
166
|
+
clearTimeout(_reconnectTimeoutHandle);
|
|
167
|
+
_reconnectTimeoutHandle = null;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Starts the heartbeat mechanism.
|
|
173
|
+
*/
|
|
174
|
+
const startHeartbeat = (): void => {
|
|
175
|
+
stopHeartbeat();
|
|
176
|
+
|
|
177
|
+
_heartbeatIntervalHandle = setInterval(() => {
|
|
178
|
+
if (_state.type !== "connected") return;
|
|
179
|
+
|
|
180
|
+
// Send ping
|
|
181
|
+
sendRaw({ type: "ping" });
|
|
182
|
+
|
|
183
|
+
// Set timeout for pong response
|
|
184
|
+
_heartbeatTimeoutHandle = setTimeout(() => {
|
|
185
|
+
// No pong received - connection is dead
|
|
186
|
+
handleConnectionLost("Heartbeat timeout");
|
|
187
|
+
}, heartbeatTimeout);
|
|
188
|
+
}, heartbeatInterval);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stops the heartbeat mechanism.
|
|
193
|
+
*/
|
|
194
|
+
const stopHeartbeat = (): void => {
|
|
195
|
+
if (_heartbeatIntervalHandle) {
|
|
196
|
+
clearInterval(_heartbeatIntervalHandle);
|
|
197
|
+
_heartbeatIntervalHandle = null;
|
|
198
|
+
}
|
|
199
|
+
if (_heartbeatTimeoutHandle) {
|
|
200
|
+
clearTimeout(_heartbeatTimeoutHandle);
|
|
201
|
+
_heartbeatTimeoutHandle = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Handles pong response - clears the heartbeat timeout.
|
|
207
|
+
*/
|
|
208
|
+
const handlePong = (): void => {
|
|
209
|
+
if (_heartbeatTimeoutHandle) {
|
|
210
|
+
clearTimeout(_heartbeatTimeoutHandle);
|
|
211
|
+
_heartbeatTimeoutHandle = null;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Flushes the message queue after reconnection.
|
|
217
|
+
*/
|
|
218
|
+
const flushMessageQueue = (): void => {
|
|
219
|
+
const queue = _messageQueue;
|
|
220
|
+
_messageQueue = [];
|
|
221
|
+
for (const message of queue) {
|
|
222
|
+
sendRaw(message);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Calculates reconnection delay with exponential backoff.
|
|
228
|
+
*/
|
|
229
|
+
const getReconnectDelay = (attempt: number): number => {
|
|
230
|
+
const delay = reconnectDelay * Math.pow(2, attempt);
|
|
231
|
+
return Math.min(delay, maxReconnectDelay);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolves the auth token (handles both string and function).
|
|
236
|
+
* Returns empty string if no token is configured.
|
|
237
|
+
*/
|
|
238
|
+
const resolveAuthToken = async (): Promise<string> => {
|
|
239
|
+
if (!authToken) return "";
|
|
240
|
+
if (typeof authToken === "string") return authToken;
|
|
241
|
+
return authToken();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Performs authentication after connection.
|
|
246
|
+
* Always sends an auth message (even with empty token) to trigger server auth flow.
|
|
247
|
+
*/
|
|
248
|
+
const authenticate = async (): Promise<void> => {
|
|
249
|
+
const token = await resolveAuthToken();
|
|
250
|
+
_state = { type: "authenticating" };
|
|
251
|
+
sendRaw({ type: "auth", token });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Handles authentication result from server.
|
|
256
|
+
*/
|
|
257
|
+
const handleAuthResult = (success: boolean, error?: string): void => {
|
|
258
|
+
if (!success) {
|
|
259
|
+
const authError = new AuthenticationError(error || "Authentication failed");
|
|
260
|
+
cleanup();
|
|
261
|
+
_connectRejecter?.(authError);
|
|
262
|
+
_connectResolver = null;
|
|
263
|
+
_connectRejecter = null;
|
|
264
|
+
emit(onEvent, { type: "error", error: authError });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Auth successful - complete connection
|
|
269
|
+
completeConnection();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Completes the connection process.
|
|
274
|
+
*/
|
|
275
|
+
const completeConnection = (): void => {
|
|
276
|
+
_state = { type: "connected" };
|
|
277
|
+
|
|
278
|
+
// Reset reconnection attempt counter on successful connection
|
|
279
|
+
_reconnectAttempt = 0;
|
|
280
|
+
|
|
281
|
+
// Clear connection timeout
|
|
282
|
+
if (_connectionTimeoutHandle) {
|
|
283
|
+
clearTimeout(_connectionTimeoutHandle);
|
|
284
|
+
_connectionTimeoutHandle = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Start heartbeat
|
|
288
|
+
startHeartbeat();
|
|
289
|
+
|
|
290
|
+
// Flush any queued messages
|
|
291
|
+
flushMessageQueue();
|
|
292
|
+
|
|
293
|
+
// Resolve connect promise
|
|
294
|
+
_connectResolver?.();
|
|
295
|
+
_connectResolver = null;
|
|
296
|
+
_connectRejecter = null;
|
|
297
|
+
|
|
298
|
+
emit(onEvent, { type: "connected" });
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Cleans up WebSocket and related state.
|
|
303
|
+
*/
|
|
304
|
+
const cleanup = (): void => {
|
|
305
|
+
clearTimers();
|
|
306
|
+
|
|
307
|
+
if (_ws) {
|
|
308
|
+
// Remove listeners to prevent callbacks
|
|
309
|
+
_ws.onopen = null;
|
|
310
|
+
_ws.onclose = null;
|
|
311
|
+
_ws.onerror = null;
|
|
312
|
+
_ws.onmessage = null;
|
|
313
|
+
|
|
314
|
+
if (_ws.readyState === WebSocket.OPEN || _ws.readyState === WebSocket.CONNECTING) {
|
|
315
|
+
_ws.close();
|
|
316
|
+
}
|
|
317
|
+
_ws = null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Handles connection lost - initiates reconnection if enabled.
|
|
323
|
+
*/
|
|
324
|
+
const handleConnectionLost = (reason?: string): void => {
|
|
325
|
+
cleanup();
|
|
326
|
+
|
|
327
|
+
if (_state.type === "disconnected") return;
|
|
328
|
+
|
|
329
|
+
const wasInitialConnect = _connectRejecter !== null;
|
|
330
|
+
|
|
331
|
+
if (wasInitialConnect) {
|
|
332
|
+
// Failed during initial connection
|
|
333
|
+
_state = { type: "disconnected" };
|
|
334
|
+
_reconnectAttempt = 0;
|
|
335
|
+
_connectRejecter!(new WebSocketError("Connection failed", undefined, reason));
|
|
336
|
+
_connectResolver = null;
|
|
337
|
+
_connectRejecter = null;
|
|
338
|
+
emit(onEvent, { type: "disconnected", reason });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!autoReconnect) {
|
|
343
|
+
_state = { type: "disconnected" };
|
|
344
|
+
_reconnectAttempt = 0;
|
|
345
|
+
emit(onEvent, { type: "disconnected", reason });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_reconnectAttempt++;
|
|
350
|
+
|
|
351
|
+
if (_reconnectAttempt > maxReconnectAttempts) {
|
|
352
|
+
_state = { type: "disconnected" };
|
|
353
|
+
_reconnectAttempt = 0;
|
|
354
|
+
emit(onEvent, { type: "disconnected", reason: "Max reconnection attempts reached" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Enter reconnecting state
|
|
359
|
+
_state = { type: "reconnecting", attempt: _reconnectAttempt };
|
|
360
|
+
emit(onEvent, { type: "reconnecting", attempt: _reconnectAttempt });
|
|
361
|
+
|
|
362
|
+
// Schedule reconnection
|
|
363
|
+
const delay = getReconnectDelay(_reconnectAttempt - 1);
|
|
364
|
+
_reconnectTimeoutHandle = setTimeout(() => {
|
|
365
|
+
_reconnectTimeoutHandle = null;
|
|
366
|
+
attemptConnection();
|
|
367
|
+
}, delay);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Attempts to establish WebSocket connection.
|
|
372
|
+
*/
|
|
373
|
+
const attemptConnection = (): void => {
|
|
374
|
+
if (_state.type === "connected") return;
|
|
375
|
+
|
|
376
|
+
_state = { type: "connecting" };
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
_ws = new WebSocket(url, protocols);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
handleConnectionLost((error as Error).message);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Set connection timeout
|
|
386
|
+
_connectionTimeoutHandle = setTimeout(() => {
|
|
387
|
+
_connectionTimeoutHandle = null;
|
|
388
|
+
handleConnectionLost("Connection timeout");
|
|
389
|
+
}, connectionTimeout);
|
|
390
|
+
|
|
391
|
+
_ws.onopen = async () => {
|
|
392
|
+
// Clear connection timeout
|
|
393
|
+
if (_connectionTimeoutHandle) {
|
|
394
|
+
clearTimeout(_connectionTimeoutHandle);
|
|
395
|
+
_connectionTimeoutHandle = null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
// Always authenticate (even with empty token) to trigger server auth flow
|
|
400
|
+
await authenticate();
|
|
401
|
+
// Connection completes after auth_result is received
|
|
402
|
+
} catch (error) {
|
|
403
|
+
handleConnectionLost((error as Error).message);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
_ws.onclose = (event) => {
|
|
408
|
+
handleConnectionLost(event.reason || `Connection closed (code: ${event.code})`);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
_ws.onerror = () => {
|
|
412
|
+
// Error details come through onclose
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
_ws.onmessage = (event) => {
|
|
416
|
+
try {
|
|
417
|
+
const encoded = JSON.parse(event.data as string) as Transport.EncodedServerMessage;
|
|
418
|
+
const message = decodeServerMessage(encoded);
|
|
419
|
+
handleMessage(message);
|
|
420
|
+
} catch {
|
|
421
|
+
// Invalid message - ignore
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Handles incoming server messages.
|
|
428
|
+
*/
|
|
429
|
+
const handleMessage = (message: Transport.ServerMessage): void => {
|
|
430
|
+
// Handle internal messages
|
|
431
|
+
if (message.type === "pong") {
|
|
432
|
+
handlePong();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (message.type === "auth_result") {
|
|
437
|
+
handleAuthResult(message.success, message.error);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Forward to subscribers
|
|
442
|
+
for (const handler of _messageHandlers) {
|
|
443
|
+
try {
|
|
444
|
+
handler(message);
|
|
445
|
+
} catch {
|
|
446
|
+
// Ignore handler errors
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// ==========================================================================
|
|
452
|
+
// Public API
|
|
453
|
+
// ==========================================================================
|
|
454
|
+
|
|
455
|
+
const transport: Transport.Transport = {
|
|
456
|
+
send: (transaction: Transaction.Transaction): void => {
|
|
457
|
+
const message: Transport.ClientMessage = { type: "submit", transaction };
|
|
458
|
+
|
|
459
|
+
if (_state.type === "connected") {
|
|
460
|
+
sendRaw(message);
|
|
461
|
+
} else if (_state.type === "reconnecting") {
|
|
462
|
+
// Queue message for when we reconnect
|
|
463
|
+
_messageQueue.push(message);
|
|
464
|
+
}
|
|
465
|
+
// If disconnected, silently drop (caller should check isConnected)
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
requestSnapshot: (): void => {
|
|
469
|
+
const message: Transport.ClientMessage = { type: "request_snapshot" };
|
|
470
|
+
|
|
471
|
+
if (_state.type === "connected") {
|
|
472
|
+
sendRaw(message);
|
|
473
|
+
} else if (_state.type === "reconnecting") {
|
|
474
|
+
_messageQueue.push(message);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
subscribe: (handler: (message: Transport.ServerMessage) => void): (() => void) => {
|
|
479
|
+
_messageHandlers.add(handler);
|
|
480
|
+
return () => {
|
|
481
|
+
_messageHandlers.delete(handler);
|
|
482
|
+
};
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
connect: async (): Promise<void> => {
|
|
486
|
+
if (_state.type === "connected") {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (_state.type === "connecting" || _state.type === "authenticating") {
|
|
491
|
+
// Already connecting - wait for existing promise
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const existingResolver = _connectResolver;
|
|
494
|
+
const existingRejecter = _connectRejecter;
|
|
495
|
+
_connectResolver = () => {
|
|
496
|
+
existingResolver?.();
|
|
497
|
+
resolve();
|
|
498
|
+
};
|
|
499
|
+
_connectRejecter = (error) => {
|
|
500
|
+
existingRejecter?.(error);
|
|
501
|
+
reject(error);
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return new Promise((resolve, reject) => {
|
|
507
|
+
_connectResolver = resolve;
|
|
508
|
+
_connectRejecter = reject;
|
|
509
|
+
attemptConnection();
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
disconnect: (): void => {
|
|
514
|
+
// Cancel any pending reconnection
|
|
515
|
+
if (_reconnectTimeoutHandle) {
|
|
516
|
+
clearTimeout(_reconnectTimeoutHandle);
|
|
517
|
+
_reconnectTimeoutHandle = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Reject any pending connect promise
|
|
521
|
+
if (_connectRejecter) {
|
|
522
|
+
_connectRejecter(new WebSocketError("Disconnected by user"));
|
|
523
|
+
_connectResolver = null;
|
|
524
|
+
_connectRejecter = null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Clean up
|
|
528
|
+
cleanup();
|
|
529
|
+
_state = { type: "disconnected" };
|
|
530
|
+
_reconnectAttempt = 0;
|
|
531
|
+
_messageQueue = [];
|
|
532
|
+
|
|
533
|
+
emit(onEvent, { type: "disconnected", reason: "User disconnected" });
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
isConnected: (): boolean => {
|
|
537
|
+
return _state.type === "connected";
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// =========================================================================
|
|
541
|
+
// Presence Methods
|
|
542
|
+
// =========================================================================
|
|
543
|
+
|
|
544
|
+
sendPresenceSet: (data: unknown): void => {
|
|
545
|
+
const message: Transport.ClientMessage = { type: "presence_set", data };
|
|
546
|
+
|
|
547
|
+
if (_state.type === "connected") {
|
|
548
|
+
sendRaw(message);
|
|
549
|
+
} else if (_state.type === "reconnecting") {
|
|
550
|
+
// Remove all set messages from the message queue
|
|
551
|
+
_messageQueue = _messageQueue.filter((message) => message.type !== "presence_set");
|
|
552
|
+
// Add the new presence set message to the queue
|
|
553
|
+
_messageQueue.push(message);
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
sendPresenceClear: (): void => {
|
|
558
|
+
const message: Transport.ClientMessage = { type: "presence_clear" };
|
|
559
|
+
|
|
560
|
+
if (_state.type === "connected") {
|
|
561
|
+
sendRaw(message);
|
|
562
|
+
} else if (_state.type === "reconnecting") {
|
|
563
|
+
// Remove all clear messages from the message queue
|
|
564
|
+
_messageQueue = _messageQueue.filter((message) => message.type !== "presence_clear");
|
|
565
|
+
// Add the new presence clear message to the queue
|
|
566
|
+
_messageQueue.push(message);
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
return transport;
|
|
572
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type * as Transaction from "../Transaction";
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Client Errors
|
|
5
|
+
// =============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base error class for all mimic-client errors.
|
|
9
|
+
*/
|
|
10
|
+
export class MimicClientError extends Error {
|
|
11
|
+
readonly _tag: string = "MimicClientError";
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "MimicClientError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Error thrown when a transaction is rejected by the server.
|
|
20
|
+
*/
|
|
21
|
+
export class TransactionRejectedError extends MimicClientError {
|
|
22
|
+
readonly _tag = "TransactionRejectedError";
|
|
23
|
+
readonly transaction: Transaction.Transaction;
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
|
|
26
|
+
constructor(transaction: Transaction.Transaction, reason: string) {
|
|
27
|
+
super(`Transaction ${transaction.id} rejected: ${reason}`);
|
|
28
|
+
this.name = "TransactionRejectedError";
|
|
29
|
+
this.transaction = transaction;
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Error thrown when the transport is not connected.
|
|
36
|
+
*/
|
|
37
|
+
export class NotConnectedError extends MimicClientError {
|
|
38
|
+
readonly _tag = "NotConnectedError";
|
|
39
|
+
constructor() {
|
|
40
|
+
super("Transport is not connected");
|
|
41
|
+
this.name = "NotConnectedError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown when connection to the server fails.
|
|
47
|
+
*/
|
|
48
|
+
export class ConnectionError extends MimicClientError {
|
|
49
|
+
readonly _tag = "ConnectionError";
|
|
50
|
+
readonly cause?: Error;
|
|
51
|
+
|
|
52
|
+
constructor(message: string, cause?: Error) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "ConnectionError";
|
|
55
|
+
this.cause = cause;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Error thrown when state drift is detected and cannot be recovered.
|
|
61
|
+
*/
|
|
62
|
+
export class StateDriftError extends MimicClientError {
|
|
63
|
+
readonly _tag = "StateDriftError";
|
|
64
|
+
readonly expectedVersion: number;
|
|
65
|
+
readonly receivedVersion: number;
|
|
66
|
+
|
|
67
|
+
constructor(expectedVersion: number, receivedVersion: number) {
|
|
68
|
+
super(
|
|
69
|
+
`State drift detected: expected version ${expectedVersion}, received ${receivedVersion}`
|
|
70
|
+
);
|
|
71
|
+
this.name = "StateDriftError";
|
|
72
|
+
this.expectedVersion = expectedVersion;
|
|
73
|
+
this.receivedVersion = receivedVersion;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Error thrown when a pending transaction times out waiting for confirmation.
|
|
79
|
+
*/
|
|
80
|
+
export class TransactionTimeoutError extends MimicClientError {
|
|
81
|
+
readonly _tag = "TransactionTimeoutError";
|
|
82
|
+
readonly transaction: Transaction.Transaction;
|
|
83
|
+
readonly timeoutMs: number;
|
|
84
|
+
|
|
85
|
+
constructor(transaction: Transaction.Transaction, timeoutMs: number) {
|
|
86
|
+
super(
|
|
87
|
+
`Transaction ${transaction.id} timed out after ${timeoutMs}ms waiting for confirmation`
|
|
88
|
+
);
|
|
89
|
+
this.name = "TransactionTimeoutError";
|
|
90
|
+
this.transaction = transaction;
|
|
91
|
+
this.timeoutMs = timeoutMs;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Error thrown when rebasing operations fails.
|
|
97
|
+
*/
|
|
98
|
+
export class RebaseError extends MimicClientError {
|
|
99
|
+
readonly _tag = "RebaseError";
|
|
100
|
+
readonly transactionId: string;
|
|
101
|
+
|
|
102
|
+
constructor(transactionId: string, message: string) {
|
|
103
|
+
super(`Failed to rebase transaction ${transactionId}: ${message}`);
|
|
104
|
+
this.name = "RebaseError";
|
|
105
|
+
this.transactionId = transactionId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Error thrown when the client document is in an invalid state.
|
|
111
|
+
*/
|
|
112
|
+
export class InvalidStateError extends MimicClientError {
|
|
113
|
+
readonly _tag = "InvalidStateError";
|
|
114
|
+
constructor(message: string) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = "InvalidStateError";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Error thrown when WebSocket connection or communication fails.
|
|
122
|
+
*/
|
|
123
|
+
export class WebSocketError extends MimicClientError {
|
|
124
|
+
readonly _tag = "WebSocketError";
|
|
125
|
+
readonly code?: number;
|
|
126
|
+
readonly reason?: string;
|
|
127
|
+
|
|
128
|
+
constructor(message: string, code?: number, reason?: string) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.name = "WebSocketError";
|
|
131
|
+
this.code = code;
|
|
132
|
+
this.reason = reason;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Error thrown when authentication fails.
|
|
138
|
+
*/
|
|
139
|
+
export class AuthenticationError extends MimicClientError {
|
|
140
|
+
readonly _tag = "AuthenticationError";
|
|
141
|
+
constructor(message: string) {
|
|
142
|
+
super(message);
|
|
143
|
+
this.name = "AuthenticationError";
|
|
144
|
+
}
|
|
145
|
+
}
|