@kyneta/websocket-transport 1.1.0 → 1.2.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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// client-program — pure Mealy machine for websocket client connection lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// The client program encodes every state transition and effect as data.
|
|
4
|
+
// The imperative shell (client-transport.ts) interprets effects as I/O.
|
|
5
|
+
// Tests assert on data — no sockets, no timing, never flaky.
|
|
6
|
+
//
|
|
7
|
+
// Algebra: Program<WsClientMsg, WebsocketClientState, WsClientEffect>
|
|
8
|
+
// Interpreter: client-transport.ts executeClientEffect()
|
|
9
|
+
//
|
|
10
|
+
// The websocket client has a 5-state lifecycle with an extra "ready" state
|
|
11
|
+
// compared to the unix socket client. The server sends a text "ready" signal
|
|
12
|
+
// after the connection opens, and only then does the client create a channel
|
|
13
|
+
// and start the establishment handshake.
|
|
14
|
+
//
|
|
15
|
+
// Race condition: the server may send "ready" before the client's open event
|
|
16
|
+
// fires (server-ready while connecting). The program handles this by
|
|
17
|
+
// transitioning directly to ready, skipping the connected state.
|
|
18
|
+
|
|
19
|
+
import type { Program } from "@kyneta/machine"
|
|
20
|
+
import type { ReconnectOptions } from "@kyneta/transport"
|
|
21
|
+
import { computeBackoffDelay, DEFAULT_RECONNECT } from "@kyneta/transport"
|
|
22
|
+
|
|
23
|
+
import type { DisconnectReason, WebsocketClientState } from "./types.js"
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Messages
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type WsClientMsg =
|
|
30
|
+
| { type: "start" }
|
|
31
|
+
| { type: "socket-opened" }
|
|
32
|
+
| { type: "server-ready" }
|
|
33
|
+
| { type: "socket-closed"; code: number; reason: string }
|
|
34
|
+
| { type: "socket-error"; error: Error }
|
|
35
|
+
| { type: "reconnect-timer-fired" }
|
|
36
|
+
| { type: "stop" }
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Effects (data — interpreted by the imperative shell)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export type WsClientEffect =
|
|
43
|
+
| { type: "create-websocket"; attempt: number }
|
|
44
|
+
| { type: "close-websocket" }
|
|
45
|
+
| { type: "add-channel-and-establish" }
|
|
46
|
+
| { type: "remove-channel" }
|
|
47
|
+
| { type: "start-reconnect-timer"; delayMs: number }
|
|
48
|
+
| { type: "cancel-reconnect-timer" }
|
|
49
|
+
| { type: "start-keepalive" }
|
|
50
|
+
| { type: "stop-keepalive" }
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Program factory
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface WsClientProgramOptions {
|
|
57
|
+
reconnect?: Partial<ReconnectOptions>
|
|
58
|
+
/** Inject jitter source for deterministic testing. Default: () => Math.random() * 1000 */
|
|
59
|
+
jitterFn?: () => number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create the websocket client connection lifecycle program — a pure Mealy machine.
|
|
64
|
+
*
|
|
65
|
+
* The returned `Program<WsClientMsg, WebsocketClientState, WsClientEffect>`
|
|
66
|
+
* encodes every state transition and effect as inspectable data. The imperative
|
|
67
|
+
* shell interprets `WsClientEffect` as actual I/O.
|
|
68
|
+
*/
|
|
69
|
+
export function createWsClientProgram(
|
|
70
|
+
options: WsClientProgramOptions = {},
|
|
71
|
+
): Program<WsClientMsg, WebsocketClientState, WsClientEffect> {
|
|
72
|
+
const { jitterFn = () => Math.random() * 1000 } = options
|
|
73
|
+
const reconnect: ReconnectOptions = {
|
|
74
|
+
...DEFAULT_RECONNECT,
|
|
75
|
+
...options.reconnect,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Attempt to transition into reconnecting, or give up and disconnect.
|
|
80
|
+
*
|
|
81
|
+
* Pure — computes the next state and effects from the current attempt
|
|
82
|
+
* count and reconnect configuration. Returns a tuple suitable for
|
|
83
|
+
* spreading into an `update` return.
|
|
84
|
+
*/
|
|
85
|
+
function tryReconnect(
|
|
86
|
+
currentAttempt: number,
|
|
87
|
+
reason: DisconnectReason,
|
|
88
|
+
...extraEffects: WsClientEffect[]
|
|
89
|
+
): [WebsocketClientState, ...WsClientEffect[]] {
|
|
90
|
+
if (!reconnect.enabled) {
|
|
91
|
+
return [{ status: "disconnected", reason }, ...extraEffects]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (currentAttempt >= reconnect.maxAttempts) {
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
status: "disconnected",
|
|
98
|
+
reason: { type: "max-retries-exceeded", attempts: currentAttempt },
|
|
99
|
+
},
|
|
100
|
+
...extraEffects,
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const delay = computeBackoffDelay(
|
|
105
|
+
currentAttempt + 1,
|
|
106
|
+
reconnect.baseDelay,
|
|
107
|
+
reconnect.maxDelay,
|
|
108
|
+
jitterFn(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
status: "reconnecting",
|
|
114
|
+
attempt: currentAttempt + 1,
|
|
115
|
+
nextAttemptMs: delay,
|
|
116
|
+
},
|
|
117
|
+
...extraEffects,
|
|
118
|
+
{ type: "start-reconnect-timer", delayMs: delay },
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
init: [{ status: "disconnected" }],
|
|
124
|
+
|
|
125
|
+
update(msg, model): [WebsocketClientState, ...WsClientEffect[]] {
|
|
126
|
+
switch (msg.type) {
|
|
127
|
+
// -----------------------------------------------------------------
|
|
128
|
+
// start
|
|
129
|
+
// -----------------------------------------------------------------
|
|
130
|
+
case "start": {
|
|
131
|
+
if (model.status !== "disconnected") return [model]
|
|
132
|
+
return [
|
|
133
|
+
{ status: "connecting", attempt: 1 },
|
|
134
|
+
{ type: "create-websocket", attempt: 1 },
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// -----------------------------------------------------------------
|
|
139
|
+
// socket-opened
|
|
140
|
+
// -----------------------------------------------------------------
|
|
141
|
+
case "socket-opened": {
|
|
142
|
+
if (model.status !== "connecting") return [model]
|
|
143
|
+
return [{ status: "connected" }, { type: "start-keepalive" }]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -----------------------------------------------------------------
|
|
147
|
+
// server-ready
|
|
148
|
+
// -----------------------------------------------------------------
|
|
149
|
+
case "server-ready": {
|
|
150
|
+
// Already ready — ignore duplicate
|
|
151
|
+
if (model.status === "ready") return [model]
|
|
152
|
+
|
|
153
|
+
// Normal path: connected → ready
|
|
154
|
+
if (model.status === "connected") {
|
|
155
|
+
return [{ status: "ready" }, { type: "add-channel-and-establish" }]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Race condition: server sent "ready" before client's open event fired.
|
|
159
|
+
// Skip connected, go directly to ready with both keepalive and channel effects.
|
|
160
|
+
if (model.status === "connecting") {
|
|
161
|
+
return [
|
|
162
|
+
{ status: "ready" },
|
|
163
|
+
{ type: "start-keepalive" },
|
|
164
|
+
{ type: "add-channel-and-establish" },
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [model]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -----------------------------------------------------------------
|
|
172
|
+
// socket-closed
|
|
173
|
+
// -----------------------------------------------------------------
|
|
174
|
+
case "socket-closed": {
|
|
175
|
+
const reason: DisconnectReason = {
|
|
176
|
+
type: "closed",
|
|
177
|
+
code: msg.code,
|
|
178
|
+
reason: msg.reason,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (model.status === "connected") {
|
|
182
|
+
return tryReconnect(0, reason, { type: "stop-keepalive" })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (model.status === "ready") {
|
|
186
|
+
return tryReconnect(
|
|
187
|
+
0,
|
|
188
|
+
reason,
|
|
189
|
+
{ type: "stop-keepalive" },
|
|
190
|
+
{ type: "remove-channel" },
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return [model]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -----------------------------------------------------------------
|
|
198
|
+
// socket-error
|
|
199
|
+
// -----------------------------------------------------------------
|
|
200
|
+
case "socket-error": {
|
|
201
|
+
const reason: DisconnectReason = {
|
|
202
|
+
type: "error",
|
|
203
|
+
error: msg.error,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (model.status === "connecting") {
|
|
207
|
+
return tryReconnect(model.attempt, reason)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (model.status === "connected") {
|
|
211
|
+
return tryReconnect(0, reason, { type: "stop-keepalive" })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (model.status === "ready") {
|
|
215
|
+
return tryReconnect(
|
|
216
|
+
0,
|
|
217
|
+
reason,
|
|
218
|
+
{ type: "stop-keepalive" },
|
|
219
|
+
{ type: "remove-channel" },
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return [model]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// -----------------------------------------------------------------
|
|
227
|
+
// reconnect-timer-fired
|
|
228
|
+
// -----------------------------------------------------------------
|
|
229
|
+
case "reconnect-timer-fired": {
|
|
230
|
+
if (model.status !== "reconnecting") return [model]
|
|
231
|
+
return [
|
|
232
|
+
{ status: "connecting", attempt: model.attempt },
|
|
233
|
+
{ type: "create-websocket", attempt: model.attempt },
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// -----------------------------------------------------------------
|
|
238
|
+
// stop
|
|
239
|
+
// -----------------------------------------------------------------
|
|
240
|
+
case "stop": {
|
|
241
|
+
if (model.status === "disconnected") return [model]
|
|
242
|
+
|
|
243
|
+
const effects: WsClientEffect[] = [{ type: "cancel-reconnect-timer" }]
|
|
244
|
+
|
|
245
|
+
if (model.status === "connecting") {
|
|
246
|
+
effects.push({ type: "close-websocket" })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (model.status === "connected") {
|
|
250
|
+
effects.push(
|
|
251
|
+
{ type: "close-websocket" },
|
|
252
|
+
{ type: "stop-keepalive" },
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (model.status === "ready") {
|
|
257
|
+
effects.push(
|
|
258
|
+
{ type: "close-websocket" },
|
|
259
|
+
{ type: "stop-keepalive" },
|
|
260
|
+
{ type: "remove-channel" },
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return [
|
|
265
|
+
{ status: "disconnected", reason: { type: "intentional" } },
|
|
266
|
+
...effects,
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|