cojson-transport-ws 0.9.23 → 0.10.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/src/index.ts CHANGED
@@ -1,239 +1,2 @@
1
- import {
2
- DisconnectedError,
3
- Peer,
4
- PingTimeoutError,
5
- SyncMessage,
6
- cojsonInternals,
7
- logger,
8
- } from "cojson";
9
- import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
10
- import { deserializeMessages, getErrorMessage } from "./serialization.js";
11
- import { AnyWebSocket } from "./types.js";
12
-
13
- export const BUFFER_LIMIT = 100_000;
14
- export const BUFFER_LIMIT_POLLING_INTERVAL = 10;
15
-
16
- export type CreateWebSocketPeerOpts = {
17
- id: string;
18
- websocket: AnyWebSocket;
19
- role: Peer["role"];
20
- expectPings?: boolean;
21
- batchingByDefault?: boolean;
22
- deletePeerStateOnClose?: boolean;
23
- onClose?: () => void;
24
- onSuccess?: () => void;
25
- };
26
-
27
- function createPingTimeoutListener(enabled: boolean, callback: () => void) {
28
- if (!enabled) {
29
- return {
30
- reset() {},
31
- clear() {},
32
- };
33
- }
34
-
35
- let pingTimeout: ReturnType<typeof setTimeout> | null = null;
36
-
37
- return {
38
- reset() {
39
- pingTimeout && clearTimeout(pingTimeout);
40
- pingTimeout = setTimeout(() => {
41
- callback();
42
- }, 10_000);
43
- },
44
- clear() {
45
- pingTimeout && clearTimeout(pingTimeout);
46
- },
47
- };
48
- }
49
-
50
- function waitForWebSocketOpen(websocket: AnyWebSocket) {
51
- return new Promise<void>((resolve) => {
52
- if (websocket.readyState === 1) {
53
- resolve();
54
- } else {
55
- websocket.addEventListener("open", resolve, { once: true });
56
- }
57
- });
58
- }
59
-
60
- function createOutgoingMessagesManager(
61
- websocket: AnyWebSocket,
62
- batchingByDefault: boolean,
63
- ) {
64
- let closed = false;
65
- const outgoingMessages = new BatchedOutgoingMessages((messages) => {
66
- if (websocket.readyState === 1) {
67
- websocket.send(messages);
68
- }
69
- });
70
-
71
- let batchingEnabled = batchingByDefault;
72
-
73
- async function sendMessage(msg: SyncMessage) {
74
- if (closed) {
75
- return Promise.reject(new Error("WebSocket closed"));
76
- }
77
-
78
- if (websocket.readyState !== 1) {
79
- await waitForWebSocketOpen(websocket);
80
- }
81
-
82
- while (
83
- websocket.bufferedAmount > BUFFER_LIMIT &&
84
- websocket.readyState === 1
85
- ) {
86
- await new Promise<void>((resolve) =>
87
- setTimeout(resolve, BUFFER_LIMIT_POLLING_INTERVAL),
88
- );
89
- }
90
-
91
- if (websocket.readyState !== 1) {
92
- return;
93
- }
94
-
95
- if (!batchingEnabled) {
96
- websocket.send(JSON.stringify(msg));
97
- } else {
98
- outgoingMessages.push(msg);
99
- }
100
- }
101
-
102
- return {
103
- sendMessage,
104
- setBatchingEnabled(enabled: boolean) {
105
- batchingEnabled = enabled;
106
- },
107
- close() {
108
- closed = true;
109
- outgoingMessages.close();
110
- },
111
- };
112
- }
113
-
114
- function createClosedEventEmitter(callback = () => {}) {
115
- let disconnected = false;
116
-
117
- return () => {
118
- if (disconnected) return;
119
- disconnected = true;
120
- callback();
121
- };
122
- }
123
-
124
- export function createWebSocketPeer({
125
- id,
126
- websocket,
127
- role,
128
- expectPings = true,
129
- batchingByDefault = true,
130
- deletePeerStateOnClose = false,
131
- onSuccess,
132
- onClose,
133
- }: CreateWebSocketPeerOpts): Peer {
134
- const incoming = new cojsonInternals.Channel<
135
- SyncMessage | DisconnectedError | PingTimeoutError
136
- >();
137
- const emitClosedEvent = createClosedEventEmitter(onClose);
138
-
139
- function handleClose() {
140
- incoming
141
- .push("Disconnected")
142
- .catch((e) => logger.error("Error while pushing disconnect msg", e));
143
- emitClosedEvent();
144
- }
145
-
146
- websocket.addEventListener("close", handleClose);
147
- websocket.addEventListener("error" as any, (err) => {
148
- logger.warn(err.message);
149
-
150
- if (err.message.includes("ECONNREFUSED")) {
151
- websocket.close();
152
- }
153
- });
154
-
155
- const pingTimeout = createPingTimeoutListener(expectPings, () => {
156
- incoming
157
- .push("PingTimeout")
158
- .catch((e) => logger.error("Error while pushing ping timeout", e));
159
- emitClosedEvent();
160
- });
161
-
162
- const outgoingMessages = createOutgoingMessagesManager(
163
- websocket,
164
- batchingByDefault,
165
- );
166
- let isFirstMessage = true;
167
-
168
- function handleIncomingMsg(event: { data: unknown }) {
169
- if (event.data === "") {
170
- return;
171
- }
172
-
173
- const result = deserializeMessages(event.data);
174
-
175
- if (!result.ok) {
176
- logger.warn(
177
- "Error while deserializing messages: " + getErrorMessage(result.error),
178
- );
179
- return;
180
- }
181
-
182
- if (isFirstMessage) {
183
- // The only way to know that the connection has been correctly established with our sync server
184
- // is to track that we got a message from the server.
185
- onSuccess?.();
186
- isFirstMessage = false;
187
- }
188
-
189
- const { messages } = result;
190
-
191
- if (messages.length > 1) {
192
- // If more than one message is received, the other peer supports batching
193
- outgoingMessages.setBatchingEnabled(true);
194
- }
195
-
196
- pingTimeout.reset();
197
-
198
- for (const msg of messages) {
199
- if (msg && "action" in msg) {
200
- incoming
201
- .push(msg)
202
- .catch((e) => logger.error("Error while pushing incoming msg", e));
203
- }
204
- }
205
- }
206
-
207
- websocket.addEventListener("message", handleIncomingMsg);
208
-
209
- return {
210
- id,
211
- incoming,
212
- outgoing: {
213
- push: outgoingMessages.sendMessage,
214
- close() {
215
- outgoingMessages.close();
216
-
217
- websocket.removeEventListener("message", handleIncomingMsg);
218
- websocket.removeEventListener("close", handleClose);
219
- pingTimeout.clear();
220
- emitClosedEvent();
221
-
222
- if (websocket.readyState === 0) {
223
- websocket.addEventListener(
224
- "open",
225
- function handleClose() {
226
- websocket.close();
227
- },
228
- { once: true },
229
- );
230
- } else if (websocket.readyState == 1) {
231
- websocket.close();
232
- }
233
- },
234
- },
235
- role,
236
- crashOnClose: false,
237
- deletePeerStateOnClose,
238
- };
239
- }
1
+ export * from "./createWebSocketPeer.js";
2
+ export * from "./WebSocketPeerWithReconnection.js";
@@ -1,5 +1,5 @@
1
1
  import { SyncMessage } from "cojson";
