@kyneta/websocket-transport 1.1.0 → 1.3.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/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client-transport.ts","../src/client-state-machine.ts"],"sourcesContent":["// client-adapter — Websocket client adapter for @kyneta/exchange.\n//\n// Connects to a Websocket server and handles bidirectional communication\n// using the kyneta wire format (CBOR codec + framing + fragmentation).\n//\n// Features:\n// - State machine with validated transitions (disconnected → connecting → connected → ready)\n// - Exponential backoff reconnection with jitter\n// - Keepalive ping/pong (text frames, default 30s)\n// - Transport-level fragmentation for large payloads\n// - Observable connection state via subscribeToTransitions()\n//\n// The connection handshake:\n// 1. Client creates Websocket, waits for open\n// 2. Server sends text \"ready\" signal\n// 3. Client creates channel + calls establishChannel()\n// 4. Synchronizer exchanges establish-request / establish-response\n//\n// Ported from @loro-extended/adapter-websocket's WsClientNetworkAdapter\n// with kyneta naming conventions and the kyneta 5-message protocol.\n\nimport type {\n Channel,\n ChannelMsg,\n GeneratedChannel,\n PeerId,\n TransportFactory,\n} from \"@kyneta/exchange\"\nimport { Transport } from \"@kyneta/exchange\"\nimport {\n cborCodec,\n decodeBinaryFrame,\n encodeComplete,\n FragmentReassembler,\n fragmentPayload,\n wrapCompleteMessage,\n} from \"@kyneta/wire\"\nimport { WebsocketClientStateMachine } from \"./client-state-machine.js\"\nimport type {\n DisconnectReason,\n TransitionListener,\n WebsocketClientState,\n WebsocketClientStateTransition,\n} from \"./types.js\"\n\n// Re-export state types for convenience\nexport type {\n DisconnectReason,\n WebsocketClientState,\n WebsocketClientStateTransition,\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in bytes.\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 * Options for the Websocket client adapter (browser connections).\n */\nexport interface WebsocketClientOptions {\n /** Websocket URL to connect to. Can be a string or a function of peerId. */\n url: string | ((peerId: PeerId) => string)\n\n /** Optional custom WebSocket implementation (for Node.js or testing). */\n WebSocket?: typeof globalThis.WebSocket\n\n /** Reconnection options. */\n reconnect?: {\n enabled: boolean\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** Keepalive interval in ms (default: 30000). */\n keepaliveInterval?: number\n\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\n */\n fragmentThreshold?: number\n\n /** Lifecycle event callbacks. */\n lifecycle?: WebsocketClientLifecycleEvents\n}\n\n/**\n * Lifecycle event callbacks for the Websocket client.\n */\nexport interface WebsocketClientLifecycleEvents {\n /** Called on every state transition (delivered async via microtask). */\n onStateChange?: (transition: WebsocketClientStateTransition) => void\n\n /** Called when the connection is lost. */\n onDisconnect?: (reason: DisconnectReason) => void\n\n /** Called when a reconnection attempt is scheduled. */\n onReconnecting?: (attempt: number, nextAttemptMs: number) => void\n\n /** Called when reconnection succeeds after a previous connection. */\n onReconnected?: () => void\n\n /** Called when the server sends the \"ready\" signal. */\n onReady?: () => void\n}\n\n/**\n * Options for service-to-service Websocket connections.\n * Extends WebsocketClientOptions with header support for authentication.\n *\n * Note: Headers are a Bun/Node-specific extension. The browser WebSocket API\n * does not support custom headers per the WHATWG spec.\n */\nexport interface ServiceWebsocketClientOptions extends WebsocketClientOptions {\n /**\n * Headers to send during Websocket upgrade.\n * Used for authentication in service-to-service communication.\n */\n headers?: Record<string, string>\n}\n\n/**\n * Default reconnection options.\n */\nconst DEFAULT_RECONNECT = {\n enabled: true,\n maxAttempts: 10,\n baseDelay: 1000,\n maxDelay: 30000,\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketClientTransport\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket client network adapter for @kyneta/exchange.\n *\n * Connects to a Websocket server, sends and receives ChannelMsg via\n * the kyneta wire format (CBOR codec + framing + fragmentation).\n *\n * Prefer the factory functions for construction:\n * - `createWebsocketClient()` — browser-to-server\n * - `createServiceWebsocketClient()` — service-to-service (with headers)\n */\nexport class WebsocketClientTransport extends Transport<void> {\n #peerId?: PeerId\n #socket?: WebSocket\n #serverChannel?: Channel\n #keepaliveTimer?: ReturnType<typeof setInterval>\n #reconnectTimer?: ReturnType<typeof setTimeout>\n #options: ServiceWebsocketClientOptions\n #WebSocketImpl: typeof globalThis.WebSocket\n #shouldReconnect = true\n #wasConnectedBefore = false\n\n // State machine\n readonly #stateMachine = new WebsocketClientStateMachine()\n\n // Fragmentation\n readonly #fragmentThreshold: number\n readonly #reassembler: FragmentReassembler\n\n constructor(options: ServiceWebsocketClientOptions) {\n super({ transportType: \"websocket-client\" })\n this.#options = options\n this.#WebSocketImpl = options.WebSocket ?? globalThis.WebSocket\n this.#fragmentThreshold =\n options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new FragmentReassembler({\n timeoutMs: 10_000,\n })\n\n // Set up lifecycle event forwarding\n this.#setupLifecycleEvents()\n }\n\n // ==========================================================================\n // Lifecycle event forwarding\n // ==========================================================================\n\n #setupLifecycleEvents(): void {\n this.#stateMachine.subscribeToTransitions(transition => {\n // Forward to onStateChange callback\n this.#options.lifecycle?.onStateChange?.(transition)\n\n const { from, to } = transition\n\n // onDisconnect: transitioning TO disconnected\n if (to.status === \"disconnected\" && to.reason) {\n this.#options.lifecycle?.onDisconnect?.(to.reason)\n }\n\n // onReconnecting: transitioning TO reconnecting\n if (to.status === \"reconnecting\") {\n this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)\n }\n\n // onReconnected: from reconnecting/connecting TO connected/ready (after prior connection)\n if (\n this.#wasConnectedBefore &&\n (from.status === \"reconnecting\" || from.status === \"connecting\") &&\n (to.status === \"connected\" || to.status === \"ready\")\n ) {\n this.#options.lifecycle?.onReconnected?.()\n }\n\n // onReady: transitioning TO ready\n if (to.status === \"ready\") {\n this.#options.lifecycle?.onReady?.()\n }\n })\n }\n\n // ==========================================================================\n // State observation API\n // ==========================================================================\n\n /**\n * Get the current state of the connection.\n */\n getState(): WebsocketClientState {\n return this.#stateMachine.getState()\n }\n\n /**\n * Subscribe to state transitions.\n * @returns Unsubscribe function\n */\n subscribeToTransitions(listener: TransitionListener): () => void {\n return this.#stateMachine.subscribeToTransitions(listener)\n }\n\n /**\n * Wait for a specific state.\n */\n waitForState(\n predicate: (state: WebsocketClientState) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<WebsocketClientState> {\n return this.#stateMachine.waitForState(predicate, options)\n }\n\n /**\n * Wait for a specific status.\n */\n waitForStatus(\n status: WebsocketClientState[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<WebsocketClientState> {\n return this.#stateMachine.waitForStatus(status, options)\n }\n\n /**\n * Check if the client is ready (server ready signal received).\n */\n get isReady(): boolean {\n return this.#stateMachine.isReady()\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {\n return\n }\n\n const frame = encodeComplete(cborCodec, msg)\n\n // Fragment large payloads for cloud infrastructure compatibility\n if (\n this.#fragmentThreshold > 0 &&\n frame.length > this.#fragmentThreshold\n ) {\n const fragments = fragmentPayload(frame, this.#fragmentThreshold)\n for (const fragment of fragments) {\n this.#socket.send(fragment)\n }\n } else {\n // Wrap with MESSAGE_COMPLETE prefix for transport layer consistency\n this.#socket.send(wrapCompleteMessage(frame))\n }\n },\n stop: () => {\n // Don't call disconnect() here — channel.stop() is called when\n // the channel is removed, which can happen during handleClose().\n // The actual disconnect is handled by onStop() or handleClose().\n },\n }\n }\n\n async onStart(): Promise<void> {\n if (!this.identity) {\n throw new Error(\n \"Adapter not properly initialized — identity not available\",\n )\n }\n this.#peerId = this.identity.peerId\n this.#shouldReconnect = true\n this.#wasConnectedBefore = false\n await this.#connect()\n }\n\n async onStop(): Promise<void> {\n this.#shouldReconnect = false\n this.#reassembler.dispose()\n this.#disconnect({ type: \"intentional\" })\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Connect to the Websocket server.\n */\n async #connect(): Promise<void> {\n const currentState = this.#stateMachine.getState()\n if (currentState.status === \"connecting\") {\n return\n }\n\n if (!this.#peerId) {\n throw new Error(\"Cannot connect: peerId not set\")\n }\n\n // Determine attempt number\n const attempt =\n currentState.status === \"reconnecting\" ? currentState.attempt : 1\n\n this.#stateMachine.transition({ status: \"connecting\", attempt })\n\n // Resolve URL\n const url =\n typeof this.#options.url === \"function\"\n ? this.#options.url(this.#peerId)\n : this.#options.url\n\n try {\n // Create WebSocket with optional headers (Bun-specific extension)\n if (\n this.#options.headers &&\n Object.keys(this.#options.headers).length > 0\n ) {\n // Bun extends the standard WebSocket API with a non-standard constructor\n type BunWebSocketConstructor = new (\n url: string,\n options: { headers: Record<string, string> },\n ) => WebSocket\n const BunWebSocket = this\n .#WebSocketImpl as unknown as BunWebSocketConstructor\n this.#socket = new BunWebSocket(url, {\n headers: this.#options.headers,\n })\n } else {\n this.#socket = new this.#WebSocketImpl(url)\n }\n this.#socket.binaryType = \"arraybuffer\"\n\n // IMPORTANT: Set up message handler IMMEDIATELY after creating the socket.\n // This must happen BEFORE waiting for the open event to avoid a race\n // condition where the server sends \"ready\" before the handler is attached.\n this.#socket.addEventListener(\"message\", event => {\n this.#handleMessage(event)\n })\n\n await new Promise<void>((resolve, reject) => {\n if (!this.#socket) {\n reject(new Error(\"Socket not created\"))\n return\n }\n\n const onOpen = () => {\n cleanup()\n resolve()\n }\n\n const onError = (event: Event) => {\n cleanup()\n reject(new Error(`WebSocket connection failed: ${event}`))\n }\n\n const onClose = () => {\n cleanup()\n reject(new Error(\"WebSocket closed during connection\"))\n }\n\n const cleanup = () => {\n this.#socket?.removeEventListener(\"open\", onOpen)\n this.#socket?.removeEventListener(\"error\", onError)\n this.#socket?.removeEventListener(\"close\", onClose)\n }\n\n this.#socket.addEventListener(\"open\", onOpen)\n this.#socket.addEventListener(\"error\", onError)\n this.#socket.addEventListener(\"close\", onClose)\n })\n\n // Socket is now open — transition to connected\n this.#stateMachine.transition({ status: \"connected\" })\n\n // Set up close handler for disconnections after connection is established\n this.#socket.addEventListener(\"close\", event => {\n this.#handleClose(event.code, event.reason)\n })\n\n // Start keepalive\n this.#startKeepalive()\n\n // Note: Channel creation is deferred until we receive the \"ready\" signal\n // from the server. This ensures the server is fully set up before we\n // start sending messages.\n } catch (error) {\n // Transition to reconnecting or disconnected\n this.#scheduleReconnect({\n type: \"error\",\n error: error instanceof Error ? error : new Error(String(error)),\n })\n }\n }\n\n /**\n * Disconnect from the Websocket server.\n */\n #disconnect(reason: DisconnectReason): void {\n this.#stopKeepalive()\n this.#clearReconnectTimer()\n\n if (this.#socket) {\n this.#socket.close(1000, \"Client disconnecting\")\n this.#socket = undefined\n }\n\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Only transition if not already disconnected\n const currentState = this.#stateMachine.getState()\n if (currentState.status !== \"disconnected\") {\n this.#stateMachine.transition({ status: \"disconnected\", reason })\n }\n }\n\n // ==========================================================================\n // Message handling\n // ==========================================================================\n\n /**\n * Handle incoming Websocket messages.\n */\n #handleMessage(event: MessageEvent): void {\n const data = event.data\n\n // Handle text messages (keepalive and ready signal)\n if (typeof data === \"string\") {\n if (data === \"ready\") {\n this.#handleServerReady()\n }\n // Ignore pong responses\n return\n }\n\n // Handle binary messages through reassembler\n if (data instanceof ArrayBuffer) {\n const result = this.#reassembler.receiveRaw(new Uint8Array(data))\n\n if (result.status === \"complete\") {\n try {\n const frame = decodeBinaryFrame(result.data)\n const messages = cborCodec.decode(frame.content.payload)\n for (const msg of messages) {\n this.#handleChannelMessage(msg)\n }\n } catch (error) {\n console.error(\"Failed to decode message:\", error)\n }\n } else if (result.status === \"error\") {\n console.error(\"Fragment reassembly error:\", result.error)\n }\n // \"pending\" status means we're waiting for more fragments — nothing to do\n }\n }\n\n /**\n * Handle the \"ready\" signal from the server.\n *\n * Creates the channel and starts the establishment handshake.\n * The \"ready\" signal is a transport-level indicator that the server's\n * Websocket handler is ready. After receiving it, we create our channel\n * and send a real establish-request.\n */\n #handleServerReady(): void {\n const currentState = this.#stateMachine.getState()\n if (currentState.status === \"ready\") {\n // Already received ready signal, ignore duplicate\n return\n }\n\n // Handle race condition: if we receive \"ready\" while still in \"connecting\" state,\n // the server sent the ready signal before our open promise resolved.\n // Transition through \"connected\" first to maintain valid state machine transitions.\n if (currentState.status === \"connecting\") {\n this.#stateMachine.transition({ status: \"connected\" })\n }\n\n // Transition to ready state\n this.#stateMachine.transition({ status: \"ready\" })\n this.#wasConnectedBefore = true\n\n // Create channel if not exists\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n this.#serverChannel = this.addChannel()\n\n // Send real establish-request over the wire\n // The server will respond with establish-response containing its actual identity\n this.establishChannel(this.#serverChannel.channelId)\n }\n\n /**\n * Handle a decoded channel message.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n if (!this.#serverChannel) {\n return\n }\n\n // Deliver synchronously — the Synchronizer's receive queue prevents recursion\n this.#serverChannel.onReceive(msg)\n }\n\n /**\n * Handle Websocket close.\n */\n #handleClose(code: number, reason: string): void {\n this.#stopKeepalive()\n\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Schedule reconnect or transition to disconnected\n this.#scheduleReconnect({ type: \"closed\", code, reason })\n }\n\n // ==========================================================================\n // Keepalive\n // ==========================================================================\n\n #startKeepalive(): void {\n this.#stopKeepalive()\n\n const interval = this.#options.keepaliveInterval ?? 30_000\n\n this.#keepaliveTimer = setInterval(() => {\n if (this.#socket?.readyState === WebSocket.OPEN) {\n this.#socket.send(\"ping\")\n }\n }, interval)\n }\n\n #stopKeepalive(): void {\n if (this.#keepaliveTimer) {\n clearInterval(this.#keepaliveTimer)\n this.#keepaliveTimer = undefined\n }\n }\n\n // ==========================================================================\n // Reconnection\n // ==========================================================================\n\n /**\n * Schedule a reconnection attempt or transition to disconnected.\n */\n #scheduleReconnect(reason: DisconnectReason): void {\n const currentState = this.#stateMachine.getState()\n\n // If already disconnected, don't transition again\n if (currentState.status === \"disconnected\") {\n return\n }\n\n const reconnectOpts = {\n ...DEFAULT_RECONNECT,\n ...this.#options.reconnect,\n }\n\n if (!this.#shouldReconnect || !reconnectOpts.enabled) {\n this.#stateMachine.transition({ status: \"disconnected\", reason })\n return\n }\n\n // Get current attempt count from state\n const currentAttempt =\n currentState.status === \"reconnecting\"\n ? currentState.attempt\n : currentState.status === \"connecting\"\n ? (currentState as { attempt: number }).attempt\n : 0\n\n if (currentAttempt >= reconnectOpts.maxAttempts) {\n this.#stateMachine.transition({\n status: \"disconnected\",\n reason: { type: \"max-retries-exceeded\", attempts: currentAttempt },\n })\n return\n }\n\n const nextAttempt = currentAttempt + 1\n\n // Exponential backoff with jitter\n const delay = Math.min(\n reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1000,\n reconnectOpts.maxDelay,\n )\n\n this.#stateMachine.transition({\n status: \"reconnecting\",\n attempt: nextAttempt,\n nextAttemptMs: delay,\n })\n\n this.#reconnectTimer = setTimeout(() => {\n this.#connect()\n }, delay)\n }\n\n #clearReconnectTimer(): void {\n if (this.#reconnectTimer) {\n clearTimeout(this.#reconnectTimer)\n this.#reconnectTimer = undefined\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory functions\n// ---------------------------------------------------------------------------\n\n/**\n * Create a Websocket client adapter factory for browser-to-server connections.\n *\n * Returns an `TransportFactory` — a closure that creates a fresh adapter\n * instance when called. Pass directly to `Exchange({ transports: [...] })`.\n *\n * @example\n * ```typescript\n * import { createWebsocketClient } from \"@kyneta/websocket-network-adapter/client\"\n *\n * const exchange = new Exchange({\n * transports: [createWebsocketClient({\n * url: \"ws://localhost:3000/ws\",\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createWebsocketClient(\n options: WebsocketClientOptions,\n): TransportFactory {\n return () => new WebsocketClientTransport(options)\n}\n\n/**\n * Create a Websocket client adapter 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-network-adapter/client\"\n *\n * const exchange = new Exchange({\n * transports: [createServiceWebsocketClient({\n * url: \"ws://primary-server:3000/ws\",\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","// client-state-machine — Websocket client state machine.\n//\n// Thin wrapper around the generic ClientStateMachine<S> from @kyneta/exchange,\n// providing websocket-specific convenience helpers (isConnectedOrReady, isReady)\n// and the 5-state transition map.\n//\n// States: disconnected → connecting → connected → ready\n// ↓ ↓ ↓\n// reconnecting ← ─ ┴ ─ ─ ─ ─ ┘\n// ↓\n// connecting (retry)\n// ↓\n// disconnected (max retries)\n\nimport { ClientStateMachine } from \"@kyneta/exchange\"\nimport type { WebsocketClientState } from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Websocket transition map\n// ---------------------------------------------------------------------------\n\nconst WS_VALID_TRANSITIONS: Record<string, string[]> = {\n disconnected: [\"connecting\"],\n connecting: [\"connected\", \"disconnected\", \"reconnecting\"],\n connected: [\"ready\", \"disconnected\", \"reconnecting\"],\n ready: [\"disconnected\", \"reconnecting\"],\n reconnecting: [\"connecting\", \"disconnected\"],\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketClientStateMachine\n// ---------------------------------------------------------------------------\n\n/**\n * Observable state machine for Websocket client connection lifecycle.\n *\n * Extends the generic `ClientStateMachine<WebsocketClientState>` with\n * websocket-specific convenience helpers.\n *\n * Usage:\n * ```typescript\n * const sm = new WebsocketClientStateMachine()\n *\n * sm.subscribeToTransitions(({ from, to }) => {\n * console.log(`${from.status} → ${to.status}`)\n * })\n *\n * sm.transition({ status: \"connecting\", attempt: 1 })\n * sm.transition({ status: \"connected\" })\n * sm.transition({ status: \"ready\" })\n *\n * // Transitions are delivered asynchronously via microtask\n * // Listener will see: disconnected → connecting, connecting → connected, connected → ready\n * ```\n */\nexport class WebsocketClientStateMachine extends ClientStateMachine<WebsocketClientState> {\n constructor() {\n super({\n initialState: { status: \"disconnected\" },\n validTransitions: WS_VALID_TRANSITIONS,\n })\n }\n\n /**\n * Check if the client is in a \"connected\" state (either connected or ready).\n */\n isConnectedOrReady(): boolean {\n const s = this.getStatus()\n return s === \"connected\" || s === \"ready\"\n }\n\n /**\n * Check if the client is ready (server ready signal received).\n */\n isReady(): boolean {\n return this.getStatus() === \"ready\"\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/client-program.ts","../src/client-transport.ts"],"sourcesContent":["// client-program — pure Mealy machine for websocket client connection lifecycle.\n//\n// The client program encodes every state transition and effect as data.\n// The imperative shell (client-transport.ts) interprets effects as I/O.\n// Tests assert on data — no sockets, no timing, never flaky.\n//\n// Algebra: Program<WsClientMsg, WebsocketClientState, WsClientEffect>\n// Interpreter: client-transport.ts executeClientEffect()\n//\n// The websocket client has a 5-state lifecycle with an extra \"ready\" state\n// compared to the unix socket client. The server sends a text \"ready\" signal\n// after the connection opens, and only then does the client create a channel\n// and start the establishment handshake.\n//\n// Race condition: the server may send \"ready\" before the client's open event\n// fires (server-ready while connecting). The program handles this by\n// transitioning directly to ready, skipping the connected state.\n\nimport type { Program } from \"@kyneta/machine\"\nimport type { ReconnectOptions } from \"@kyneta/transport\"\nimport { computeBackoffDelay, DEFAULT_RECONNECT } from \"@kyneta/transport\"\n\nimport type { DisconnectReason, WebsocketClientState } from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Messages\n// ---------------------------------------------------------------------------\n\nexport type WsClientMsg =\n | { type: \"start\" }\n | { type: \"socket-opened\" }\n | { type: \"server-ready\" }\n | { type: \"socket-closed\"; code: number; reason: string }\n | { type: \"socket-error\"; error: Error }\n | { type: \"reconnect-timer-fired\" }\n | { type: \"stop\" }\n\n// ---------------------------------------------------------------------------\n// Effects (data — interpreted by the imperative shell)\n// ---------------------------------------------------------------------------\n\nexport type WsClientEffect =\n | { type: \"create-websocket\"; attempt: number }\n | { type: \"close-websocket\" }\n | { type: \"add-channel-and-establish\" }\n | { type: \"remove-channel\" }\n | { type: \"start-reconnect-timer\"; delayMs: number }\n | { type: \"cancel-reconnect-timer\" }\n | { type: \"start-keepalive\" }\n | { type: \"stop-keepalive\" }\n\n// ---------------------------------------------------------------------------\n// Program factory\n// ---------------------------------------------------------------------------\n\nexport interface WsClientProgramOptions {\n reconnect?: Partial<ReconnectOptions>\n /** Inject jitter source for deterministic testing. Default: () => Math.random() * 1000 */\n jitterFn?: () => number\n}\n\n/**\n * Create the websocket client connection lifecycle program — a pure Mealy machine.\n *\n * The returned `Program<WsClientMsg, WebsocketClientState, WsClientEffect>`\n * encodes every state transition and effect as inspectable data. The imperative\n * shell interprets `WsClientEffect` as actual I/O.\n */\nexport function createWsClientProgram(\n options: WsClientProgramOptions = {},\n): Program<WsClientMsg, WebsocketClientState, WsClientEffect> {\n const { jitterFn = () => Math.random() * 1000 } = options\n const reconnect: ReconnectOptions = {\n ...DEFAULT_RECONNECT,\n ...options.reconnect,\n }\n\n /**\n * Attempt to transition into reconnecting, or give up and disconnect.\n *\n * Pure — computes the next state and effects from the current attempt\n * count and reconnect configuration. Returns a tuple suitable for\n * spreading into an `update` return.\n */\n function tryReconnect(\n currentAttempt: number,\n reason: DisconnectReason,\n ...extraEffects: WsClientEffect[]\n ): [WebsocketClientState, ...WsClientEffect[]] {\n if (!reconnect.enabled) {\n return [{ status: \"disconnected\", reason }, ...extraEffects]\n }\n\n if (currentAttempt >= reconnect.maxAttempts) {\n return [\n {\n status: \"disconnected\",\n reason: { type: \"max-retries-exceeded\", attempts: currentAttempt },\n },\n ...extraEffects,\n ]\n }\n\n const delay = computeBackoffDelay(\n currentAttempt + 1,\n reconnect.baseDelay,\n reconnect.maxDelay,\n jitterFn(),\n )\n\n return [\n {\n status: \"reconnecting\",\n attempt: currentAttempt + 1,\n nextAttemptMs: delay,\n },\n ...extraEffects,\n { type: \"start-reconnect-timer\", delayMs: delay },\n ]\n }\n\n return {\n init: [{ status: \"disconnected\" }],\n\n update(msg, model): [WebsocketClientState, ...WsClientEffect[]] {\n switch (msg.type) {\n // -----------------------------------------------------------------\n // start\n // -----------------------------------------------------------------\n case \"start\": {\n if (model.status !== \"disconnected\") return [model]\n return [\n { status: \"connecting\", attempt: 1 },\n { type: \"create-websocket\", attempt: 1 },\n ]\n }\n\n // -----------------------------------------------------------------\n // socket-opened\n // -----------------------------------------------------------------\n case \"socket-opened\": {\n if (model.status !== \"connecting\") return [model]\n return [{ status: \"connected\" }, { type: \"start-keepalive\" }]\n }\n\n // -----------------------------------------------------------------\n // server-ready\n // -----------------------------------------------------------------\n case \"server-ready\": {\n // Already ready — ignore duplicate\n if (model.status === \"ready\") return [model]\n\n // Normal path: connected → ready\n if (model.status === \"connected\") {\n return [{ status: \"ready\" }, { type: \"add-channel-and-establish\" }]\n }\n\n // Race condition: server sent \"ready\" before client's open event fired.\n // Skip connected, go directly to ready with both keepalive and channel effects.\n if (model.status === \"connecting\") {\n return [\n { status: \"ready\" },\n { type: \"start-keepalive\" },\n { type: \"add-channel-and-establish\" },\n ]\n }\n\n return [model]\n }\n\n // -----------------------------------------------------------------\n // socket-closed\n // -----------------------------------------------------------------\n case \"socket-closed\": {\n const reason: DisconnectReason = {\n type: \"closed\",\n code: msg.code,\n reason: msg.reason,\n }\n\n if (model.status === \"connected\") {\n return tryReconnect(0, reason, { type: \"stop-keepalive\" })\n }\n\n if (model.status === \"ready\") {\n return tryReconnect(\n 0,\n reason,\n { type: \"stop-keepalive\" },\n { type: \"remove-channel\" },\n )\n }\n\n return [model]\n }\n\n // -----------------------------------------------------------------\n // socket-error\n // -----------------------------------------------------------------\n case \"socket-error\": {\n const reason: DisconnectReason = {\n type: \"error\",\n error: msg.error,\n }\n\n if (model.status === \"connecting\") {\n return tryReconnect(model.attempt, reason)\n }\n\n if (model.status === \"connected\") {\n return tryReconnect(0, reason, { type: \"stop-keepalive\" })\n }\n\n if (model.status === \"ready\") {\n return tryReconnect(\n 0,\n reason,\n { type: \"stop-keepalive\" },\n { type: \"remove-channel\" },\n )\n }\n\n return [model]\n }\n\n // -----------------------------------------------------------------\n // reconnect-timer-fired\n // -----------------------------------------------------------------\n case \"reconnect-timer-fired\": {\n if (model.status !== \"reconnecting\") return [model]\n return [\n { status: \"connecting\", attempt: model.attempt },\n { type: \"create-websocket\", attempt: model.attempt },\n ]\n }\n\n // -----------------------------------------------------------------\n // stop\n // -----------------------------------------------------------------\n case \"stop\": {\n if (model.status === \"disconnected\") return [model]\n\n const effects: WsClientEffect[] = [{ type: \"cancel-reconnect-timer\" }]\n\n if (model.status === \"connecting\") {\n effects.push({ type: \"close-websocket\" })\n }\n\n if (model.status === \"connected\") {\n effects.push(\n { type: \"close-websocket\" },\n { type: \"stop-keepalive\" },\n )\n }\n\n if (model.status === \"ready\") {\n effects.push(\n { type: \"close-websocket\" },\n { type: \"stop-keepalive\" },\n { type: \"remove-channel\" },\n )\n }\n\n return [\n { status: \"disconnected\", reason: { type: \"intentional\" } },\n ...effects,\n ]\n }\n }\n },\n }\n}\n","// client-transport — Websocket client transport for @kyneta/exchange.\n//\n// Thin imperative shell around the pure client program (client-program.ts).\n// The program produces data effects; this module interprets them as I/O.\n//\n// FC/IS design:\n// - client-program.ts: pure Mealy machine (functional core)\n// - client-transport.ts: effect executor (imperative shell)\n//\n// Uses the kyneta wire format (CBOR codec + framing + fragmentation)\n// for binary messages. Text frames carry the \"ready\" handshake and\n// keepalive ping/pong.\n\nimport type { ObservableHandle, TransitionListener } from \"@kyneta/machine\"\nimport { createObservableProgram } from \"@kyneta/machine\"\nimport type {\n Channel,\n ChannelMsg,\n GeneratedChannel,\n PeerId,\n TransportFactory,\n} from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n decodeBinaryMessages,\n encodeBinaryAndSend,\n FragmentReassembler,\n} from \"@kyneta/wire\"\nimport {\n createWsClientProgram,\n type WsClientEffect,\n type WsClientMsg,\n} from \"./client-program.js\"\nimport type {\n DisconnectReason,\n WebsocketClientState,\n WebsocketClientStateTransition,\n} from \"./types.js\"\n\n// Re-export state types for convenience\nexport type {\n DisconnectReason,\n WebsocketClientState,\n WebsocketClientStateTransition,\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in bytes.\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 * Options for the Websocket client transport (browser connections).\n */\nexport interface WebsocketClientOptions {\n /** Websocket URL to connect to. Can be a string or a function of peerId. */\n url: string | ((peerId: PeerId) => string)\n\n /** Optional custom WebSocket implementation (for Node.js or testing). */\n WebSocket?: typeof globalThis.WebSocket\n\n /** Reconnection options. */\n reconnect?: {\n enabled?: boolean\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** Keepalive interval in ms (default: 30000). */\n keepaliveInterval?: number\n\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\n */\n fragmentThreshold?: number\n\n /** Lifecycle event callbacks. */\n lifecycle?: WebsocketClientLifecycleEvents\n}\n\n/**\n * Lifecycle event callbacks for the Websocket client.\n */\nexport interface WebsocketClientLifecycleEvents {\n /** Called on every state transition (delivered async via microtask). */\n onStateChange?: (transition: WebsocketClientStateTransition) => void\n\n /** Called when the connection is lost. */\n onDisconnect?: (reason: DisconnectReason) => void\n\n /** Called when a reconnection attempt is scheduled. */\n onReconnecting?: (attempt: number, nextAttemptMs: number) => void\n\n /** Called when reconnection succeeds after a previous connection. */\n onReconnected?: () => void\n\n /** Called when the server sends the \"ready\" signal. */\n onReady?: () => void\n}\n\n/**\n * Options for service-to-service Websocket connections.\n * Extends WebsocketClientOptions with header support for authentication.\n *\n * Note: Headers are a Bun/Node-specific extension. The browser WebSocket API\n * does not support custom headers per the WHATWG spec.\n */\nexport interface ServiceWebsocketClientOptions extends WebsocketClientOptions {\n /**\n * Headers to send during Websocket upgrade.\n * Used for authentication in service-to-service communication.\n */\n headers?: Record<string, string>\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketClientTransport\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket client network transport for @kyneta/exchange.\n *\n * Connects to a Websocket server, sends and receives ChannelMsg via\n * the kyneta wire format (CBOR codec + framing + fragmentation).\n *\n * Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —\n * a pure Mealy machine whose transitions are deterministically testable.\n * This class is the imperative shell that interprets data effects as I/O.\n *\n * Prefer the factory functions for construction:\n * - `createWebsocketClient()` — browser-to-server\n * - `createServiceWebsocketClient()` — service-to-service (with headers)\n */\nexport class WebsocketClientTransport extends Transport<void> {\n #peerId?: PeerId\n #options: ServiceWebsocketClientOptions\n #WebSocketImpl: typeof globalThis.WebSocket\n\n // Observable program handle — created in constructor, drives all state\n #handle: ObservableHandle<WsClientMsg, WebsocketClientState>\n\n // Executor-local I/O state — not in the program model\n #socket?: WebSocket\n #serverChannel?: Channel\n #keepaliveTimer?: ReturnType<typeof setInterval>\n #reconnectTimer?: ReturnType<typeof setTimeout>\n\n // Fragmentation\n readonly #fragmentThreshold: number\n readonly #reassembler: FragmentReassembler\n\n constructor(options: ServiceWebsocketClientOptions) {\n super({ transportType: \"websocket-client\" })\n this.#options = options\n this.#WebSocketImpl = options.WebSocket ?? globalThis.WebSocket\n this.#fragmentThreshold =\n options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new FragmentReassembler({\n timeoutMs: 10_000,\n })\n\n const program = createWsClientProgram({\n reconnect: options.reconnect,\n })\n\n this.#handle = createObservableProgram(program, (effect, dispatch) => {\n this.#executeEffect(effect, dispatch)\n })\n\n // Set up lifecycle event forwarding\n this.#setupLifecycleEvents()\n }\n\n // ==========================================================================\n // Effect executor — interprets data effects as I/O\n // ==========================================================================\n\n #executeEffect(\n effect: WsClientEffect,\n dispatch: (msg: WsClientMsg) => void,\n ): void {\n switch (effect.type) {\n case \"create-websocket\": {\n this.#doCreateWebsocket(dispatch)\n break\n }\n\n case \"close-websocket\": {\n if (this.#socket) {\n this.#socket.close(1000, \"Client disconnecting\")\n this.#socket = undefined\n }\n break\n }\n\n case \"add-channel-and-establish\": {\n // Clean up previous channel if it exists (e.g. after reconnect)\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n this.#serverChannel = this.addChannel()\n\n // Establish immediately — the server already signaled ready\n this.establishChannel(this.#serverChannel.channelId)\n break\n }\n\n case \"remove-channel\": {\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n break\n }\n\n case \"start-reconnect-timer\": {\n this.#reconnectTimer = setTimeout(() => {\n this.#reconnectTimer = undefined\n dispatch({ type: \"reconnect-timer-fired\" })\n }, effect.delayMs)\n break\n }\n\n case \"cancel-reconnect-timer\": {\n if (this.#reconnectTimer !== undefined) {\n clearTimeout(this.#reconnectTimer)\n this.#reconnectTimer = undefined\n }\n break\n }\n\n case \"start-keepalive\": {\n this.#startKeepalive()\n break\n }\n\n case \"stop-keepalive\": {\n this.#stopKeepalive()\n break\n }\n }\n }\n\n // ==========================================================================\n // WebSocket creation — the core I/O operation\n // ==========================================================================\n\n /**\n * Create a WebSocket and wire up event handlers to dispatch messages.\n *\n * The message handler is set up IMMEDIATELY after creation (before\n * the open event) to handle the race condition where the server sends\n * \"ready\" before the client's open promise resolves.\n */\n #doCreateWebsocket(dispatch: (msg: WsClientMsg) => void): void {\n const peerId = this.#peerId\n if (!peerId) {\n dispatch({\n type: \"socket-error\",\n error: new Error(\"Cannot connect: peerId not set\"),\n })\n return\n }\n\n // Resolve URL\n const url =\n typeof this.#options.url === \"function\"\n ? this.#options.url(peerId)\n : this.#options.url\n\n try {\n // Create WebSocket with optional headers (Bun-specific extension)\n if (\n this.#options.headers &&\n Object.keys(this.#options.headers).length > 0\n ) {\n type BunWebSocketConstructor = new (\n url: string,\n options: { headers: Record<string, string> },\n ) => WebSocket\n const BunWebSocket = this\n .#WebSocketImpl as unknown as BunWebSocketConstructor\n this.#socket = new BunWebSocket(url, {\n headers: this.#options.headers,\n })\n } else {\n this.#socket = new this.#WebSocketImpl(url)\n }\n this.#socket.binaryType = \"arraybuffer\"\n\n const socket = this.#socket\n\n // Set up message handler IMMEDIATELY to handle the \"ready\" race condition.\n // The server may send \"ready\" before the open event fires.\n socket.addEventListener(\"message\", (event: MessageEvent) => {\n this.#handleMessage(event, dispatch)\n })\n\n // Track whether we've dispatched a terminal event for this connection attempt\n let settled = false\n\n const onOpen = () => {\n cleanup()\n settled = true\n dispatch({ type: \"socket-opened\" })\n\n // After open, set up permanent close handler for post-connection closes\n socket.addEventListener(\"close\", (event: CloseEvent) => {\n dispatch({\n type: \"socket-closed\",\n code: event.code,\n reason: event.reason,\n })\n })\n }\n\n const onError = () => {\n if (settled) return\n cleanup()\n settled = true\n dispatch({\n type: \"socket-error\",\n error: new Error(\"WebSocket connection failed\"),\n })\n }\n\n const onClose = () => {\n if (settled) return\n cleanup()\n settled = true\n dispatch({\n type: \"socket-error\",\n error: new Error(\"WebSocket closed during connection\"),\n })\n }\n\n const cleanup = () => {\n socket.removeEventListener(\"open\", onOpen)\n socket.removeEventListener(\"error\", onError)\n socket.removeEventListener(\"close\", onClose)\n }\n\n socket.addEventListener(\"open\", onOpen)\n socket.addEventListener(\"error\", onError)\n socket.addEventListener(\"close\", onClose)\n } catch (error) {\n dispatch({\n type: \"socket-error\",\n error: error instanceof Error ? error : new Error(String(error)),\n })\n }\n }\n\n // ==========================================================================\n // Message handling — I/O parsing logic\n // ==========================================================================\n\n /**\n * Handle incoming Websocket messages.\n *\n * Text frames carry the \"ready\" handshake and keepalive pong.\n * Binary frames carry CBOR-encoded ChannelMsg.\n */\n #handleMessage(\n event: MessageEvent,\n dispatch: (msg: WsClientMsg) => void,\n ): void {\n const data = event.data\n\n // Handle text messages (keepalive and ready signal)\n if (typeof data === \"string\") {\n if (data === \"ready\") {\n dispatch({ type: \"server-ready\" })\n }\n // Ignore pong responses and other text\n return\n }\n\n // Handle binary messages through shared decode pipeline\n if (data instanceof ArrayBuffer) {\n try {\n const messages = decodeBinaryMessages(\n new Uint8Array(data),\n this.#reassembler,\n )\n if (messages) {\n for (const msg of messages) {\n this.#handleChannelMessage(msg)\n }\n }\n } catch (error) {\n console.error(\"Failed to decode message:\", error)\n }\n }\n }\n\n /**\n * Handle a decoded channel message.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n if (!this.#serverChannel) {\n return\n }\n\n // Deliver synchronously — the Synchronizer's receive queue prevents recursion\n this.#serverChannel.onReceive(msg)\n }\n\n // ==========================================================================\n // Keepalive\n // ==========================================================================\n\n #startKeepalive(): void {\n this.#stopKeepalive()\n\n const interval = this.#options.keepaliveInterval ?? 30_000\n\n this.#keepaliveTimer = setInterval(() => {\n if (this.#socket?.readyState === WebSocket.OPEN) {\n this.#socket.send(\"ping\")\n }\n }, interval)\n }\n\n #stopKeepalive(): void {\n if (this.#keepaliveTimer) {\n clearInterval(this.#keepaliveTimer)\n this.#keepaliveTimer = undefined\n }\n }\n\n // ==========================================================================\n // Lifecycle event forwarding\n // ==========================================================================\n\n #setupLifecycleEvents(): void {\n // wasConnectedBefore is observer-local state, not in the program model\n let wasConnectedBefore = false\n\n this.#handle.subscribeToTransitions(transition => {\n // Forward to onStateChange callback\n this.#options.lifecycle?.onStateChange?.(transition)\n\n const { from, to } = transition\n\n // onDisconnect: transitioning TO disconnected\n if (to.status === \"disconnected\" && to.reason) {\n this.#options.lifecycle?.onDisconnect?.(to.reason)\n }\n\n // onReconnecting: transitioning TO reconnecting\n if (to.status === \"reconnecting\") {\n this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)\n }\n\n // onReconnected: from reconnecting/connecting TO connected/ready (after prior connection)\n if (\n wasConnectedBefore &&\n (from.status === \"reconnecting\" || from.status === \"connecting\") &&\n (to.status === \"connected\" || to.status === \"ready\")\n ) {\n this.#options.lifecycle?.onReconnected?.()\n }\n\n // onReady: transitioning TO ready\n if (to.status === \"ready\") {\n this.#options.lifecycle?.onReady?.()\n wasConnectedBefore = true\n }\n })\n }\n\n // ==========================================================================\n // State observation — delegated to the observable handle\n // ==========================================================================\n\n /**\n * Get the current connection state.\n */\n getState(): WebsocketClientState {\n return this.#handle.getState()\n }\n\n /**\n * Subscribe to state transitions.\n */\n subscribeToTransitions(\n listener: TransitionListener<WebsocketClientState>,\n ): () => void {\n return this.#handle.subscribeToTransitions(listener)\n }\n\n /**\n * Wait for a specific state.\n */\n waitForState(\n predicate: (state: WebsocketClientState) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<WebsocketClientState> {\n return this.#handle.waitForState(predicate, options)\n }\n\n /**\n * Wait for a specific status.\n */\n waitForStatus(\n status: WebsocketClientState[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<WebsocketClientState> {\n return this.#handle.waitForStatus(status, options)\n }\n\n /**\n * Whether the client is ready (server ready signal received).\n */\n get isReady(): boolean {\n return this.#handle.getState().status === \"ready\"\n }\n\n // ==========================================================================\n // Transport abstract method implementations\n // ==========================================================================\n\n protected generate(): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const socket = this.#socket\n if (!socket || socket.readyState !== WebSocket.OPEN) {\n return\n }\n\n encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>\n socket.send(new Uint8Array(data).buffer),\n )\n },\n stop: () => {\n // Don't call disconnect here — channel.stop() is called when\n // the channel is removed, which can happen during effect execution.\n // The actual disconnect is handled by onStop() or the program.\n },\n }\n }\n\n async onStart(): Promise<void> {\n if (!this.identity) {\n throw new Error(\n \"Transport not properly initialized — identity not available\",\n )\n }\n this.#peerId = this.identity.peerId\n this.#handle.dispatch({ type: \"start\" })\n }\n\n async onStop(): Promise<void> {\n this.#reassembler.dispose()\n this.#handle.dispatch({ type: \"stop\" })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory functions\n// ---------------------------------------------------------------------------\n\n/**\n * Create a Websocket client transport factory for browser-to-server\n * connections.\n *\n * Returns an `TransportFactory` — a closure that creates a fresh transport\n * instance when called. Pass directly to `Exchange({ transports: [...] })`.\n *\n * @example\n * ```typescript\n * import { createWebsocketClient } from \"@kyneta/websocket-transport/client\"\n *\n * const exchange = new Exchange({\n * transports: [createWebsocketClient({\n * url: \"ws://localhost:3000/ws\",\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createWebsocketClient(\n options: WebsocketClientOptions,\n): TransportFactory {\n return () => new WebsocketClientTransport(options)\n}\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/client\"\n *\n * const exchange = new Exchange({\n * transports: [createServiceWebsocketClient({\n * url: \"ws://primary-server:3000/ws\",\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":";;;;;AAoBA,SAAS,qBAAqB,yBAAyB;AAgDhD,SAAS,sBACd,UAAkC,CAAC,GACyB;AAC5D,QAAM,EAAE,WAAW,MAAM,KAAK,OAAO,IAAI,IAAK,IAAI;AAClD,QAAM,YAA8B;AAAA,IAClC,GAAG;AAAA,IACH,GAAG,QAAQ;AAAA,EACb;AASA,WAAS,aACP,gBACA,WACG,cAC0C;AAC7C,QAAI,CAAC,UAAU,SAAS;AACtB,aAAO,CAAC,EAAE,QAAQ,gBAAgB,OAAO,GAAG,GAAG,YAAY;AAAA,IAC7D;AAEA,QAAI,kBAAkB,UAAU,aAAa;AAC3C,aAAO;AAAA,QACL;AAAA,UACE,QAAQ;AAAA,UACR,QAAQ,EAAE,MAAM,wBAAwB,UAAU,eAAe;AAAA,QACnE;AAAA,QACA,GAAG;AAAA,MACL;AAAA,IACF;AAEA,UAAM,QAAQ;AAAA,MACZ,iBAAiB;AAAA,MACjB,UAAU;AAAA,MACV,UAAU;AAAA,MACV,SAAS;AAAA,IACX;AAEA,WAAO;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS,iBAAiB;AAAA,QAC1B,eAAe;AAAA,MACjB;AAAA,MACA,GAAG;AAAA,MACH,EAAE,MAAM,yBAAyB,SAAS,MAAM;AAAA,IAClD;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM,CAAC,EAAE,QAAQ,eAAe,CAAC;AAAA,IAEjC,OAAO,KAAK,OAAoD;AAC9D,cAAQ,IAAI,MAAM;AAAA;AAAA;AAAA;AAAA,QAIhB,KAAK,SAAS;AACZ,cAAI,MAAM,WAAW,eAAgB,QAAO,CAAC,KAAK;AAClD,iBAAO;AAAA,YACL,EAAE,QAAQ,cAAc,SAAS,EAAE;AAAA,YACnC,EAAE,MAAM,oBAAoB,SAAS,EAAE;AAAA,UACzC;AAAA,QACF;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,iBAAiB;AACpB,cAAI,MAAM,WAAW,aAAc,QAAO,CAAC,KAAK;AAChD,iBAAO,CAAC,EAAE,QAAQ,YAAY,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAAA,QAC9D;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,gBAAgB;AAEnB,cAAI,MAAM,WAAW,QAAS,QAAO,CAAC,KAAK;AAG3C,cAAI,MAAM,WAAW,aAAa;AAChC,mBAAO,CAAC,EAAE,QAAQ,QAAQ,GAAG,EAAE,MAAM,4BAA4B,CAAC;AAAA,UACpE;AAIA,cAAI,MAAM,WAAW,cAAc;AACjC,mBAAO;AAAA,cACL,EAAE,QAAQ,QAAQ;AAAA,cAClB,EAAE,MAAM,kBAAkB;AAAA,cAC1B,EAAE,MAAM,4BAA4B;AAAA,YACtC;AAAA,UACF;AAEA,iBAAO,CAAC,KAAK;AAAA,QACf;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,iBAAiB;AACpB,gBAAM,SAA2B;AAAA,YAC/B,MAAM;AAAA,YACN,MAAM,IAAI;AAAA,YACV,QAAQ,IAAI;AAAA,UACd;AAEA,cAAI,MAAM,WAAW,aAAa;AAChC,mBAAO,aAAa,GAAG,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAAA,UAC3D;AAEA,cAAI,MAAM,WAAW,SAAS;AAC5B,mBAAO;AAAA,cACL;AAAA,cACA;AAAA,cACA,EAAE,MAAM,iBAAiB;AAAA,cACzB,EAAE,MAAM,iBAAiB;AAAA,YAC3B;AAAA,UACF;AAEA,iBAAO,CAAC,KAAK;AAAA,QACf;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,gBAAgB;AACnB,gBAAM,SAA2B;AAAA,YAC/B,MAAM;AAAA,YACN,OAAO,IAAI;AAAA,UACb;AAEA,cAAI,MAAM,WAAW,cAAc;AACjC,mBAAO,aAAa,MAAM,SAAS,MAAM;AAAA,UAC3C;AAEA,cAAI,MAAM,WAAW,aAAa;AAChC,mBAAO,aAAa,GAAG,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAAA,UAC3D;AAEA,cAAI,MAAM,WAAW,SAAS;AAC5B,mBAAO;AAAA,cACL;AAAA,cACA;AAAA,cACA,EAAE,MAAM,iBAAiB;AAAA,cACzB,EAAE,MAAM,iBAAiB;AAAA,YAC3B;AAAA,UACF;AAEA,iBAAO,CAAC,KAAK;AAAA,QACf;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,yBAAyB;AAC5B,cAAI,MAAM,WAAW,eAAgB,QAAO,CAAC,KAAK;AAClD,iBAAO;AAAA,YACL,EAAE,QAAQ,cAAc,SAAS,MAAM,QAAQ;AAAA,YAC/C,EAAE,MAAM,oBAAoB,SAAS,MAAM,QAAQ;AAAA,UACrD;AAAA,QACF;AAAA;AAAA;AAAA;AAAA,QAKA,KAAK,QAAQ;AACX,cAAI,MAAM,WAAW,eAAgB,QAAO,CAAC,KAAK;AAElD,gBAAM,UAA4B,CAAC,EAAE,MAAM,yBAAyB,CAAC;AAErE,cAAI,MAAM,WAAW,cAAc;AACjC,oBAAQ,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAAA,UAC1C;AAEA,cAAI,MAAM,WAAW,aAAa;AAChC,oBAAQ;AAAA,cACN,EAAE,MAAM,kBAAkB;AAAA,cAC1B,EAAE,MAAM,iBAAiB;AAAA,YAC3B;AAAA,UACF;AAEA,cAAI,MAAM,WAAW,SAAS;AAC5B,oBAAQ;AAAA,cACN,EAAE,MAAM,kBAAkB;AAAA,cAC1B,EAAE,MAAM,iBAAiB;AAAA,cACzB,EAAE,MAAM,iBAAiB;AAAA,YAC3B;AAAA,UACF;AAEA,iBAAO;AAAA,YACL,EAAE,QAAQ,gBAAgB,QAAQ,EAAE,MAAM,cAAc,EAAE;AAAA,YAC1D,GAAG;AAAA,UACL;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;ACjQA,SAAS,+BAA+B;AAQxC,SAAS,iBAAiB;AAC1B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA2BA,IAAM,6BAA6B,MAAM;AAuFzC,IAAM,2BAAN,cAAuC,UAAgB;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGA;AAAA;AAAA,EAGA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAGS;AAAA,EACA;AAAA,EAET,YAAY,SAAwC;AAClD,UAAM,EAAE,eAAe,mBAAmB,CAAC;AAC3C,SAAK,WAAW;AAChB,SAAK,iBAAiB,QAAQ,aAAa,WAAW;AACtD,SAAK,qBACH,QAAQ,qBAAqB;AAC/B,SAAK,eAAe,IAAI,oBAAoB;AAAA,MAC1C,WAAW;AAAA,IACb,CAAC;AAED,UAAM,UAAU,sBAAsB;AAAA,MACpC,WAAW,QAAQ;AAAA,IACrB,CAAC;AAED,SAAK,UAAU,wBAAwB,SAAS,CAAC,QAAQ,aAAa;AACpE,WAAK,eAAe,QAAQ,QAAQ;AAAA,IACtC,CAAC;AAGD,SAAK,sBAAsB;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA,EAMA,eACE,QACA,UACM;AACN,YAAQ,OAAO,MAAM;AAAA,MACnB,KAAK,oBAAoB;AACvB,aAAK,mBAAmB,QAAQ;AAChC;AAAA,MACF;AAAA,MAEA,KAAK,mBAAmB;AACtB,YAAI,KAAK,SAAS;AAChB,eAAK,QAAQ,MAAM,KAAM,sBAAsB;AAC/C,eAAK,UAAU;AAAA,QACjB;AACA;AAAA,MACF;AAAA,MAEA,KAAK,6BAA6B;AAEhC,YAAI,KAAK,gBAAgB;AACvB,eAAK,cAAc,KAAK,eAAe,SAAS;AAChD,eAAK,iBAAiB;AAAA,QACxB;AAEA,aAAK,iBAAiB,KAAK,WAAW;AAGtC,aAAK,iBAAiB,KAAK,eAAe,SAAS;AACnD;AAAA,MACF;AAAA,MAEA,KAAK,kBAAkB;AACrB,YAAI,KAAK,gBAAgB;AACvB,eAAK,cAAc,KAAK,eAAe,SAAS;AAChD,eAAK,iBAAiB;AAAA,QACxB;AACA;AAAA,MACF;AAAA,MAEA,KAAK,yBAAyB;AAC5B,aAAK,kBAAkB,WAAW,MAAM;AACtC,eAAK,kBAAkB;AACvB,mBAAS,EAAE,MAAM,wBAAwB,CAAC;AAAA,QAC5C,GAAG,OAAO,OAAO;AACjB;AAAA,MACF;AAAA,MAEA,KAAK,0BAA0B;AAC7B,YAAI,KAAK,oBAAoB,QAAW;AACtC,uBAAa,KAAK,eAAe;AACjC,eAAK,kBAAkB;AAAA,QACzB;AACA;AAAA,MACF;AAAA,MAEA,KAAK,mBAAmB;AACtB,aAAK,gBAAgB;AACrB;AAAA,MACF;AAAA,MAEA,KAAK,kBAAkB;AACrB,aAAK,eAAe;AACpB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,mBAAmB,UAA4C;AAC7D,UAAM,SAAS,KAAK;AACpB,QAAI,CAAC,QAAQ;AACX,eAAS;AAAA,QACP,MAAM;AAAA,QACN,OAAO,IAAI,MAAM,gCAAgC;AAAA,MACnD,CAAC;AACD;AAAA,IACF;AAGA,UAAM,MACJ,OAAO,KAAK,SAAS,QAAQ,aACzB,KAAK,SAAS,IAAI,MAAM,IACxB,KAAK,SAAS;AAEpB,QAAI;AAEF,UACE,KAAK,SAAS,WACd,OAAO,KAAK,KAAK,SAAS,OAAO,EAAE,SAAS,GAC5C;AAKA,cAAM,eAAe,KAClB;AACH,aAAK,UAAU,IAAI,aAAa,KAAK;AAAA,UACnC,SAAS,KAAK,SAAS;AAAA,QACzB,CAAC;AAAA,MACH,OAAO;AACL,aAAK,UAAU,IAAI,KAAK,eAAe,GAAG;AAAA,MAC5C;AACA,WAAK,QAAQ,aAAa;AAE1B,YAAM,SAAS,KAAK;AAIpB,aAAO,iBAAiB,WAAW,CAAC,UAAwB;AAC1D,aAAK,eAAe,OAAO,QAAQ;AAAA,MACrC,CAAC;AAGD,UAAI,UAAU;AAEd,YAAM,SAAS,MAAM;AACnB,gBAAQ;AACR,kBAAU;AACV,iBAAS,EAAE,MAAM,gBAAgB,CAAC;AAGlC,eAAO,iBAAiB,SAAS,CAAC,UAAsB;AACtD,mBAAS;AAAA,YACP,MAAM;AAAA,YACN,MAAM,MAAM;AAAA,YACZ,QAAQ,MAAM;AAAA,UAChB,CAAC;AAAA,QACH,CAAC;AAAA,MACH;AAEA,YAAM,UAAU,MAAM;AACpB,YAAI,QAAS;AACb,gBAAQ;AACR,kBAAU;AACV,iBAAS;AAAA,UACP,MAAM;AAAA,UACN,OAAO,IAAI,MAAM,6BAA6B;AAAA,QAChD,CAAC;AAAA,MACH;AAEA,YAAM,UAAU,MAAM;AACpB,YAAI,QAAS;AACb,gBAAQ;AACR,kBAAU;AACV,iBAAS;AAAA,UACP,MAAM;AAAA,UACN,OAAO,IAAI,MAAM,oCAAoC;AAAA,QACvD,CAAC;AAAA,MACH;AAEA,YAAM,UAAU,MAAM;AACpB,eAAO,oBAAoB,QAAQ,MAAM;AACzC,eAAO,oBAAoB,SAAS,OAAO;AAC3C,eAAO,oBAAoB,SAAS,OAAO;AAAA,MAC7C;AAEA,aAAO,iBAAiB,QAAQ,MAAM;AACtC,aAAO,iBAAiB,SAAS,OAAO;AACxC,aAAO,iBAAiB,SAAS,OAAO;AAAA,IAC1C,SAAS,OAAO;AACd,eAAS;AAAA,QACP,MAAM;AAAA,QACN,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AAAA,MACjE,CAAC;AAAA,IACH;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,eACE,OACA,UACM;AACN,UAAM,OAAO,MAAM;AAGnB,QAAI,OAAO,SAAS,UAAU;AAC5B,UAAI,SAAS,SAAS;AACpB,iBAAS,EAAE,MAAM,eAAe,CAAC;AAAA,MACnC;AAEA;AAAA,IACF;AAGA,QAAI,gBAAgB,aAAa;AAC/B,UAAI;AACF,cAAM,WAAW;AAAA,UACf,IAAI,WAAW,IAAI;AAAA,UACnB,KAAK;AAAA,QACP;AACA,YAAI,UAAU;AACZ,qBAAW,OAAO,UAAU;AAC1B,iBAAK,sBAAsB,GAAG;AAAA,UAChC;AAAA,QACF;AAAA,MACF,SAAS,OAAO;AACd,gBAAQ,MAAM,6BAA6B,KAAK;AAAA,MAClD;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,sBAAsB,KAAuB;AAC3C,QAAI,CAAC,KAAK,gBAAgB;AACxB;AAAA,IACF;AAGA,SAAK,eAAe,UAAU,GAAG;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA,EAMA,kBAAwB;AACtB,SAAK,eAAe;AAEpB,UAAM,WAAW,KAAK,SAAS,qBAAqB;AAEpD,SAAK,kBAAkB,YAAY,MAAM;AACvC,UAAI,KAAK,SAAS,eAAe,UAAU,MAAM;AAC/C,aAAK,QAAQ,KAAK,MAAM;AAAA,MAC1B;AAAA,IACF,GAAG,QAAQ;AAAA,EACb;AAAA,EAEA,iBAAuB;AACrB,QAAI,KAAK,iBAAiB;AACxB,oBAAc,KAAK,eAAe;AAClC,WAAK,kBAAkB;AAAA,IACzB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,wBAA8B;AAE5B,QAAI,qBAAqB;AAEzB,SAAK,QAAQ,uBAAuB,gBAAc;AAEhD,WAAK,SAAS,WAAW,gBAAgB,UAAU;AAEnD,YAAM,EAAE,MAAM,GAAG,IAAI;AAGrB,UAAI,GAAG,WAAW,kBAAkB,GAAG,QAAQ;AAC7C,aAAK,SAAS,WAAW,eAAe,GAAG,MAAM;AAAA,MACnD;AAGA,UAAI,GAAG,WAAW,gBAAgB;AAChC,aAAK,SAAS,WAAW,iBAAiB,GAAG,SAAS,GAAG,aAAa;AAAA,MACxE;AAGA,UACE,uBACC,KAAK,WAAW,kBAAkB,KAAK,WAAW,kBAClD,GAAG,WAAW,eAAe,GAAG,WAAW,UAC5C;AACA,aAAK,SAAS,WAAW,gBAAgB;AAAA,MAC3C;AAGA,UAAI,GAAG,WAAW,SAAS;AACzB,aAAK,SAAS,WAAW,UAAU;AACnC,6BAAqB;AAAA,MACvB;AAAA,IACF,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,WAAiC;AAC/B,WAAO,KAAK,QAAQ,SAAS;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA,EAKA,uBACE,UACY;AACZ,WAAO,KAAK,QAAQ,uBAAuB,QAAQ;AAAA,EACrD;AAAA;AAAA;AAAA;AAAA,EAKA,aACE,WACA,SAC+B;AAC/B,WAAO,KAAK,QAAQ,aAAa,WAAW,OAAO;AAAA,EACrD;AAAA;AAAA;AAAA;AAAA,EAKA,cACE,QACA,SAC+B;AAC/B,WAAO,KAAK,QAAQ,cAAc,QAAQ,OAAO;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,UAAmB;AACrB,WAAO,KAAK,QAAQ,SAAS,EAAE,WAAW;AAAA,EAC5C;AAAA;AAAA;AAAA;AAAA,EAMU,WAA6B;AACrC,WAAO;AAAA,MACL,eAAe,KAAK;AAAA,MACpB,MAAM,CAAC,QAAoB;AACzB,cAAM,SAAS,KAAK;AACpB,YAAI,CAAC,UAAU,OAAO,eAAe,UAAU,MAAM;AACnD;AAAA,QACF;AAEA;AAAA,UAAoB;AAAA,UAAK,KAAK;AAAA,UAAoB,UAChD,OAAO,KAAK,IAAI,WAAW,IAAI,EAAE,MAAM;AAAA,QACzC;AAAA,MACF;AAAA,MACA,MAAM,MAAM;AAAA,MAIZ;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,UAAyB;AAC7B,QAAI,CAAC,KAAK,UAAU;AAClB,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,SAAK,UAAU,KAAK,SAAS;AAC7B,SAAK,QAAQ,SAAS,EAAE,MAAM,QAAQ,CAAC;AAAA,EACzC;AAAA,EAEA,MAAM,SAAwB;AAC5B,SAAK,aAAa,QAAQ;AAC1B,SAAK,QAAQ,SAAS,EAAE,MAAM,OAAO,CAAC;AAAA,EACxC;AACF;AAyBO,SAAS,sBACd,SACkB;AAClB,SAAO,MAAM,IAAI,yBAAyB,OAAO;AACnD;AAyBO,SAAS,6BACd,SACkB;AAClB,SAAO,MAAM,IAAI,yBAAyB,OAAO;AACnD;","names":[]}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/
|
|
2
|
-
import { S as Socket, c as WebsocketConnectionOptions, d as WebsocketConnectionResult } from './types-
|
|
3
|
-
export { D as DisconnectReason, N as NodeWebsocketLike, a as SocketReadyState, e as WebsocketConnectionHandle, f as wrapNodeWebsocket, w as wrapStandardWebsocket } from './types-
|
|
1
|
+
import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/transport';
|
|
2
|
+
import { S as Socket, c as WebsocketConnectionOptions, d as WebsocketConnectionResult } from './types-DdNb8cAz.js';
|
|
3
|
+
export { D as DisconnectReason, N as NodeWebsocketLike, a as SocketReadyState, e as WebsocketConnectionHandle, f as wrapNodeWebsocket, w as wrapStandardWebsocket } from './types-DdNb8cAz.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Default fragment threshold in bytes.
|
package/dist/server.js
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
wrapNodeWebsocket,
|
|
3
3
|
wrapStandardWebsocket
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-PSG3LLT5.js";
|
|
5
5
|
|
|
6
6
|
// src/server-transport.ts
|
|
7
|
-
import { Transport } from "@kyneta/
|
|
7
|
+
import { Transport } from "@kyneta/transport";
|
|
8
8
|
|
|
9
9
|
// src/connection.ts
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
FragmentReassembler,
|
|
15
|
-
fragmentPayload,
|
|
16
|
-
wrapCompleteMessage
|
|
11
|
+
decodeBinaryMessages,
|
|
12
|
+
encodeBinaryAndSend,
|
|
13
|
+
FragmentReassembler
|
|
17
14
|
} from "@kyneta/wire";
|
|
18
15
|
var DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024;
|
|
19
16
|
var WebsocketConnection = class {
|
|
@@ -77,15 +74,11 @@ var WebsocketConnection = class {
|
|
|
77
74
|
if (this.#socket.readyState !== "open") {
|
|
78
75
|
return;
|
|
79
76
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
} else {
|
|
87
|
-
this.#socket.send(wrapCompleteMessage(frame));
|
|
88
|
-
}
|
|
77
|
+
encodeBinaryAndSend(
|
|
78
|
+
msg,
|
|
79
|
+
this.#fragmentThreshold,
|
|
80
|
+
(data) => this.#socket.send(data)
|
|
81
|
+
);
|
|
89
82
|
}
|
|
90
83
|
/**
|
|
91
84
|
* Send a "ready" signal to the client.
|
|
@@ -118,19 +111,15 @@ var WebsocketConnection = class {
|
|
|
118
111
|
this.#handleKeepalive(data);
|
|
119
112
|
return;
|
|
120
113
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const frame = decodeBinaryFrame(result.data);
|
|
125
|
-
const messages = cborCodec.decode(frame.content.payload);
|
|
114
|
+
try {
|
|
115
|
+
const messages = decodeBinaryMessages(data, this.#reassembler);
|
|
116
|
+
if (messages) {
|
|
126
117
|
for (const msg of messages) {
|
|
127
118
|
this.#handleChannelMessage(msg);
|
|
128
119
|
}
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.error("Failed to decode wire message:", error);
|
|
131
120
|
}
|
|
132
|
-
}
|
|
133
|
-
console.error("
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error("Failed to decode wire message:", error);
|
|
134
123
|
}
|
|
135
124
|
}
|
|
136
125
|
/**
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server-transport.ts","../src/connection.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-network-adapter/server\"\n// import { createBunWebsocketHandlers } from \"@kyneta/websocket-network-adapter/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-network-adapter/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/exchange\"\nimport { Transport } from \"@kyneta/exchange\"\nimport {\n DEFAULT_FRAGMENT_THRESHOLD,\n WebsocketConnection,\n type WebsocketConnectionConfig,\n} from \"./connection.js\"\nimport type {\n Socket,\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/exchange\"\nimport {\n cborCodec,\n decodeBinaryFrame,\n encodeComplete,\n FragmentReassembler,\n fragmentPayload,\n wrapCompleteMessage,\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 const frame = encodeComplete(cborCodec, msg)\n\n // Fragment large payloads for cloud infrastructure compatibility\n if (this.#fragmentThreshold > 0 && frame.length > this.#fragmentThreshold) {\n const fragments = fragmentPayload(frame, this.#fragmentThreshold)\n for (const fragment of fragments) {\n this.#socket.send(fragment)\n }\n } else {\n // Wrap with MESSAGE_COMPLETE prefix for transport layer consistency\n this.#socket.send(wrapCompleteMessage(frame))\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 reassembler\n const result = this.#reassembler.receiveRaw(data)\n\n if (result.status === \"complete\") {\n try {\n const frame = decodeBinaryFrame(result.data)\n const messages = cborCodec.decode(frame.content.payload)\n for (const msg of messages) {\n this.#handleChannelMessage(msg)\n }\n } catch (error) {\n console.error(\"Failed to decode wire message:\", error)\n }\n } else if (result.status === \"error\") {\n console.error(\"Fragment reassembly error:\", result.error)\n }\n // \"pending\" status means we're waiting for more fragments — nothing to do\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"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/server-transport.ts","../src/connection.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-network-adapter/server\"\n// import { createBunWebsocketHandlers } from \"@kyneta/websocket-network-adapter/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-network-adapter/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"],"mappings":"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","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { StateTransition, TransitionListener as TransitionListener$1, PeerId } from '@kyneta/
|
|
1
|
+
import { StateTransition, TransitionListener as TransitionListener$1, PeerId } from '@kyneta/transport';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Websocket ready states — mirrors the standard WebSocket readyState
|
|
@@ -146,4 +146,4 @@ interface NodeWebsocketLike {
|
|
|
146
146
|
*/
|
|
147
147
|
declare function wrapNodeWebsocket(ws: NodeWebsocketLike): Socket;
|
|
148
148
|
|
|
149
|
-
export { type DisconnectReason as D, type NodeWebsocketLike as N, type Socket as S, type TransitionListener as T, type
|
|
149
|
+
export { type DisconnectReason as D, type NodeWebsocketLike as N, type Socket as S, type TransitionListener as T, type WebsocketClientState as W, type SocketReadyState as a, type WebsocketClientStateTransition as b, type WebsocketConnectionOptions as c, type WebsocketConnectionResult as d, type WebsocketConnectionHandle as e, wrapNodeWebsocket as f, wrapStandardWebsocket as w };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/websocket-transport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Websocket network adapter for @kyneta/exchange — client, server, and Bun integration",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"./src/*": "./src/*"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@kyneta/
|
|
37
|
-
"@kyneta/
|
|
36
|
+
"@kyneta/transport": "^1.3.0",
|
|
37
|
+
"@kyneta/machine": "^1.3.0",
|
|
38
|
+
"@kyneta/wire": "^1.3.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/node": "^22",
|
|
@@ -42,9 +43,11 @@
|
|
|
42
43
|
"tsup": "^8.5.0",
|
|
43
44
|
"typescript": "^5.9.2",
|
|
44
45
|
"vitest": "^4.0.17",
|
|
45
|
-
"@kyneta/
|
|
46
|
-
"@kyneta/
|
|
47
|
-
"@kyneta/
|
|
46
|
+
"@kyneta/exchange": "^1.3.0",
|
|
47
|
+
"@kyneta/transport": "^1.3.0",
|
|
48
|
+
"@kyneta/schema": "^1.3.0",
|
|
49
|
+
"@kyneta/wire": "^1.3.0",
|
|
50
|
+
"@kyneta/machine": "^1.3.0"
|
|
48
51
|
},
|
|
49
52
|
"scripts": {
|
|
50
53
|
"build": "tsup",
|