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