2
- import { CoValueKnownState } from "cojson/src/sync.js";
2
+ import { CojsonInternalTypes } from "cojson";
3
3
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
4
  import {
5
5
  BatchedOutgoingMessages,
@@ -58,7 +58,7 @@ describe("BatchedOutgoingMessages", () => {
58
58
  sessions: {
59
59
  // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
60
60
  payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
61
- } as CoValueKnownState["sessions"],
61
+ } as CojsonInternalTypes.CoValueKnownState["sessions"],
62
62
  };
63
63
 
64
64
  batchedMessages.push(largeMessage);
@@ -82,7 +82,7 @@ describe("BatchedOutgoingMessages", () => {
82
82
  sessions: {
83
83
  // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
84
84
  payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
85
- } as CoValueKnownState["sessions"],
85
+ } as CojsonInternalTypes.CoValueKnownState["sessions"],
86
86
  };
87
87
 
88
88
  batchedMessages.push(smallMessage);
@@ -0,0 +1,143 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { WebSocketPeerWithReconnection } from "../WebSocketPeerWithReconnection";
3
+ import { startSyncServer } from "./syncServer";
4
+ import { waitFor } from "./utils";
5
+
6
+ describe("WebSocketPeerWithReconnection", () => {
7
+ let server: any;
8
+ let syncServerUrl: string;
9
+
10
+ beforeEach(async () => {
11
+ const result = await startSyncServer();
12
+ server = result;
13
+ syncServerUrl = result.syncServer;
14
+ });
15
+
16
+ afterEach(() => {
17
+ server.close();
18
+ });
19
+
20
+ test("should connect successfully to sync server", async () => {
21
+ const addPeer = vi.fn();
22
+ const removePeer = vi.fn();
23
+
24
+ const peer = new WebSocketPeerWithReconnection({
25
+ peer: syncServerUrl,
26
+ reconnectionTimeout: 100,
27
+ addPeer,
28
+ removePeer,
29
+ });
30
+
31
+ peer.enable();
32
+
33
+ // Wait for connection to establish
34
+ await new Promise((resolve) => setTimeout(resolve, 100));
35
+
36
+ expect(addPeer).toHaveBeenCalledTimes(1);
37
+ expect(removePeer).not.toHaveBeenCalled();
38
+
39
+ peer.disable();
40
+ });
41
+
42
+ test("should attempt reconnection when server disconnects", async () => {
43
+ const addPeer = vi.fn();
44
+ const removePeer = vi.fn();
45
+
46
+ const peer = new WebSocketPeerWithReconnection({
47
+ peer: syncServerUrl,
48
+ reconnectionTimeout: 100,
49
+ addPeer,
50
+ removePeer,
51
+ });
52
+
53
+ peer.enable();
54
+
55
+ // Wait for initial connection
56
+ await new Promise((resolve) => setTimeout(resolve, 100));
57
+
58
+ addPeer.mockClear();
59
+
60
+ // Close server to simulate disconnect
61
+ server.close();
62
+
63
+ // Wait for disconnect to be detected
64
+ await new Promise((resolve) => setTimeout(resolve, 200));
65
+
66
+ expect(removePeer).toHaveBeenCalled();
67
+ expect(peer.reconnectionAttempts).toBeGreaterThan(0);
68
+
69
+ // Start server again
70
+ server = await startSyncServer(server.port);
71
+
72
+ // Wait for reconnection
73
+ await new Promise((resolve) => setTimeout(resolve, 300));
74
+
75
+ expect(addPeer).toHaveBeenCalled();
76
+
77
+ peer.disable();
78
+ });
79
+
80
+ test("should stop reconnection attempts when disabled", async () => {
81
+ const addPeer = vi.fn();
82
+ const removePeer = vi.fn();
83
+
84
+ const peer = new WebSocketPeerWithReconnection({
85
+ peer: syncServerUrl,
86
+ reconnectionTimeout: 100,
87
+ addPeer,
88
+ removePeer,
89
+ });
90
+
91
+ peer.enable();
92
+
93
+ // Wait for initial connection
94
+ await new Promise((resolve) => setTimeout(resolve, 100));
95
+
96
+ // Close server and disable peer
97
+ server.close();
98
+ peer.disable();
99
+
100
+ // Wait to ensure no reconnection attempts
101
+ await new Promise((resolve) => setTimeout(resolve, 300));
102
+
103
+ expect(addPeer).toHaveBeenCalledTimes(1);
104
+ expect(removePeer).toHaveBeenCalledTimes(1);
105
+ expect(peer.reconnectionAttempts).toBe(0);
106
+ });
107
+
108
+ test("should reset reconnection attempts when connection is successful", async () => {
109
+ const addPeer = vi.fn();
110
+ const removePeer = vi.fn();
111
+
112
+ const peer = new WebSocketPeerWithReconnection({
113
+ peer: syncServerUrl,
114
+ reconnectionTimeout: 10,
115
+ addPeer,
116
+ removePeer,
117
+ });
118
+
119
+ peer.enable();
120
+
121
+ // Wait for initial connection
122
+ await new Promise((resolve) => setTimeout(resolve, 100));
123
+
124
+ // Close server to trigger reconnection attempts
125
+ server.close();
126
+
127
+ // Wait for some reconnection attempts
128
+ await new Promise((resolve) => setTimeout(resolve, 300));
129
+
130
+ const previousAttempts = peer.reconnectionAttempts;
131
+ expect(previousAttempts).toBeGreaterThan(0);
132
+
133
+ // Start server again
134
+ server = await startSyncServer(server.port);
135
+
136
+ // Wait for successful reconnection
137
+ await new Promise((resolve) => setTimeout(resolve, 1000));
138
+
139
+ await waitFor(() => expect(peer.reconnectionAttempts).toBe(0));
140
+
141
+ peer.disable();
142
+ });
143
+ });
@@ -7,7 +7,7 @@ import {
7
7
  BUFFER_LIMIT_POLLING_INTERVAL,
8
8
  CreateWebSocketPeerOpts,
9
9
  createWebSocketPeer,
10
- } from "../index.js";
10
+ } from "../createWebSocketPeer.js";
11
11
  import { AnyWebSocket } from "../types.js";
