@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
package/src/client.ts
CHANGED
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
// to create a Websocket client adapter for browser or service connections.
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
|
-
// Client
|
|
7
|
+
// Client program (pure Mealy machine)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createWsClientProgram,
|
|
12
|
+
type WsClientEffect,
|
|
13
|
+
type WsClientMsg,
|
|
14
|
+
type WsClientProgramOptions,
|
|
15
|
+
} from "./client-program.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Client transport + factory functions
|
|
8
19
|
// ---------------------------------------------------------------------------
|
|
9
20
|
|
|
10
21
|
export {
|
|
@@ -20,12 +31,6 @@ export {
|
|
|
20
31
|
WebsocketClientTransport,
|
|
21
32
|
} from "./client-transport.js"
|
|
22
33
|
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// State machine
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
export { WebsocketClientStateMachine } from "./client-state-machine.js"
|
|
28
|
-
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// Shared types
|
|
31
36
|
// ---------------------------------------------------------------------------
|
package/src/connection.ts
CHANGED
|
@@ -10,14 +10,11 @@
|
|
|
10
10
|
// Ported from @loro-extended/adapter-websocket's WsConnection with
|
|
11
11
|
// kyneta naming conventions and the kyneta wire format.
|
|
12
12
|
|
|
13
|
-
import type { Channel, ChannelMsg, PeerId } from "@kyneta/
|
|
13
|
+
import type { Channel, ChannelMsg, PeerId } from "@kyneta/transport"
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
encodeComplete,
|
|
15
|
+
decodeBinaryMessages,
|
|
16
|
+
encodeBinaryAndSend,
|
|
18
17
|
FragmentReassembler,
|
|
19
|
-
fragmentPayload,
|
|
20
|
-
wrapCompleteMessage,
|
|
21
18
|
} from "@kyneta/wire"
|
|
22
19
|
import type { Socket } from "./types.js"
|
|
23
20
|
|
|
@@ -126,18 +123,9 @@ export class WebsocketConnection {
|
|
|
126
123
|
return
|
|
127
124
|
}
|
|
128
125
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (this.#fragmentThreshold > 0 && frame.length > this.#fragmentThreshold) {
|
|
133
|
-
const fragments = fragmentPayload(frame, this.#fragmentThreshold)
|
|
134
|
-
for (const fragment of fragments) {
|
|
135
|
-
this.#socket.send(fragment)
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
// Wrap with MESSAGE_COMPLETE prefix for transport layer consistency
|
|
139
|
-
this.#socket.send(wrapCompleteMessage(frame))
|
|
140
|
-
}
|
|
126
|
+
encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>
|
|
127
|
+
this.#socket.send(data),
|
|
128
|
+
)
|
|
141
129
|
}
|
|
142
130
|
|
|
143
131
|
/**
|
|
@@ -176,23 +164,17 @@ export class WebsocketConnection {
|
|
|
176
164
|
return
|
|
177
165
|
}
|
|
178
166
|
|
|
179
|
-
// Handle binary protocol messages through
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const frame = decodeBinaryFrame(result.data)
|
|
185
|
-
const messages = cborCodec.decode(frame.content.payload)
|
|
167
|
+
// Handle binary protocol messages through shared decode pipeline
|
|
168
|
+
try {
|
|
169
|
+
const messages = decodeBinaryMessages(data, this.#reassembler)
|
|
170
|
+
if (messages) {
|
|
186
171
|
for (const msg of messages) {
|
|
187
172
|
this.#handleChannelMessage(msg)
|
|
188
173
|
}
|
|
189
|
-
} catch (error) {
|
|
190
|
-
console.error("Failed to decode wire message:", error)
|
|
191
174
|
}
|
|
192
|
-
}
|
|
193
|
-
console.error("
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error("Failed to decode wire message:", error)
|
|
194
177
|
}
|
|
195
|
-
// "pending" status means we're waiting for more fragments — nothing to do
|
|
196
178
|
}
|
|
197
179
|
|
|
198
180
|
/**
|
package/src/server-transport.ts
CHANGED
|
@@ -28,15 +28,13 @@
|
|
|
28
28
|
// Ported from @loro-extended/adapter-websocket's WsServerNetworkAdapter with
|
|
29
29
|
// kyneta naming conventions and the kyneta 5-message protocol.
|
|
30
30
|
|
|
31
|
-
import type { ChannelMsg, GeneratedChannel, PeerId } from "@kyneta/
|
|
32
|
-
import { Transport } from "@kyneta/
|
|
31
|
+
import type { ChannelMsg, GeneratedChannel, PeerId } from "@kyneta/transport"
|
|
32
|
+
import { Transport } from "@kyneta/transport"
|
|
33
33
|
import {
|
|
34
34
|
DEFAULT_FRAGMENT_THRESHOLD,
|
|
35
35
|
WebsocketConnection,
|
|
36
|
-
type WebsocketConnectionConfig,
|
|
37
36
|
} from "./connection.js"
|
|
38
37
|
import type {
|
|
39
|
-
Socket,
|
|
40
38
|
WebsocketConnectionOptions,
|
|
41
39
|
WebsocketConnectionResult,
|
|
42
40
|
} from "./types.js"
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
TransitionListener as GenericTransitionListener,
|
|
13
13
|
PeerId,
|
|
14
14
|
StateTransition,
|
|
15
|
-
} from "@kyneta/
|
|
15
|
+
} from "@kyneta/transport"
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Socket ready states
|
|
@@ -167,7 +167,9 @@ export type TransitionListener = GenericTransitionListener<WebsocketClientState>
|
|
|
167
167
|
export function wrapStandardWebsocket(ws: WebSocket): Socket {
|
|
168
168
|
return {
|
|
169
169
|
send(data: Uint8Array | string): void {
|
|
170
|
-
ws.send(
|
|
170
|
+
ws.send(
|
|
171
|
+
typeof data === "string" ? data : (data as Uint8Array<ArrayBuffer>),
|
|
172
|
+
)
|
|
171
173
|
},
|
|
172
174
|
|
|
173
175
|
close(code?: number, reason?: string): void {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["// types — framework-agnostic Websocket abstractions for @kyneta/websocket-network-adapter.\n//\n// The `Socket` interface decouples the adapter from any specific Websocket\n// library (browser WebSocket, Node `ws`, Bun ServerWebSocket). Platform-\n// specific wrappers (`wrapStandardWebsocket`, `wrapNodeWebsocket`,\n// `wrapBunWebsocket`) adapt concrete implementations to this interface.\n//\n// Ported from @loro-extended/adapter-websocket's WsSocket with kyneta\n// naming conventions applied.\n\nimport type {\n TransitionListener as GenericTransitionListener,\n PeerId,\n StateTransition,\n} from \"@kyneta/exchange\"\n\n// ---------------------------------------------------------------------------\n// Socket ready states\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket ready states — mirrors the standard WebSocket readyState\n * values as human-readable strings.\n */\nexport type SocketReadyState = \"connecting\" | \"open\" | \"closing\" | \"closed\"\n\n// ---------------------------------------------------------------------------\n// Socket interface\n// ---------------------------------------------------------------------------\n\n/**\n * Framework-agnostic Websocket interface.\n *\n * This allows the adapter to work with any Websocket library:\n * - Browser `WebSocket` via `wrapStandardWebsocket()`\n * - Node.js `ws` library via `wrapNodeWebsocket()`\n * - Bun `ServerWebSocket` via `wrapBunWebsocket()`\n *\n * The interface is intentionally minimal — only the operations the\n * adapter needs are exposed.\n */\nexport interface Socket {\n /** Send binary or text data through the Websocket. */\n send(data: Uint8Array | string): void\n\n /** Close the Websocket connection. */\n close(code?: number, reason?: string): void\n\n /** Register a handler for incoming messages (binary or text). */\n onMessage(handler: (data: Uint8Array | string) => void): void\n\n /** Register a handler for connection close. */\n onClose(handler: (code: number, reason: string) => void): void\n\n /** Register a handler for errors. */\n onError(handler: (error: Error) => void): void\n\n /** The current ready state of the Websocket. */\n readonly readyState: SocketReadyState\n}\n\n// ---------------------------------------------------------------------------\n// Connection types — used by server adapter\n// ---------------------------------------------------------------------------\n\n/**\n * Options for handling a new Websocket connection on the server.\n */\nexport interface WebsocketConnectionOptions {\n /** The Websocket instance, wrapped in the Socket interface. */\n socket: Socket\n\n /** Optional peer ID extracted from the upgrade request. */\n peerId?: PeerId\n\n /** Optional authentication token from the upgrade request. */\n authToken?: string\n}\n\n/**\n * Handle for an active Websocket connection.\n */\nexport interface WebsocketConnectionHandle {\n /** The peer ID for this connection. */\n readonly peerId: PeerId\n\n /** The channel ID for this connection. */\n readonly channelId: number\n\n /** Close the connection. */\n close(code?: number, reason?: string): void\n}\n\n/**\n * Result of handling a Websocket connection on the server.\n */\nexport interface WebsocketConnectionResult {\n /** The connection handle for managing this peer. */\n connection: WebsocketConnectionHandle\n\n /** Call this to start processing messages. */\n start(): void\n}\n\n// ---------------------------------------------------------------------------\n// Disconnect reason\n// ---------------------------------------------------------------------------\n\n/**\n * Discriminated union describing why a Websocket connection was lost.\n */\nexport type DisconnectReason =\n | { type: \"intentional\" }\n | { type: \"error\"; error: Error }\n | { type: \"closed\"; code: number; reason: string }\n | { type: \"max-retries-exceeded\"; attempts: number }\n | { type: \"not-started\" }\n\n// ---------------------------------------------------------------------------\n// Connection state (for client adapter observability)\n// ---------------------------------------------------------------------------\n\n/**\n * All possible states of the Websocket client.\n *\n * State machine transitions:\n * ```\n * disconnected → connecting → connected → ready\n * ↓ ↓ ↓\n * reconnecting ← ─ ┴ ─ ─ ─ ─ ┘\n * ↓\n * connecting (retry)\n * ↓\n * disconnected (max retries)\n * ```\n */\nexport type WebsocketClientState =\n | { status: \"disconnected\"; reason?: DisconnectReason }\n | { status: \"connecting\"; attempt: number }\n | { status: \"connected\" }\n | { status: \"ready\" }\n | { status: \"reconnecting\"; attempt: number; nextAttemptMs: number }\n\n/**\n * A state transition event for websocket client states.\n * Specialized from the generic `StateTransition<S>`.\n */\nexport type WebsocketClientStateTransition =\n StateTransition<WebsocketClientState>\n\n/**\n * Listener for websocket client state transitions.\n * Specialized from the generic `TransitionListener<S>`.\n */\nexport type TransitionListener = GenericTransitionListener<WebsocketClientState>\n\n// ---------------------------------------------------------------------------\n// Socket wrapper — standard WebSocket API (browser + Node ws)\n// ---------------------------------------------------------------------------\n\n/**\n * Wrap a standard `WebSocket` (browser or Node.js `ws` via `ws` package\n * in `WebSocket`-compatible mode) into the `Socket` interface.\n *\n * Handles `ArrayBuffer`, `Blob`, and string messages.\n */\nexport function wrapStandardWebsocket(ws: WebSocket): Socket {\n return {\n send(data: Uint8Array | string): void {\n ws.send(data)\n },\n\n close(code?: number, reason?: string): void {\n ws.close(code, reason)\n },\n\n onMessage(handler: (data: Uint8Array | string) => void): void {\n ws.addEventListener(\"message\", event => {\n if (event.data instanceof ArrayBuffer) {\n handler(new Uint8Array(event.data))\n } else if (typeof Blob !== \"undefined\" && event.data instanceof Blob) {\n // Handle Blob data (browser)\n event.data.arrayBuffer().then(buffer => {\n handler(new Uint8Array(buffer))\n })\n } else {\n handler(event.data as string)\n }\n })\n },\n\n onClose(handler: (code: number, reason: string) => void): void {\n ws.addEventListener(\"close\", event => {\n handler(event.code, event.reason)\n })\n },\n\n onError(handler: (error: Error) => void): void {\n ws.addEventListener(\"error\", _event => {\n handler(new Error(\"WebSocket error\"))\n })\n },\n\n get readyState(): SocketReadyState {\n switch (ws.readyState) {\n case WebSocket.CONNECTING:\n return \"connecting\"\n case WebSocket.OPEN:\n return \"open\"\n case WebSocket.CLOSING:\n return \"closing\"\n case WebSocket.CLOSED:\n return \"closed\"\n default:\n return \"closed\"\n }\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Socket wrapper — Node.js `ws` library (raw API, not WebSocket-compat)\n// ---------------------------------------------------------------------------\n\n/**\n * The minimal interface we need from the Node.js `ws` library's `WebSocket`.\n *\n * Using a structural type rather than importing `ws` — consumers provide\n * the actual `ws` instance, we just need these methods.\n */\nexport interface NodeWebsocketLike {\n send(data: Uint8Array | string): void\n close(code?: number, reason?: string): void\n on(\n event: \"message\",\n handler: (data: Buffer | ArrayBuffer | string, isBinary: boolean) => void,\n ): void\n on(event: \"close\", handler: (code: number, reason: Buffer) => void): void\n on(event: \"error\", handler: (error: Error) => void): void\n readyState: number\n}\n\n/**\n * Wrap a Node.js `ws` library WebSocket into the `Socket` interface.\n *\n * Handles `Buffer` → `Uint8Array` conversion for binary messages.\n */\nexport function wrapNodeWebsocket(ws: NodeWebsocketLike): Socket {\n const CONNECTING = 0\n const OPEN = 1\n const CLOSING = 2\n\n return {\n send(data: Uint8Array | string): void {\n ws.send(data)\n },\n\n close(code?: number, reason?: string): void {\n ws.close(code, reason)\n },\n\n onMessage(handler: (data: Uint8Array | string) => void): void {\n ws.on(\n \"message\",\n (data: Buffer | ArrayBuffer | string, isBinary: boolean) => {\n if (isBinary) {\n if (data instanceof ArrayBuffer) {\n handler(new Uint8Array(data))\n } else if (typeof Buffer !== \"undefined\" && Buffer.isBuffer(data)) {\n handler(new Uint8Array(data))\n } else {\n handler(new Uint8Array(data as unknown as ArrayBuffer))\n }\n } else {\n if (typeof Buffer !== \"undefined\" && Buffer.isBuffer(data)) {\n handler(data.toString(\"utf-8\"))\n } else {\n handler(data as string)\n }\n }\n },\n )\n },\n\n onClose(handler: (code: number, reason: string) => void): void {\n ws.on(\"close\", (code: number, reason: Buffer) => {\n handler(code, reason.toString())\n })\n },\n\n onError(handler: (error: Error) => void): void {\n ws.on(\"error\", handler)\n },\n\n get readyState(): SocketReadyState {\n switch (ws.readyState) {\n case CONNECTING:\n return \"connecting\"\n case OPEN:\n return \"open\"\n case CLOSING:\n return \"closing\"\n default:\n return \"closed\"\n }\n },\n }\n}\n"],"mappings":";AAsKO,SAAS,sBAAsB,IAAuB;AAC3D,SAAO;AAAA,IACL,KAAK,MAAiC;AACpC,SAAG,KAAK,IAAI;AAAA,IACd;AAAA,IAEA,MAAM,MAAe,QAAuB;AAC1C,SAAG,MAAM,MAAM,MAAM;AAAA,IACvB;AAAA,IAEA,UAAU,SAAoD;AAC5D,SAAG,iBAAiB,WAAW,WAAS;AACtC,YAAI,MAAM,gBAAgB,aAAa;AACrC,kBAAQ,IAAI,WAAW,MAAM,IAAI,CAAC;AAAA,QACpC,WAAW,OAAO,SAAS,eAAe,MAAM,gBAAgB,MAAM;AAEpE,gBAAM,KAAK,YAAY,EAAE,KAAK,YAAU;AACtC,oBAAQ,IAAI,WAAW,MAAM,CAAC;AAAA,UAChC,CAAC;AAAA,QACH,OAAO;AACL,kBAAQ,MAAM,IAAc;AAAA,QAC9B;AAAA,MACF,CAAC;AAAA,IACH;AAAA,IAEA,QAAQ,SAAuD;AAC7D,SAAG,iBAAiB,SAAS,WAAS;AACpC,gBAAQ,MAAM,MAAM,MAAM,MAAM;AAAA,MAClC,CAAC;AAAA,IACH;AAAA,IAEA,QAAQ,SAAuC;AAC7C,SAAG,iBAAiB,SAAS,YAAU;AACrC,gBAAQ,IAAI,MAAM,iBAAiB,CAAC;AAAA,MACtC,CAAC;AAAA,IACH;AAAA,IAEA,IAAI,aAA+B;AACjC,cAAQ,GAAG,YAAY;AAAA,QACrB,KAAK,UAAU;AACb,iBAAO;AAAA,QACT,KAAK,UAAU;AACb,iBAAO;AAAA,QACT,KAAK,UAAU;AACb,iBAAO;AAAA,QACT,KAAK,UAAU;AACb,iBAAO;AAAA,QACT;AACE,iBAAO;AAAA,MACX;AAAA,IACF;AAAA,EACF;AACF;AA6BO,SAAS,kBAAkB,IAA+B;AAC/D,QAAM,aAAa;AACnB,QAAM,OAAO;AACb,QAAM,UAAU;AAEhB,SAAO;AAAA,IACL,KAAK,MAAiC;AACpC,SAAG,KAAK,IAAI;AAAA,IACd;AAAA,IAEA,MAAM,MAAe,QAAuB;AAC1C,SAAG,MAAM,MAAM,MAAM;AAAA,IACvB;AAAA,IAEA,UAAU,SAAoD;AAC5D,SAAG;AAAA,QACD;AAAA,QACA,CAAC,MAAqC,aAAsB;AAC1D,cAAI,UAAU;AACZ,gBAAI,gBAAgB,aAAa;AAC/B,sBAAQ,IAAI,WAAW,IAAI,CAAC;AAAA,YAC9B,WAAW,OAAO,WAAW,eAAe,OAAO,SAAS,IAAI,GAAG;AACjE,sBAAQ,IAAI,WAAW,IAAI,CAAC;AAAA,YAC9B,OAAO;AACL,sBAAQ,IAAI,WAAW,IAA8B,CAAC;AAAA,YACxD;AAAA,UACF,OAAO;AACL,gBAAI,OAAO,WAAW,eAAe,OAAO,SAAS,IAAI,GAAG;AAC1D,sBAAQ,KAAK,SAAS,OAAO,CAAC;AAAA,YAChC,OAAO;AACL,sBAAQ,IAAc;AAAA,YACxB;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,IAEA,QAAQ,SAAuD;AAC7D,SAAG,GAAG,SAAS,CAAC,MAAc,WAAmB;AAC/C,gBAAQ,MAAM,OAAO,SAAS,CAAC;AAAA,MACjC,CAAC;AAAA,IACH;AAAA,IAEA,QAAQ,SAAuC;AAC7C,SAAG,GAAG,SAAS,OAAO;AAAA,IACxB;AAAA,IAEA,IAAI,aAA+B;AACjC,cAAQ,GAAG,YAAY;AAAA,QACrB,KAAK;AACH,iBAAO;AAAA,QACT,KAAK;AACH,iBAAO;AAAA,QACT,KAAK;AACH,iBAAO;AAAA,QACT;AACE,iBAAO;AAAA,MACX;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1,472 +0,0 @@
|
|
|
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
|
-
})
|