@kyneta/websocket-transport 1.1.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/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/bun.d.ts +91 -0
- package/dist/bun.js +48 -0
- package/dist/bun.js.map +1 -0
- package/dist/chunk-5FHT54WT.js +109 -0
- package/dist/chunk-5FHT54WT.js.map +1 -0
- package/dist/client.d.ts +185 -0
- package/dist/client.js +418 -0
- package/dist/client.js.map +1 -0
- package/dist/server.d.ts +161 -0
- package/dist/server.js +315 -0
- package/dist/server.js.map +1 -0
- package/dist/types-DG_89zA4.d.ts +149 -0
- package/package.json +54 -0
- package/src/__tests__/client-state-machine.test.ts +472 -0
- package/src/bun-websocket.ts +163 -0
- package/src/bun.ts +24 -0
- package/src/client-state-machine.ts +78 -0
- package/src/client-transport.ts +711 -0
- package/src/client.ts +39 -0
- package/src/connection.ts +224 -0
- package/src/server-transport.ts +282 -0
- package/src/server.ts +39 -0
- package/src/types.ts +308 -0
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kyneta/websocket-transport",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Websocket network adapter for @kyneta/exchange — client, server, and Bun integration",
|
|
5
|
+
"author": "Duane Johnson",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/halecraft/kyneta",
|
|
10
|
+
"directory": "packages/exchange/network-adapters/websocket"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
"./client": {
|
|
22
|
+
"types": "./dist/client.d.ts",
|
|
23
|
+
"import": "./dist/client.js"
|
|
24
|
+
},
|
|
25
|
+
"./server": {
|
|
26
|
+
"types": "./dist/server.d.ts",
|
|
27
|
+
"import": "./dist/server.js"
|
|
28
|
+
},
|
|
29
|
+
"./bun": {
|
|
30
|
+
"types": "./dist/bun.d.ts",
|
|
31
|
+
"import": "./dist/bun.js"
|
|
32
|
+
},
|
|
33
|
+
"./src/*": "./src/*"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@kyneta/exchange": "^1.1.0",
|
|
37
|
+
"@kyneta/wire": "^1.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22",
|
|
41
|
+
"bun-types": "latest",
|
|
42
|
+
"tsup": "^8.5.0",
|
|
43
|
+
"typescript": "^5.9.2",
|
|
44
|
+
"vitest": "^4.0.17",
|
|
45
|
+
"@kyneta/schema": "^1.1.0",
|
|
46
|
+
"@kyneta/wire": "^1.1.0",
|
|
47
|
+
"@kyneta/exchange": "^1.1.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"test": "verify logic",
|
|
52
|
+
"verify": "verify"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// WebsocketClientStateMachine tests.
|
|
2
|
+
//
|
|
3
|
+
// Verifies the state machine's validated transitions, async microtask
|
|
4
|
+
// delivery, waitForState/waitForStatus, and error handling.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, vi } from "vitest"
|
|
7
|
+
import { WebsocketClientStateMachine } from "../client-state-machine.js"
|
|
8
|
+
import type {
|
|
9
|
+
WebsocketClientState,
|
|
10
|
+
WebsocketClientStateTransition,
|
|
11
|
+
} from "../types.js"
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Drain the microtask queue so transition listeners fire. */
|
|
18
|
+
async function flush(): Promise<void> {
|
|
19
|
+
await new Promise<void>(r => queueMicrotask(r))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Initial state
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("WebsocketClientStateMachine — initial state", () => {
|
|
27
|
+
it("starts in disconnected state", () => {
|
|
28
|
+
const sm = new WebsocketClientStateMachine()
|
|
29
|
+
expect(sm.getState()).toEqual({ status: "disconnected" })
|
|
30
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("isReady() returns false initially", () => {
|
|
34
|
+
const sm = new WebsocketClientStateMachine()
|
|
35
|
+
expect(sm.isReady()).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("isConnectedOrReady() returns false initially", () => {
|
|
39
|
+
const sm = new WebsocketClientStateMachine()
|
|
40
|
+
expect(sm.isConnectedOrReady()).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Valid transitions
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe("WebsocketClientStateMachine — valid transitions", () => {
|
|
49
|
+
it("disconnected → connecting", () => {
|
|
50
|
+
const sm = new WebsocketClientStateMachine()
|
|
51
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
52
|
+
expect(sm.getStatus()).toBe("connecting")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("connecting → connected", () => {
|
|
56
|
+
const sm = new WebsocketClientStateMachine()
|
|
57
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
58
|
+
sm.transition({ status: "connected" })
|
|
59
|
+
expect(sm.getStatus()).toBe("connected")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("connected → ready", () => {
|
|
63
|
+
const sm = new WebsocketClientStateMachine()
|
|
64
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
65
|
+
sm.transition({ status: "connected" })
|
|
66
|
+
sm.transition({ status: "ready" })
|
|
67
|
+
expect(sm.getStatus()).toBe("ready")
|
|
68
|
+
expect(sm.isReady()).toBe(true)
|
|
69
|
+
expect(sm.isConnectedOrReady()).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("connecting → disconnected", () => {
|
|
73
|
+
const sm = new WebsocketClientStateMachine()
|
|
74
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
75
|
+
sm.transition({
|
|
76
|
+
status: "disconnected",
|
|
77
|
+
reason: { type: "error", error: new Error("fail") },
|
|
78
|
+
})
|
|
79
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("connecting → reconnecting", () => {
|
|
83
|
+
const sm = new WebsocketClientStateMachine()
|
|
84
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
85
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
|
|
86
|
+
expect(sm.getStatus()).toBe("reconnecting")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("connected → disconnected", () => {
|
|
90
|
+
const sm = new WebsocketClientStateMachine()
|
|
91
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
92
|
+
sm.transition({ status: "connected" })
|
|
93
|
+
sm.transition({ status: "disconnected", reason: { type: "intentional" } })
|
|
94
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("connected → reconnecting", () => {
|
|
98
|
+
const sm = new WebsocketClientStateMachine()
|
|
99
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
100
|
+
sm.transition({ status: "connected" })
|
|
101
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 2000 })
|
|
102
|
+
expect(sm.getStatus()).toBe("reconnecting")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("ready → disconnected", () => {
|
|
106
|
+
const sm = new WebsocketClientStateMachine()
|
|
107
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
108
|
+
sm.transition({ status: "connected" })
|
|
109
|
+
sm.transition({ status: "ready" })
|
|
110
|
+
sm.transition({
|
|
111
|
+
status: "disconnected",
|
|
112
|
+
reason: { type: "closed", code: 1000, reason: "done" },
|
|
113
|
+
})
|
|
114
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("ready → reconnecting", () => {
|
|
118
|
+
const sm = new WebsocketClientStateMachine()
|
|
119
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
120
|
+
sm.transition({ status: "connected" })
|
|
121
|
+
sm.transition({ status: "ready" })
|
|
122
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 500 })
|
|
123
|
+
expect(sm.getStatus()).toBe("reconnecting")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("reconnecting → connecting", () => {
|
|
127
|
+
const sm = new WebsocketClientStateMachine()
|
|
128
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
129
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
|
|
130
|
+
sm.transition({ status: "connecting", attempt: 2 })
|
|
131
|
+
expect(sm.getStatus()).toBe("connecting")
|
|
132
|
+
expect((sm.getState() as { attempt: number }).attempt).toBe(2)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("reconnecting → disconnected", () => {
|
|
136
|
+
const sm = new WebsocketClientStateMachine()
|
|
137
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
138
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
|
|
139
|
+
sm.transition({
|
|
140
|
+
status: "disconnected",
|
|
141
|
+
reason: { type: "max-retries-exceeded", attempts: 10 },
|
|
142
|
+
})
|
|
143
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("full lifecycle: disconnect → connect → connected → ready → disconnect", () => {
|
|
147
|
+
const sm = new WebsocketClientStateMachine()
|
|
148
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
149
|
+
sm.transition({ status: "connected" })
|
|
150
|
+
sm.transition({ status: "ready" })
|
|
151
|
+
sm.transition({ status: "disconnected", reason: { type: "intentional" } })
|
|
152
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Invalid transitions
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
describe("WebsocketClientStateMachine — invalid transitions", () => {
|
|
161
|
+
it("rejects disconnected → connected (must go through connecting)", () => {
|
|
162
|
+
const sm = new WebsocketClientStateMachine()
|
|
163
|
+
expect(() => sm.transition({ status: "connected" })).toThrow(
|
|
164
|
+
"Invalid state transition: disconnected -> connected",
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("rejects disconnected → ready", () => {
|
|
169
|
+
const sm = new WebsocketClientStateMachine()
|
|
170
|
+
expect(() => sm.transition({ status: "ready" })).toThrow(
|
|
171
|
+
"Invalid state transition",
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("rejects disconnected → reconnecting", () => {
|
|
176
|
+
const sm = new WebsocketClientStateMachine()
|
|
177
|
+
expect(() =>
|
|
178
|
+
sm.transition({
|
|
179
|
+
status: "reconnecting",
|
|
180
|
+
attempt: 1,
|
|
181
|
+
nextAttemptMs: 1000,
|
|
182
|
+
}),
|
|
183
|
+
).toThrow("Invalid state transition")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("rejects connecting → ready (must go through connected)", () => {
|
|
187
|
+
const sm = new WebsocketClientStateMachine()
|
|
188
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
189
|
+
expect(() => sm.transition({ status: "ready" })).toThrow(
|
|
190
|
+
"Invalid state transition: connecting -> ready",
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("rejects ready → connecting (must go through reconnecting)", () => {
|
|
195
|
+
const sm = new WebsocketClientStateMachine()
|
|
196
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
197
|
+
sm.transition({ status: "connected" })
|
|
198
|
+
sm.transition({ status: "ready" })
|
|
199
|
+
expect(() => sm.transition({ status: "connecting", attempt: 2 })).toThrow(
|
|
200
|
+
"Invalid state transition: ready -> connecting",
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("allows forced invalid transitions with force: true", () => {
|
|
205
|
+
const sm = new WebsocketClientStateMachine()
|
|
206
|
+
sm.transition({ status: "ready" }, { force: true })
|
|
207
|
+
expect(sm.getStatus()).toBe("ready")
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Async delivery via microtask
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
describe("WebsocketClientStateMachine — async delivery", () => {
|
|
216
|
+
it("delivers transitions asynchronously via microtask", async () => {
|
|
217
|
+
const sm = new WebsocketClientStateMachine()
|
|
218
|
+
const transitions: WebsocketClientStateTransition[] = []
|
|
219
|
+
|
|
220
|
+
sm.subscribeToTransitions(t => transitions.push(t))
|
|
221
|
+
|
|
222
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
223
|
+
|
|
224
|
+
// Synchronously — listener has NOT been called yet
|
|
225
|
+
expect(transitions).toHaveLength(0)
|
|
226
|
+
|
|
227
|
+
// After microtask — listener is called
|
|
228
|
+
await flush()
|
|
229
|
+
expect(transitions).toHaveLength(1)
|
|
230
|
+
expect(transitions[0]!.from.status).toBe("disconnected")
|
|
231
|
+
expect(transitions[0]!.to.status).toBe("connecting")
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("batches multiple transitions in the same synchronous call stack", async () => {
|
|
235
|
+
const sm = new WebsocketClientStateMachine()
|
|
236
|
+
const transitions: WebsocketClientStateTransition[] = []
|
|
237
|
+
|
|
238
|
+
sm.subscribeToTransitions(t => transitions.push(t))
|
|
239
|
+
|
|
240
|
+
// Multiple transitions in one synchronous block
|
|
241
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
242
|
+
sm.transition({ status: "connected" })
|
|
243
|
+
sm.transition({ status: "ready" })
|
|
244
|
+
|
|
245
|
+
// Nothing delivered yet
|
|
246
|
+
expect(transitions).toHaveLength(0)
|
|
247
|
+
|
|
248
|
+
// All three delivered in one batch
|
|
249
|
+
await flush()
|
|
250
|
+
expect(transitions).toHaveLength(3)
|
|
251
|
+
expect(transitions[0]!.to.status).toBe("connecting")
|
|
252
|
+
expect(transitions[1]!.to.status).toBe("connected")
|
|
253
|
+
expect(transitions[2]!.to.status).toBe("ready")
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("transitions have timestamps", async () => {
|
|
257
|
+
const sm = new WebsocketClientStateMachine()
|
|
258
|
+
const transitions: WebsocketClientStateTransition[] = []
|
|
259
|
+
|
|
260
|
+
sm.subscribeToTransitions(t => transitions.push(t))
|
|
261
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
262
|
+
|
|
263
|
+
await flush()
|
|
264
|
+
expect(transitions[0]!.timestamp).toBeGreaterThan(0)
|
|
265
|
+
expect(typeof transitions[0]!.timestamp).toBe("number")
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it("unsubscribe stops delivery", async () => {
|
|
269
|
+
const sm = new WebsocketClientStateMachine()
|
|
270
|
+
const transitions: WebsocketClientStateTransition[] = []
|
|
271
|
+
|
|
272
|
+
const unsub = sm.subscribeToTransitions(t => transitions.push(t))
|
|
273
|
+
|
|
274
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
275
|
+
await flush()
|
|
276
|
+
expect(transitions).toHaveLength(1)
|
|
277
|
+
|
|
278
|
+
unsub()
|
|
279
|
+
|
|
280
|
+
sm.transition({ status: "connected" })
|
|
281
|
+
await flush()
|
|
282
|
+
|
|
283
|
+
// No more deliveries after unsubscribe
|
|
284
|
+
expect(transitions).toHaveLength(1)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("multiple listeners all receive transitions", async () => {
|
|
288
|
+
const sm = new WebsocketClientStateMachine()
|
|
289
|
+
const a: WebsocketClientStateTransition[] = []
|
|
290
|
+
const b: WebsocketClientStateTransition[] = []
|
|
291
|
+
|
|
292
|
+
sm.subscribeToTransitions(t => a.push(t))
|
|
293
|
+
sm.subscribeToTransitions(t => b.push(t))
|
|
294
|
+
|
|
295
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
296
|
+
await flush()
|
|
297
|
+
|
|
298
|
+
expect(a).toHaveLength(1)
|
|
299
|
+
expect(b).toHaveLength(1)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("listener errors do not break other listeners", async () => {
|
|
303
|
+
const sm = new WebsocketClientStateMachine()
|
|
304
|
+
const received: string[] = []
|
|
305
|
+
|
|
306
|
+
// First listener throws
|
|
307
|
+
sm.subscribeToTransitions(() => {
|
|
308
|
+
throw new Error("boom")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Second listener should still receive
|
|
312
|
+
sm.subscribeToTransitions(t => {
|
|
313
|
+
received.push(t.to.status)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// Suppress console.error for this test
|
|
317
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
318
|
+
|
|
319
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
320
|
+
await flush()
|
|
321
|
+
|
|
322
|
+
expect(received).toEqual(["connecting"])
|
|
323
|
+
consoleError.mockRestore()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// waitForState / waitForStatus
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
describe("WebsocketClientStateMachine — waitForState", () => {
|
|
332
|
+
it("resolves immediately if already in desired state", async () => {
|
|
333
|
+
const sm = new WebsocketClientStateMachine()
|
|
334
|
+
const state = await sm.waitForState(s => s.status === "disconnected")
|
|
335
|
+
expect(state.status).toBe("disconnected")
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("resolves when the desired state is reached", async () => {
|
|
339
|
+
const sm = new WebsocketClientStateMachine()
|
|
340
|
+
|
|
341
|
+
const promise = sm.waitForState(s => s.status === "connected")
|
|
342
|
+
|
|
343
|
+
// Transition to connecting, then connected
|
|
344
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
345
|
+
sm.transition({ status: "connected" })
|
|
346
|
+
|
|
347
|
+
const state = await promise
|
|
348
|
+
expect(state.status).toBe("connected")
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("rejects on timeout", async () => {
|
|
352
|
+
const sm = new WebsocketClientStateMachine()
|
|
353
|
+
|
|
354
|
+
await expect(
|
|
355
|
+
sm.waitForState(s => s.status === "ready", { timeoutMs: 50 }),
|
|
356
|
+
).rejects.toThrow("Timeout waiting for state after 50ms")
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it("cleans up listener after resolution", async () => {
|
|
360
|
+
const sm = new WebsocketClientStateMachine()
|
|
361
|
+
|
|
362
|
+
// We can't directly inspect listener count, but we can verify
|
|
363
|
+
// that the promise resolves correctly and doesn't leak
|
|
364
|
+
const promise = sm.waitForState(s => s.status === "connecting")
|
|
365
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
366
|
+
|
|
367
|
+
const state = await promise
|
|
368
|
+
expect(state.status).toBe("connecting")
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe("WebsocketClientStateMachine — waitForStatus", () => {
|
|
373
|
+
it("resolves immediately if already in desired status", async () => {
|
|
374
|
+
const sm = new WebsocketClientStateMachine()
|
|
375
|
+
const state = await sm.waitForStatus("disconnected")
|
|
376
|
+
expect(state.status).toBe("disconnected")
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it("resolves when the desired status is reached", async () => {
|
|
380
|
+
const sm = new WebsocketClientStateMachine()
|
|
381
|
+
|
|
382
|
+
const promise = sm.waitForStatus("ready")
|
|
383
|
+
|
|
384
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
385
|
+
sm.transition({ status: "connected" })
|
|
386
|
+
sm.transition({ status: "ready" })
|
|
387
|
+
|
|
388
|
+
const state = await promise
|
|
389
|
+
expect(state.status).toBe("ready")
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it("rejects on timeout", async () => {
|
|
393
|
+
const sm = new WebsocketClientStateMachine()
|
|
394
|
+
|
|
395
|
+
await expect(sm.waitForStatus("ready", { timeoutMs: 50 })).rejects.toThrow(
|
|
396
|
+
"Timeout",
|
|
397
|
+
)
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// reset
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
describe("WebsocketClientStateMachine — reset", () => {
|
|
406
|
+
it("resets to initial disconnected state", () => {
|
|
407
|
+
const sm = new WebsocketClientStateMachine()
|
|
408
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
409
|
+
sm.transition({ status: "connected" })
|
|
410
|
+
sm.transition({ status: "ready" })
|
|
411
|
+
expect(sm.getStatus()).toBe("ready")
|
|
412
|
+
|
|
413
|
+
sm.reset()
|
|
414
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it("clears pending transitions", async () => {
|
|
418
|
+
const sm = new WebsocketClientStateMachine()
|
|
419
|
+
const transitions: WebsocketClientStateTransition[] = []
|
|
420
|
+
|
|
421
|
+
sm.subscribeToTransitions(t => transitions.push(t))
|
|
422
|
+
|
|
423
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
424
|
+
sm.reset() // Should clear the pending transition
|
|
425
|
+
|
|
426
|
+
await flush()
|
|
427
|
+
|
|
428
|
+
// The transition that happened before reset should still be delivered
|
|
429
|
+
// because it was already queued before reset() was called.
|
|
430
|
+
// But the state should be disconnected.
|
|
431
|
+
expect(sm.getStatus()).toBe("disconnected")
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// isConnectedOrReady
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
describe("WebsocketClientStateMachine — isConnectedOrReady", () => {
|
|
440
|
+
it("returns false for disconnected", () => {
|
|
441
|
+
const sm = new WebsocketClientStateMachine()
|
|
442
|
+
expect(sm.isConnectedOrReady()).toBe(false)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it("returns false for connecting", () => {
|
|
446
|
+
const sm = new WebsocketClientStateMachine()
|
|
447
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
448
|
+
expect(sm.isConnectedOrReady()).toBe(false)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it("returns true for connected", () => {
|
|
452
|
+
const sm = new WebsocketClientStateMachine()
|
|
453
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
454
|
+
sm.transition({ status: "connected" })
|
|
455
|
+
expect(sm.isConnectedOrReady()).toBe(true)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it("returns true for ready", () => {
|
|
459
|
+
const sm = new WebsocketClientStateMachine()
|
|
460
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
461
|
+
sm.transition({ status: "connected" })
|
|
462
|
+
sm.transition({ status: "ready" })
|
|
463
|
+
expect(sm.isConnectedOrReady()).toBe(true)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it("returns false for reconnecting", () => {
|
|
467
|
+
const sm = new WebsocketClientStateMachine()
|
|
468
|
+
sm.transition({ status: "connecting", attempt: 1 })
|
|
469
|
+
sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
|
|
470
|
+
expect(sm.isConnectedOrReady()).toBe(false)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// bun-websocket — Bun-specific Websocket wrapper for @kyneta/websocket-network-adapter.
|
|
2
|
+
//
|
|
3
|
+
// Provides a wrapper to adapt Bun's ServerWebSocket to the Socket interface
|
|
4
|
+
// expected by WebsocketServerTransport.
|
|
5
|
+
//
|
|
6
|
+
// Bun's WebSocket API is callback-based at the server level (not per-socket),
|
|
7
|
+
// so we bridge that gap by storing handlers in ws.data.
|
|
8
|
+
//
|
|
9
|
+
// Ported from @loro-extended/adapter-websocket's bun.ts with kyneta
|
|
10
|
+
// naming conventions applied.
|
|
11
|
+
|
|
12
|
+
/// <reference types="bun-types" />
|
|
13
|
+
|
|
14
|
+
import type { ServerWebSocket } from "bun"
|
|
15
|
+
import type { Socket, SocketReadyState } from "./types.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// BunWebsocketData — stored in ws.data for per-socket handler callbacks
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Data structure stored in `ws.data` for handler callbacks.
|
|
23
|
+
* Use this type when defining your `Bun.serve()` generic.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* Bun.serve<BunWebsocketData>({
|
|
28
|
+
* websocket: { ... }
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export type BunWebsocketData = {
|
|
33
|
+
handlers: {
|
|
34
|
+
onMessage?: (data: Uint8Array | string) => void
|
|
35
|
+
onClose?: (code: number, reason: string) => void
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// wrapBunWebsocket
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Wrap Bun's `ServerWebSocket` to match the `Socket` interface.
|
|
45
|
+
*
|
|
46
|
+
* Bun's WebSocket API uses server-level callbacks (`websocket: { message, close }`)
|
|
47
|
+
* rather than per-socket event handlers. This wrapper bridges that gap by
|
|
48
|
+
* storing handlers in `ws.data` and having the server-level callbacks delegate
|
|
49
|
+
* to them.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import { WebsocketServerTransport } from "@kyneta/websocket-network-adapter/server"
|
|
54
|
+
* import { wrapBunWebsocket, type BunWebsocketData } from "@kyneta/websocket-network-adapter/bun"
|
|
55
|
+
*
|
|
56
|
+
* const serverAdapter = new WebsocketServerTransport()
|
|
57
|
+
*
|
|
58
|
+
* Bun.serve<BunWebsocketData>({
|
|
59
|
+
* websocket: {
|
|
60
|
+
* open(ws) {
|
|
61
|
+
* const socket = wrapBunWebsocket(ws)
|
|
62
|
+
* serverAdapter.handleConnection({ socket }).start()
|
|
63
|
+
* },
|
|
64
|
+
* message(ws, msg) {
|
|
65
|
+
* const data = msg instanceof ArrayBuffer ? new Uint8Array(msg) : msg
|
|
66
|
+
* ws.data?.handlers?.onMessage?.(data)
|
|
67
|
+
* },
|
|
68
|
+
* close(ws, code, reason) {
|
|
69
|
+
* ws.data?.handlers?.onClose?.(code, reason)
|
|
70
|
+
* },
|
|
71
|
+
* },
|
|
72
|
+
* })
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function wrapBunWebsocket(
|
|
76
|
+
ws: ServerWebSocket<BunWebsocketData>,
|
|
77
|
+
): Socket {
|
|
78
|
+
ws.data = { handlers: {} }
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
send(data: Uint8Array | string): void {
|
|
82
|
+
ws.send(data)
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
close(code?: number, reason?: string): void {
|
|
86
|
+
ws.close(code, reason)
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
onMessage(handler: (data: Uint8Array | string) => void): void {
|
|
90
|
+
ws.data.handlers.onMessage = handler
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
onClose(handler: (code: number, reason: string) => void): void {
|
|
94
|
+
ws.data.handlers.onClose = handler
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
onError(_handler: (error: Error) => void): void {
|
|
98
|
+
// Bun handles errors at the server level, not per-socket
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
get readyState(): SocketReadyState {
|
|
102
|
+
const states: SocketReadyState[] = [
|
|
103
|
+
"connecting",
|
|
104
|
+
"open",
|
|
105
|
+
"closing",
|
|
106
|
+
"closed",
|
|
107
|
+
]
|
|
108
|
+
return states[ws.readyState] ?? "closed"
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// createBunWebsocketHandlers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create Bun Websocket handlers that integrate with `WebsocketServerTransport`.
|
|
119
|
+
*
|
|
120
|
+
* This helper eliminates boilerplate by providing pre-configured handlers
|
|
121
|
+
* for `open`, `message`, and `close` events that automatically wire up
|
|
122
|
+
* to the adapter's `handleConnection()` method.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```typescript
|
|
126
|
+
* import { WebsocketServerTransport } from "@kyneta/websocket-network-adapter/server"
|
|
127
|
+
* import { createBunWebsocketHandlers, type BunWebsocketData } from "@kyneta/websocket-network-adapter/bun"
|
|
128
|
+
*
|
|
129
|
+
* const serverAdapter = new WebsocketServerTransport()
|
|
130
|
+
*
|
|
131
|
+
* Bun.serve<BunWebsocketData>({
|
|
132
|
+
* fetch(req, server) {
|
|
133
|
+
* server.upgrade(req)
|
|
134
|
+
* return new Response("upgrade failed", { status: 400 })
|
|
135
|
+
* },
|
|
136
|
+
* websocket: createBunWebsocketHandlers(serverAdapter),
|
|
137
|
+
* })
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function createBunWebsocketHandlers(wsAdapter: {
|
|
141
|
+
handleConnection: (opts: { socket: Socket }) => { start: () => void }
|
|
142
|
+
}) {
|
|
143
|
+
return {
|
|
144
|
+
open(ws: ServerWebSocket<BunWebsocketData>) {
|
|
145
|
+
wsAdapter.handleConnection({ socket: wrapBunWebsocket(ws) }).start()
|
|
146
|
+
},
|
|
147
|
+
message(
|
|
148
|
+
ws: ServerWebSocket<BunWebsocketData>,
|
|
149
|
+
msg: string | ArrayBuffer | Buffer,
|
|
150
|
+
) {
|
|
151
|
+
const data =
|
|
152
|
+
msg instanceof ArrayBuffer
|
|
153
|
+
? new Uint8Array(msg)
|
|
154
|
+
: Buffer.isBuffer(msg)
|
|
155
|
+
? new Uint8Array(msg)
|
|
156
|
+
: msg
|
|
157
|
+
ws.data.handlers.onMessage?.(data)
|
|
158
|
+
},
|
|
159
|
+
close(ws: ServerWebSocket<BunWebsocketData>, code: number, reason: string) {
|
|
160
|
+
ws.data.handlers.onClose?.(code, reason)
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|