@kyneta/websocket-transport 1.2.0 → 1.3.1
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 +28 -15
- package/dist/browser.d.ts +58 -0
- package/dist/browser.js +15 -0
- package/dist/browser.js.map +1 -0
- package/dist/bun.d.ts +6 -6
- package/dist/bun.js.map +1 -1
- package/dist/{client.js → chunk-YZQF5RLV.js} +123 -17
- package/dist/chunk-YZQF5RLV.js.map +1 -0
- package/dist/{client.d.ts → client-transport-DUAFjVbh.d.ts} +25 -100
- package/dist/server.d.ts +41 -4
- package/dist/server.js +10 -1
- package/dist/server.js.map +1 -1
- package/dist/{types-DdNb8cAz.d.ts → types-D0lbeevu.d.ts} +50 -2
- package/package.json +13 -13
- package/src/__tests__/client-transport.test.ts +168 -0
- package/src/{client.ts → browser.ts} +15 -10
- package/src/bun-websocket.ts +5 -5
- package/src/bun.ts +1 -1
- package/src/client-transport.ts +42 -66
- package/src/server-transport.ts +3 -3
- package/src/server.ts +18 -4
- package/src/service-client.ts +52 -0
- package/src/types.ts +74 -6
- package/dist/chunk-PSG3LLT5.js +0 -111
- package/dist/chunk-PSG3LLT5.js.map +0 -1
- package/dist/client.js.map +0 -1
|
@@ -1,58 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
export { S as Socket, a as SocketReadyState, T as TransitionListener, w as wrapStandardWebsocket } from './types-DdNb8cAz.js';
|
|
5
|
-
|
|
6
|
-
type WsClientMsg = {
|
|
7
|
-
type: "start";
|
|
8
|
-
} | {
|
|
9
|
-
type: "socket-opened";
|
|
10
|
-
} | {
|
|
11
|
-
type: "server-ready";
|
|
12
|
-
} | {
|
|
13
|
-
type: "socket-closed";
|
|
14
|
-
code: number;
|
|
15
|
-
reason: string;
|
|
16
|
-
} | {
|
|
17
|
-
type: "socket-error";
|
|
18
|
-
error: Error;
|
|
19
|
-
} | {
|
|
20
|
-
type: "reconnect-timer-fired";
|
|
21
|
-
} | {
|
|
22
|
-
type: "stop";
|
|
23
|
-
};
|
|
24
|
-
type WsClientEffect = {
|
|
25
|
-
type: "create-websocket";
|
|
26
|
-
attempt: number;
|
|
27
|
-
} | {
|
|
28
|
-
type: "close-websocket";
|
|
29
|
-
} | {
|
|
30
|
-
type: "add-channel-and-establish";
|
|
31
|
-
} | {
|
|
32
|
-
type: "remove-channel";
|
|
33
|
-
} | {
|
|
34
|
-
type: "start-reconnect-timer";
|
|
35
|
-
delayMs: number;
|
|
36
|
-
} | {
|
|
37
|
-
type: "cancel-reconnect-timer";
|
|
38
|
-
} | {
|
|
39
|
-
type: "start-keepalive";
|
|
40
|
-
} | {
|
|
41
|
-
type: "stop-keepalive";
|
|
42
|
-
};
|
|
43
|
-
interface WsClientProgramOptions {
|
|
44
|
-
reconnect?: Partial<ReconnectOptions>;
|
|
45
|
-
/** Inject jitter source for deterministic testing. Default: () => Math.random() * 1000 */
|
|
46
|
-
jitterFn?: () => number;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Create the websocket client connection lifecycle program — a pure Mealy machine.
|
|
50
|
-
*
|
|
51
|
-
* The returned `Program<WsClientMsg, WebsocketClientState, WsClientEffect>`
|
|
52
|
-
* encodes every state transition and effect as inspectable data. The imperative
|
|
53
|
-
* shell interprets `WsClientEffect` as actual I/O.
|
|
54
|
-
*/
|
|
55
|
-
declare function createWsClientProgram(options?: WsClientProgramOptions): Program<WsClientMsg, WebsocketClientState, WsClientEffect>;
|
|
1
|
+
import { TransitionListener } from '@kyneta/machine';
|
|
2
|
+
import { PeerId, Transport, GeneratedChannel, TransportFactory } from '@kyneta/transport';
|
|
3
|
+
import { f as WebsocketClientStateTransition, D as DisconnectReason, c as WebSocketConstructor, W as WebsocketClientState } from './types-D0lbeevu.js';
|
|
56
4
|
|
|
57
5
|
/**
|
|
58
6
|
* Default fragment threshold in bytes.
|
|
@@ -65,8 +13,22 @@ declare const DEFAULT_FRAGMENT_THRESHOLD: number;
|
|
|
65
13
|
interface WebsocketClientOptions {
|
|
66
14
|
/** Websocket URL to connect to. Can be a string or a function of peerId. */
|
|
67
15
|
url: string | ((peerId: PeerId) => string);
|
|
68
|
-
/**
|
|
69
|
-
|
|
16
|
+
/**
|
|
17
|
+
* WebSocket constructor — caller must provide explicitly.
|
|
18
|
+
*
|
|
19
|
+
* In browsers, pass the global `WebSocket`. In Node.js, pass `ws`'s
|
|
20
|
+
* `WebSocket`. In Bun, pass `globalThis.WebSocket`. The transport
|
|
21
|
+
* never probes `globalThis` on its own.
|
|
22
|
+
*/
|
|
23
|
+
WebSocket: WebSocketConstructor;
|
|
24
|
+
/**
|
|
25
|
+
* Headers to send during Websocket upgrade.
|
|
26
|
+
* Used for authentication in service-to-service communication.
|
|
27
|
+
*
|
|
28
|
+
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket
|
|
29
|
+
* API does not support custom headers per the WHATWG spec.
|
|
30
|
+
*/
|
|
31
|
+
headers?: Record<string, string>;
|
|
70
32
|
/** Reconnection options. */
|
|
71
33
|
reconnect?: {
|
|
72
34
|
enabled?: boolean;
|
|
@@ -100,20 +62,6 @@ interface WebsocketClientLifecycleEvents {
|
|
|
100
62
|
/** Called when the server sends the "ready" signal. */
|
|
101
63
|
onReady?: () => void;
|
|
102
64
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Options for service-to-service Websocket connections.
|
|
105
|
-
* Extends WebsocketClientOptions with header support for authentication.
|
|
106
|
-
*
|
|
107
|
-
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket API
|
|
108
|
-
* does not support custom headers per the WHATWG spec.
|
|
109
|
-
*/
|
|
110
|
-
interface ServiceWebsocketClientOptions extends WebsocketClientOptions {
|
|
111
|
-
/**
|
|
112
|
-
* Headers to send during Websocket upgrade.
|
|
113
|
-
* Used for authentication in service-to-service communication.
|
|
114
|
-
*/
|
|
115
|
-
headers?: Record<string, string>;
|
|
116
|
-
}
|
|
117
65
|
/**
|
|
118
66
|
* Websocket client network transport for @kyneta/exchange.
|
|
119
67
|
*
|
|
@@ -125,12 +73,12 @@ interface ServiceWebsocketClientOptions extends WebsocketClientOptions {
|
|
|
125
73
|
* This class is the imperative shell that interprets data effects as I/O.
|
|
126
74
|
*
|
|
127
75
|
* Prefer the factory functions for construction:
|
|
128
|
-
* - `createWebsocketClient()` — browser-to-server
|
|
129
|
-
* - `createServiceWebsocketClient()` — service-to-service
|
|
76
|
+
* - `createWebsocketClient()` — browser-to-server (from `./browser`)
|
|
77
|
+
* - `createServiceWebsocketClient()` — service-to-service with headers (from `./server`)
|
|
130
78
|
*/
|
|
131
79
|
declare class WebsocketClientTransport extends Transport<void> {
|
|
132
80
|
#private;
|
|
133
|
-
constructor(options:
|
|
81
|
+
constructor(options: WebsocketClientOptions);
|
|
134
82
|
/**
|
|
135
83
|
* Get the current connection state.
|
|
136
84
|
*/
|
|
@@ -168,40 +116,17 @@ declare class WebsocketClientTransport extends Transport<void> {
|
|
|
168
116
|
*
|
|
169
117
|
* @example
|
|
170
118
|
* ```typescript
|
|
171
|
-
* import { createWebsocketClient } from "@kyneta/websocket-transport/
|
|
119
|
+
* import { createWebsocketClient } from "@kyneta/websocket-transport/browser"
|
|
172
120
|
*
|
|
173
121
|
* const exchange = new Exchange({
|
|
174
122
|
* transports: [createWebsocketClient({
|
|
175
123
|
* url: "ws://localhost:3000/ws",
|
|
124
|
+
* WebSocket,
|
|
176
125
|
* reconnect: { enabled: true },
|
|
177
126
|
* })],
|
|
178
127
|
* })
|
|
179
128
|
* ```
|
|
180
129
|
*/
|
|
181
130
|
declare function createWebsocketClient(options: WebsocketClientOptions): TransportFactory;
|
|
182
|
-
/**
|
|
183
|
-
* Create a Websocket client transport for service-to-service connections.
|
|
184
|
-
*
|
|
185
|
-
* This factory is for backend environments (Bun, Node.js) where you need
|
|
186
|
-
* to pass authentication headers during the Websocket upgrade.
|
|
187
|
-
*
|
|
188
|
-
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket API
|
|
189
|
-
* does not support custom headers. For browser clients, use
|
|
190
|
-
* `createWebsocketClient()` and authenticate via URL query parameters.
|
|
191
|
-
*
|
|
192
|
-
* @example
|
|
193
|
-
* ```typescript
|
|
194
|
-
* import { createServiceWebsocketClient } from "@kyneta/websocket-transport/client"
|
|
195
|
-
*
|
|
196
|
-
* const exchange = new Exchange({
|
|
197
|
-
* transports: [createServiceWebsocketClient({
|
|
198
|
-
* url: "ws://primary-server:3000/ws",
|
|
199
|
-
* headers: { Authorization: "Bearer token" },
|
|
200
|
-
* reconnect: { enabled: true },
|
|
201
|
-
* })],
|
|
202
|
-
* })
|
|
203
|
-
* ```
|
|
204
|
-
*/
|
|
205
|
-
declare function createServiceWebsocketClient(options: ServiceWebsocketClientOptions): TransportFactory;
|
|
206
131
|
|
|
207
|
-
export { DEFAULT_FRAGMENT_THRESHOLD
|
|
132
|
+
export { DEFAULT_FRAGMENT_THRESHOLD as D, type WebsocketClientLifecycleEvents as W, type WebsocketClientOptions as a, WebsocketClientTransport as b, createWebsocketClient as c };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/transport';
|
|
2
|
-
import { S as Socket,
|
|
3
|
-
export { D as DisconnectReason, N as NodeWebsocketLike, a as SocketReadyState, e as WebsocketConnectionHandle,
|
|
1
|
+
import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel, TransportFactory } from '@kyneta/transport';
|
|
2
|
+
import { S as Socket, g as WebsocketConnectionOptions, h as WebsocketConnectionResult } from './types-D0lbeevu.js';
|
|
3
|
+
export { D as DisconnectReason, N as NodeWebsocketLike, R as READY_STATE, a as SocketReadyState, b as WebSocketCloseEvent, c as WebSocketConstructor, d as WebSocketLike, e as WebSocketMessageEvent, i as WebsocketConnectionHandle, w as wrapNodeWebsocket, j as wrapStandardWebsocket } from './types-D0lbeevu.js';
|
|
4
|
+
import { a as WebsocketClientOptions } from './client-transport-DUAFjVbh.js';
|
|
5
|
+
import '@kyneta/machine';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Default fragment threshold in bytes.
|
|
@@ -158,4 +160,39 @@ declare class WebsocketServerTransport extends Transport<PeerId> {
|
|
|
158
160
|
get connectionCount(): number;
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Options for service-to-service Websocket connections.
|
|
165
|
+
*
|
|
166
|
+
* Identical to `WebsocketClientOptions` — the `headers` field is always
|
|
167
|
+
* available on the base options. This alias exists for API clarity:
|
|
168
|
+
* importing `ServiceWebsocketClientOptions` from `./server` signals
|
|
169
|
+
* intent and pairs with `createServiceWebsocketClient`.
|
|
170
|
+
*/
|
|
171
|
+
type ServiceWebsocketClientOptions = WebsocketClientOptions;
|
|
172
|
+
/**
|
|
173
|
+
* Create a Websocket client transport for service-to-service connections.
|
|
174
|
+
*
|
|
175
|
+
* This factory is for backend environments (Bun, Node.js) where you need
|
|
176
|
+
* to pass authentication headers during the Websocket upgrade.
|
|
177
|
+
*
|
|
178
|
+
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket API
|
|
179
|
+
* does not support custom headers. For browser clients, use
|
|
180
|
+
* `createWebsocketClient()` and authenticate via URL query parameters.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```typescript
|
|
184
|
+
* import { createServiceWebsocketClient } from "@kyneta/websocket-transport/server"
|
|
185
|
+
*
|
|
186
|
+
* const exchange = new Exchange({
|
|
187
|
+
* transports: [createServiceWebsocketClient({
|
|
188
|
+
* url: "ws://primary-server:3000/ws",
|
|
189
|
+
* WebSocket,
|
|
190
|
+
* headers: { Authorization: "Bearer token" },
|
|
191
|
+
* reconnect: { enabled: true },
|
|
192
|
+
* })],
|
|
193
|
+
* })
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
declare function createServiceWebsocketClient(options: ServiceWebsocketClientOptions): TransportFactory;
|
|
197
|
+
|
|
198
|
+
export { DEFAULT_FRAGMENT_THRESHOLD, type ServiceWebsocketClientOptions, Socket, WebsocketConnection, type WebsocketConnectionConfig, WebsocketConnectionOptions, WebsocketConnectionResult, WebsocketServerTransport, type WebsocketServerTransportOptions, createServiceWebsocketClient };
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
READY_STATE,
|
|
3
|
+
WebsocketClientTransport,
|
|
2
4
|
wrapNodeWebsocket,
|
|
3
5
|
wrapStandardWebsocket
|
|
4
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-YZQF5RLV.js";
|
|
5
7
|
|
|
6
8
|
// src/server-transport.ts
|
|
7
9
|
import { Transport } from "@kyneta/transport";
|
|
@@ -294,10 +296,17 @@ var WebsocketServerTransport = class extends Transport {
|
|
|
294
296
|
return this.#connections.size;
|
|
295
297
|
}
|
|
296
298
|
};
|
|
299
|
+
|
|
300
|
+
// src/service-client.ts
|
|
301
|
+
function createServiceWebsocketClient(options) {
|
|
302
|
+
return () => new WebsocketClientTransport(options);
|
|
303
|
+
}
|
|
297
304
|
export {
|
|
298
305
|
DEFAULT_FRAGMENT_THRESHOLD,
|
|
306
|
+
READY_STATE,
|
|
299
307
|
WebsocketConnection,
|
|
300
308
|
WebsocketServerTransport,
|
|
309
|
+
createServiceWebsocketClient,
|
|
301
310
|
wrapNodeWebsocket,
|
|
302
311
|
wrapStandardWebsocket
|
|
303
312
|
};
|
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/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
|
+
{"version":3,"sources":["../src/server-transport.ts","../src/connection.ts","../src/service-client.ts"],"sourcesContent":["// server-adapter — Websocket server adapter for @kyneta/exchange.\n//\n// Manages Websocket connections from clients, encoding/decoding via the\n// kyneta wire format. Framework-agnostic — works with any Websocket\n// library through the Socket interface.\n//\n// Usage with Bun:\n// import { WebsocketServerTransport } from \"@kyneta/websocket-transport/server\"\n// import { createBunWebsocketHandlers } from \"@kyneta/websocket-transport/bun\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// Bun.serve({\n// websocket: createBunWebsocketHandlers(serverAdapter),\n// fetch(req, server) { server.upgrade(req); return new Response(\"\", { status: 101 }) },\n// })\n//\n// Usage with Node.js `ws`:\n// import { WebsocketServerTransport, wrapNodeWebsocket } from \"@kyneta/websocket-transport/server\"\n// import { WebSocketServer } from \"ws\"\n//\n// const serverAdapter = new WebsocketServerTransport()\n// const wss = new WebSocketServer({ server })\n// wss.on(\"connection\", (ws) => {\n// const { start } = serverAdapter.handleConnection({ socket: wrapNodeWebsocket(ws) })\n// start()\n// })\n//\n// Ported from @loro-extended/adapter-websocket's WsServerNetworkAdapter with\n// kyneta naming conventions and the kyneta 5-message protocol.\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n DEFAULT_FRAGMENT_THRESHOLD,\n WebsocketConnection,\n} from \"./connection.js\"\nimport type {\n WebsocketConnectionOptions,\n WebsocketConnectionResult,\n} from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the Websocket server adapter.\n */\nexport interface WebsocketServerTransportOptions {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// Peer ID generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a random peer ID for connections that don't provide one.\n */\nfunction generatePeerId(): PeerId {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n let result = \"ws-\"\n for (let i = 0; i < 12; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length))\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// WebsocketServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * Websocket server network adapter.\n *\n * Framework-agnostic — works with any Websocket library through the\n * `Socket` interface. Use `handleConnection()` to integrate with your\n * framework's Websocket upgrade handler.\n *\n * Each client connection is tracked as a `WebsocketConnection` keyed\n * by peer ID. The adapter creates a channel per connection and routes\n * outbound messages through the connection's send method.\n *\n * The connection handshake follows a two-phase protocol:\n * 1. Server sends text `\"ready\"` signal (transport-level)\n * 2. Client sends `establish-request` (protocol-level)\n * 3. Server responds with `establish-response` (handled by Synchronizer)\n *\n * The server does NOT call `establishChannel()` — it waits for the\n * client's establish-request to avoid a race condition where the binary\n * establish-request could arrive before the client has processed \"ready\".\n */\nexport class WebsocketServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, WebsocketConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: WebsocketServerTransportOptions) {\n super({ transportType: \"websocket-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via handleConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.close(1001, \"Server shutting down\")\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Handle a new Websocket connection.\n *\n * Call this from your framework's Websocket upgrade handler.\n * Returns a connection handle and a `start()` function that begins\n * message processing and sends the \"ready\" signal.\n *\n * @param options - Connection options including the Socket and optional peer ID\n * @returns A connection handle and start function\n *\n * @example Bun\n * ```typescript\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapBunWebsocket(ws),\n * })\n * start()\n * ```\n *\n * @example Node.js ws\n * ```typescript\n * wss.on(\"connection\", (ws) => {\n * const { start } = serverAdapter.handleConnection({\n * socket: wrapNodeWebsocket(ws),\n * })\n * start()\n * })\n * ```\n */\n handleConnection(\n options: WebsocketConnectionOptions,\n ): WebsocketConnectionResult {\n const { socket, peerId: providedPeerId } = options\n\n // Generate peer ID if not provided\n const peerId = providedPeerId ?? generatePeerId()\n\n // Check for existing connection with same peer ID\n const existingConnection = this.#connections.get(peerId)\n if (existingConnection) {\n existingConnection.close(1000, \"Replaced by new connection\")\n this.unregisterConnection(peerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(peerId)\n\n // Create connection object with fragmentation config\n const connection = new WebsocketConnection(\n peerId,\n channel.channelId,\n socket,\n {\n fragmentThreshold: this.#fragmentThreshold,\n },\n )\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(peerId, connection)\n\n // Set up close handler\n socket.onClose((_code, _reason) => {\n this.unregisterConnection(peerId)\n })\n\n socket.onError(_error => {\n this.unregisterConnection(peerId)\n })\n\n return {\n connection,\n start: () => {\n connection.start()\n\n // Send ready signal to client so it knows the server is ready\n // This is a transport-level signal, separate from protocol-level establishment\n connection.sendReady()\n\n // NOTE: We do NOT call establishChannel() here.\n // The client will send establish-request after receiving \"ready\".\n // Our channel gets established when the Synchronizer receives\n // and processes that establish-request.\n //\n // This prevents a race condition where our binary establish-request\n // could arrive before the client has processed \"ready\" and created\n // its channel.\n },\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): WebsocketConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): WebsocketConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Unregister a connection, removing its channel and cleaning up state.\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Broadcast a message to all connected peers.\n */\n broadcast(msg: ChannelMsg): void {\n for (const connection of this.#connections.values()) {\n connection.send(msg)\n }\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n","// connection — WebsocketConnection for server-side peer connections.\n//\n// Wraps a Socket + CBOR codec + FragmentReassembler to provide\n// send/receive for ChannelMsg over a single Websocket connection.\n//\n// Used by WebsocketServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single socket with reconnection logic.\n//\n// Ported from @loro-extended/adapter-websocket's WsConnection with\n// kyneta naming conventions and the kyneta wire format.\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport {\n decodeBinaryMessages,\n encodeBinaryAndSend,\n FragmentReassembler,\n} from \"@kyneta/wire\"\nimport type { Socket } from \"./types.js\"\n\n/**\n * Default fragment threshold in bytes.\n * Messages larger than this are fragmented for cloud infrastructure compatibility.\n * AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024\n\n/**\n * Configuration for creating a WebsocketConnection.\n */\nexport interface WebsocketConnectionConfig {\n /**\n * Fragment threshold in bytes. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation (not recommended for cloud deployments).\n * Default: 100KB (safe for AWS API Gateway's 128KB limit)\n */\n fragmentThreshold?: number\n}\n\n/**\n * Represents a single Websocket connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, and reassembly for one\n * connected client. Created by `WebsocketServerTransport.handleConnection()`.\n *\n * The connection uses the CBOR codec for binary transport — this is\n * the natural choice for Websocket's binary frame support.\n */\nexport class WebsocketConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #socket: Socket\n #channel: Channel | null = null\n #started = false\n\n // Fragmentation support\n readonly #fragmentThreshold: number\n readonly #reassembler: FragmentReassembler\n\n constructor(\n peerId: PeerId,\n channelId: number,\n socket: Socket,\n config?: WebsocketConnectionConfig,\n ) {\n this.peerId = peerId\n this.channelId = channelId\n this.#socket = socket\n this.#fragmentThreshold =\n config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new FragmentReassembler({\n timeoutMs: 10_000,\n onTimeout: (frameId: string) => {\n console.warn(\n `[WebsocketConnection] Fragment batch timed out: ${frameId}`,\n )\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Start processing messages on this connection.\n *\n * Sets up the message handler on the socket. Must be called after\n * the connection is fully set up (channel assigned, stored in adapter).\n */\n start(): void {\n if (this.#started) {\n return\n }\n this.#started = true\n\n this.#socket.onMessage(data => {\n this.#handleMessage(data)\n })\n }\n\n /**\n * Send a ChannelMsg through the Websocket.\n *\n * Encodes via CBOR codec → frame → fragment if needed → socket.send().\n */\n send(msg: ChannelMsg): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n\n encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>\n this.#socket.send(data),\n )\n }\n\n /**\n * Send a \"ready\" signal to the client.\n *\n * This is a transport-level text message that tells the client the\n * server is ready to receive protocol messages. The client creates\n * its channel and sends establish-request after receiving this.\n */\n sendReady(): void {\n if (this.#socket.readyState !== \"open\") {\n return\n }\n this.#socket.send(\"ready\")\n }\n\n /**\n * Close the connection and clean up resources.\n */\n close(code?: number, reason?: string): void {\n this.#reassembler.dispose()\n this.#socket.close(code, reason)\n }\n\n // ==========================================================================\n // INTERNAL — message handling\n // ==========================================================================\n\n /**\n * Handle an incoming message from the Websocket.\n */\n #handleMessage(data: Uint8Array | string): void {\n // Handle keepalive ping/pong (text frames)\n if (typeof data === \"string\") {\n this.#handleKeepalive(data)\n return\n }\n\n // Handle binary protocol messages through shared decode pipeline\n try {\n const messages = decodeBinaryMessages(data, this.#reassembler)\n if (messages) {\n for (const msg of messages) {\n this.#handleChannelMessage(msg)\n }\n }\n } catch (error) {\n console.error(\"Failed to decode wire message:\", error)\n }\n }\n\n /**\n * Handle a decoded channel message.\n *\n * Delivers messages synchronously. The Synchronizer's receive queue\n * handles recursion prevention by queuing messages and processing\n * them iteratively.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n if (!this.#channel) {\n console.error(\"Cannot handle message: channel not set\")\n return\n }\n\n // Deliver synchronously — the Synchronizer's receive queue prevents recursion\n this.#channel.onReceive(msg)\n }\n\n /**\n * Handle keepalive ping/pong messages.\n */\n #handleKeepalive(text: string): void {\n if (text === \"ping\") {\n this.#socket.send(\"pong\")\n }\n // Ignore \"pong\" and \"ready\" responses\n }\n}\n","// service-client — service-to-service WebSocket client factory.\n//\n// Extracted from client-transport.ts so that the service client factory\n// lives in the `./server` entry point (where it belongs) rather than\n// the `./browser` entry point. Backend code imports from `./server`;\n// browser code imports from `./browser`.\n\nimport type { TransportFactory } from \"@kyneta/transport\"\nimport {\n WebsocketClientTransport,\n type WebsocketClientOptions,\n} from \"./client-transport.js\"\n\n/**\n * Options for service-to-service Websocket connections.\n *\n * Identical to `WebsocketClientOptions` — the `headers` field is always\n * available on the base options. This alias exists for API clarity:\n * importing `ServiceWebsocketClientOptions` from `./server` signals\n * intent and pairs with `createServiceWebsocketClient`.\n */\nexport type ServiceWebsocketClientOptions = WebsocketClientOptions\n\n/**\n * Create a Websocket client transport for service-to-service connections.\n *\n * This factory is for backend environments (Bun, Node.js) where you need\n * to pass authentication headers during the Websocket upgrade.\n *\n * Note: Headers are a Bun/Node-specific extension. The browser WebSocket API\n * does not support custom headers. For browser clients, use\n * `createWebsocketClient()` and authenticate via URL query parameters.\n *\n * @example\n * ```typescript\n * import { createServiceWebsocketClient } from \"@kyneta/websocket-transport/server\"\n *\n * const exchange = new Exchange({\n * transports: [createServiceWebsocketClient({\n * url: \"ws://primary-server:3000/ws\",\n * WebSocket,\n * headers: { Authorization: \"Bearer token\" },\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createServiceWebsocketClient(\n options: ServiceWebsocketClientOptions,\n): TransportFactory {\n return () => new WebsocketClientTransport(options)\n}"],"mappings":"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","names":[]}
|
|
@@ -1,5 +1,53 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TransitionListener as TransitionListener$1, StateTransition, PeerId } from '@kyneta/transport';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* WebSocket readyState constants per the WHATWG WebSocket spec.
|
|
5
|
+
* Replaces references to `WebSocket.CONNECTING`, `WebSocket.OPEN`, etc.
|
|
6
|
+
* so that shared code never depends on the browser global.
|
|
7
|
+
*/
|
|
8
|
+
declare const READY_STATE: {
|
|
9
|
+
readonly CONNECTING: 0;
|
|
10
|
+
readonly OPEN: 1;
|
|
11
|
+
readonly CLOSING: 2;
|
|
12
|
+
readonly CLOSED: 3;
|
|
13
|
+
};
|
|
14
|
+
/** Minimal message event — only the fields the transport accesses. */
|
|
15
|
+
interface WebSocketMessageEvent {
|
|
16
|
+
readonly data: string | ArrayBuffer;
|
|
17
|
+
}
|
|
18
|
+
/** Minimal close event — only the fields the transport accesses. */
|
|
19
|
+
interface WebSocketCloseEvent {
|
|
20
|
+
readonly code: number;
|
|
21
|
+
readonly reason: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Structural type for a constructed WebSocket instance.
|
|
25
|
+
*
|
|
26
|
+
* Covers the browser's `WebSocket`, the `ws` library's `WebSocket`,
|
|
27
|
+
* and Bun's client `WebSocket` — all satisfy this interface without casting.
|
|
28
|
+
*
|
|
29
|
+
* The client transport uses `addEventListener`/`removeEventListener` for
|
|
30
|
+
* one-shot connection handlers with explicit cleanup during the connect
|
|
31
|
+
* phase. This is why `WebSocketLike` exists alongside the server-side
|
|
32
|
+
* `Socket` interface (which uses single-callback registration).
|
|
33
|
+
*/
|
|
34
|
+
interface WebSocketLike {
|
|
35
|
+
readonly readyState: number;
|
|
36
|
+
binaryType: string;
|
|
37
|
+
send(data: string | ArrayBufferLike | Uint8Array): void;
|
|
38
|
+
close(code?: number, reason?: string): void;
|
|
39
|
+
addEventListener(type: string, listener: (event: any) => void): void;
|
|
40
|
+
removeEventListener(type: string, listener: (event: any) => void): void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Structural type for a WebSocket constructor.
|
|
44
|
+
*
|
|
45
|
+
* Type safety for constructor arguments is intentionally at the options
|
|
46
|
+
* layer (`WebsocketClientOptions.headers`), not here. The `...rest: any[]`
|
|
47
|
+
* absorbs both the browser's `protocols` arg and backend's `{ headers }`
|
|
48
|
+
* arg without requiring the transport to know which runtime it's in.
|
|
49
|
+
*/
|
|
50
|
+
type WebSocketConstructor = new (url: string, ...rest: any[]) => WebSocketLike;
|
|
3
51
|
/**
|
|
4
52
|
* Websocket ready states — mirrors the standard WebSocket readyState
|
|
5
53
|
* values as human-readable strings.
|
|
@@ -146,4 +194,4 @@ interface NodeWebsocketLike {
|
|
|
146
194
|
*/
|
|
147
195
|
declare function wrapNodeWebsocket(ws: NodeWebsocketLike): Socket;
|
|
148
196
|
|
|
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
|
|
197
|
+
export { type DisconnectReason as D, type NodeWebsocketLike as N, READY_STATE as R, type Socket as S, type TransitionListener as T, type WebsocketClientState as W, type SocketReadyState as a, type WebSocketCloseEvent as b, type WebSocketConstructor as c, type WebSocketLike as d, type WebSocketMessageEvent as e, type WebsocketClientStateTransition as f, type WebsocketConnectionOptions as g, type WebsocketConnectionResult as h, type WebsocketConnectionHandle as i, wrapStandardWebsocket as j, wrapNodeWebsocket as w };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/websocket-transport",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Websocket network adapter for @kyneta/exchange —
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Websocket network adapter for @kyneta/exchange — browser, server, and Bun integration",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"src"
|
|
19
19
|
],
|
|
20
20
|
"exports": {
|
|
21
|
-
"./
|
|
22
|
-
"types": "./dist/
|
|
23
|
-
"import": "./dist/
|
|
21
|
+
"./browser": {
|
|
22
|
+
"types": "./dist/browser.d.ts",
|
|
23
|
+
"import": "./dist/browser.js"
|
|
24
24
|
},
|
|
25
25
|
"./server": {
|
|
26
26
|
"types": "./dist/server.d.ts",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"./src/*": "./src/*"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@kyneta/transport": "^1.
|
|
37
|
-
"@kyneta/machine": "^1.
|
|
38
|
-
"@kyneta/wire": "^1.
|
|
36
|
+
"@kyneta/transport": "^1.3.1",
|
|
37
|
+
"@kyneta/machine": "^1.3.1",
|
|
38
|
+
"@kyneta/wire": "^1.3.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^22",
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
"tsup": "^8.5.0",
|
|
44
44
|
"typescript": "^5.9.2",
|
|
45
45
|
"vitest": "^4.0.17",
|
|
46
|
-
"@kyneta/exchange": "^1.
|
|
47
|
-
"@kyneta/
|
|
48
|
-
"@kyneta/
|
|
49
|
-
"@kyneta/
|
|
50
|
-
"@kyneta/
|
|
46
|
+
"@kyneta/exchange": "^1.3.1",
|
|
47
|
+
"@kyneta/machine": "^1.3.1",
|
|
48
|
+
"@kyneta/wire": "^1.3.1",
|
|
49
|
+
"@kyneta/transport": "^1.3.1",
|
|
50
|
+
"@kyneta/schema": "^1.3.1"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "tsup",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// client-transport.test — unit tests for the websocket client transport's
|
|
2
|
+
// constructor injection and header-passing behavior.
|
|
3
|
+
//
|
|
4
|
+
// These tests verify two properties:
|
|
5
|
+
// 1. The transport uses the caller-provided WebSocket constructor.
|
|
6
|
+
// 2. When `headers` are provided, they're passed as `{ headers }` in the
|
|
7
|
+
// second argument. When absent, the constructor is called with just the URL.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from "vitest"
|
|
10
|
+
import type { TransportContext } from "@kyneta/transport"
|
|
11
|
+
import type { PeerIdentityDetails } from "@kyneta/transport"
|
|
12
|
+
import { WebsocketClientTransport } from "../client-transport.js"
|
|
13
|
+
import type { WebSocketConstructor, WebSocketLike } from "../types.js"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Mock WebSocket
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface MockCall {
|
|
20
|
+
url: string
|
|
21
|
+
rest: any[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a mock WebSocket class that records constructor invocations
|
|
26
|
+
* and implements WebSocketLike with no-op methods.
|
|
27
|
+
*/
|
|
28
|
+
function createMockWebSocketClass() {
|
|
29
|
+
const calls: MockCall[] = []
|
|
30
|
+
|
|
31
|
+
const MockWebSocket = vi.fn(function (
|
|
32
|
+
this: WebSocketLike,
|
|
33
|
+
url: string,
|
|
34
|
+
...rest: any[]
|
|
35
|
+
) {
|
|
36
|
+
calls.push({ url, rest })
|
|
37
|
+
;(this as any).readyState = 0
|
|
38
|
+
;(this as any).binaryType = "blob"
|
|
39
|
+
;(this as any).send = vi.fn()
|
|
40
|
+
;(this as any).close = vi.fn()
|
|
41
|
+
;(this as any).addEventListener = vi.fn()
|
|
42
|
+
;(this as any).removeEventListener = vi.fn()
|
|
43
|
+
}) as unknown as WebSocketConstructor
|
|
44
|
+
|
|
45
|
+
return { MockWebSocket, calls }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Transport lifecycle helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const testIdentity: PeerIdentityDetails = {
|
|
53
|
+
peerId: "test-peer-123",
|
|
54
|
+
name: "Test Peer",
|
|
55
|
+
type: "user",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createTransportContext(
|
|
59
|
+
overrides: Partial<TransportContext> = {},
|
|
60
|
+
): TransportContext {
|
|
61
|
+
return {
|
|
62
|
+
identity: testIdentity,
|
|
63
|
+
onChannelReceive: vi.fn(),
|
|
64
|
+
onChannelAdded: vi.fn(),
|
|
65
|
+
onChannelRemoved: vi.fn(),
|
|
66
|
+
onChannelEstablish: vi.fn(),
|
|
67
|
+
...overrides,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Initialize and start a WebsocketClientTransport through the full
|
|
73
|
+
* Transport lifecycle so the program's "create-websocket" effect fires.
|
|
74
|
+
*/
|
|
75
|
+
async function startTransport(transport: WebsocketClientTransport) {
|
|
76
|
+
const ctx = createTransportContext()
|
|
77
|
+
transport._initialize(ctx)
|
|
78
|
+
await transport._start()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tests
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("WebsocketClientTransport — constructor injection", () => {
|
|
86
|
+
it("calls the provided WebSocket constructor with the URL", async () => {
|
|
87
|
+
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
88
|
+
|
|
89
|
+
const transport = new WebsocketClientTransport({
|
|
90
|
+
url: "ws://localhost:9999/ws",
|
|
91
|
+
WebSocket: MockWebSocket,
|
|
92
|
+
reconnect: { enabled: false },
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await startTransport(transport)
|
|
96
|
+
|
|
97
|
+
expect(calls).toHaveLength(1)
|
|
98
|
+
expect(calls[0]!.url).toBe("ws://localhost:9999/ws")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("resolves URL function with peerId before passing to constructor", async () => {
|
|
102
|
+
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
103
|
+
|
|
104
|
+
const transport = new WebsocketClientTransport({
|
|
105
|
+
url: (peerId) => `ws://localhost:9999/ws/${peerId}`,
|
|
106
|
+
WebSocket: MockWebSocket,
|
|
107
|
+
reconnect: { enabled: false },
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await startTransport(transport)
|
|
111
|
+
|
|
112
|
+
expect(calls).toHaveLength(1)
|
|
113
|
+
expect(calls[0]!.url).toBe(`ws://localhost:9999/ws/${testIdentity.peerId}`)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("WebsocketClientTransport — header passing", () => {
|
|
118
|
+
it("passes { headers } as second arg when headers are non-empty", async () => {
|
|
119
|
+
const headers = {
|
|
120
|
+
Authorization: "Bearer test-token",
|
|
121
|
+
"X-Custom": "value",
|
|
122
|
+
}
|
|
123
|
+
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
124
|
+
|
|
125
|
+
const transport = new WebsocketClientTransport({
|
|
126
|
+
url: "ws://localhost:9999/ws",
|
|
127
|
+
WebSocket: MockWebSocket,
|
|
128
|
+
headers,
|
|
129
|
+
reconnect: { enabled: false },
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await startTransport(transport)
|
|
133
|
+
|
|
134
|
+
expect(calls).toHaveLength(1)
|
|
135
|
+
expect(calls[0]!.rest).toEqual([{ headers }])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("omits second arg when headers are not provided", async () => {
|
|
139
|
+
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
140
|
+
|
|
141
|
+
const transport = new WebsocketClientTransport({
|
|
142
|
+
url: "ws://localhost:9999/ws",
|
|
143
|
+
WebSocket: MockWebSocket,
|
|
144
|
+
reconnect: { enabled: false },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
await startTransport(transport)
|
|
148
|
+
|
|
149
|
+
expect(calls).toHaveLength(1)
|
|
150
|
+
expect(calls[0]!.rest).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("omits second arg when headers is an empty object", async () => {
|
|
154
|
+
const { MockWebSocket, calls } = createMockWebSocketClass()
|
|
155
|
+
|
|
156
|
+
const transport = new WebsocketClientTransport({
|
|
157
|
+
url: "ws://localhost:9999/ws",
|
|
158
|
+
WebSocket: MockWebSocket,
|
|
159
|
+
headers: {},
|
|
160
|
+
reconnect: { enabled: false },
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await startTransport(transport)
|
|
164
|
+
|
|
165
|
+
expect(calls).toHaveLength(1)
|
|
166
|
+
expect(calls[0]!.rest).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
})
|