cojson-transport-ws 0.8.12 → 0.8.16

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,151 +1,211 @@
1
1
  import {
2
- DisconnectedError,
3
- Peer,
4
- PingTimeoutError,
5
- SyncMessage,
6
- cojsonInternals,
2
+ DisconnectedError,
3
+ Peer,
4
+ PingTimeoutError,
5
+ SyncMessage,
6
+ cojsonInternals,
7
7
  } from "cojson";
8
- import { AnyWebSocket } from "./types.js";
9
8
  import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
10
9
  import { deserializeMessages } from "./serialization.js";
10
+ import { AnyWebSocket } from "./types.js";
11
11
 
12
12
  export const BUFFER_LIMIT = 100_000;
13
13
  export const BUFFER_LIMIT_POLLING_INTERVAL = 10;
14
14
 
15
15
  export type CreateWebSocketPeerOpts = {
16
- id: string;
17
- websocket: AnyWebSocket;
18
- role: Peer["role"];
19
- expectPings?: boolean;
20
- batchingByDefault?: boolean;
16
+ id: string;
17
+ websocket: AnyWebSocket;
18
+ role: Peer["role"];
19
+ expectPings?: boolean;
20
+ batchingByDefault?: boolean;
21
+ onClose?: () => void;
21
22
  };
22
23
 
