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