@kyneta/websocket-transport 1.3.1 → 1.5.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.
@@ -1,614 +0,0 @@
1
- // src/client-program.ts
2
- import { computeBackoffDelay, DEFAULT_RECONNECT } from "@kyneta/transport";
3
- function createWsClientProgram(options = {}) {
4
- const { jitterFn = () => Math.random() * 1e3 } = options;
5
- const reconnect = {
6
- ...DEFAULT_RECONNECT,
7
- ...options.reconnect
8
- };
9
- function tryReconnect(currentAttempt, reason, ...extraEffects) {
10
- if (!reconnect.enabled) {
11
- return [{ status: "disconnected", reason }, ...extraEffects];
12
- }
13
- if (currentAttempt >= reconnect.maxAttempts) {
14
- return [
15
- {
16
- status: "disconnected",
17
- reason: { type: "max-retries-exceeded", attempts: currentAttempt }
18
- },
19
- ...extraEffects
20
- ];
21
- }
22
- const delay = computeBackoffDelay(
23
- currentAttempt + 1,
24
- reconnect.baseDelay,
25
- reconnect.maxDelay,
26
- jitterFn()
27
- );
28
- return [
29
- {
30
- status: "reconnecting",
31
- attempt: currentAttempt + 1,
32
- nextAttemptMs: delay
33
- },
34
- ...extraEffects,
35
- { type: "start-reconnect-timer", delayMs: delay }
36
- ];
37
- }
38
- return {
39
- init: [{ status: "disconnected" }],
40
- update(msg, model) {
41
- switch (msg.type) {
42
- // -----------------------------------------------------------------
43
- // start
44
- // -----------------------------------------------------------------
45
- case "start": {
46
- if (model.status !== "disconnected") return [model];
47
- return [
48
- { status: "connecting", attempt: 1 },
49
- { type: "create-websocket", attempt: 1 }
50
- ];
51
- }
52
- // -----------------------------------------------------------------
53
- // socket-opened
54
- // -----------------------------------------------------------------
55
- case "socket-opened": {
56
- if (model.status !== "connecting") return [model];
57
- return [{ status: "connected" }, { type: "start-keepalive" }];
58
- }
59
- // -----------------------------------------------------------------
60
- // server-ready
61
- // -----------------------------------------------------------------
62
- case "server-ready": {
63
- if (model.status === "ready") return [model];
64
- if (model.status === "connected") {
65
- return [{ status: "ready" }, { type: "add-channel-and-establish" }];
66
- }
67
- if (model.status === "connecting") {
68
- return [
69
- { status: "ready" },
70
- { type: "start-keepalive" },
71
- { type: "add-channel-and-establish" }
72
- ];
73
- }
74
- return [model];
75
- }
76
- // -----------------------------------------------------------------
77
- // socket-closed
78
- // -----------------------------------------------------------------
79
- case "socket-closed": {
80
- const reason = {
81
- type: "closed",
82
- code: msg.code,
83
- reason: msg.reason
84
- };
85
- if (model.status === "connected") {
86
- return tryReconnect(0, reason, { type: "stop-keepalive" });
87
- }
88
- if (model.status === "ready") {
89
- return tryReconnect(
90
- 0,
91
- reason,
92
- { type: "stop-keepalive" },
93
- { type: "remove-channel" }
94
- );
95
- }
96
- return [model];
97
- }
98
- // -----------------------------------------------------------------
99
- // socket-error
100
- // -----------------------------------------------------------------
101
- case "socket-error": {
102
- const reason = {
103
- type: "error",
104
- error: msg.error
105
- };
106
- if (model.status === "connecting") {
107
- return tryReconnect(model.attempt, reason);
108
- }
109
- if (model.status === "connected") {
110
- return tryReconnect(0, reason, { type: "stop-keepalive" });
111
- }
112
- if (model.status === "ready") {
113
- return tryReconnect(
114
- 0,
115
- reason,
116
- { type: "stop-keepalive" },
117
- { type: "remove-channel" }
118
- );
119
- }
120
- return [model];
121
- }
122
- // -----------------------------------------------------------------
123
- // reconnect-timer-fired
124
- // -----------------------------------------------------------------
125
- case "reconnect-timer-fired": {
126
- if (model.status !== "reconnecting") return [model];
127
- return [
128
- { status: "connecting", attempt: model.attempt },
129
- { type: "create-websocket", attempt: model.attempt }
130
- ];
131
- }
132
- // -----------------------------------------------------------------
133
- // stop
134
- // -----------------------------------------------------------------
135
- case "stop": {
136
- if (model.status === "disconnected") return [model];
137
- const effects = [{ type: "cancel-reconnect-timer" }];
138
- if (model.status === "connecting") {
139
- effects.push({ type: "close-websocket" });
140
- }
141
- if (model.status === "connected") {
142
- effects.push(
143
- { type: "close-websocket" },
144
- { type: "stop-keepalive" }
145
- );
146
- }
147
- if (model.status === "ready") {
148
- effects.push(
149
- { type: "close-websocket" },
150
- { type: "stop-keepalive" },
151
- { type: "remove-channel" }
152
- );
153
- }
154
- return [
155
- { status: "disconnected", reason: { type: "intentional" } },
156
- ...effects
157
- ];
158
- }
159
- }
160
- }
161
- };
162
- }
163
-
164
- // src/types.ts
165
- var READY_STATE = {
166
- CONNECTING: 0,
167
- OPEN: 1,
168
- CLOSING: 2,
169
- CLOSED: 3
170
- };
171
- function wrapStandardWebsocket(ws) {
172
- return {
173
- send(data) {
174
- ws.send(
175
- typeof data === "string" ? data : data
176
- );
177
- },
178
- close(code, reason) {
179
- ws.close(code, reason);
180
- },
181
- onMessage(handler) {
182
- ws.addEventListener("message", (event) => {
183
- if (event.data instanceof ArrayBuffer) {
184
- handler(new Uint8Array(event.data));
185
- } else if (typeof Blob !== "undefined" && event.data instanceof Blob) {
186
- event.data.arrayBuffer().then((buffer) => {
187
- handler(new Uint8Array(buffer));
188
- });
189
- } else {
190
- handler(event.data);
191
- }
192
- });
193
- },
194
- onClose(handler) {
195
- ws.addEventListener("close", (event) => {
196
- handler(event.code, event.reason);
197
- });
198
- },
199
- onError(handler) {
200
- ws.addEventListener("error", (_event) => {
201
- handler(new Error("WebSocket error"));
202
- });
203
- },
204
- get readyState() {
205
- switch (ws.readyState) {
206
- case READY_STATE.CONNECTING:
207
- return "connecting";
208
- case READY_STATE.OPEN:
209
- return "open";
210
- case READY_STATE.CLOSING:
211
- return "closing";
212
- case READY_STATE.CLOSED:
213
- return "closed";
214
- default:
215
- return "closed";
216
- }
217
- }
218
- };
219
- }
220
- function wrapNodeWebsocket(ws) {
221
- const CONNECTING = 0;
222
- const OPEN = 1;
223
- const CLOSING = 2;
224
- return {
225
- send(data) {
226
- ws.send(data);
227
- },
228
- close(code, reason) {
229
- ws.close(code, reason);
230
- },
231
- onMessage(handler) {
232
- ws.on(
233
- "message",
234
- (data, isBinary) => {
235
- if (isBinary) {
236
- if (data instanceof ArrayBuffer) {
237
- handler(new Uint8Array(data));
238
- } else if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
239
- handler(new Uint8Array(data));
240
- } else {
241
- handler(new Uint8Array(data));
242
- }
243
- } else {
244
- if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
245
- handler(data.toString("utf-8"));
246
- } else {
247
- handler(data);
248
- }
249
- }
250
- }
251
- );
252
- },
253
- onClose(handler) {
254
- ws.on("close", (code, reason) => {
255
- handler(code, reason.toString());
256
- });
257
- },
258
- onError(handler) {
259
- ws.on("error", handler);
260
- },
261
- get readyState() {
262
- switch (ws.readyState) {
263
- case CONNECTING:
264
- return "connecting";
265
- case OPEN:
266
- return "open";
267
- case CLOSING:
268
- return "closing";
269
- default:
270
- return "closed";
271
- }
272
- }
273
- };
274
- }
275
-
276
- // src/client-transport.ts
277
- import { createObservableProgram } from "@kyneta/machine";
278
- import { Transport } from "@kyneta/transport";
279
- import {
280
- decodeBinaryMessages,
281
- encodeBinaryAndSend,
282
- FragmentReassembler
283
- } from "@kyneta/wire";
284
- var DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024;
285
- var WebsocketClientTransport = class extends Transport {
286
- #peerId;
287
- #options;
288
- #WebSocketImpl;
289
- // Observable program handle — created in constructor, drives all state
290
- #handle;
291
- // Executor-local I/O state — not in the program model
292
- #socket;
293
- #serverChannel;
294
- #keepaliveTimer;
295
- #reconnectTimer;
296
- // Fragmentation
297
- #fragmentThreshold;
298
- #reassembler;
299
- constructor(options) {
300
- super({ transportType: "websocket-client" });
301
- this.#options = options;
302
- this.#WebSocketImpl = options.WebSocket;
303
- this.#fragmentThreshold = options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
304
- this.#reassembler = new FragmentReassembler({
305
- timeoutMs: 1e4
306
- });
307
- const program = createWsClientProgram({
308
- reconnect: options.reconnect
309
- });
310
- this.#handle = createObservableProgram(program, (effect, dispatch) => {
311
- this.#executeEffect(effect, dispatch);
312
- });
313
- this.#setupLifecycleEvents();
314
- }
315
- // ==========================================================================
316
- // Effect executor — interprets data effects as I/O
317
- // ==========================================================================
318
- #executeEffect(effect, dispatch) {
319
- switch (effect.type) {
320
- case "create-websocket": {
321
- this.#doCreateWebsocket(dispatch);
322
- break;
323
- }
324
- case "close-websocket": {
325
- if (this.#socket) {
326
- this.#socket.close(1e3, "Client disconnecting");
327
- this.#socket = void 0;
328
- }
329
- break;
330
- }
331
- case "add-channel-and-establish": {
332
- if (this.#serverChannel) {
333
- this.removeChannel(this.#serverChannel.channelId);
334
- this.#serverChannel = void 0;
335
- }
336
- this.#serverChannel = this.addChannel();
337
- this.establishChannel(this.#serverChannel.channelId);
338
- break;
339
- }
340
- case "remove-channel": {
341
- if (this.#serverChannel) {
342
- this.removeChannel(this.#serverChannel.channelId);
343
- this.#serverChannel = void 0;
344
- }
345
- break;
346
- }
347
- case "start-reconnect-timer": {
348
- this.#reconnectTimer = setTimeout(() => {
349
- this.#reconnectTimer = void 0;
350
- dispatch({ type: "reconnect-timer-fired" });
351
- }, effect.delayMs);
352
- break;
353
- }
354
- case "cancel-reconnect-timer": {
355
- if (this.#reconnectTimer !== void 0) {
356
- clearTimeout(this.#reconnectTimer);
357
- this.#reconnectTimer = void 0;
358
- }
359
- break;
360
- }
361
- case "start-keepalive": {
362
- this.#startKeepalive();
363
- break;
364
- }
365
- case "stop-keepalive": {
366
- this.#stopKeepalive();
367
- break;
368
- }
369
- }
370
- }
371
- // ==========================================================================
372
- // WebSocket creation — the core I/O operation
373
- // ==========================================================================
374
- /**
375
- * Create a WebSocket and wire up event handlers to dispatch messages.
376
- *
377
- * The message handler is set up IMMEDIATELY after creation (before
378
- * the open event) to handle the race condition where the server sends
379
- * "ready" before the client's open promise resolves.
380
- */
381
- #doCreateWebsocket(dispatch) {
382
- const peerId = this.#peerId;
383
- if (!peerId) {
384
- dispatch({
385
- type: "socket-error",
386
- error: new Error("Cannot connect: peerId not set")
387
- });
388
- return;
389
- }
390
- const url = typeof this.#options.url === "function" ? this.#options.url(peerId) : this.#options.url;
391
- try {
392
- if (this.#options.headers && Object.keys(this.#options.headers).length > 0) {
393
- this.#socket = new this.#WebSocketImpl(url, {
394
- headers: this.#options.headers
395
- });
396
- } else {
397
- this.#socket = new this.#WebSocketImpl(url);
398
- }
399
- this.#socket.binaryType = "arraybuffer";
400
- const socket = this.#socket;
401
- socket.addEventListener("message", (event) => {
402
- this.#handleMessage(event, dispatch);
403
- });
404
- let settled = false;
405
- const onOpen = () => {
406
- cleanup();
407
- settled = true;
408
- dispatch({ type: "socket-opened" });
409
- socket.addEventListener("close", (event) => {
410
- dispatch({
411
- type: "socket-closed",
412
- code: event.code,
413
- reason: event.reason
414
- });
415
- });
416
- };
417
- const onError = () => {
418
- if (settled) return;
419
- cleanup();
420
- settled = true;
421
- dispatch({
422
- type: "socket-error",
423
- error: new Error("WebSocket connection failed")
424
- });
425
- };
426
- const onClose = () => {
427
- if (settled) return;
428
- cleanup();
429
- settled = true;
430
- dispatch({
431
- type: "socket-error",
432
- error: new Error("WebSocket closed during connection")
433
- });
434
- };
435
- const cleanup = () => {
436
- socket.removeEventListener("open", onOpen);
437
- socket.removeEventListener("error", onError);
438
- socket.removeEventListener("close", onClose);
439
- };
440
- socket.addEventListener("open", onOpen);
441
- socket.addEventListener("error", onError);
442
- socket.addEventListener("close", onClose);
443
- } catch (error) {
444
- dispatch({
445
- type: "socket-error",
446
- error: error instanceof Error ? error : new Error(String(error))
447
- });
448
- }
449
- }
450
- // ==========================================================================
451
- // Message handling — I/O parsing logic
452
- // ==========================================================================
453
- /**
454
- * Handle incoming Websocket messages.
455
- *
456
- * Text frames carry the "ready" handshake and keepalive pong.
457
- * Binary frames carry CBOR-encoded ChannelMsg.
458
- */
459
- #handleMessage(event, dispatch) {
460
- const data = event.data;
461
- if (typeof data === "string") {
462
- if (data === "ready") {
463
- dispatch({ type: "server-ready" });
464
- }
465
- return;
466
- }
467
- if (data instanceof ArrayBuffer) {
468
- try {
469
- const messages = decodeBinaryMessages(
470
- new Uint8Array(data),
471
- this.#reassembler
472
- );
473
- if (messages) {
474
- for (const msg of messages) {
475
- this.#handleChannelMessage(msg);
476
- }
477
- }
478
- } catch (error) {
479
- console.error("Failed to decode message:", error);
480
- }
481
- }
482
- }
483
- /**
484
- * Handle a decoded channel message.
485
- */
486
- #handleChannelMessage(msg) {
487
- if (!this.#serverChannel) {
488
- return;
489
- }
490
- this.#serverChannel.onReceive(msg);
491
- }
492
- // ==========================================================================
493
- // Keepalive
494
- // ==========================================================================
495
- #startKeepalive() {
496
- this.#stopKeepalive();
497
- const interval = this.#options.keepaliveInterval ?? 3e4;
498
- this.#keepaliveTimer = setInterval(() => {
499
- if (this.#socket?.readyState === READY_STATE.OPEN) {
500
- this.#socket.send("ping");
501
- }
502
- }, interval);
503
- }
504
- #stopKeepalive() {
505
- if (this.#keepaliveTimer) {
506
- clearInterval(this.#keepaliveTimer);
507
- this.#keepaliveTimer = void 0;
508
- }
509
- }
510
- // ==========================================================================
511
- // Lifecycle event forwarding
512
- // ==========================================================================
513
- #setupLifecycleEvents() {
514
- let wasConnectedBefore = false;
515
- this.#handle.subscribeToTransitions((transition) => {
516
- this.#options.lifecycle?.onStateChange?.(transition);
517
- const { from, to } = transition;
518
- if (to.status === "disconnected" && to.reason) {
519
- this.#options.lifecycle?.onDisconnect?.(to.reason);
520
- }
521
- if (to.status === "reconnecting") {
522
- this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs);
523
- }
524
- if (wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && (to.status === "connected" || to.status === "ready")) {
525
- this.#options.lifecycle?.onReconnected?.();
526
- }
527
- if (to.status === "ready") {
528
- this.#options.lifecycle?.onReady?.();
529
- wasConnectedBefore = true;
530
- }
531
- });
532
- }
533
- // ==========================================================================
534
- // State observation — delegated to the observable handle
535
- // ==========================================================================
536
- /**
537
- * Get the current connection state.
538
- */
539
- getState() {
540
- return this.#handle.getState();
541
- }
542
- /**
543
- * Subscribe to state transitions.
544
- */
545
- subscribeToTransitions(listener) {
546
- return this.#handle.subscribeToTransitions(listener);
547
- }
548
- /**
549
- * Wait for a specific state.
550
- */
551
- waitForState(predicate, options) {
552
- return this.#handle.waitForState(predicate, options);
553
- }
554
- /**
555
- * Wait for a specific status.
556
- */
557
- waitForStatus(status, options) {
558
- return this.#handle.waitForStatus(status, options);
559
- }
560
- /**
561
- * Whether the client is ready (server ready signal received).
562
- */
563
- get isReady() {
564
- return this.#handle.getState().status === "ready";
565
- }
566
- // ==========================================================================
567
- // Transport abstract method implementations
568
- // ==========================================================================
569
- generate() {
570
- return {
571
- transportType: this.transportType,
572
- send: (msg) => {
573
- const socket = this.#socket;
574
- if (!socket || socket.readyState !== READY_STATE.OPEN) {
575
- return;
576
- }
577
- encodeBinaryAndSend(
578
- msg,
579
- this.#fragmentThreshold,
580
- (data) => socket.send(new Uint8Array(data).buffer)
581
- );
582
- },
583
- stop: () => {
584
- }
585
- };
586
- }
587
- async onStart() {
588
- if (!this.identity) {
589
- throw new Error(
590
- "Transport not properly initialized \u2014 identity not available"
591
- );
592
- }
593
- this.#peerId = this.identity.peerId;
594
- this.#handle.dispatch({ type: "start" });
595
- }
596
- async onStop() {
597
- this.#reassembler.dispose();
598
- this.#handle.dispatch({ type: "stop" });
599
- }
600
- };
601
- function createWebsocketClient(options) {
602
- return () => new WebsocketClientTransport(options);
603
- }
604
-
605
- export {
606
- createWsClientProgram,
607
- READY_STATE,
608
- wrapStandardWebsocket,
609
- wrapNodeWebsocket,
610
- DEFAULT_FRAGMENT_THRESHOLD,
611
- WebsocketClientTransport,
612
- createWebsocketClient
613
- };
614
- //# sourceMappingURL=chunk-YZQF5RLV.js.map