23
- export function createWebSocketPeer({
24
- id,
25
- websocket,
26
- role,
27
- expectPings = true,
28
- batchingByDefault = true,
29
- }: CreateWebSocketPeerOpts): Peer {
30
- const incoming = new cojsonInternals.Channel<
31
- SyncMessage | DisconnectedError | PingTimeoutError
32
- >();
33
-
34
- websocket.addEventListener("close", function handleClose() {
35
- incoming
36
- .push("Disconnected")
37
- .catch((e) =>
38
- console.error("Error while pushing disconnect msg", e),
39
- );
40
- });
24
+ function createPingTimeoutListener(enabled: boolean, callback: () => void) {
25
+ if (!enabled) {
26
+ return {
27
+ reset() {},
28
+ clear() {},
29
+ };
30
+ }
31
+
32
+ let pingTimeout: ReturnType<typeof setTimeout> | null = null;
33
+
34
+ return {
35
+ reset() {
36
+ pingTimeout && clearTimeout(pingTimeout);
37
+ pingTimeout = setTimeout(() => {
38
+ callback();
39
+ }, 10_000);
40
+ },
41
+ clear() {
42
+ pingTimeout && clearTimeout(pingTimeout);
43
+ },
44
+ };
45
+ }
41
46
 
42
- let pingTimeout: ReturnType<typeof setTimeout> | null = null;
47
+ function waitForWebSocketOpen(websocket: AnyWebSocket) {
48
+ return new Promise<void>((resolve) => {
49
+ if (websocket.readyState === 1) {
50
+ resolve();
51
+ } else {
52
+ websocket.addEventListener("open", resolve, { once: true });
53
+ }
54
+ });
55
+ }
43
56
 
44
- let supportsBatching = batchingByDefault;
57
+ function createOutgoingMessagesManager(
58
+ websocket: AnyWebSocket,
59
+ batchingByDefault: boolean,
60
+ ) {
61
+ const outgoingMessages = new BatchedOutgoingMessages((messages) => {
62
+ if (websocket.readyState === 1) {
63
+ websocket.send(messages);
64
+ }
65
+ });
45
66
 
46
- websocket.addEventListener("message", function handleIncomingMsg(event) {
47
- const result = deserializeMessages(event.data as string);
67
+ let batchingEnabled = batchingByDefault;
48
68
 
49
- if (!result.ok) {
50
- console.error("Error while deserializing messages", event.data, result.error);
51
- return;
52
- }
69
+ async function sendMessage(msg: SyncMessage) {
70
+ if (websocket.readyState !== 1) {
71
+ await waitForWebSocketOpen(websocket);
72
+ }
53
73
 
54
- const { messages } = result;
74
+ while (
75
+ websocket.bufferedAmount > BUFFER_LIMIT &&
76
+ websocket.readyState === 1
77
+ ) {
78
+ await new Promise<void>((resolve) =>
79
+ setTimeout(resolve, BUFFER_LIMIT_POLLING_INTERVAL),
80
+ );
81
+ }
55
82
 
56
- if (!supportsBatching && messages.length > 1) {
57
- // If more than one message is received, the other peer supports batching
58
- supportsBatching = true;
59
- }
83
+ if (websocket.readyState !== 1) {
84
+ return;
85
+ }
60
86
 
61
- if (expectPings) {
62
- pingTimeout && clearTimeout(pingTimeout);
63
- pingTimeout = setTimeout(() => {
64
- incoming
65
- .push("PingTimeout")
66
- .catch((e) =>
67
- console.error("Error while pushing ping timeout", e),
68
- );
69
- }, 10_000);
70
- }
87
+ if (!batchingEnabled) {
88
+ websocket.send(JSON.stringify(msg));
89
+ } else {
90
+ outgoingMessages.push(msg);
91
+ }
92
+ }
93
+
94
+ return {
95
+ sendMessage,
96
+ setBatchingEnabled(enabled: boolean) {
97
+ batchingEnabled = enabled;
98
+ },
99
+ close() {
100
+ outgoingMessages.close();
101
+ },
102
+ };
103
+ }
71
104
 
72
- for (const msg of messages) {
73
- if (msg && "action" in msg) {
74
- incoming
75
- .push(msg)
76
- .catch((e) =>
77
- console.error("Error while pushing incoming msg", e),
78
- );
79
- }
80
- }
81
- });
105
+ function createClosedEventEmitter(callback = () => {}) {
106
+ let disconnected = false;
82
107
 
83
- const websocketOpen = new Promise<void>((resolve) => {
84
- if (websocket.readyState === 1) {
85
- resolve();
86
- } else {
87
- websocket.addEventListener("open", resolve, { once: true });
88
- }
89
- });
108
+ return () => {
109
+ if (disconnected) return;
110
+ disconnected = true;
111
+ callback();
112
+ };
113
+ }
90
114
 
91
- const outgoingMessages = new BatchedOutgoingMessages((messages) => {
92
- if (websocket.readyState === 1) {
93
- websocket.send(
94
- messages,
95
- );
96
- }
97
- });
115
+ export function createWebSocketPeer({
116
+ id,
117
+ websocket,
118
+ role,
119
+ expectPings = true,
120
+ batchingByDefault = true,
121
+ onClose,
122
+ }: CreateWebSocketPeerOpts): Peer {
123
+ const incoming = new cojsonInternals.Channel<
124
+ SyncMessage | DisconnectedError | PingTimeoutError
125
+ >();
126
+ const emitClosedEvent = createClosedEventEmitter(onClose);
127
+
128
+ function handleClose() {
129
+ incoming
130
+ .push("Disconnected")
131
+ .catch((e) => console.error("Error while pushing disconnect msg", e));
132
+ emitClosedEvent();
133
+ }
134
+
135
+ websocket.addEventListener("close", handleClose);
136
+
137
+ const pingTimeout = createPingTimeoutListener(expectPings, () => {
138
+ incoming
139
+ .push("PingTimeout")
140
+ .catch((e) => console.error("Error while pushing ping timeout", e));
141
+ emitClosedEvent();
142
+ });
143
+
144
+ const outgoingMessages = createOutgoingMessagesManager(
145
+ websocket,
146
+ batchingByDefault,
147
+ );
148
+
149
+ function handleIncomingMsg(event: { data: unknown }) {
150
+ const result = deserializeMessages(event.data);
151
+
152
+ if (!result.ok) {
153
+ console.error(
154
+ "Error while deserializing messages",
155
+ event.data,
156
+ result.error,
157
+ );
158
+ return;
159
+ }
98
160
 
99
- async function pushMessage(msg: SyncMessage) {
100
- if (websocket.readyState !== 1) {
101
- await websocketOpen;
102
- }
161
+ const { messages } = result;
103
162
 
104
- while (
105
- websocket.bufferedAmount > BUFFER_LIMIT &&
106
- websocket.readyState === 1
107
- ) {
108
- await new Promise<void>((resolve) =>
109
- setTimeout(resolve, BUFFER_LIMIT_POLLING_INTERVAL),
110
- );
111
- }
163
+ if (messages.length > 1) {
164
+ // If more than one message is received, the other peer supports batching
165
+ outgoingMessages.setBatchingEnabled(true);
166
+ }
112
167
 
113
- if (websocket.readyState !== 1) {
114
- return;
115
- }
168
+ pingTimeout.reset();
116
169
 
117
- if (!supportsBatching) {
118
- websocket.send(JSON.stringify(msg));
119
- } else {
120
- outgoingMessages.push(msg);
121
- }
170
+ for (const msg of messages) {
171
+ if (msg && "action" in msg) {
172
+ incoming
173
+ .push(msg)
174
+ .catch((e) => console.error("Error while pushing incoming msg", e));
175
+ }
122
176
  }
177
+ }
123
178
 
124
- return {
125
- id,
126
- incoming,
127
- outgoing: {
128
- push: pushMessage,
129
- close() {
130
- console.log("Trying to close", id, websocket.readyState);
131
- if (supportsBatching) {
132
- outgoingMessages.close();
133
- }
134
-
135
- if (websocket.readyState === 0) {
136
- websocket.addEventListener(
137
- "open",
138
- function handleClose() {
139
- websocket.close();
140
- },
141
- { once: true },
142
- );
143
- } else if (websocket.readyState == 1) {
144
- websocket.close();
145
- }
179
+ websocket.addEventListener("message", handleIncomingMsg);
180
+
181
+ return {
182
+ id,
183
+ incoming,
184
+ outgoing: {
185
+ push: outgoingMessages.sendMessage,
186
+ close() {
187
+ console.log("Trying to close", id, websocket.readyState);
188
+ outgoingMessages.close();
189
+
190
+ websocket.removeEventListener("message", handleIncomingMsg);
191
+ websocket.removeEventListener("close", handleClose);
192
+ pingTimeout.clear();
193
+ emitClosedEvent();
194
+
195
+ if (websocket.readyState === 0) {
196
+ websocket.addEventListener(
197
+ "open",
198
+ function handleClose() {
199
+ websocket.close();
146
200
  },
147
- },
148
- role,
149
- crashOnClose: false,
150
- };
201
+ { once: true },
202
+ );
203
+ } else if (websocket.readyState == 1) {
204
+ websocket.close();
205
+ }
206
+ },
207
+ },
208
+ role,
209
+ crashOnClose: false,
210
+ };
151
211
  }
@@ -2,23 +2,32 @@ import { SyncMessage } from "cojson";
2
2
  import { PingMsg } from "./types.js";
3
3
 
4
4
  export function addMessageToBacklog(backlog: string, message: SyncMessage) {
5
- if (!backlog) {
6
- return JSON.stringify(message);
7
- }
8
- return `${backlog}\n${JSON.stringify(message)}`;
5
+ if (!backlog) {
6
+ return JSON.stringify(message);
7
+ }
8
+ return `${backlog}\n${JSON.stringify(message)}`;
9
9
  }
10
10
 
11
- export function deserializeMessages(messages: string) {
12
- try {
13
- return {
14
- ok: true,
15
- messages: messages.split("\n").map((msg) => JSON.parse(msg)) as SyncMessage[] | PingMsg[],
16
- } as const;
17
- } catch (e) {
18
- console.error("Error while deserializing messages", e);
19
- return {
20
- ok: false,
21
- error: e,
22
- } as const;
23
- }
11
+ export function deserializeMessages(messages: unknown) {
12
+ if (typeof messages !== "string") {
13
+ return {
14
+ ok: false,
15
+ error: new Error("Expected a string"),
16
+ } as const;
17
+ }
18
+
19
+ try {
20
+ return {
21
+ ok: true,
22
+ messages: messages.split("\n").map((msg) => JSON.parse(msg)) as
23
+ | SyncMessage[]
24
+ | PingMsg[],
25
+ } as const;
26
+ } catch (e) {
27
+ console.error("Error while deserializing messages", e);
28
+ return {
29
+ ok: false,
30
+ error: e,
31
+ } as const;
32
+ }
24
33
  }
@@ -1,114 +1,146 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { BatchedOutgoingMessages, MAX_OUTGOING_MESSAGES_CHUNK_BYTES } from "../BatchedOutgoingMessages.js";
3
1
  import { SyncMessage } from "cojson";
4
2
  import { CoValueKnownState } from "cojson/src/sync.js";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import {
5
+ BatchedOutgoingMessages,
6
+ MAX_OUTGOING_MESSAGES_CHUNK_BYTES,
7
+ } from "../BatchedOutgoingMessages.js";
5
8
 
6
9
  beforeEach(() => {
7
- vi.useFakeTimers();
8
- })
10
+ vi.useFakeTimers();
11
+ });
9
12
 
10
13
  afterEach(() => {
11
- vi.useRealTimers();
12
- })
14
+ vi.useRealTimers();
15
+ });
13
16
 
