@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/src/client-transport.ts
CHANGED
|
@@ -1,44 +1,38 @@
|
|
|
1
|
-
// client-
|
|
1
|
+
// client-transport — Websocket client transport for @kyneta/exchange.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Thin imperative shell around the pure client program (client-program.ts).
|
|
4
|
+
// The program produces data effects; this module interprets them as I/O.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// - Keepalive ping/pong (text frames, default 30s)
|
|
10
|
-
// - Transport-level fragmentation for large payloads
|
|
11
|
-
// - Observable connection state via subscribeToTransitions()
|
|
6
|
+
// FC/IS design:
|
|
7
|
+
// - client-program.ts: pure Mealy machine (functional core)
|
|
8
|
+
// - client-transport.ts: effect executor (imperative shell)
|
|
12
9
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// 3. Client creates channel + calls establishChannel()
|
|
17
|
-
// 4. Synchronizer exchanges establish-request / establish-response
|
|
18
|
-
//
|
|
19
|
-
// Ported from @loro-extended/adapter-websocket's WsClientNetworkAdapter
|
|
20
|
-
// with kyneta naming conventions and the kyneta 5-message protocol.
|
|
10
|
+
// Uses the kyneta wire format (CBOR codec + framing + fragmentation)
|
|
11
|
+
// for binary messages. Text frames carry the "ready" handshake and
|
|
12
|
+
// keepalive ping/pong.
|
|
21
13
|
|
|
14
|
+
import type { ObservableHandle, TransitionListener } from "@kyneta/machine"
|
|
15
|
+
import { createObservableProgram } from "@kyneta/machine"
|
|
22
16
|
import type {
|
|
23
17
|
Channel,
|
|
24
18
|
ChannelMsg,
|
|
25
19
|
GeneratedChannel,
|
|
26
20
|
PeerId,
|
|
27
21
|
TransportFactory,
|
|
28
|
-
} from "@kyneta/
|
|
29
|
-
import { Transport } from "@kyneta/
|
|
22
|
+
} from "@kyneta/transport"
|
|
23
|
+
import { Transport } from "@kyneta/transport"
|
|
30
24
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
encodeComplete,
|
|
25
|
+
decodeBinaryMessages,
|
|
26
|
+
encodeBinaryAndSend,
|
|
34
27
|
FragmentReassembler,
|
|
35
|
-
fragmentPayload,
|
|
36
|
-
wrapCompleteMessage,
|
|
37
28
|
} from "@kyneta/wire"
|
|
38
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createWsClientProgram,
|
|
31
|
+
type WsClientEffect,
|
|
32
|
+
type WsClientMsg,
|
|
33
|
+
} from "./client-program.js"
|
|
39
34
|
import type {
|
|
40
35
|
DisconnectReason,
|
|
41
|
-
TransitionListener,
|
|
42
36
|
WebsocketClientState,
|
|
43
37
|
WebsocketClientStateTransition,
|
|
44
38
|
} from "./types.js"
|
|
@@ -61,7 +55,7 @@ export type {
|
|
|
61
55
|
export const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024
|
|
62
56
|
|
|
63
57
|
/**
|
|
64
|
-
* Options for the Websocket client
|
|
58
|
+
* Options for the Websocket client transport (browser connections).
|
|
65
59
|
*/
|
|
66
60
|
export interface WebsocketClientOptions {
|
|
67
61
|
/** Websocket URL to connect to. Can be a string or a function of peerId. */
|
|
@@ -72,7 +66,7 @@ export interface WebsocketClientOptions {
|
|
|
72
66
|
|
|
73
67
|
/** Reconnection options. */
|
|
74
68
|
reconnect?: {
|
|
75
|
-
enabled
|
|
69
|
+
enabled?: boolean
|
|
76
70
|
maxAttempts?: number
|
|
77
71
|
baseDelay?: number
|
|
78
72
|
maxDelay?: number
|
|
@@ -127,43 +121,37 @@ export interface ServiceWebsocketClientOptions extends WebsocketClientOptions {
|
|
|
127
121
|
headers?: Record<string, string>
|
|
128
122
|
}
|
|
129
123
|
|
|
130
|
-
/**
|
|
131
|
-
* Default reconnection options.
|
|
132
|
-
*/
|
|
133
|
-
const DEFAULT_RECONNECT = {
|
|
134
|
-
enabled: true,
|
|
135
|
-
maxAttempts: 10,
|
|
136
|
-
baseDelay: 1000,
|
|
137
|
-
maxDelay: 30000,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
124
|
// ---------------------------------------------------------------------------
|
|
141
125
|
// WebsocketClientTransport
|
|
142
126
|
// ---------------------------------------------------------------------------
|
|
143
127
|
|
|
144
128
|
/**
|
|
145
|
-
* Websocket client network
|
|
129
|
+
* Websocket client network transport for @kyneta/exchange.
|
|
146
130
|
*
|
|
147
131
|
* Connects to a Websocket server, sends and receives ChannelMsg via
|
|
148
132
|
* the kyneta wire format (CBOR codec + framing + fragmentation).
|
|
149
133
|
*
|
|
134
|
+
* Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —
|
|
135
|
+
* a pure Mealy machine whose transitions are deterministically testable.
|
|
136
|
+
* This class is the imperative shell that interprets data effects as I/O.
|
|
137
|
+
*
|
|
150
138
|
* Prefer the factory functions for construction:
|
|
151
139
|
* - `createWebsocketClient()` — browser-to-server
|
|
152
140
|
* - `createServiceWebsocketClient()` — service-to-service (with headers)
|
|
153
141
|
*/
|
|
154
142
|
export class WebsocketClientTransport extends Transport<void> {
|
|
155
143
|
#peerId?: PeerId
|
|
144
|
+
#options: ServiceWebsocketClientOptions
|
|
145
|
+
#WebSocketImpl: typeof globalThis.WebSocket
|
|
146
|
+
|
|
147
|
+
// Observable program handle — created in constructor, drives all state
|
|
148
|
+
#handle: ObservableHandle<WsClientMsg, WebsocketClientState>
|
|
149
|
+
|
|
150
|
+
// Executor-local I/O state — not in the program model
|
|
156
151
|
#socket?: WebSocket
|
|
157
152
|
#serverChannel?: Channel
|
|
158
153
|
#keepaliveTimer?: ReturnType<typeof setInterval>
|
|
159
154
|
#reconnectTimer?: ReturnType<typeof setTimeout>
|
|
160
|
-
#options: ServiceWebsocketClientOptions
|
|
161
|
-
#WebSocketImpl: typeof globalThis.WebSocket
|
|
162
|
-
#shouldReconnect = true
|
|
163
|
-
#wasConnectedBefore = false
|
|
164
|
-
|
|
165
|
-
// State machine
|
|
166
|
-
readonly #stateMachine = new WebsocketClientStateMachine()
|
|
167
155
|
|
|
168
156
|
// Fragmentation
|
|
169
157
|
readonly #fragmentThreshold: number
|
|
@@ -179,174 +167,115 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
179
167
|
timeoutMs: 10_000,
|
|
180
168
|
})
|
|
181
169
|
|
|
170
|
+
const program = createWsClientProgram({
|
|
171
|
+
reconnect: options.reconnect,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
this.#handle = createObservableProgram(program, (effect, dispatch) => {
|
|
175
|
+
this.#executeEffect(effect, dispatch)
|
|
176
|
+
})
|
|
177
|
+
|
|
182
178
|
// Set up lifecycle event forwarding
|
|
183
179
|
this.#setupLifecycleEvents()
|
|
184
180
|
}
|
|
185
181
|
|
|
186
182
|
// ==========================================================================
|
|
187
|
-
//
|
|
183
|
+
// Effect executor — interprets data effects as I/O
|
|
188
184
|
// ==========================================================================
|
|
189
185
|
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (to.status === "disconnected" && to.reason) {
|
|
199
|
-
this.#options.lifecycle?.onDisconnect?.(to.reason)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// onReconnecting: transitioning TO reconnecting
|
|
203
|
-
if (to.status === "reconnecting") {
|
|
204
|
-
this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)
|
|
186
|
+
#executeEffect(
|
|
187
|
+
effect: WsClientEffect,
|
|
188
|
+
dispatch: (msg: WsClientMsg) => void,
|
|
189
|
+
): void {
|
|
190
|
+
switch (effect.type) {
|
|
191
|
+
case "create-websocket": {
|
|
192
|
+
this.#doCreateWebsocket(dispatch)
|
|
193
|
+
break
|
|
205
194
|
}
|
|
206
195
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
this.#options.lifecycle?.onReconnected?.()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// onReady: transitioning TO ready
|
|
217
|
-
if (to.status === "ready") {
|
|
218
|
-
this.#options.lifecycle?.onReady?.()
|
|
196
|
+
case "close-websocket": {
|
|
197
|
+
if (this.#socket) {
|
|
198
|
+
this.#socket.close(1000, "Client disconnecting")
|
|
199
|
+
this.#socket = undefined
|
|
200
|
+
}
|
|
201
|
+
break
|
|
219
202
|
}
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
203
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
*/
|
|
230
|
-
getState(): WebsocketClientState {
|
|
231
|
-
return this.#stateMachine.getState()
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Subscribe to state transitions.
|
|
236
|
-
* @returns Unsubscribe function
|
|
237
|
-
*/
|
|
238
|
-
subscribeToTransitions(listener: TransitionListener): () => void {
|
|
239
|
-
return this.#stateMachine.subscribeToTransitions(listener)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Wait for a specific state.
|
|
244
|
-
*/
|
|
245
|
-
waitForState(
|
|
246
|
-
predicate: (state: WebsocketClientState) => boolean,
|
|
247
|
-
options?: { timeoutMs?: number },
|
|
248
|
-
): Promise<WebsocketClientState> {
|
|
249
|
-
return this.#stateMachine.waitForState(predicate, options)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Wait for a specific status.
|
|
254
|
-
*/
|
|
255
|
-
waitForStatus(
|
|
256
|
-
status: WebsocketClientState["status"],
|
|
257
|
-
options?: { timeoutMs?: number },
|
|
258
|
-
): Promise<WebsocketClientState> {
|
|
259
|
-
return this.#stateMachine.waitForStatus(status, options)
|
|
260
|
-
}
|
|
204
|
+
case "add-channel-and-establish": {
|
|
205
|
+
// Clean up previous channel if it exists (e.g. after reconnect)
|
|
206
|
+
if (this.#serverChannel) {
|
|
207
|
+
this.removeChannel(this.#serverChannel.channelId)
|
|
208
|
+
this.#serverChannel = undefined
|
|
209
|
+
}
|
|
261
210
|
|
|
262
|
-
|
|
263
|
-
* Check if the client is ready (server ready signal received).
|
|
264
|
-
*/
|
|
265
|
-
get isReady(): boolean {
|
|
266
|
-
return this.#stateMachine.isReady()
|
|
267
|
-
}
|
|
211
|
+
this.#serverChannel = this.addChannel()
|
|
268
212
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
213
|
+
// Establish immediately — the server already signaled ready
|
|
214
|
+
this.establishChannel(this.#serverChannel.channelId)
|
|
215
|
+
break
|
|
216
|
+
}
|
|
272
217
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
278
|
-
return
|
|
218
|
+
case "remove-channel": {
|
|
219
|
+
if (this.#serverChannel) {
|
|
220
|
+
this.removeChannel(this.#serverChannel.channelId)
|
|
221
|
+
this.#serverChannel = undefined
|
|
279
222
|
}
|
|
223
|
+
break
|
|
224
|
+
}
|
|
280
225
|
|
|
281
|
-
|
|
226
|
+
case "start-reconnect-timer": {
|
|
227
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
228
|
+
this.#reconnectTimer = undefined
|
|
229
|
+
dispatch({ type: "reconnect-timer-fired" })
|
|
230
|
+
}, effect.delayMs)
|
|
231
|
+
break
|
|
232
|
+
}
|
|
282
233
|
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
this.#
|
|
286
|
-
|
|
287
|
-
) {
|
|
288
|
-
const fragments = fragmentPayload(frame, this.#fragmentThreshold)
|
|
289
|
-
for (const fragment of fragments) {
|
|
290
|
-
this.#socket.send(fragment)
|
|
291
|
-
}
|
|
292
|
-
} else {
|
|
293
|
-
// Wrap with MESSAGE_COMPLETE prefix for transport layer consistency
|
|
294
|
-
this.#socket.send(wrapCompleteMessage(frame))
|
|
234
|
+
case "cancel-reconnect-timer": {
|
|
235
|
+
if (this.#reconnectTimer !== undefined) {
|
|
236
|
+
clearTimeout(this.#reconnectTimer)
|
|
237
|
+
this.#reconnectTimer = undefined
|
|
295
238
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
// Don't call disconnect() here — channel.stop() is called when
|
|
299
|
-
// the channel is removed, which can happen during handleClose().
|
|
300
|
-
// The actual disconnect is handled by onStop() or handleClose().
|
|
301
|
-
},
|
|
302
|
-
}
|
|
303
|
-
}
|
|
239
|
+
break
|
|
240
|
+
}
|
|
304
241
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
|
-
this.#peerId = this.identity.peerId
|
|
312
|
-
this.#shouldReconnect = true
|
|
313
|
-
this.#wasConnectedBefore = false
|
|
314
|
-
await this.#connect()
|
|
315
|
-
}
|
|
242
|
+
case "start-keepalive": {
|
|
243
|
+
this.#startKeepalive()
|
|
244
|
+
break
|
|
245
|
+
}
|
|
316
246
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
247
|
+
case "stop-keepalive": {
|
|
248
|
+
this.#stopKeepalive()
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
}
|
|
321
252
|
}
|
|
322
253
|
|
|
323
254
|
// ==========================================================================
|
|
324
|
-
//
|
|
255
|
+
// WebSocket creation — the core I/O operation
|
|
325
256
|
// ==========================================================================
|
|
326
257
|
|
|
327
258
|
/**
|
|
328
|
-
*
|
|
259
|
+
* Create a WebSocket and wire up event handlers to dispatch messages.
|
|
260
|
+
*
|
|
261
|
+
* The message handler is set up IMMEDIATELY after creation (before
|
|
262
|
+
* the open event) to handle the race condition where the server sends
|
|
263
|
+
* "ready" before the client's open promise resolves.
|
|
329
264
|
*/
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
265
|
+
#doCreateWebsocket(dispatch: (msg: WsClientMsg) => void): void {
|
|
266
|
+
const peerId = this.#peerId
|
|
267
|
+
if (!peerId) {
|
|
268
|
+
dispatch({
|
|
269
|
+
type: "socket-error",
|
|
270
|
+
error: new Error("Cannot connect: peerId not set"),
|
|
271
|
+
})
|
|
333
272
|
return
|
|
334
273
|
}
|
|
335
274
|
|
|
336
|
-
if (!this.#peerId) {
|
|
337
|
-
throw new Error("Cannot connect: peerId not set")
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Determine attempt number
|
|
341
|
-
const attempt =
|
|
342
|
-
currentState.status === "reconnecting" ? currentState.attempt : 1
|
|
343
|
-
|
|
344
|
-
this.#stateMachine.transition({ status: "connecting", attempt })
|
|
345
|
-
|
|
346
275
|
// Resolve URL
|
|
347
276
|
const url =
|
|
348
277
|
typeof this.#options.url === "function"
|
|
349
|
-
? this.#options.url(
|
|
278
|
+
? this.#options.url(peerId)
|
|
350
279
|
: this.#options.url
|
|
351
280
|
|
|
352
281
|
try {
|
|
@@ -355,7 +284,6 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
355
284
|
this.#options.headers &&
|
|
356
285
|
Object.keys(this.#options.headers).length > 0
|
|
357
286
|
) {
|
|
358
|
-
// Bun extends the standard WebSocket API with a non-standard constructor
|
|
359
287
|
type BunWebSocketConstructor = new (
|
|
360
288
|
url: string,
|
|
361
289
|
options: { headers: Record<string, string> },
|
|
@@ -370,171 +298,112 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
370
298
|
}
|
|
371
299
|
this.#socket.binaryType = "arraybuffer"
|
|
372
300
|
|
|
373
|
-
|
|
374
|
-
// This must happen BEFORE waiting for the open event to avoid a race
|
|
375
|
-
// condition where the server sends "ready" before the handler is attached.
|
|
376
|
-
this.#socket.addEventListener("message", event => {
|
|
377
|
-
this.#handleMessage(event)
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
await new Promise<void>((resolve, reject) => {
|
|
381
|
-
if (!this.#socket) {
|
|
382
|
-
reject(new Error("Socket not created"))
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const onOpen = () => {
|
|
387
|
-
cleanup()
|
|
388
|
-
resolve()
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const onError = (event: Event) => {
|
|
392
|
-
cleanup()
|
|
393
|
-
reject(new Error(`WebSocket connection failed: ${event}`))
|
|
394
|
-
}
|
|
301
|
+
const socket = this.#socket
|
|
395
302
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const cleanup = () => {
|
|
402
|
-
this.#socket?.removeEventListener("open", onOpen)
|
|
403
|
-
this.#socket?.removeEventListener("error", onError)
|
|
404
|
-
this.#socket?.removeEventListener("close", onClose)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
this.#socket.addEventListener("open", onOpen)
|
|
408
|
-
this.#socket.addEventListener("error", onError)
|
|
409
|
-
this.#socket.addEventListener("close", onClose)
|
|
303
|
+
// Set up message handler IMMEDIATELY to handle the "ready" race condition.
|
|
304
|
+
// The server may send "ready" before the open event fires.
|
|
305
|
+
socket.addEventListener("message", (event: MessageEvent) => {
|
|
306
|
+
this.#handleMessage(event, dispatch)
|
|
410
307
|
})
|
|
411
308
|
|
|
412
|
-
//
|
|
413
|
-
|
|
309
|
+
// Track whether we've dispatched a terminal event for this connection attempt
|
|
310
|
+
let settled = false
|
|
311
|
+
|
|
312
|
+
const onOpen = () => {
|
|
313
|
+
cleanup()
|
|
314
|
+
settled = true
|
|
315
|
+
dispatch({ type: "socket-opened" })
|
|
316
|
+
|
|
317
|
+
// After open, set up permanent close handler for post-connection closes
|
|
318
|
+
socket.addEventListener("close", (event: CloseEvent) => {
|
|
319
|
+
dispatch({
|
|
320
|
+
type: "socket-closed",
|
|
321
|
+
code: event.code,
|
|
322
|
+
reason: event.reason,
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
}
|
|
414
326
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
327
|
+
const onError = () => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
cleanup()
|
|
330
|
+
settled = true
|
|
331
|
+
dispatch({
|
|
332
|
+
type: "socket-error",
|
|
333
|
+
error: new Error("WebSocket connection failed"),
|
|
334
|
+
})
|
|
335
|
+
}
|
|
419
336
|
|
|
420
|
-
|
|
421
|
-
|
|
337
|
+
const onClose = () => {
|
|
338
|
+
if (settled) return
|
|
339
|
+
cleanup()
|
|
340
|
+
settled = true
|
|
341
|
+
dispatch({
|
|
342
|
+
type: "socket-error",
|
|
343
|
+
error: new Error("WebSocket closed during connection"),
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const cleanup = () => {
|
|
348
|
+
socket.removeEventListener("open", onOpen)
|
|
349
|
+
socket.removeEventListener("error", onError)
|
|
350
|
+
socket.removeEventListener("close", onClose)
|
|
351
|
+
}
|
|
422
352
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
353
|
+
socket.addEventListener("open", onOpen)
|
|
354
|
+
socket.addEventListener("error", onError)
|
|
355
|
+
socket.addEventListener("close", onClose)
|
|
426
356
|
} catch (error) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
type: "error",
|
|
357
|
+
dispatch({
|
|
358
|
+
type: "socket-error",
|
|
430
359
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
431
360
|
})
|
|
432
361
|
}
|
|
433
362
|
}
|
|
434
363
|
|
|
435
|
-
/**
|
|
436
|
-
* Disconnect from the Websocket server.
|
|
437
|
-
*/
|
|
438
|
-
#disconnect(reason: DisconnectReason): void {
|
|
439
|
-
this.#stopKeepalive()
|
|
440
|
-
this.#clearReconnectTimer()
|
|
441
|
-
|
|
442
|
-
if (this.#socket) {
|
|
443
|
-
this.#socket.close(1000, "Client disconnecting")
|
|
444
|
-
this.#socket = undefined
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (this.#serverChannel) {
|
|
448
|
-
this.removeChannel(this.#serverChannel.channelId)
|
|
449
|
-
this.#serverChannel = undefined
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Only transition if not already disconnected
|
|
453
|
-
const currentState = this.#stateMachine.getState()
|
|
454
|
-
if (currentState.status !== "disconnected") {
|
|
455
|
-
this.#stateMachine.transition({ status: "disconnected", reason })
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
364
|
// ==========================================================================
|
|
460
|
-
// Message handling
|
|
365
|
+
// Message handling — I/O parsing logic
|
|
461
366
|
// ==========================================================================
|
|
462
367
|
|
|
463
368
|
/**
|
|
464
369
|
* Handle incoming Websocket messages.
|
|
370
|
+
*
|
|
371
|
+
* Text frames carry the "ready" handshake and keepalive pong.
|
|
372
|
+
* Binary frames carry CBOR-encoded ChannelMsg.
|
|
465
373
|
*/
|
|
466
|
-
#handleMessage(
|
|
374
|
+
#handleMessage(
|
|
375
|
+
event: MessageEvent,
|
|
376
|
+
dispatch: (msg: WsClientMsg) => void,
|
|
377
|
+
): void {
|
|
467
378
|
const data = event.data
|
|
468
379
|
|
|
469
380
|
// Handle text messages (keepalive and ready signal)
|
|
470
381
|
if (typeof data === "string") {
|
|
471
382
|
if (data === "ready") {
|
|
472
|
-
|
|
383
|
+
dispatch({ type: "server-ready" })
|
|
473
384
|
}
|
|
474
|
-
// Ignore pong responses
|
|
385
|
+
// Ignore pong responses and other text
|
|
475
386
|
return
|
|
476
387
|
}
|
|
477
388
|
|
|
478
|
-
// Handle binary messages through
|
|
389
|
+
// Handle binary messages through shared decode pipeline
|
|
479
390
|
if (data instanceof ArrayBuffer) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
391
|
+
try {
|
|
392
|
+
const messages = decodeBinaryMessages(
|
|
393
|
+
new Uint8Array(data),
|
|
394
|
+
this.#reassembler,
|
|
395
|
+
)
|
|
396
|
+
if (messages) {
|
|
486
397
|
for (const msg of messages) {
|
|
487
398
|
this.#handleChannelMessage(msg)
|
|
488
399
|
}
|
|
489
|
-
} catch (error) {
|
|
490
|
-
console.error("Failed to decode message:", error)
|
|
491
400
|
}
|
|
492
|
-
}
|
|
493
|
-
console.error("
|
|
401
|
+
} catch (error) {
|
|
402
|
+
console.error("Failed to decode message:", error)
|
|
494
403
|
}
|
|
495
|
-
// "pending" status means we're waiting for more fragments — nothing to do
|
|
496
404
|
}
|
|
497
405
|
}
|
|
498
406
|
|
|
499
|
-
/**
|
|
500
|
-
* Handle the "ready" signal from the server.
|
|
501
|
-
*
|
|
502
|
-
* Creates the channel and starts the establishment handshake.
|
|
503
|
-
* The "ready" signal is a transport-level indicator that the server's
|
|
504
|
-
* Websocket handler is ready. After receiving it, we create our channel
|
|
505
|
-
* and send a real establish-request.
|
|
506
|
-
*/
|
|
507
|
-
#handleServerReady(): void {
|
|
508
|
-
const currentState = this.#stateMachine.getState()
|
|
509
|
-
if (currentState.status === "ready") {
|
|
510
|
-
// Already received ready signal, ignore duplicate
|
|
511
|
-
return
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Handle race condition: if we receive "ready" while still in "connecting" state,
|
|
515
|
-
// the server sent the ready signal before our open promise resolved.
|
|
516
|
-
// Transition through "connected" first to maintain valid state machine transitions.
|
|
517
|
-
if (currentState.status === "connecting") {
|
|
518
|
-
this.#stateMachine.transition({ status: "connected" })
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Transition to ready state
|
|
522
|
-
this.#stateMachine.transition({ status: "ready" })
|
|
523
|
-
this.#wasConnectedBefore = true
|
|
524
|
-
|
|
525
|
-
// Create channel if not exists
|
|
526
|
-
if (this.#serverChannel) {
|
|
527
|
-
this.removeChannel(this.#serverChannel.channelId)
|
|
528
|
-
this.#serverChannel = undefined
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
this.#serverChannel = this.addChannel()
|
|
532
|
-
|
|
533
|
-
// Send real establish-request over the wire
|
|
534
|
-
// The server will respond with establish-response containing its actual identity
|
|
535
|
-
this.establishChannel(this.#serverChannel.channelId)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
407
|
/**
|
|
539
408
|
* Handle a decoded channel message.
|
|
540
409
|
*/
|
|
@@ -547,21 +416,6 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
547
416
|
this.#serverChannel.onReceive(msg)
|
|
548
417
|
}
|
|
549
418
|
|
|
550
|
-
/**
|
|
551
|
-
* Handle Websocket close.
|
|
552
|
-
*/
|
|
553
|
-
#handleClose(code: number, reason: string): void {
|
|
554
|
-
this.#stopKeepalive()
|
|
555
|
-
|
|
556
|
-
if (this.#serverChannel) {
|
|
557
|
-
this.removeChannel(this.#serverChannel.channelId)
|
|
558
|
-
this.#serverChannel = undefined
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Schedule reconnect or transition to disconnected
|
|
562
|
-
this.#scheduleReconnect({ type: "closed", code, reason })
|
|
563
|
-
}
|
|
564
|
-
|
|
565
419
|
// ==========================================================================
|
|
566
420
|
// Keepalive
|
|
567
421
|
// ==========================================================================
|
|
@@ -586,70 +440,131 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
586
440
|
}
|
|
587
441
|
|
|
588
442
|
// ==========================================================================
|
|
589
|
-
//
|
|
443
|
+
// Lifecycle event forwarding
|
|
444
|
+
// ==========================================================================
|
|
445
|
+
|
|
446
|
+
#setupLifecycleEvents(): void {
|
|
447
|
+
// wasConnectedBefore is observer-local state, not in the program model
|
|
448
|
+
let wasConnectedBefore = false
|
|
449
|
+
|
|
450
|
+
this.#handle.subscribeToTransitions(transition => {
|
|
451
|
+
// Forward to onStateChange callback
|
|
452
|
+
this.#options.lifecycle?.onStateChange?.(transition)
|
|
453
|
+
|
|
454
|
+
const { from, to } = transition
|
|
455
|
+
|
|
456
|
+
// onDisconnect: transitioning TO disconnected
|
|
457
|
+
if (to.status === "disconnected" && to.reason) {
|
|
458
|
+
this.#options.lifecycle?.onDisconnect?.(to.reason)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// onReconnecting: transitioning TO reconnecting
|
|
462
|
+
if (to.status === "reconnecting") {
|
|
463
|
+
this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// onReconnected: from reconnecting/connecting TO connected/ready (after prior connection)
|
|
467
|
+
if (
|
|
468
|
+
wasConnectedBefore &&
|
|
469
|
+
(from.status === "reconnecting" || from.status === "connecting") &&
|
|
470
|
+
(to.status === "connected" || to.status === "ready")
|
|
471
|
+
) {
|
|
472
|
+
this.#options.lifecycle?.onReconnected?.()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// onReady: transitioning TO ready
|
|
476
|
+
if (to.status === "ready") {
|
|
477
|
+
this.#options.lifecycle?.onReady?.()
|
|
478
|
+
wasConnectedBefore = true
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ==========================================================================
|
|
484
|
+
// State observation — delegated to the observable handle
|
|
590
485
|
// ==========================================================================
|
|
591
486
|
|
|
592
487
|
/**
|
|
593
|
-
*
|
|
488
|
+
* Get the current connection state.
|
|
594
489
|
*/
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// If already disconnected, don't transition again
|
|
599
|
-
if (currentState.status === "disconnected") {
|
|
600
|
-
return
|
|
601
|
-
}
|
|
490
|
+
getState(): WebsocketClientState {
|
|
491
|
+
return this.#handle.getState()
|
|
492
|
+
}
|
|
602
493
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
494
|
+
/**
|
|
495
|
+
* Subscribe to state transitions.
|
|
496
|
+
*/
|
|
497
|
+
subscribeToTransitions(
|
|
498
|
+
listener: TransitionListener<WebsocketClientState>,
|
|
499
|
+
): () => void {
|
|
500
|
+
return this.#handle.subscribeToTransitions(listener)
|
|
501
|
+
}
|
|
607
502
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
503
|
+
/**
|
|
504
|
+
* Wait for a specific state.
|
|
505
|
+
*/
|
|
506
|
+
waitForState(
|
|
507
|
+
predicate: (state: WebsocketClientState) => boolean,
|
|
508
|
+
options?: { timeoutMs?: number },
|
|
509
|
+
): Promise<WebsocketClientState> {
|
|
510
|
+
return this.#handle.waitForState(predicate, options)
|
|
511
|
+
}
|
|
612
512
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
this.#stateMachine.transition({
|
|
623
|
-
status: "disconnected",
|
|
624
|
-
reason: { type: "max-retries-exceeded", attempts: currentAttempt },
|
|
625
|
-
})
|
|
626
|
-
return
|
|
627
|
-
}
|
|
513
|
+
/**
|
|
514
|
+
* Wait for a specific status.
|
|
515
|
+
*/
|
|
516
|
+
waitForStatus(
|
|
517
|
+
status: WebsocketClientState["status"],
|
|
518
|
+
options?: { timeoutMs?: number },
|
|
519
|
+
): Promise<WebsocketClientState> {
|
|
520
|
+
return this.#handle.waitForStatus(status, options)
|
|
521
|
+
}
|
|
628
522
|
|
|
629
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Whether the client is ready (server ready signal received).
|
|
525
|
+
*/
|
|
526
|
+
get isReady(): boolean {
|
|
527
|
+
return this.#handle.getState().status === "ready"
|
|
528
|
+
}
|
|
630
529
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
reconnectOpts.maxDelay,
|
|
635
|
-
)
|
|
530
|
+
// ==========================================================================
|
|
531
|
+
// Transport abstract method implementations
|
|
532
|
+
// ==========================================================================
|
|
636
533
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
534
|
+
protected generate(): GeneratedChannel {
|
|
535
|
+
return {
|
|
536
|
+
transportType: this.transportType,
|
|
537
|
+
send: (msg: ChannelMsg) => {
|
|
538
|
+
const socket = this.#socket
|
|
539
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
540
|
+
return
|
|
541
|
+
}
|
|
642
542
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
543
|
+
encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>
|
|
544
|
+
socket.send(new Uint8Array(data).buffer),
|
|
545
|
+
)
|
|
546
|
+
},
|
|
547
|
+
stop: () => {
|
|
548
|
+
// Don't call disconnect here — channel.stop() is called when
|
|
549
|
+
// the channel is removed, which can happen during effect execution.
|
|
550
|
+
// The actual disconnect is handled by onStop() or the program.
|
|
551
|
+
},
|
|
552
|
+
}
|
|
646
553
|
}
|
|
647
554
|
|
|
648
|
-
|
|
649
|
-
if (this
|
|
650
|
-
|
|
651
|
-
|
|
555
|
+
async onStart(): Promise<void> {
|
|
556
|
+
if (!this.identity) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
"Transport not properly initialized — identity not available",
|
|
559
|
+
)
|
|
652
560
|
}
|
|
561
|
+
this.#peerId = this.identity.peerId
|
|
562
|
+
this.#handle.dispatch({ type: "start" })
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async onStop(): Promise<void> {
|
|
566
|
+
this.#reassembler.dispose()
|
|
567
|
+
this.#handle.dispatch({ type: "stop" })
|
|
653
568
|
}
|
|
654
569
|
}
|
|
655
570
|
|
|
@@ -658,14 +573,15 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
658
573
|
// ---------------------------------------------------------------------------
|
|
659
574
|
|
|
660
575
|
/**
|
|
661
|
-
* Create a Websocket client
|
|
576
|
+
* Create a Websocket client transport factory for browser-to-server
|
|
577
|
+
* connections.
|
|
662
578
|
*
|
|
663
|
-
* Returns an `TransportFactory` — a closure that creates a fresh
|
|
579
|
+
* Returns an `TransportFactory` — a closure that creates a fresh transport
|
|
664
580
|
* instance when called. Pass directly to `Exchange({ transports: [...] })`.
|
|
665
581
|
*
|
|
666
582
|
* @example
|
|
667
583
|
* ```typescript
|
|
668
|
-
* import { createWebsocketClient } from "@kyneta/websocket-
|
|
584
|
+
* import { createWebsocketClient } from "@kyneta/websocket-transport/client"
|
|
669
585
|
*
|
|
670
586
|
* const exchange = new Exchange({
|
|
671
587
|
* transports: [createWebsocketClient({
|
|
@@ -682,7 +598,7 @@ export function createWebsocketClient(
|
|
|
682
598
|
}
|
|
683
599
|
|
|
684
600
|
/**
|
|
685
|
-
* Create a Websocket client
|
|
601
|
+
* Create a Websocket client transport for service-to-service connections.
|
|
686
602
|
*
|
|
687
603
|
* This factory is for backend environments (Bun, Node.js) where you need
|
|
688
604
|
* to pass authentication headers during the Websocket upgrade.
|
|
@@ -693,7 +609,7 @@ export function createWebsocketClient(
|
|
|
693
609
|
*
|
|
694
610
|
* @example
|
|
695
611
|
* ```typescript
|
|
696
|
-
* import { createServiceWebsocketClient } from "@kyneta/websocket-
|
|
612
|
+
* import { createServiceWebsocketClient } from "@kyneta/websocket-transport/client"
|
|
697
613
|
*
|
|
698
614
|
* const exchange = new Exchange({
|
|
699
615
|
* transports: [createServiceWebsocketClient({
|