@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.
@@ -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
+ }