@kyneta/websocket-transport 1.3.1 → 1.5.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/dist/browser.d.ts +31 -30
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +2 -15
- package/dist/bun.d.ts +18 -18
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +95 -43
- package/dist/bun.js.map +1 -1
- package/dist/client-transport-B2V7s2VP.js +525 -0
- package/dist/client-transport-B2V7s2VP.js.map +1 -0
- package/dist/client-transport-D3tYQYrS.d.ts +134 -0
- package/dist/client-transport-D3tYQYrS.d.ts.map +1 -0
- package/dist/server.d.ts +121 -118
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +304 -305
- package/dist/server.js.map +1 -1
- package/dist/{types-D0lbeevu.d.ts → types-c1S_xIRG.d.ts} +78 -69
- package/dist/types-c1S_xIRG.d.ts.map +1 -0
- package/package.json +13 -12
- package/src/__tests__/client-transport.test.ts +8 -9
- package/src/browser.ts +3 -3
- package/src/bun-websocket.ts +3 -3
- package/src/client-transport.ts +42 -17
- package/src/connection.ts +38 -13
- package/src/server-transport.ts +9 -24
- package/src/server.ts +5 -1
- package/src/service-client.ts +2 -2
- package/src/types.ts +17 -12
- package/dist/browser.js.map +0 -1
- package/dist/chunk-YZQF5RLV.js +0 -614
- package/dist/chunk-YZQF5RLV.js.map +0 -1
- package/dist/client-transport-DUAFjVbh.d.ts +0 -132
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server-transport.ts","../src/connection.ts","../src/service-client.ts"],"sourcesContent":["// server-adapter — Websocket server adapter for @kyneta/exchange.\n//\n// Manages Websocket connections from clients, encoding/decoding via the\n// kyneta wire format. Framework-agnostic — works with any Websocket\n// library through the Socket interface.\n//\n// Usage with Bun:\n// import { WebsocketServerTransport } from \"@kyneta/websocket-transport/server\"\n// import { createBunWebsocketHandlers } from \"@kyneta/websocket-transport/bun\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// Bun.serve({\n// websocket: createBunWebsocketHandlers(serverAdapter),\n// fetch(req, server) { server.upgrade(req); return new Response(\"\", { status: 101 }) },\n// })\n//\n// Usage with Node.js `ws`:\n// import { WebsocketServerTransport, wrapNodeWebsocket } from \"@kyneta/websocket-transport/server\"\n// import { WebSocketServer } from \"ws\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// const wss = new WebSocketServer({ server })\n// wss.on(\"connection\", (ws) => {\n// const { start } = serverAdapter.handleConnection({ socket: wrapNodeWebsocket(ws) })\n// start()\n// })\n//\n// Ported from @loro-extended/adapter-websocket's WsServerNetworkAdapter with\n// kyneta naming conventions and the kyneta 5-message protocol.\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n DEFAULT_FRAGMENT_THRESHOLD,\n WebsocketConnection,\n} from \"./connection.js\"\nimport type {\n WebsocketConnectionOptions,\n WebsocketConnectionResult,\n} from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the Websocket server adapter.\n */\nexport interface WebsocketServerTransportOptions {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// Peer ID generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a random peer ID for connections that don't provide one.\n */\nfunction generatePeerId(): PeerId {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n let result = \"ws-\"\n for (let i = 0; i < 12; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length))\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket server network adapter.\n *\n * Framework-agnostic — works with any Websocket library through the\n * `Socket` interface. Use `handleConnection()` to integrate with your\n * framework's Websocket upgrade handler.\n *\n * Each client connection is tracked as a `WebsocketConnection` keyed\n * by peer ID. The adapter creates a channel per connection and routes\n * outbound messages through the connection's send method.\n *\n * The connection handshake follows a two-phase protocol:\n * 1. Server sends text `\"ready\"` signal (transport-level)\n * 2. Client sends `establish-request` (protocol-level)\n * 3. Server responds with `establish-response` (handled by Synchronizer)\n *\n * The server does NOT call `establishChannel()` — it waits for the\n * client's establish-request to avoid a race condition where the binary\n * establish-request could arrive before the client has processed \"ready\".\n */\nexport class WebsocketServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, WebsocketConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: WebsocketServerTransportOptions) {\n super({ transportType: \"websocket-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via handleConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.close(1001, \"Server shutting down\")\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Handle a new Websocket connection.\n *\n * Call this from your framework's Websocket upgrade handler.\n * Returns a connection handle and a `start()` function that begins\n * message processing and sends the \"ready\" signal.\n *\n * @param options - Connection options including the Socket and optional peer ID\n * @returns A connection handle and start function\n *\n * @example Bun\n * ```typescript\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapBunWebsocket(ws),\n * })\n * start()\n * ```\n *\n * @example Node.js ws\n * ```typescript\n * wss.on(\"connection\", (ws) => {\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapNodeWebsocket(ws),\n * })\n * start()\n * })\n * ```\n */\n handleConnection(\n options: WebsocketConnectionOptions,\n ): WebsocketConnectionResult {\n const { socket, peerId: providedPeerId } = options\n\n // Generate peer ID if not provided\n const peerId = providedPeerId ?? generatePeerId()\n\n // Check for existing connection with same peer ID\n const existingConnection = this.#connections.get(peerId)\n if (existingConnection) {\n existingConnection.close(1000, \"Replaced by new connection\")\n this.unregisterConnection(peerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(peerId)\n\n // Create connection object with fragmentation config\n const connection = new WebsocketConnection(\n peerId,\n channel.channelId,\n socket,\n {\n fragmentThreshold: this.#fragmentThreshold,\n },\n )\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(peerId, connection)\n\n // Set up close handler\n socket.onClose((_code, _reason) => {\n this.unregisterConnection(peerId)\n })\n\n socket.onError(_error => {\n this.unregisterConnection(peerId)\n })\n\n return {\n connection,\n start: () => {\n connection.start()\n\n // Send ready signal to client so it knows the server is ready\n // This is a transport-level signal, separate from protocol-level establishment\n connection.sendReady()\n\n // NOTE: We do NOT call establishChannel() here.\n // The client will send establish-request after receiving \"ready\".\n // Our channel gets established when the Synchronizer receives\n // and processes that establish-request.\n //\n // This prevents a race condition where our binary establish-request\n // could arrive before the client has processed \"ready\" and created\n // its channel.\n },\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): WebsocketConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): WebsocketConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Unregister a connection, removing its channel and cleaning up state.\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Broadcast a message to all connected peers.\n */\n broadcast(msg: ChannelMsg): void {\n for (const connection of this.#connections.values()) {\n connection.send(msg)\n }\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n","// connection — WebsocketConnection for server-side peer connections.\n//\n// Wraps a Socket + CBOR codec + FragmentReassembler to provide\n// send/receive for ChannelMsg over a single Websocket connection.\n//\n// Used by WebsocketServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single socket with reconnection logic.\n//\n// Ported from @loro-extended/adapter-websocket's WsConnection with\n// kyneta naming conventions and the kyneta wire format.\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport {\n decodeBinaryMessages,\n encodeBinaryAndSend,\n FragmentReassembler,\n} from \"@kyneta/wire\"\nimport type { Socket } from \"./types.js\"\n\n/**\n * Default fragment threshold in bytes.\n * Messages larger than this are fragmented for cloud infrastructure compatibility.\n * AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024\n\n/**\n * Configuration for creating a WebsocketConnection.\n */\nexport interface WebsocketConnectionConfig {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n/**\n * Represents a single Websocket connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, and reassembly for one\n * connected client. Created by `WebsocketServerTransport.handleConnection()`.\n *\n * The connection uses the CBOR codec for binary transport — this is\n * the natural choice for Websocket's binary frame support.\n */\nexport class WebsocketConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #socket: Socket\n #channel: Channel | null = null\n #started = false\n\n // Fragmentation support\n readonly #fragmentThreshold: number\n readonly #reassembler: FragmentReassembler\n\n constructor(\n peerId: PeerId,\n channelId: number,\n socket: Socket,\n config?: WebsocketConnectionConfig,\n ) {\n this.peerId = peerId\n this.channelId = channelId\n this.#socket = socket\n this.#fragmentThreshold =\n config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new FragmentReassembler({\n timeoutMs: 10_000,\n onTimeout: (frameId: string) => {\n console.warn(\n `[WebsocketConnection] Fragment batch timed out: ${frameId}`,\n )\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Start processing messages on this connection.\n *\n * Sets up the message handler on the socket. Must be called after\n * the connection is fully set up (channel assigned, stored in adapter).\n */\n start(): void {\n if (this.#started) {\n return\n }\n this.#started = true\n\n this.#socket.onMessage(data => {\n this.#handleMessage(data)\n })\n }\n\n /**\n * Send a ChannelMsg through the Websocket.\n *\n * Encodes via CBOR codec → frame → fragment if needed → socket.send().\n */\n send(msg: ChannelMsg): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n\n encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>\n this.#socket.send(data),\n )\n }\n\n /**\n * Send a \"ready\" signal to the client.\n *\n * This is a transport-level text message that tells the client the\n * server is ready to receive protocol messages. The client creates\n * its channel and sends establish-request after receiving this.\n */\n sendReady(): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n this.#socket.send(\"ready\")\n }\n\n /**\n * Close the connection and clean up resources.\n */\n close(code?: number, reason?: string): void {\n this.#reassembler.dispose()\n this.#socket.close(code, reason)\n }\n\n // ==========================================================================\n // INTERNAL — message handling\n // ==========================================================================\n\n /**\n * Handle an incoming message from the Websocket.\n */\n #handleMessage(data: Uint8Array | string): void {\n // Handle keepalive ping/pong (text frames)\n if (typeof data === \"string\") {\n this.#handleKeepalive(data)\n return\n }\n\n // Handle binary protocol messages through shared decode pipeline\n try {\n const messages = decodeBinaryMessages(data, this.#reassembler)\n if (messages) {\n for (const msg of messages) {\n this.#handleChannelMessage(msg)\n }\n }\n } catch (error) {\n console.error(\"Failed to decode wire message:\", error)\n }\n }\n\n /**\n * Handle a decoded channel message.\n *\n * Delivers messages synchronously. The Synchronizer's receive queue\n * handles recursion prevention by queuing messages and processing\n * them iteratively.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n if (!this.#channel) {\n console.error(\"Cannot handle message: channel not set\")\n return\n }\n\n // Deliver synchronously — the Synchronizer's receive queue prevents recursion\n this.#channel.onReceive(msg)\n }\n\n /**\n * Handle keepalive ping/pong messages.\n */\n #handleKeepalive(text: string): void {\n if (text === \"ping\") {\n this.#socket.send(\"pong\")\n }\n // Ignore \"pong\" and \"ready\" responses\n }\n}\n","// service-client — service-to-service WebSocket client factory.\n//\n// Extracted from client-transport.ts so that the service client factory\n// lives in the `./server` entry point (where it belongs) rather than\n// the `./browser` entry point. Backend code imports from `./server`;\n// browser code imports from `./browser`.\n\nimport type { TransportFactory } from \"@kyneta/transport\"\nimport {\n WebsocketClientTransport,\n type WebsocketClientOptions,\n} from \"./client-transport.js\"\n\n/**\n * Options for service-to-service Websocket connections.\n *\n * Identical to `WebsocketClientOptions` — the `headers` field is always\n * available on the base options. This alias exists for API clarity:\n * importing `ServiceWebsocketClientOptions` from `./server` signals\n * intent and pairs with `createServiceWebsocketClient`.\n */\nexport type ServiceWebsocketClientOptions = WebsocketClientOptions\n\n/**\n * Create a Websocket client transport for service-to-service connections.\n *\n * This factory is for backend environments (Bun, Node.js) where you need\n * to pass authentication headers during the Websocket upgrade.\n *\n * Note: Headers are a Bun/Node-specific extension. The browser WebSocket API\n * does not support custom headers. For browser clients, use\n * `createWebsocketClient()` and authenticate via URL query parameters.\n *\n * @example\n * ```typescript\n * import { createServiceWebsocketClient } from \"@kyneta/websocket-transport/server\"\n *\n * const exchange = new Exchange({\n * transports: [createServiceWebsocketClient({\n * url: \"ws://primary-server:3000/ws\",\n * WebSocket,\n * headers: { Authorization: \"Bearer token\" },\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createServiceWebsocketClient(\n options: ServiceWebsocketClientOptions,\n): TransportFactory {\n return () => new WebsocketClientTransport(options)\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"file":"server.js","names":["#fragmentThreshold","#reassembler","#socket","#channel","#started","#handleMessage","#aliasState","#nextFrameId","#handleKeepalive","#handleChannelMessage","#fragmentThreshold","#connections"],"sources":["../src/connection.ts","../src/server-transport.ts","../src/service-client.ts"],"sourcesContent":["// connection — WebsocketConnection for server-side peer connections.\n//\n// Wraps a Socket + CBOR codec + FragmentReassembler to provide\n// send/receive for ChannelMsg over a single Websocket connection.\n//\n// Used by WebsocketServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single socket with reconnection logic.\n//\n// Ported from @loro-extended/adapter-websocket's WsConnection with\n// kyneta naming conventions and the kyneta wire format.\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport {\n type AliasState,\n applyInboundAliasing,\n applyOutboundAliasing,\n createFrameIdCounter,\n decodeBinaryWires,\n emptyAliasState,\n encodeWireFrameAndSend,\n FragmentReassembler,\n} from \"@kyneta/wire\"\nimport type { Socket } from \"./types.js\"\n\n/**\n * Default fragment threshold in bytes.\n * Messages larger than this are fragmented for cloud infrastructure compatibility.\n * AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024\n\n/**\n * Configuration for creating a WebsocketConnection.\n */\nexport interface WebsocketConnectionConfig {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n/**\n * Represents a single Websocket connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, and reassembly for one\n * connected client. Created by `WebsocketServerTransport.handleConnection()`.\n *\n * The connection uses the CBOR codec for binary transport — this is\n * the natural choice for Websocket's binary frame support.\n */\nexport class WebsocketConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #socket: Socket\n #channel: Channel | null = null\n #started = false\n\n // Fragmentation support\n readonly #fragmentThreshold: number\n readonly #reassembler: FragmentReassembler\n #nextFrameId = createFrameIdCounter()\n\n // Per-channel alias state (Phase 4). Captures features from establish\n // messages flowing through; gates dx/shx emissions on mutualAlias.\n #aliasState: AliasState = emptyAliasState()\n\n constructor(\n peerId: PeerId,\n channelId: number,\n socket: Socket,\n config?: WebsocketConnectionConfig,\n ) {\n this.peerId = peerId\n this.channelId = channelId\n this.#socket = socket\n this.#fragmentThreshold =\n config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new FragmentReassembler({\n timeoutMs: 10_000,\n onTimeout: (frameId: number) => {\n console.warn(\n `[WebsocketConnection] Fragment batch timed out: ${frameId}`,\n )\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Start processing messages on this connection.\n *\n * Sets up the message handler on the socket. Must be called after\n * the connection is fully set up (channel assigned, stored in adapter).\n */\n start(): void {\n if (this.#started) {\n return\n }\n this.#started = true\n\n this.#socket.onMessage(data => {\n this.#handleMessage(data)\n })\n }\n\n /**\n * Send a ChannelMsg through the Websocket.\n *\n * Pipeline: alias transformer → wire encode → frame → fragment if needed\n * → socket.send().\n */\n send(msg: ChannelMsg): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n\n const { state, wire } = applyOutboundAliasing(this.#aliasState, msg)\n this.#aliasState = state\n\n encodeWireFrameAndSend(\n wire,\n data => this.#socket.send(data),\n this.#fragmentThreshold,\n this.#nextFrameId,\n )\n }\n\n /**\n * Send a \"ready\" signal to the client.\n *\n * This is a transport-level text message that tells the client the\n * server is ready to receive protocol messages. The client creates\n * its channel and sends establish after receiving this.\n */\n sendReady(): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n this.#socket.send(\"ready\")\n }\n\n /**\n * Close the connection and clean up resources.\n */\n close(code?: number, reason?: string): void {\n this.#reassembler.dispose()\n this.#socket.close(code, reason)\n }\n\n // ==========================================================================\n // INTERNAL — message handling\n // ==========================================================================\n\n /**\n * Handle an incoming message from the Websocket.\n */\n #handleMessage(data: Uint8Array<ArrayBuffer> | string): void {\n // Handle keepalive ping/pong (text frames)\n if (typeof data === \"string\") {\n this.#handleKeepalive(data)\n return\n }\n\n // Binary path: reassemble → wire → alias transformer → ChannelMsg.\n try {\n const wires = decodeBinaryWires(data, this.#reassembler)\n if (!wires) return\n for (const wire of wires) {\n const result = applyInboundAliasing(this.#aliasState, wire)\n this.#aliasState = result.state\n if (result.error || !result.msg) {\n console.warn(\n \"[WebsocketConnection] alias resolution failed:\",\n result.error,\n )\n continue\n }\n this.#handleChannelMessage(result.msg)\n }\n } catch (error) {\n console.error(\"Failed to decode wire message:\", error)\n }\n }\n\n /**\n * Handle a decoded channel message.\n *\n * Delivers messages synchronously. The Synchronizer's receive queue\n * handles recursion prevention by queuing messages and processing\n * them iteratively.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n if (!this.#channel) {\n console.error(\"Cannot handle message: channel not set\")\n return\n }\n\n // Deliver synchronously — the Synchronizer's receive queue prevents recursion\n this.#channel.onReceive(msg)\n }\n\n /**\n * Handle keepalive ping/pong messages.\n */\n #handleKeepalive(text: string): void {\n if (text === \"ping\") {\n this.#socket.send(\"pong\")\n }\n // Ignore \"pong\" and \"ready\" responses\n }\n}\n","// server-adapter — Websocket server adapter for @kyneta/exchange.\n//\n// Manages Websocket connections from clients, encoding/decoding via the\n// kyneta wire format. Framework-agnostic — works with any Websocket\n// library through the Socket interface.\n//\n// Usage with Bun:\n// import { WebsocketServerTransport } from \"@kyneta/websocket-transport/server\"\n// import { createBunWebsocketHandlers } from \"@kyneta/websocket-transport/bun\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// Bun.serve({\n// websocket: createBunWebsocketHandlers(serverAdapter),\n// fetch(req, server) { server.upgrade(req); return new Response(\"\", { status: 101 }) },\n// })\n//\n// Usage with Node.js `ws`:\n// import { WebsocketServerTransport, wrapNodeWebsocket } from \"@kyneta/websocket-transport/server\"\n// import { WebSocketServer } from \"ws\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// const wss = new WebSocketServer({ server })\n// wss.on(\"connection\", (ws) => {\n// const { start } = serverAdapter.handleConnection({ socket: wrapNodeWebsocket(ws) })\n// start()\n// })\n//\n// Ported from @loro-extended/adapter-websocket's WsServerNetworkAdapter with\n// kyneta naming conventions and the kyneta 5-message protocol.\n\nimport { randomPeerId } from \"@kyneta/random\"\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n DEFAULT_FRAGMENT_THRESHOLD,\n WebsocketConnection,\n} from \"./connection.js\"\nimport type {\n WebsocketConnectionOptions,\n WebsocketConnectionResult,\n} from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the Websocket server adapter.\n */\nexport interface WebsocketServerTransportOptions {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket server network adapter.\n *\n * Framework-agnostic — works with any Websocket library through the\n * `Socket` interface. Use `handleConnection()` to integrate with your\n * framework's Websocket upgrade handler.\n *\n * Each client connection is tracked as a `WebsocketConnection` keyed\n * by peer ID. The adapter creates a channel per connection and routes\n * outbound messages through the connection's send method.\n *\n * The connection handshake follows a two-phase protocol:\n * 1. Server sends text `\"ready\"` signal (transport-level)\n * 2. Client sends `establish` (protocol-level)\n * 3. Server upgrades channel and sends present (handled by Synchronizer)\n *\n * The server does NOT call `establishChannel()` — it waits for the\n * client's establish to avoid a race condition where the binary\n * establish could arrive before the client has processed \"ready\".\n */\nexport class WebsocketServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, WebsocketConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: WebsocketServerTransportOptions) {\n super({ transportType: \"websocket-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via handleConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.close(1001, \"Server shutting down\")\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Handle a new Websocket connection.\n *\n * Call this from your framework's Websocket upgrade handler.\n * Returns a connection handle and a `start()` function that begins\n * message processing and sends the \"ready\" signal.\n *\n * @param options - Connection options including the Socket and optional peer ID\n * @returns A connection handle and start function\n *\n * @example Bun\n * ```typescript\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapBunWebsocket(ws),\n * })\n * start()\n * ```\n *\n * @example Node.js ws\n * ```typescript\n * wss.on(\"connection\", (ws) => {\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapNodeWebsocket(ws),\n * })\n * start()\n * })\n * ```\n */\n handleConnection(\n options: WebsocketConnectionOptions,\n ): WebsocketConnectionResult {\n const { socket, peerId: providedPeerId } = options\n\n // Generate peer ID if not provided\n const peerId = providedPeerId ?? (`ws-${randomPeerId()}` as PeerId)\n\n // Check for existing connection with same peer ID\n const existingConnection = this.#connections.get(peerId)\n if (existingConnection) {\n existingConnection.close(1000, \"Replaced by new connection\")\n this.unregisterConnection(peerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(peerId)\n\n // Create connection object with fragmentation config\n const connection = new WebsocketConnection(\n peerId,\n channel.channelId,\n socket,\n {\n fragmentThreshold: this.#fragmentThreshold,\n },\n )\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(peerId, connection)\n\n // Set up close handler\n socket.onClose((_code, _reason) => {\n this.unregisterConnection(peerId)\n })\n\n socket.onError(_error => {\n this.unregisterConnection(peerId)\n })\n\n return {\n connection,\n start: () => {\n connection.start()\n\n // Send ready signal to client so it knows the server is ready\n // This is a transport-level signal, separate from protocol-level establishment\n connection.sendReady()\n\n // NOTE: We do NOT call establishChannel() here.\n // The client will send establish after receiving \"ready\".\n // Our channel gets established when the Synchronizer receives\n // and processes that establish message.\n //\n // This prevents a race condition where our binary establish\n // could arrive before the client has processed \"ready\" and created\n // its channel.\n },\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): WebsocketConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): WebsocketConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Unregister a connection, removing its channel and cleaning up state.\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Broadcast a message to all connected peers.\n */\n broadcast(msg: ChannelMsg): void {\n for (const connection of this.#connections.values()) {\n connection.send(msg)\n }\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n","// service-client — service-to-service WebSocket client factory.\n//\n// Extracted from client-transport.ts so that the service client factory\n// lives in the `./server` entry point (where it belongs) rather than\n// the `./browser` entry point. Backend code imports from `./server`;\n// browser code imports from `./browser`.\n\nimport type { TransportFactory } from \"@kyneta/transport\"\nimport {\n type WebsocketClientOptions,\n WebsocketClientTransport,\n} from \"./client-transport.js\"\n\n/**\n * Options for service-to-service Websocket connections.\n *\n * Identical to `WebsocketClientOptions` — the `headers` field is always\n * available on the base options. This alias exists for API clarity:\n * importing `ServiceWebsocketClientOptions` from `./server` signals\n * intent and pairs with `createServiceWebsocketClient`.\n */\nexport type ServiceWebsocketClientOptions = WebsocketClientOptions\n\n/**\n * Create a Websocket client transport for service-to-service connections.\n *\n * This factory is for backend environments (Bun, Node.js) where you need\n * to pass authentication headers during the Websocket upgrade.\n *\n * Note: Headers are a Bun/Node-specific extension. The browser WebSocket API\n * does not support custom headers. For browser clients, use\n * `createWebsocketClient()` and authenticate via URL query parameters.\n *\n * @example\n * ```typescript\n * import { createServiceWebsocketClient } from \"@kyneta/websocket-transport/server\"\n *\n * const exchange = new Exchange({\n * transports: [createServiceWebsocketClient({\n * url: \"ws://primary-server:3000/ws\",\n * WebSocket,\n * headers: { Authorization: \"Bearer token\" },\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createServiceWebsocketClient(\n options: ServiceWebsocketClientOptions,\n): TransportFactory {\n return () => new WebsocketClientTransport(options)\n}\n"],"mappings":"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"}
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PeerId, StateTransition, TransitionListener } from "@kyneta/transport";
|
|
2
2
|
|
|
3
|
+
//#region src/types.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* WebSocket readyState constants per the WHATWG WebSocket spec.
|
|
5
6
|
* Replaces references to `WebSocket.CONNECTING`, `WebSocket.OPEN`, etc.
|
|
6
7
|
* so that shared code never depends on the browser global.
|
|
7
8
|
*/
|
|
8
9
|
declare const READY_STATE: {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
readonly CONNECTING: 0;
|
|
11
|
+
readonly OPEN: 1;
|
|
12
|
+
readonly CLOSING: 2;
|
|
13
|
+
readonly CLOSED: 3;
|
|
13
14
|
};
|
|
14
15
|
/** Minimal message event — only the fields the transport accesses. */
|
|
15
16
|
interface WebSocketMessageEvent {
|
|
16
|
-
|
|
17
|
+
readonly data: string | ArrayBuffer;
|
|
17
18
|
}
|
|
18
19
|
/** Minimal close event — only the fields the transport accesses. */
|
|
19
20
|
interface WebSocketCloseEvent {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
readonly code: number;
|
|
22
|
+
readonly reason: string;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Structural type for a constructed WebSocket instance.
|
|
@@ -32,12 +33,12 @@ interface WebSocketCloseEvent {
|
|
|
32
33
|
* `Socket` interface (which uses single-callback registration).
|
|
33
34
|
*/
|
|
34
35
|
interface WebSocketLike {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
readonly readyState: number;
|
|
37
|
+
binaryType: string;
|
|
38
|
+
send(data: string | ArrayBuffer): void;
|
|
39
|
+
close(code?: number, reason?: string): void;
|
|
40
|
+
addEventListener(type: string, listener: (event: any) => void): void;
|
|
41
|
+
removeEventListener(type: string, listener: (event: any) => void): void;
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
44
|
* Structural type for a WebSocket constructor.
|
|
@@ -65,67 +66,74 @@ type SocketReadyState = "connecting" | "open" | "closing" | "closed";
|
|
|
65
66
|
* adapter needs are exposed.
|
|
66
67
|
*/
|
|
67
68
|
interface Socket {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Narrowed to `Uint8Array<ArrayBuffer>` because the strictest downstream
|
|
71
|
+
* runtimes reject `SharedArrayBuffer`-backed views: Bun's `BufferSource`
|
|
72
|
+
* resolves to `ArrayBufferView<ArrayBuffer> | ArrayBuffer`, and Hono's
|
|
73
|
+
* `WSContext.send` takes `Uint8Array<ArrayBuffer>` directly. The wire
|
|
74
|
+
* pipeline allocates with `new Uint8Array(n)`, so producers satisfy this
|
|
75
|
+
* without changes.
|
|
76
|
+
*/
|
|
77
|
+
send(data: Uint8Array<ArrayBuffer> | string): void;
|
|
78
|
+
/** Close the Websocket connection. */
|
|
79
|
+
close(code?: number, reason?: string): void;
|
|
80
|
+
/** Register a handler for incoming messages (binary or text). */
|
|
81
|
+
onMessage(handler: (data: Uint8Array<ArrayBuffer> | string) => void): void;
|
|
82
|
+
/** Register a handler for connection close. */
|
|
83
|
+
onClose(handler: (code: number, reason: string) => void): void;
|
|
84
|
+
/** Register a handler for errors. */
|
|
85
|
+
onError(handler: (error: Error) => void): void;
|
|
86
|
+
/** The current ready state of the Websocket. */
|
|
87
|
+
readonly readyState: SocketReadyState;
|
|
80
88
|
}
|
|
81
89
|
/**
|
|
82
90
|
* Options for handling a new Websocket connection on the server.
|
|
83
91
|
*/
|
|
84
92
|
interface WebsocketConnectionOptions {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
/** The Websocket instance, wrapped in the Socket interface. */
|
|
94
|
+
socket: Socket;
|
|
95
|
+
/** Optional peer ID extracted from the upgrade request. */
|
|
96
|
+
peerId?: PeerId;
|
|
97
|
+
/** Optional authentication token from the upgrade request. */
|
|
98
|
+
authToken?: string;
|
|
91
99
|
}
|
|
92
100
|
/**
|
|
93
101
|
* Handle for an active Websocket connection.
|
|
94
102
|
*/
|
|
95
103
|
interface WebsocketConnectionHandle {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
/** The peer ID for this connection. */
|
|
105
|
+
readonly peerId: PeerId;
|
|
106
|
+
/** The channel ID for this connection. */
|
|
107
|
+
readonly channelId: number;
|
|
108
|
+
/** Close the connection. */
|
|
109
|
+
close(code?: number, reason?: string): void;
|
|
102
110
|
}
|
|
103
111
|
/**
|
|
104
112
|
* Result of handling a Websocket connection on the server.
|
|
105
113
|
*/
|
|
106
114
|
interface WebsocketConnectionResult {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
/** The connection handle for managing this peer. */
|
|
116
|
+
connection: WebsocketConnectionHandle;
|
|
117
|
+
/** Call this to start processing messages. */
|
|
118
|
+
start(): void;
|
|
111
119
|
}
|
|
112
120
|
/**
|
|
113
121
|
* Discriminated union describing why a Websocket connection was lost.
|
|
114
122
|
*/
|
|
115
123
|
type DisconnectReason = {
|
|
116
|
-
|
|
124
|
+
type: "intentional";
|
|
117
125
|
} | {
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
type: "error";
|
|
127
|
+
error: Error;
|
|
120
128
|
} | {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
type: "closed";
|
|
130
|
+
code: number;
|
|
131
|
+
reason: string;
|
|
124
132
|
} | {
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
type: "max-retries-exceeded";
|
|
134
|
+
attempts: number;
|
|
127
135
|
} | {
|
|
128
|
-
|
|
136
|
+
type: "not-started";
|
|
129
137
|
};
|
|
130
138
|
/**
|
|
131
139
|
* All possible states of the Websocket client.
|
|
@@ -142,19 +150,19 @@ type DisconnectReason = {
|
|
|
142
150
|
* ```
|
|
143
151
|
*/
|
|
144
152
|
type WebsocketClientState = {
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
status: "disconnected";
|
|
154
|
+
reason?: DisconnectReason;
|
|
147
155
|
} | {
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
status: "connecting";
|
|
157
|
+
attempt: number;
|
|
150
158
|
} | {
|
|
151
|
-
|
|
159
|
+
status: "connected";
|
|
152
160
|
} | {
|
|
153
|
-
|
|
161
|
+
status: "ready";
|
|
154
162
|
} | {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
163
|
+
status: "reconnecting";
|
|
164
|
+
attempt: number;
|
|
165
|
+
nextAttemptMs: number;
|
|
158
166
|
};
|
|
159
167
|
/**
|
|
160
168
|
* A state transition event for websocket client states.
|
|
@@ -165,7 +173,7 @@ type WebsocketClientStateTransition = StateTransition<WebsocketClientState>;
|
|
|
165
173
|
* Listener for websocket client state transitions.
|
|
166
174
|
* Specialized from the generic `TransitionListener<S>`.
|
|
167
175
|
*/
|
|
168
|
-
type TransitionListener = TransitionListener
|
|
176
|
+
type TransitionListener$1 = TransitionListener<WebsocketClientState>;
|
|
169
177
|
/**
|
|
170
178
|
* Wrap a standard `WebSocket` (browser or Node.js `ws` via `ws` package
|
|
171
179
|
* in `WebSocket`-compatible mode) into the `Socket` interface.
|
|
@@ -180,12 +188,12 @@ declare function wrapStandardWebsocket(ws: WebSocket): Socket;
|
|
|
180
188
|
* the actual `ws` instance, we just need these methods.
|
|
181
189
|
*/
|
|
182
190
|
interface NodeWebsocketLike {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
send(data: Uint8Array<ArrayBuffer> | string): void;
|
|
192
|
+
close(code?: number, reason?: string): void;
|
|
193
|
+
on(event: "message", handler: (data: Buffer | ArrayBuffer | string, isBinary: boolean) => void): void;
|
|
194
|
+
on(event: "close", handler: (code: number, reason: Buffer) => void): void;
|
|
195
|
+
on(event: "error", handler: (error: Error) => void): void;
|
|
196
|
+
readyState: number;
|
|
189
197
|
}
|
|
190
198
|
/**
|
|
191
199
|
* Wrap a Node.js `ws` library WebSocket into the `Socket` interface.
|
|
@@ -193,5 +201,6 @@ interface NodeWebsocketLike {
|
|
|
193
201
|
* Handles `Buffer` → `Uint8Array` conversion for binary messages.
|
|
194
202
|
*/
|
|
195
203
|
declare function wrapNodeWebsocket(ws: NodeWebsocketLike): Socket;
|
|
196
|
-
|
|
197
|
-
export {
|
|
204
|
+
//#endregion
|
|
205
|
+
export { wrapStandardWebsocket as _, SocketReadyState as a, WebSocketConstructor as c, WebsocketClientState as d, WebsocketClientStateTransition as f, wrapNodeWebsocket as g, WebsocketConnectionResult as h, Socket as i, WebSocketLike as l, WebsocketConnectionOptions as m, NodeWebsocketLike as n, TransitionListener$1 as o, WebsocketConnectionHandle as p, READY_STATE as r, WebSocketCloseEvent as s, DisconnectReason as t, WebSocketMessageEvent as u };
|
|
206
|
+
//# sourceMappingURL=types-c1S_xIRG.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types-c1S_xIRG.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;AAyBA;;;cAAa,WAAA;EAAA;;;;;;UAYI,qBAAA;EAAA,SACN,IAAA,WAAe,WAAA;AAAA;;UAIT,mBAAA;EAAA,SACN,IAAA;EAAA,SACA,MAAA;AAAA;;;AAkBX;;;;;;;;;UAAiB,aAAA;EAAA,SACN,UAAA;EACT,UAAA;EACA,IAAA,CAAK,IAAA,WAAe,WAAA;EACpB,KAAA,CAAM,IAAA,WAAe,MAAA;EACrB,gBAAA,CAAiB,IAAA,UAAc,QAAA,GAAW,KAAA;EAC1C,mBAAA,CAAoB,IAAA,UAAc,QAAA,GAAW,KAAA;AAAA;;;;;;AAW/C;;;KAAY,oBAAA,QACV,GAAA,aACG,IAAA,YACA,aAAA;;;;;KAUO,gBAAA;AAAZ;;;;;AAiBA;;;;;;AAjBA,UAiBiB,MAAA;EAqBU;;;;;;;;EAZzB,IAAA,CAAK,IAAA,EAAM,UAAA,CAAW,WAAA;EAGhB;EAAN,KAAA,CAAM,IAAA,WAAe,MAAA;EAGrB;EAAA,SAAA,CAAU,OAAA,GAAU,IAAA,EAAM,UAAA,CAAW,WAAA;EAAA;EAGrC,OAAA,CAAQ,OAAA,GAAU,IAAA,UAAc,MAAA;EAHtB;EAMV,OAAA,CAAQ,OAAA,GAAU,KAAA,EAAO,KAAA;EAHP;EAAA,SAMT,UAAA,EAAY,gBAAA;AAAA;;;;UAUN,0BAAA;EAVN;EAYT,MAAA,EAAQ,MAAA;EAZ6B;EAerC,MAAA,GAAS,MAAA;EALM;EAQf,SAAA;AAAA;;;;UAMe,yBAAA;EATN;EAAA,SAWA,MAAA,EAAQ,MAAA;EARR;EAAA,SAWA,SAAA;EALM;EAQf,KAAA,CAAM,IAAA,WAAe,MAAA;AAAA;;;;UAMN,yBAAA;EANf;EAQA,UAAA,EAAY,yBAAA;EARS;EAWrB,KAAA;AAAA;AALF;;;AAAA,KAeY,gBAAA;EACN,IAAA;AAAA;EACA,IAAA;EAAe,KAAA,EAAO,KAAA;AAAA;EACtB,IAAA;EAAgB,IAAA;EAAc,MAAA;AAAA;EAC9B,IAAA;EAA8B,QAAA;AAAA;EAC9B,IAAA;AAAA;;;;;;;;AAoBN;;;;;;;KAAY,oBAAA;EACN,MAAA;EAAwB,MAAA,GAAS,gBAAA;AAAA;EACjC,MAAA;EAAsB,OAAA;AAAA;EACtB,MAAA;AAAA;EACA,MAAA;AAAA;EACA,MAAA;EAAwB,OAAA;EAAiB,aAAA;AAAA;;;;;KAMnC,8BAAA,GACV,eAAA,CAAgB,oBAAA;;;;;KAMN,oBAAA,GAAqB,kBAAA,CAA0B,oBAAA;;;;AA4E3D;;;iBAhEgB,qBAAA,CAAsB,EAAA,EAAI,SAAA,GAAY,MAAA;;;;;;;UAgErC,iBAAA;EACf,IAAA,CAAK,IAAA,EAAM,UAAA,CAAW,WAAA;EACtB,KAAA,CAAM,IAAA,WAAe,MAAA;EACrB,EAAA,CACE,KAAA,aACA,OAAA,GAAU,IAAA,EAAM,MAAA,GAAS,WAAA,WAAsB,QAAA;EAEjD,EAAA,CAAG,KAAA,WAAgB,OAAA,GAAU,IAAA,UAAc,MAAA,EAAQ,MAAA;EACnD,EAAA,CAAG,KAAA,WAAgB,OAAA,GAAU,KAAA,EAAO,KAAA;EACpC,UAAA;AAAA;;;;;;iBAQc,iBAAA,CAAkB,EAAA,EAAI,iBAAA,GAAoB,MAAA"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/websocket-transport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Websocket network adapter for @kyneta/exchange — browser, server, and Bun integration",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/halecraft/kyneta",
|
|
10
|
-
"directory": "packages/exchange/
|
|
10
|
+
"directory": "packages/exchange/transports/websocket"
|
|
11
11
|
},
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
@@ -33,24 +33,25 @@
|
|
|
33
33
|
"./src/*": "./src/*"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@kyneta/
|
|
37
|
-
"@kyneta/
|
|
38
|
-
"@kyneta/
|
|
36
|
+
"@kyneta/machine": "^1.5.0",
|
|
37
|
+
"@kyneta/random": "^1.5.0",
|
|
38
|
+
"@kyneta/transport": "^1.5.0",
|
|
39
|
+
"@kyneta/wire": "^1.5.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@types/node": "^22",
|
|
42
43
|
"bun-types": "latest",
|
|
43
|
-
"
|
|
44
|
+
"tsdown": "^0.21.9",
|
|
44
45
|
"typescript": "^5.9.2",
|
|
45
46
|
"vitest": "^4.0.17",
|
|
46
|
-
"@kyneta/exchange": "^1.
|
|
47
|
-
"@kyneta/machine": "^1.
|
|
48
|
-
"@kyneta/
|
|
49
|
-
"@kyneta/transport": "^1.
|
|
50
|
-
"@kyneta/
|
|
47
|
+
"@kyneta/exchange": "^1.5.0",
|
|
48
|
+
"@kyneta/machine": "^1.5.0",
|
|
49
|
+
"@kyneta/schema": "^1.5.0",
|
|
50
|
+
"@kyneta/transport": "^1.5.0",
|
|
51
|
+
"@kyneta/wire": "^1.5.0"
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
|
-
"build": "
|
|
54
|
+
"build": "tsdown",
|
|
54
55
|
"test": "verify logic",
|
|
55
56
|
"verify": "verify"
|
|
56
57
|
}
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
// 2. When `headers` are provided, they're passed as `{ headers }` in the
|
|
7
7
|
// second argument. When absent, the constructor is called with just the URL.
|
|
8
8
|
|
|
9
|
+
import type { PeerIdentityDetails, TransportContext } from "@kyneta/transport"
|
|
9
10
|
import { describe, expect, it, vi } from "vitest"
|
|
10
|
-
import type { TransportContext } from "@kyneta/transport"
|
|
11
|
-
import type { PeerIdentityDetails } from "@kyneta/transport"
|
|
12
11
|
import { WebsocketClientTransport } from "../client-transport.js"
|
|
13
12
|
import type { WebSocketConstructor, WebSocketLike } from "../types.js"
|
|
14
13
|
|
|
@@ -95,14 +94,14 @@ describe("WebsocketClientTransport — constructor injection", () => {
|
|
|
95
94
|
await startTransport(transport)
|
|
96
95
|
|
|
97
96
|
expect(calls).toHaveLength(1)
|
|
98
|
-
expect(calls[0]
|
|
97
|
+
expect(calls[0]?.url).toBe("ws://localhost:9999/ws")
|
|
99
98
|
})
|
|
100
99
|
|
|
101
100
|
it("resolves URL function with peerId before passing to constructor", async () => {
|
|
102
101
|
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
103
102
|
|
|
104
103
|
const transport = new WebsocketClientTransport({
|
|
105
|
-
url:
|
|
104
|
+
url: peerId => `ws://localhost:9999/ws/${peerId}`,
|
|
106
105
|
WebSocket: MockWebSocket,
|
|
107
106
|
reconnect: { enabled: false },
|
|
108
107
|
})
|
|
@@ -110,7 +109,7 @@ describe("WebsocketClientTransport — constructor injection", () => {
|
|
|
110
109
|
await startTransport(transport)
|
|
111
110
|
|
|
112
111
|
expect(calls).toHaveLength(1)
|
|
113
|
-
expect(calls[0]
|
|
112
|
+
expect(calls[0]?.url).toBe(`ws://localhost:9999/ws/${testIdentity.peerId}`)
|
|
114
113
|
})
|
|
115
114
|
})
|
|
116
115
|
|
|
@@ -132,7 +131,7 @@ describe("WebsocketClientTransport — header passing", () => {
|
|
|
132
131
|
await startTransport(transport)
|
|
133
132
|
|
|
134
133
|
expect(calls).toHaveLength(1)
|
|
135
|
-
expect(calls[0]
|
|
134
|
+
expect(calls[0]?.rest).toEqual([{ headers }])
|
|
136
135
|
})
|
|
137
136
|
|
|
138
137
|
it("omits second arg when headers are not provided", async () => {
|
|
@@ -147,7 +146,7 @@ describe("WebsocketClientTransport — header passing", () => {
|
|
|
147
146
|
await startTransport(transport)
|
|
148
147
|
|
|
149
148
|
expect(calls).toHaveLength(1)
|
|
150
|
-
expect(calls[0]
|
|
149
|
+
expect(calls[0]?.rest).toEqual([])
|
|
151
150
|
})
|
|
152
151
|
|
|
153
152
|
it("omits second arg when headers is an empty object", async () => {
|
|
@@ -163,6 +162,6 @@ describe("WebsocketClientTransport — header passing", () => {
|
|
|
163
162
|
await startTransport(transport)
|
|
164
163
|
|
|
165
164
|
expect(calls).toHaveLength(1)
|
|
166
|
-
expect(calls[0]
|
|
165
|
+
expect(calls[0]?.rest).toEqual([])
|
|
167
166
|
})
|
|
168
|
-
})
|
|
167
|
+
})
|
package/src/browser.ts
CHANGED
|
@@ -37,13 +37,13 @@ export type {
|
|
|
37
37
|
DisconnectReason,
|
|
38
38
|
Socket,
|
|
39
39
|
SocketReadyState,
|
|
40
|
-
WebsocketClientState,
|
|
41
|
-
WebsocketClientStateTransition,
|
|
42
40
|
TransitionListener,
|
|
43
41
|
WebSocketCloseEvent,
|
|
44
42
|
WebSocketConstructor,
|
|
45
43
|
WebSocketLike,
|
|
46
44
|
WebSocketMessageEvent,
|
|
45
|
+
WebsocketClientState,
|
|
46
|
+
WebsocketClientStateTransition,
|
|
47
47
|
} from "./types.js"
|
|
48
48
|
|
|
49
|
-
export { READY_STATE } from "./types.js"
|
|
49
|
+
export { READY_STATE } from "./types.js"
|
package/src/bun-websocket.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { Socket, SocketReadyState } from "./types.js"
|
|
|
31
31
|
*/
|
|
32
32
|
export type BunWebsocketData = {
|
|
33
33
|
handlers: {
|
|
34
|
-
onMessage?: (data: Uint8Array | string) => void
|
|
34
|
+
onMessage?: (data: Uint8Array<ArrayBuffer> | string) => void
|
|
35
35
|
onClose?: (code: number, reason: string) => void
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -78,7 +78,7 @@ export function wrapBunWebsocket(
|
|
|
78
78
|
ws.data = { handlers: {} }
|
|
79
79
|
|
|
80
80
|
return {
|
|
81
|
-
send(data: Uint8Array | string): void {
|
|
81
|
+
send(data: Uint8Array<ArrayBuffer> | string): void {
|
|
82
82
|
ws.send(data)
|
|
83
83
|
},
|
|
84
84
|
|
|
@@ -86,7 +86,7 @@ export function wrapBunWebsocket(
|
|
|
86
86
|
ws.close(code, reason)
|
|
87
87
|
},
|
|
88
88
|
|
|
89
|
-
onMessage(handler: (data: Uint8Array | string) => void): void {
|
|
89
|
+
onMessage(handler: (data: Uint8Array<ArrayBuffer> | string) => void): void {
|
|
90
90
|
ws.data.handlers.onMessage = handler
|
|
91
91
|
},
|
|
92
92
|
|
package/src/client-transport.ts
CHANGED
|
@@ -22,8 +22,13 @@ import type {
|
|
|
22
22
|
} from "@kyneta/transport"
|
|
23
23
|
import { Transport } from "@kyneta/transport"
|
|
24
24
|
import {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
type AliasState,
|
|
26
|
+
applyInboundAliasing,
|
|
27
|
+
applyOutboundAliasing,
|
|
28
|
+
createFrameIdCounter,
|
|
29
|
+
decodeBinaryWires,
|
|
30
|
+
emptyAliasState,
|
|
31
|
+
encodeWireFrameAndSend,
|
|
27
32
|
FragmentReassembler,
|
|
28
33
|
} from "@kyneta/wire"
|
|
29
34
|
import {
|
|
@@ -31,6 +36,11 @@ import {
|
|
|
31
36
|
type WsClientEffect,
|
|
32
37
|
type WsClientMsg,
|
|
33
38
|
} from "./client-program.js"
|
|
39
|
+
import type {
|
|
40
|
+
DisconnectReason,
|
|
41
|
+
WebsocketClientState,
|
|
42
|
+
WebsocketClientStateTransition,
|
|
43
|
+
} from "./types.js"
|
|
34
44
|
import {
|
|
35
45
|
READY_STATE,
|
|
36
46
|
type WebSocketCloseEvent,
|
|
@@ -38,11 +48,6 @@ import {
|
|
|
38
48
|
type WebSocketLike,
|
|
39
49
|
type WebSocketMessageEvent,
|
|
40
50
|
} from "./types.js"
|
|
41
|
-
import type {
|
|
42
|
-
DisconnectReason,
|
|
43
|
-
WebsocketClientState,
|
|
44
|
-
WebsocketClientStateTransition,
|
|
45
|
-
} from "./types.js"
|
|
46
51
|
|
|
47
52
|
// Re-export state types for convenience
|
|
48
53
|
export type {
|
|
@@ -164,6 +169,9 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
164
169
|
readonly #fragmentThreshold: number
|
|
165
170
|
readonly #reassembler: FragmentReassembler
|
|
166
171
|
|
|
172
|
+
// Per-channel alias state (Phase 4). Single channel per client.
|
|
173
|
+
#aliasState: AliasState = emptyAliasState()
|
|
174
|
+
|
|
167
175
|
constructor(options: WebsocketClientOptions) {
|
|
168
176
|
super({ transportType: "websocket-client" })
|
|
169
177
|
this.#options = options
|
|
@@ -215,6 +223,10 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
215
223
|
this.#serverChannel = undefined
|
|
216
224
|
}
|
|
217
225
|
|
|
226
|
+
// Fresh reassembler for the new connection — stale fragments from
|
|
227
|
+
// the old connection must not collide with new fragments.
|
|
228
|
+
this.#reassembler.reset()
|
|
229
|
+
|
|
218
230
|
this.#serverChannel = this.addChannel()
|
|
219
231
|
|
|
220
232
|
// Establish immediately — the server already signaled ready
|
|
@@ -392,14 +404,19 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
392
404
|
// Handle binary messages through shared decode pipeline
|
|
393
405
|
if (data instanceof ArrayBuffer) {
|
|
394
406
|
try {
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
407
|
+
const wires = decodeBinaryWires(new Uint8Array(data), this.#reassembler)
|
|
408
|
+
if (!wires) return
|
|
409
|
+
for (const wire of wires) {
|
|
410
|
+
const result = applyInboundAliasing(this.#aliasState, wire)
|
|
411
|
+
this.#aliasState = result.state
|
|
412
|
+
if (result.error || !result.msg) {
|
|
413
|
+
console.warn(
|
|
414
|
+
"[WebsocketClient] alias resolution failed:",
|
|
415
|
+
result.error,
|
|
416
|
+
)
|
|
417
|
+
continue
|
|
402
418
|
}
|
|
419
|
+
this.#handleChannelMessage(result.msg)
|
|
403
420
|
}
|
|
404
421
|
} catch (error) {
|
|
405
422
|
console.error("Failed to decode message:", error)
|
|
@@ -535,6 +552,9 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
535
552
|
// ==========================================================================
|
|
536
553
|
|
|
537
554
|
protected generate(): GeneratedChannel {
|
|
555
|
+
const nextFrameId = createFrameIdCounter()
|
|
556
|
+
// New channel = fresh alias state; reset on (re)connect.
|
|
557
|
+
this.#aliasState = emptyAliasState()
|
|
538
558
|
return {
|
|
539
559
|
transportType: this.transportType,
|
|
540
560
|
send: (msg: ChannelMsg) => {
|
|
@@ -543,8 +563,14 @@ export class WebsocketClientTransport extends Transport<void> {
|
|
|
543
563
|
return
|
|
544
564
|
}
|
|
545
565
|
|
|
546
|
-
|
|
547
|
-
|
|
566
|
+
const { state, wire } = applyOutboundAliasing(this.#aliasState, msg)
|
|
567
|
+
this.#aliasState = state
|
|
568
|
+
|
|
569
|
+
encodeWireFrameAndSend(
|
|
570
|
+
wire,
|
|
571
|
+
data => socket.send(new Uint8Array(data).buffer),
|
|
572
|
+
this.#fragmentThreshold,
|
|
573
|
+
nextFrameId,
|
|
548
574
|
)
|
|
549
575
|
},
|
|
550
576
|
stop: () => {
|
|
@@ -600,4 +626,3 @@ export function createWebsocketClient(
|
|
|
600
626
|
): TransportFactory {
|
|
601
627
|
return () => new WebsocketClientTransport(options)
|
|
602
628
|
}
|
|
603
|
-
|