14
17
  describe("BatchedOutgoingMessages", () => {
15
- function setup() {
16
- const sendMock = vi.fn();
17
- const batchedMessages = new BatchedOutgoingMessages(sendMock);
18
- return { sendMock, batchedMessages };
19
- }
20
-
21
- test("should batch messages and send them after a timeout", () => {
22
- const { sendMock, batchedMessages } = setup();
23
- const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} };
24
- const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} };
25
-
26
- batchedMessages.push(message1);
27
- batchedMessages.push(message2);
28
-
29
- expect(sendMock).not.toHaveBeenCalled();
30
-
31
- vi.runAllTimers();
32
-
33
- expect(sendMock).toHaveBeenCalledTimes(1);
34
- expect(sendMock).toHaveBeenCalledWith(
35
- `${JSON.stringify(message1)}\n${JSON.stringify(message2)}`
36
- );
37
- });
38
-
39
- test("should send messages immediately when reaching MAX_OUTGOING_MESSAGES_CHUNK_BYTES", () => {
40
- const { sendMock, batchedMessages } = setup();
41
- const largeMessage: SyncMessage = {
42
- action: "known",
43
- id: "co_z_large",
44
- header: false,
45
- sessions: {
46
- // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
47
- payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES)
48
- } as CoValueKnownState['sessions'],
49
-
50
- };
51
-
52
- batchedMessages.push(largeMessage);
53
-
54
- expect(sendMock).toHaveBeenCalledTimes(1);
55
- expect(sendMock).toHaveBeenCalledWith(JSON.stringify(largeMessage));
56
- });
57
-
58
- test("should send accumulated messages before a large message", () => {
59
- const { sendMock, batchedMessages } = setup();
60
- const smallMessage: SyncMessage = { action: "known", id: "co_z_small", header: false, sessions: {} };
61
- const largeMessage: SyncMessage = {
62
- action: "known",
63
- id: "co_z_large",
64
- header: false,
65
- sessions: {
66
- // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
67
- payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES)
68
- } as CoValueKnownState['sessions'],
69
- };
70
-
71
- batchedMessages.push(smallMessage);
72
- batchedMessages.push(largeMessage);
73
-
74
- vi.runAllTimers();
75
-
76
- expect(sendMock).toHaveBeenCalledTimes(2);
77
- expect(sendMock).toHaveBeenNthCalledWith(1, JSON.stringify(smallMessage));
78
- expect(sendMock).toHaveBeenNthCalledWith(2, JSON.stringify(largeMessage));
79
- });
80
-
81
- test("should send remaining messages on close", () => {
82
- const { sendMock, batchedMessages } = setup();
83
- const message: SyncMessage = { action: "known", id: "co_z_test", header: false, sessions: {} };
84
-
85
- batchedMessages.push(message);
86
- expect(sendMock).not.toHaveBeenCalled();
87
-
88
- batchedMessages.close();
89
-
90
- expect(sendMock).toHaveBeenCalledTimes(1);
91
- expect(sendMock).toHaveBeenCalledWith(JSON.stringify(message));
92
- });
93
-
94
- test("should clear timeout when pushing new messages", () => {
95
- const { sendMock, batchedMessages } = setup();
96
- const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} };
97
- const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} };
98
-
99
- batchedMessages.push(message1);
100
-
101
- const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
102
-
103
- batchedMessages.push(message2);
104
-
105
- expect(clearTimeoutSpy).toHaveBeenCalled();
106
-
107
- vi.runAllTimers();
108
-
109
- expect(sendMock).toHaveBeenCalledTimes(1);
110
- expect(sendMock).toHaveBeenCalledWith(
111
- `${JSON.stringify(message1)}\n${JSON.stringify(message2)}`
112
- );
113
- });
114
- });
18
+ function setup() {
19
+ const sendMock = vi.fn();
20
+ const batchedMessages = new BatchedOutgoingMessages(sendMock);
21
+ return { sendMock, batchedMessages };
22
+ }
23
+
24
+ test("should batch messages and send them after a timeout", () => {
25
+ const { sendMock, batchedMessages } = setup();
26
+ const message1: SyncMessage = {
27
+ action: "known",
28
+ id: "co_z1",
29
+ header: false,
30
+ sessions: {},
31
+ };
32
+ const message2: SyncMessage = {
33
+ action: "known",
34
+ id: "co_z2",
35
+ header: false,
36
+ sessions: {},
37
+ };
38
+
39
+ batchedMessages.push(message1);
40
+ batchedMessages.push(message2);
41
+
42
+ expect(sendMock).not.toHaveBeenCalled();
43
+
44
+ vi.runAllTimers();
45
+
46
+ expect(sendMock).toHaveBeenCalledTimes(1);
47
+ expect(sendMock).toHaveBeenCalledWith(
48
+ `${JSON.stringify(message1)}\n${JSON.stringify(message2)}`,
49
+ );
50
+ });
51
+
52
+ test("should send messages immediately when reaching MAX_OUTGOING_MESSAGES_CHUNK_BYTES", () => {
53
+ const { sendMock, batchedMessages } = setup();
54
+ const largeMessage: SyncMessage = {
55
+ action: "known",
56
+ id: "co_z_large",
57
+ header: false,
58
+ sessions: {
59
+ // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
60
+ payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
61
+ } as CoValueKnownState["sessions"],
62
+ };
63
+
64
+ batchedMessages.push(largeMessage);
65
+
66
+ expect(sendMock).toHaveBeenCalledTimes(1);
67
+ expect(sendMock).toHaveBeenCalledWith(JSON.stringify(largeMessage));
68
+ });
69
+
70
+ test("should send accumulated messages before a large message", () => {
71
+ const { sendMock, batchedMessages } = setup();
72
+ const smallMessage: SyncMessage = {
73
+ action: "known",
74
+ id: "co_z_small",
75
+ header: false,
76
+ sessions: {},
77
+ };
78
+ const largeMessage: SyncMessage = {
79
+ action: "known",
80
+ id: "co_z_large",
81
+ header: false,
82
+ sessions: {
83
+ // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
84
+ payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
85
+ } as CoValueKnownState["sessions"],
86
+ };
87
+
88
+ batchedMessages.push(smallMessage);
89
+ batchedMessages.push(largeMessage);
90
+
91
+ vi.runAllTimers();
92
+
93
+ expect(sendMock).toHaveBeenCalledTimes(2);
94
+ expect(sendMock).toHaveBeenNthCalledWith(1, JSON.stringify(smallMessage));
95
+ expect(sendMock).toHaveBeenNthCalledWith(2, JSON.stringify(largeMessage));
96
+ });
97
+
98
+ test("should send remaining messages on close", () => {
99
+ const { sendMock, batchedMessages } = setup();
100
+ const message: SyncMessage = {
101
+ action: "known",
102
+ id: "co_z_test",
103
+ header: false,
104
+ sessions: {},
105
+ };
106
+
107
+ batchedMessages.push(message);
108
+ expect(sendMock).not.toHaveBeenCalled();
109
+
110
+ batchedMessages.close();
111
+
112
+ expect(sendMock).toHaveBeenCalledTimes(1);
113
+ expect(sendMock).toHaveBeenCalledWith(JSON.stringify(message));
114
+ });
115
+
116
+ test("should clear timeout when pushing new messages", () => {
117
+ const { sendMock, batchedMessages } = setup();
118
+ const message1: SyncMessage = {
119
+ action: "known",
120
+ id: "co_z1",
121
+ header: false,
122
+ sessions: {},
123
+ };
124
+ const message2: SyncMessage = {
125
+ action: "known",
126
+ id: "co_z2",
127
+ header: false,
128
+ sessions: {},
129
+ };
130
+
131
+ batchedMessages.push(message1);
132
+
133
+ const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
134
+
135
+ batchedMessages.push(message2);
136
+
137
+ expect(clearTimeoutSpy).toHaveBeenCalled();
138
+
139
+ vi.runAllTimers();
140
+
141
+ expect(sendMock).toHaveBeenCalledTimes(1);
142
+ expect(sendMock).toHaveBeenCalledWith(
143
+ `${JSON.stringify(message1)}\n${JSON.stringify(message2)}`,
144
+ );
145
+ });
146
+ });