12
12
 
13
13
  function setup(opts: Partial<CreateWebSocketPeerOpts> = {}) {
@@ -1,7 +1,8 @@
1
- import { ControlledAgent, LocalNode, WasmCrypto } from "cojson";
1
+ import { ControlledAgent, LocalNode } from "cojson";
2
+ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
2
3
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
3
4
  import { WebSocket } from "ws";
4
- import { createWebSocketPeer } from "../index";
5
+ import { createWebSocketPeer } from "../createWebSocketPeer";
5
6
  import { startSyncServer } from "./syncServer";
6
7
 
7
8
  describe("WebSocket Peer Integration", () => {
@@ -86,7 +87,6 @@ describe("WebSocket Peer Integration", () => {
86
87
 
87
88
  // Wait for sync
88
89
  await map.core.waitForSync();
89
- console.log("synced");
90
90
 
91
91
  // Verify data reached the server
92
92
  const serverNode = server.localNode;
@@ -1,7 +1,8 @@
1
1
  import { createServer } from "http";
2
- import { ControlledAgent, LocalNode, WasmCrypto } from "cojson";
2
+ import { ControlledAgent, LocalNode } from "cojson";
3
+ import { WasmCrypto } from "cojson/crypto/WasmCrypto";
3
4
  import { WebSocket, WebSocketServer } from "ws";
4
- import { createWebSocketPeer } from "../index";
5
+ import { createWebSocketPeer } from "../createWebSocketPeer";
5
6
 
6
7
  export const startSyncServer = async (port?: number) => {
7
8
  const crypto = await WasmCrypto.create();
@@ -0,0 +1,27 @@
1
+ export function waitFor(callback: () => boolean | void) {
2
+ return new Promise<void>((resolve, reject) => {
3
+ const checkPassed = () => {
4
+ try {
5
+ return { ok: callback(), error: null };
6
+ } catch (error) {
7
+ return { ok: false, error };
8
+ }
9
+ };
10
+
11
+ let retries = 0;
12
+
13
+ const interval = setInterval(() => {
14
+ const { ok, error } = checkPassed();
15
+
16
+ if (ok !== false) {
17
+ clearInterval(interval);
18
+ resolve();
19
+ }
20
+
21
+ if (++retries > 10) {
22
+ clearInterval(interval);
23
+ reject(error);
24
+ }
25
+ }, 100);
26
+ });
27
+ }