@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/src/client.ts ADDED
@@ -0,0 +1,39 @@
1
+ // client — barrel export for @kyneta/websocket-network-adapter/client.
2
+ //
3
+ // This is the client-side entry point. It exports everything needed
4
+ // to create a Websocket client adapter for browser or service connections.
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Client adapter + factory functions
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export {
11
+ createServiceWebsocketClient,
12
+ createWebsocketClient,
13
+ DEFAULT_FRAGMENT_THRESHOLD,
14
+ type DisconnectReason,
15
+ type ServiceWebsocketClientOptions,
16
+ type WebsocketClientLifecycleEvents,
17
+ type WebsocketClientOptions,
18
+ type WebsocketClientState,
19
+ type WebsocketClientStateTransition,
20
+ WebsocketClientTransport,
21
+ } from "./client-transport.js"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // State machine
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export { WebsocketClientStateMachine } from "./client-state-machine.js"
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Shared types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type {
34
+ Socket,
35
+ SocketReadyState,
36
+ TransitionListener,
37
+ } from "./types.js"
38
+
39
+ export { wrapStandardWebsocket } from "./types.js"
@@ -0,0 +1,224 @@
1
+ // connection — WebsocketConnection for server-side peer connections.
2
+ //
3
+ // Wraps a Socket + CBOR codec + FragmentReassembler to provide
4
+ // send/receive for ChannelMsg over a single Websocket connection.
5
+ //
6
+ // Used by WebsocketServerTransport to manage individual client connections.
7
+ // The client adapter handles its own encoding/decoding inline since it
8
+ // manages a single socket with reconnection logic.
9
+ //
10
+ // Ported from @loro-extended/adapter-websocket's WsConnection with
11
+ // kyneta naming conventions and the kyneta wire format.
12
+
13
+ import type { Channel, ChannelMsg, PeerId } from "@kyneta/exchange"
14
+ import {
15
+ cborCodec,
16
+ decodeBinaryFrame,
17
+ encodeComplete,
18
+ FragmentReassembler,
19
+ fragmentPayload,
20
+ wrapCompleteMessage,
21
+ } from "@kyneta/wire"
22
+ import type { Socket } from "./types.js"
23
+
24
+ /**
25
+ * Default fragment threshold in bytes.
26
+ * Messages larger than this are fragmented for cloud infrastructure compatibility.
27
+ * AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.
28
+ */
29
+ export const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024
30
+
31
+ /**
32
+ * Configuration for creating a WebsocketConnection.
33
+ */
34
+ export interface WebsocketConnectionConfig {
35
+ /**
36
+ * Fragment threshold in bytes. Messages larger than this are fragmented.
37
+ * Set to 0 to disable fragmentation (not recommended for cloud deployments).
38
+ * Default: 100KB (safe for AWS API Gateway's 128KB limit)
39
+ */
40
+ fragmentThreshold?: number
41
+ }
42
+
43
+ /**
44
+ * Represents a single Websocket connection to a peer (server-side).
45
+ *
46
+ * Manages encoding, framing, fragmentation, and reassembly for one
47
+ * connected client. Created by `WebsocketServerTransport.handleConnection()`.
48
+ *
49
+ * The connection uses the CBOR codec for binary transport — this is
50
+ * the natural choice for Websocket's binary frame support.
51
+ */
52
+ export class WebsocketConnection {
53
+ readonly peerId: PeerId
54
+ readonly channelId: number
55
+
56
+ #socket: Socket
57
+ #channel: Channel | null = null
58
+ #started = false
59
+
60
+ // Fragmentation support
61
+ readonly #fragmentThreshold: number
62
+ readonly #reassembler: FragmentReassembler
63
+
64
+ constructor(
65
+ peerId: PeerId,
66
+ channelId: number,
67
+ socket: Socket,
68
+ config?: WebsocketConnectionConfig,
69
+ ) {
70
+ this.peerId = peerId
71
+ this.channelId = channelId
72
+ this.#socket = socket
73
+ this.#fragmentThreshold =
74
+ config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
75
+ this.#reassembler = new FragmentReassembler({
76
+ timeoutMs: 10_000,
77
+ onTimeout: (frameId: string) => {
78
+ console.warn(
79
+ `[WebsocketConnection] Fragment batch timed out: ${frameId}`,
80
+ )
81
+ },
82
+ })
83
+ }
84
+
85
+ // ==========================================================================
86
+ // INTERNAL API — for adapter use
87
+ // ==========================================================================
88
+
89
+ /**
90
+ * Set the channel reference.
91
+ * Called by the adapter when the channel is created.
92
+ * @internal
93
+ */
94
+ _setChannel(channel: Channel): void {
95
+ this.#channel = channel
96
+ }
97
+
98
+ // ==========================================================================
99
+ // PUBLIC API
100
+ // ==========================================================================
101
+
102
+ /**
103
+ * Start processing messages on this connection.
104
+ *
105
+ * Sets up the message handler on the socket. Must be called after
106
+ * the connection is fully set up (channel assigned, stored in adapter).
107
+ */
108
+ start(): void {
109
+ if (this.#started) {
110
+ return
111
+ }
112
+ this.#started = true
113
+
114
+ this.#socket.onMessage(data => {
115
+ this.#handleMessage(data)
116
+ })
117
+ }
118
+
119
+ /**
120
+ * Send a ChannelMsg through the Websocket.
121
+ *
122
+ * Encodes via CBOR codec → frame → fragment if needed → socket.send().
123
+ */
124
+ send(msg: ChannelMsg): void {
125
+ if (this.#socket.readyState !== "open") {
126
+ return
127
+ }
128
+
129
+ const frame = encodeComplete(cborCodec, msg)
130
+
131
+ // Fragment large payloads for cloud infrastructure compatibility
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
+ }
141
+ }
142
+
143
+ /**
144
+ * Send a "ready" signal to the client.
145
+ *
146
+ * This is a transport-level text message that tells the client the
147
+ * server is ready to receive protocol messages. The client creates
148
+ * its channel and sends establish-request after receiving this.
149
+ */
150
+ sendReady(): void {
151
+ if (this.#socket.readyState !== "open") {
152
+ return
153
+ }
154
+ this.#socket.send("ready")
155
+ }
156
+
157
+ /**
158
+ * Close the connection and clean up resources.
159
+ */
160
+ close(code?: number, reason?: string): void {
161
+ this.#reassembler.dispose()
162
+ this.#socket.close(code, reason)
163
+ }
164
+
165
+ // ==========================================================================
166
+ // INTERNAL — message handling
167
+ // ==========================================================================
168
+
169
+ /**
170
+ * Handle an incoming message from the Websocket.
171
+ */
172
+ #handleMessage(data: Uint8Array | string): void {
173
+ // Handle keepalive ping/pong (text frames)
174
+ if (typeof data === "string") {
175
+ this.#handleKeepalive(data)
176
+ return
177
+ }
178
+
179
+ // Handle binary protocol messages through reassembler
180
+ const result = this.#reassembler.receiveRaw(data)
181
+
182
+ if (result.status === "complete") {
183
+ try {
184
+ const frame = decodeBinaryFrame(result.data)
185
+ const messages = cborCodec.decode(frame.content.payload)
186
+ for (const msg of messages) {
187
+ this.#handleChannelMessage(msg)
188
+ }
189
+ } catch (error) {
190
+ console.error("Failed to decode wire message:", error)
191
+ }
192
+ } else if (result.status === "error") {
193
+ console.error("Fragment reassembly error:", result.error)
194
+ }
195
+ // "pending" status means we're waiting for more fragments — nothing to do
196
+ }
197
+
198
+ /**
199
+ * Handle a decoded channel message.
200
+ *
201
+ * Delivers messages synchronously. The Synchronizer's receive queue
202
+ * handles recursion prevention by queuing messages and processing
203
+ * them iteratively.
204
+ */
205
+ #handleChannelMessage(msg: ChannelMsg): void {
206
+ if (!this.#channel) {
207
+ console.error("Cannot handle message: channel not set")
208
+ return
209
+ }
210
+
211
+ // Deliver synchronously — the Synchronizer's receive queue prevents recursion
212
+ this.#channel.onReceive(msg)
213
+ }
214
+
215
+ /**
216
+ * Handle keepalive ping/pong messages.
217
+ */
218
+ #handleKeepalive(text: string): void {
219
+ if (text === "ping") {
220
+ this.#socket.send("pong")
221
+ }
222
+ // Ignore "pong" and "ready" responses
223
+ }
224
+ }
@@ -0,0 +1,282 @@
1
+ // server-adapter — Websocket server adapter for @kyneta/exchange.
2
+ //
3
+ // Manages Websocket connections from clients, encoding/decoding via the
4
+ // kyneta wire format. Framework-agnostic — works with any Websocket
5
+ // library through the Socket interface.
6
+ //
7
+ // Usage with Bun:
8
+ // import { WebsocketServerTransport } from "@kyneta/websocket-network-adapter/server"
9
+ // import { createBunWebsocketHandlers } from "@kyneta/websocket-network-adapter/bun"
10
+ //
11
+ // const serverAdapter = new WebsocketServerTransport()
12
+ // Bun.serve({
13
+ // websocket: createBunWebsocketHandlers(serverAdapter),
14
+ // fetch(req, server) { server.upgrade(req); return new Response("", { status: 101 }) },
15
+ // })
16
+ //
17
+ // Usage with Node.js `ws`:
18
+ // import { WebsocketServerTransport, wrapNodeWebsocket } from "@kyneta/websocket-network-adapter/server"
19
+ // import { WebSocketServer } from "ws"
20
+ //
21
+ // const serverAdapter = new WebsocketServerTransport()
22
+ // const wss = new WebSocketServer({ server })
23
+ // wss.on("connection", (ws) => {
24
+ // const { start } = serverAdapter.handleConnection({ socket: wrapNodeWebsocket(ws) })
25
+ // start()
26
+ // })
27
+ //
28
+ // Ported from @loro-extended/adapter-websocket's WsServerNetworkAdapter with
29
+ // kyneta naming conventions and the kyneta 5-message protocol.
30
+
31
+ import type { ChannelMsg, GeneratedChannel, PeerId } from "@kyneta/exchange"
32
+ import { Transport } from "@kyneta/exchange"
33
+ import {
34
+ DEFAULT_FRAGMENT_THRESHOLD,
35
+ WebsocketConnection,
36
+ type WebsocketConnectionConfig,
37
+ } from "./connection.js"
38
+ import type {
39
+ Socket,
40
+ WebsocketConnectionOptions,
41
+ WebsocketConnectionResult,
42
+ } from "./types.js"
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Options
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Options for the Websocket server adapter.
50
+ */
51
+ export interface WebsocketServerTransportOptions {
52
+ /**
53
+ * Fragment threshold in bytes. Messages larger than this are fragmented.
54
+ * Set to 0 to disable fragmentation (not recommended for cloud deployments).
55
+ * Default: 100KB (safe for AWS API Gateway's 128KB limit)
56
+ */
57
+ fragmentThreshold?: number
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Peer ID generation
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Generate a random peer ID for connections that don't provide one.
66
+ */
67
+ function generatePeerId(): PeerId {
68
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
69
+ let result = "ws-"
70
+ for (let i = 0; i < 12; i++) {
71
+ result += chars.charAt(Math.floor(Math.random() * chars.length))
72
+ }
73
+ return result
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // WebsocketServerTransport
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Websocket server network adapter.
82
+ *
83
+ * Framework-agnostic — works with any Websocket library through the
84
+ * `Socket` interface. Use `handleConnection()` to integrate with your
85
+ * framework's Websocket upgrade handler.
86
+ *
87
+ * Each client connection is tracked as a `WebsocketConnection` keyed
88
+ * by peer ID. The adapter creates a channel per connection and routes
89
+ * outbound messages through the connection's send method.
90
+ *
91
+ * The connection handshake follows a two-phase protocol:
92
+ * 1. Server sends text `"ready"` signal (transport-level)
93
+ * 2. Client sends `establish-request` (protocol-level)
94
+ * 3. Server responds with `establish-response` (handled by Synchronizer)
95
+ *
96
+ * The server does NOT call `establishChannel()` — it waits for the
97
+ * client's establish-request to avoid a race condition where the binary
98
+ * establish-request could arrive before the client has processed "ready".
99
+ */
100
+ export class WebsocketServerTransport extends Transport<PeerId> {
101
+ #connections = new Map<PeerId, WebsocketConnection>()
102
+ readonly #fragmentThreshold: number
103
+
104
+ constructor(options?: WebsocketServerTransportOptions) {
105
+ super({ transportType: "websocket-server" })
106
+ this.#fragmentThreshold =
107
+ options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
108
+ }
109
+
110
+ // ==========================================================================
111
+ // Adapter abstract method implementations
112
+ // ==========================================================================
113
+
114
+ protected generate(peerId: PeerId): GeneratedChannel {
115
+ return {
116
+ transportType: this.transportType,
117
+ send: (msg: ChannelMsg) => {
118
+ const connection = this.#connections.get(peerId)
119
+ if (connection) {
120
+ connection.send(msg)
121
+ }
122
+ },
123
+ stop: () => {
124
+ this.unregisterConnection(peerId)
125
+ },
126
+ }
127
+ }
128
+
129
+ async onStart(): Promise<void> {
130
+ // Server adapter starts passively — connections arrive via handleConnection()
131
+ }
132
+
133
+ async onStop(): Promise<void> {
134
+ // Disconnect all active connections
135
+ for (const connection of this.#connections.values()) {
136
+ connection.close(1001, "Server shutting down")
137
+ }
138
+ this.#connections.clear()
139
+ }
140
+
141
+ // ==========================================================================
142
+ // Connection management
143
+ // ==========================================================================
144
+
145
+ /**
146
+ * Handle a new Websocket connection.
147
+ *
148
+ * Call this from your framework's Websocket upgrade handler.
149
+ * Returns a connection handle and a `start()` function that begins
150
+ * message processing and sends the "ready" signal.
151
+ *
152
+ * @param options - Connection options including the Socket and optional peer ID
153
+ * @returns A connection handle and start function
154
+ *
155
+ * @example Bun
156
+ * ```typescript
157
+ * const { start } = serverAdapter.handleConnection({
158
+ * socket: wrapBunWebsocket(ws),
159
+ * })
160
+ * start()
161
+ * ```
162
+ *
163
+ * @example Node.js ws
164
+ * ```typescript
165
+ * wss.on("connection", (ws) => {
166
+ * const { start } = serverAdapter.handleConnection({
167
+ * socket: wrapNodeWebsocket(ws),
168
+ * })
169
+ * start()
170
+ * })
171
+ * ```
172
+ */
173
+ handleConnection(
174
+ options: WebsocketConnectionOptions,
175
+ ): WebsocketConnectionResult {
176
+ const { socket, peerId: providedPeerId } = options
177
+
178
+ // Generate peer ID if not provided
179
+ const peerId = providedPeerId ?? generatePeerId()
180
+
181
+ // Check for existing connection with same peer ID
182
+ const existingConnection = this.#connections.get(peerId)
183
+ if (existingConnection) {
184
+ existingConnection.close(1000, "Replaced by new connection")
185
+ this.unregisterConnection(peerId)
186
+ }
187
+
188
+ // Create channel for this peer
189
+ const channel = this.addChannel(peerId)
190
+
191
+ // Create connection object with fragmentation config
192
+ const connection = new WebsocketConnection(
193
+ peerId,
194
+ channel.channelId,
195
+ socket,
196
+ {
197
+ fragmentThreshold: this.#fragmentThreshold,
198
+ },
199
+ )
200
+ connection._setChannel(channel)
201
+
202
+ // Store connection
203
+ this.#connections.set(peerId, connection)
204
+
205
+ // Set up close handler
206
+ socket.onClose((_code, _reason) => {
207
+ this.unregisterConnection(peerId)
208
+ })
209
+
210
+ socket.onError(_error => {
211
+ this.unregisterConnection(peerId)
212
+ })
213
+
214
+ return {
215
+ connection,
216
+ start: () => {
217
+ connection.start()
218
+
219
+ // Send ready signal to client so it knows the server is ready
220
+ // This is a transport-level signal, separate from protocol-level establishment
221
+ connection.sendReady()
222
+
223
+ // NOTE: We do NOT call establishChannel() here.
224
+ // The client will send establish-request after receiving "ready".
225
+ // Our channel gets established when the Synchronizer receives
226
+ // and processes that establish-request.
227
+ //
228
+ // This prevents a race condition where our binary establish-request
229
+ // could arrive before the client has processed "ready" and created
230
+ // its channel.
231
+ },
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get an active connection by peer ID.
237
+ */
238
+ getConnection(peerId: PeerId): WebsocketConnection | undefined {
239
+ return this.#connections.get(peerId)
240
+ }
241
+
242
+ /**
243
+ * Get all active connections.
244
+ */
245
+ getAllConnections(): WebsocketConnection[] {
246
+ return Array.from(this.#connections.values())
247
+ }
248
+
249
+ /**
250
+ * Check if a peer is connected.
251
+ */
252
+ isConnected(peerId: PeerId): boolean {
253
+ return this.#connections.has(peerId)
254
+ }
255
+
256
+ /**
257
+ * Unregister a connection, removing its channel and cleaning up state.
258
+ */
259
+ unregisterConnection(peerId: PeerId): void {
260
+ const connection = this.#connections.get(peerId)
261
+ if (connection) {
262
+ this.removeChannel(connection.channelId)
263
+ this.#connections.delete(peerId)
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Broadcast a message to all connected peers.
269
+ */
270
+ broadcast(msg: ChannelMsg): void {
271
+ for (const connection of this.#connections.values()) {
272
+ connection.send(msg)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Get the number of connected peers.
278
+ */
279
+ get connectionCount(): number {
280
+ return this.#connections.size
281
+ }
282
+ }
package/src/server.ts ADDED
@@ -0,0 +1,39 @@
1
+ // server — barrel export for @kyneta/websocket-network-adapter/server.
2
+ //
3
+ // This is the server-side entry point. It exports everything needed
4
+ // to create a Websocket server adapter with any framework.
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Server adapter
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export {
11
+ WebsocketServerTransport,
12
+ type WebsocketServerTransportOptions,
13
+ } from "./server-transport.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Connection
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export {
20
+ DEFAULT_FRAGMENT_THRESHOLD,
21
+ WebsocketConnection,
22
+ type WebsocketConnectionConfig,
23
+ } from "./connection.js"
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Shared types + wrappers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type {
30
+ DisconnectReason,
31
+ NodeWebsocketLike,
32
+ Socket,
33
+ SocketReadyState,
34
+ WebsocketConnectionHandle,
35
+ WebsocketConnectionOptions,
36
+ WebsocketConnectionResult,
37
+ } from "./types.js"
38
+
39
+ export { wrapNodeWebsocket, wrapStandardWebsocket } from "./types.js"