@muspellheim/shared 0.9.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ import { OutputTracker } from "../common/output_tracker";
4
+ import type { MessageClient } from "./message_client";
5
+
6
+ export const HEARTBEAT_TYPE = "heartbeat";
7
+
8
+ const MESSAGE_SENT_EVENT = "message-sent";
9
+
10
+ /**
11
+ * Options for the WebSocket client.
12
+ */
13
+ export interface WebSocketOptions {
14
+ /**
15
+ * The heartbeat interval in milliseconds. A value <= 0 disables the
16
+ * heartbeat.
17
+ */
18
+ heartbeat?: number;
19
+
20
+ /**
21
+ * The time in milliseconds to wait before retrying a connection after an
22
+ * error. A value <= 0 disables automatic retries.
23
+ */
24
+ retry?: number;
25
+ }
26
+
27
+ /**
28
+ * A client for the WebSocket protocol.
29
+ */
30
+ export class WebSocketClient extends EventTarget implements MessageClient {
31
+ /**
32
+ * Create a WebSocket client.
33
+ *
34
+ * @param options The options for the WebSocket client.
35
+ * @return A new WebSocket client.
36
+ */
37
+ static create({
38
+ heartbeat = 30000,
39
+ retry = 1000,
40
+ }: WebSocketOptions = {}): WebSocketClient {
41
+ return new WebSocketClient(heartbeat, retry, WebSocket);
42
+ }
43
+
44
+ /**
45
+ * Create a nulled WebSocket client.
46
+ *
47
+ * @param options The options for the WebSocket client.
48
+ * @return A new nulled WebSocket client.
49
+ */
50
+ static createNull({ heartbeat = 0, retry = 0 }: WebSocketOptions = {}) {
51
+ return new WebSocketClient(
52
+ heartbeat,
53
+ retry,
54
+ WebSocketStub as unknown as typeof WebSocket,
55
+ );
56
+ }
57
+
58
+ readonly #heartbeat: number;
59
+ readonly #retry: number;
60
+ readonly #webSocketConstructor: typeof WebSocket;
61
+
62
+ #webSocket?: WebSocket;
63
+ #heartbeatId?: ReturnType<typeof setTimeout>;
64
+ #retryId?: ReturnType<typeof setTimeout>;
65
+
66
+ private constructor(
67
+ heartbeat: number,
68
+ retry: number,
69
+ webSocketConstructor: typeof WebSocket,
70
+ ) {
71
+ super();
72
+ this.#heartbeat = heartbeat;
73
+ this.#retry = retry;
74
+ this.#webSocketConstructor = webSocketConstructor;
75
+ }
76
+
77
+ get isConnected(): boolean {
78
+ return this.#webSocket?.readyState === WebSocket.OPEN;
79
+ }
80
+
81
+ get url(): string | undefined {
82
+ return this.#webSocket?.url;
83
+ }
84
+
85
+ async connect(url: string | URL): Promise<void> {
86
+ await new Promise<void>((resolve, reject) => {
87
+ this.#stopRetry();
88
+
89
+ if (this.isConnected) {
90
+ reject(new Error("Already connected."));
91
+ return;
92
+ }
93
+
94
+ try {
95
+ this.#webSocket = new this.#webSocketConstructor(url);
96
+ this.#webSocket.addEventListener("open", (e) => {
97
+ this.#handleOpen(e);
98
+ resolve();
99
+ });
100
+ this.#webSocket.addEventListener("message", (e) =>
101
+ this.#handleMessage(e),
102
+ );
103
+ this.#webSocket.addEventListener("close", (e) => this.#handleClose(e));
104
+ this.#webSocket.addEventListener("error", (e) => this.#handleError(e));
105
+ } catch (error) {
106
+ reject(error);
107
+ }
108
+ });
109
+ }
110
+
111
+ async send(
112
+ message: string | ArrayBuffer | Blob | ArrayBufferView,
113
+ ): Promise<void> {
114
+ if (!this.isConnected) {
115
+ throw new Error("Not connected.");
116
+ }
117
+
118
+ this.#webSocket!.send(message);
119
+ this.dispatchEvent(
120
+ new CustomEvent(MESSAGE_SENT_EVENT, { detail: message }),
121
+ );
122
+ await Promise.resolve();
123
+ }
124
+
125
+ /**
126
+ * Return a tracker for messages sent.
127
+ *
128
+ * @return A new output tracker.
129
+ */
130
+ trackMessageSent(): OutputTracker<string> {
131
+ return OutputTracker.create(this, MESSAGE_SENT_EVENT);
132
+ }
133
+
134
+ /**
135
+ * Close the connection.
136
+ *
137
+ * If a code is provided, also a reason should be provided.
138
+ *
139
+ * @param code An optional code.
140
+ * @param reason An optional reason.
141
+ */
142
+ async close(code?: number, reason?: string): Promise<void> {
143
+ await new Promise<void>((resolve) => {
144
+ this.#stopRetry();
145
+
146
+ if (!this.isConnected) {
147
+ resolve();
148
+ return;
149
+ }
150
+
151
+ this.#webSocket!.addEventListener("close", () => resolve());
152
+ this.#webSocket!.close(code, reason);
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Simulate a message event from the server.
158
+ *
159
+ * @param message The message to receive.
160
+ */
161
+ simulateMessage(message: string | number | boolean | object | null) {
162
+ if (typeof message !== "string") {
163
+ message = JSON.stringify(message);
164
+ }
165
+ this.#handleMessage(new MessageEvent("message", { data: message }));
166
+ }
167
+
168
+ /**
169
+ * Simulate a heartbeat.
170
+ */
171
+ simulateHeartbeat() {
172
+ this.#sendHeartbeat();
173
+ }
174
+
175
+ /**
176
+ * Simulate a close event.
177
+ *
178
+ * @param code An optional code.
179
+ * @param reason An optional reason.
180
+ */
181
+ simulateClose(code?: number, reason?: string) {
182
+ this.#handleClose(new CloseEvent("close", { code, reason }));
183
+ }
184
+
185
+ /**
186
+ * Simulate an error event.
187
+ */
188
+ simulateError() {
189
+ this.#webSocket?.close();
190
+ this.#handleError(new Event("error"));
191
+ }
192
+
193
+ #handleOpen(event: Event) {
194
+ this.dispatchEvent(new Event(event.type, event));
195
+ this.#startHeartbeat();
196
+ }
197
+
198
+ #handleMessage(event: MessageEvent) {
199
+ this.dispatchEvent(
200
+ new MessageEvent(event.type, event as unknown as MessageEventInit),
201
+ );
202
+ }
203
+
204
+ #handleClose(event: CloseEvent) {
205
+ this.#stopHeartbeat();
206
+ this.dispatchEvent(new CloseEvent(event.type, event));
207
+ }
208
+
209
+ #handleError(event: Event) {
210
+ this.dispatchEvent(new Event(event.type, event));
211
+ this.#startRetry();
212
+ }
213
+
214
+ #startRetry() {
215
+ if (this.#retry <= 0) {
216
+ return;
217
+ }
218
+ this.#retryId = setInterval(
219
+ () => this.connect(this.#webSocket!.url),
220
+ this.#retry,
221
+ );
222
+ }
223
+
224
+ #stopRetry() {
225
+ clearInterval(this.#retryId);
226
+ this.#retryId = undefined;
227
+ }
228
+
229
+ #startHeartbeat() {
230
+ if (this.#heartbeat <= 0) {
231
+ return;
232
+ }
233
+
234
+ this.#heartbeatId = setInterval(
235
+ () => this.#sendHeartbeat(),
236
+ this.#heartbeat,
237
+ );
238
+ }
239
+
240
+ #stopHeartbeat() {
241
+ clearInterval(this.#heartbeatId);
242
+ this.#heartbeatId = undefined;
243
+ }
244
+
245
+ #sendHeartbeat() {
246
+ if (this.#heartbeatId == null) {
247
+ return;
248
+ }
249
+
250
+ void this.send(HEARTBEAT_TYPE);
251
+ }
252
+ }
253
+
254
+ class WebSocketStub extends EventTarget {
255
+ url: string;
256
+ readyState: number = WebSocket.CONNECTING;
257
+
258
+ constructor(url: string | URL) {
259
+ super();
260
+ this.url = url.toString();
261
+ setTimeout(() => {
262
+ this.readyState = WebSocket.OPEN;
263
+ this.dispatchEvent(new Event("open"));
264
+ }, 0);
265
+ }
266
+
267
+ send() {}
268
+
269
+ close() {
270
+ this.readyState = WebSocket.CLOSED;
271
+ this.dispatchEvent(new Event("close"));
272
+ }
273
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ export * from "./common/mod";
4
+ export * from "./domain/mod";
5
+ export * from "./infrastructure/mod";
@@ -0,0 +1,3 @@
1
+ // Copyright (c) 2025 Falko Schumann. All rights reserved. MIT license.
2
+
3
+ /// <reference types="vite/client" />