@kyneta/websocket-transport 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/bun.d.ts +91 -0
- package/dist/bun.js +48 -0
- package/dist/bun.js.map +1 -0
- package/dist/chunk-5FHT54WT.js +109 -0
- package/dist/chunk-5FHT54WT.js.map +1 -0
- package/dist/client.d.ts +185 -0
- package/dist/client.js +418 -0
- package/dist/client.js.map +1 -0
- package/dist/server.d.ts +161 -0
- package/dist/server.js +315 -0
- package/dist/server.js.map +1 -0
- package/dist/types-DG_89zA4.d.ts +149 -0
- package/package.json +54 -0
- package/src/__tests__/client-state-machine.test.ts +472 -0
- package/src/bun-websocket.ts +163 -0
- package/src/bun.ts +24 -0
- package/src/client-state-machine.ts +78 -0
- package/src/client-transport.ts +711 -0
- package/src/client.ts +39 -0
- package/src/connection.ts +224 -0
- package/src/server-transport.ts +282 -0
- package/src/server.ts +39 -0
- package/src/types.ts +308 -0
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/exchange';
|
|
2
|
+
import { S as Socket, c as WebsocketConnectionOptions, d as WebsocketConnectionResult } from './types-DG_89zA4.js';
|
|
3
|
+
export { D as DisconnectReason, N as NodeWebsocketLike, a as SocketReadyState, e as WebsocketConnectionHandle, f as wrapNodeWebsocket, w as wrapStandardWebsocket } from './types-DG_89zA4.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default fragment threshold in bytes.
|
|
7
|
+
* Messages larger than this are fragmented for cloud infrastructure compatibility.
|
|
8
|
+
* AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.
|
|
9
|
+
*/
|
|
10
|
+
declare const DEFAULT_FRAGMENT_THRESHOLD: number;
|
|
11
|
+
/**
|
|
12
|
+
* Configuration for creating a WebsocketConnection.
|
|
13
|
+
*/
|
|
14
|
+
interface WebsocketConnectionConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Fragment threshold in bytes. Messages larger than this are fragmented.
|
|
17
|
+
* Set to 0 to disable fragmentation (not recommended for cloud deployments).
|
|
18
|
+
* Default: 100KB (safe for AWS API Gateway's 128KB limit)
|
|
19
|
+
*/
|
|
20
|
+
fragmentThreshold?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Represents a single Websocket connection to a peer (server-side).
|
|
24
|
+
*
|
|
25
|
+
* Manages encoding, framing, fragmentation, and reassembly for one
|
|
26
|
+
* connected client. Created by `WebsocketServerTransport.handleConnection()`.
|
|
27
|
+
*
|
|
28
|
+
* The connection uses the CBOR codec for binary transport — this is
|
|
29
|
+
* the natural choice for Websocket's binary frame support.
|
|
30
|
+
*/
|
|
31
|
+
declare class WebsocketConnection {
|
|
32
|
+
#private;
|
|
33
|
+
readonly peerId: PeerId;
|
|
34
|
+
readonly channelId: number;
|
|
35
|
+
constructor(peerId: PeerId, channelId: number, socket: Socket, config?: WebsocketConnectionConfig);
|
|
36
|
+
/**
|
|
37
|
+
* Set the channel reference.
|
|
38
|
+
* Called by the adapter when the channel is created.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
_setChannel(channel: Channel): void;
|
|
42
|
+
/**
|
|
43
|
+
* Start processing messages on this connection.
|
|
44
|
+
*
|
|
45
|
+
* Sets up the message handler on the socket. Must be called after
|
|
46
|
+
* the connection is fully set up (channel assigned, stored in adapter).
|
|
47
|
+
*/
|
|
48
|
+
start(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Send a ChannelMsg through the Websocket.
|
|
51
|
+
*
|
|
52
|
+
* Encodes via CBOR codec → frame → fragment if needed → socket.send().
|
|
53
|
+
*/
|
|
54
|
+
send(msg: ChannelMsg): void;
|
|
55
|
+
/**
|
|
56
|
+
* Send a "ready" signal to the client.
|
|
57
|
+
*
|
|
58
|
+
* This is a transport-level text message that tells the client the
|
|
59
|
+
* server is ready to receive protocol messages. The client creates
|
|
60
|
+
* its channel and sends establish-request after receiving this.
|
|
61
|
+
*/
|
|
62
|
+
sendReady(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Close the connection and clean up resources.
|
|
65
|
+
*/
|
|
66
|
+
close(code?: number, reason?: string): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options for the Websocket server adapter.
|
|
71
|
+
*/
|
|
72
|
+
interface WebsocketServerTransportOptions {
|
|
73
|
+
/**
|
|
74
|
+
* Fragment threshold in bytes. Messages larger than this are fragmented.
|
|
75
|
+
* Set to 0 to disable fragmentation (not recommended for cloud deployments).
|
|
76
|
+
* Default: 100KB (safe for AWS API Gateway's 128KB limit)
|
|
77
|
+
*/
|
|
78
|
+
fragmentThreshold?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Websocket server network adapter.
|
|
82
|
+
*
|
|
83
|
+
* Framework-agnostic — works with any Websocket library through the
|
|
84
|
+
* `Socket` interface. Use `handleConnection()` to integrate with your
|
|
85
|
+
* framework's Websocket upgrade handler.
|
|
86
|
+
*
|
|
87
|
+
* Each client connection is tracked as a `WebsocketConnection` keyed
|
|
88
|
+
* by peer ID. The adapter creates a channel per connection and routes
|
|
89
|
+
* outbound messages through the connection's send method.
|
|
90
|
+
*
|
|
91
|
+
* The connection handshake follows a two-phase protocol:
|
|
92
|
+
* 1. Server sends text `"ready"` signal (transport-level)
|
|
93
|
+
* 2. Client sends `establish-request` (protocol-level)
|
|
94
|
+
* 3. Server responds with `establish-response` (handled by Synchronizer)
|
|
95
|
+
*
|
|
96
|
+
* The server does NOT call `establishChannel()` — it waits for the
|
|
97
|
+
* client's establish-request to avoid a race condition where the binary
|
|
98
|
+
* establish-request could arrive before the client has processed "ready".
|
|
99
|
+
*/
|
|
100
|
+
declare class WebsocketServerTransport extends Transport<PeerId> {
|
|
101
|
+
#private;
|
|
102
|
+
constructor(options?: WebsocketServerTransportOptions);
|
|
103
|
+
protected generate(peerId: PeerId): GeneratedChannel;
|
|
104
|
+
onStart(): Promise<void>;
|
|
105
|
+
onStop(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Handle a new Websocket connection.
|
|
108
|
+
*
|
|
109
|
+
* Call this from your framework's Websocket upgrade handler.
|
|
110
|
+
* Returns a connection handle and a `start()` function that begins
|
|
111
|
+
* message processing and sends the "ready" signal.
|
|
112
|
+
*
|
|
113
|
+
* @param options - Connection options including the Socket and optional peer ID
|
|
114
|
+
* @returns A connection handle and start function
|
|
115
|
+
*
|
|
116
|
+
* @example Bun
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const { start } = serverAdapter.handleConnection({
|
|
119
|
+
* socket: wrapBunWebsocket(ws),
|
|
120
|
+
* })
|
|
121
|
+
* start()
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @example Node.js ws
|
|
125
|
+
* ```typescript
|
|
126
|
+
* wss.on("connection", (ws) => {
|
|
127
|
+
* const { start } = serverAdapter.handleConnection({
|
|
128
|
+
* socket: wrapNodeWebsocket(ws),
|
|
129
|
+
* })
|
|
130
|
+
* start()
|
|
131
|
+
* })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
handleConnection(options: WebsocketConnectionOptions): WebsocketConnectionResult;
|
|
135
|
+
/**
|
|
136
|
+
* Get an active connection by peer ID.
|
|
137
|
+
*/
|
|
138
|
+
getConnection(peerId: PeerId): WebsocketConnection | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* Get all active connections.
|
|
141
|
+
*/
|
|
142
|
+
getAllConnections(): WebsocketConnection[];
|
|
143
|
+
/**
|
|
144
|
+
* Check if a peer is connected.
|
|
145
|
+
*/
|
|
146
|
+
isConnected(peerId: PeerId): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Unregister a connection, removing its channel and cleaning up state.
|
|
149
|
+
*/
|
|
150
|
+
unregisterConnection(peerId: PeerId): void;
|
|
151
|
+
/**
|
|
152
|
+
* Broadcast a message to all connected peers.
|
|
153
|
+
*/
|
|
154
|
+
broadcast(msg: ChannelMsg): void;
|
|
155
|
+
/**
|
|
156
|
+
* Get the number of connected peers.
|
|
157
|
+
*/
|
|
158
|
+
get connectionCount(): number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { DEFAULT_FRAGMENT_THRESHOLD, Socket, WebsocketConnection, type WebsocketConnectionConfig, WebsocketConnectionOptions, WebsocketConnectionResult, WebsocketServerTransport, type WebsocketServerTransportOptions };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
wrapNodeWebsocket,
|
|
3
|
+
wrapStandardWebsocket
|
|
4
|
+
} from "./chunk-5FHT54WT.js";
|
|
5
|
+
|
|
6
|
+
// src/server-transport.ts
|
|
7
|
+
import { Transport } from "@kyneta/exchange";
|
|
8
|
+
|
|
9
|
+
// src/connection.ts
|
|
10
|
+
import {
|
|
11
|
+
cborCodec,
|
|
12
|
+
decodeBinaryFrame,
|
|
13
|
+
encodeComplete,
|
|
14
|
+
FragmentReassembler,
|
|
15
|
+
fragmentPayload,
|
|
16
|
+
wrapCompleteMessage
|
|
17
|
+
} from "@kyneta/wire";
|
|
18
|
+
var DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024;
|
|
19
|
+
var WebsocketConnection = class {
|
|
20
|
+
peerId;
|
|
21
|
+
channelId;
|
|
22
|
+
#socket;
|
|
23
|
+
#channel = null;
|
|
24
|
+
#started = false;
|
|
25
|
+
// Fragmentation support
|
|
26
|
+
#fragmentThreshold;
|
|
27
|
+
#reassembler;
|
|
28
|
+
constructor(peerId, channelId, socket, config) {
|
|
29
|
+
this.peerId = peerId;
|
|
30
|
+
this.channelId = channelId;
|
|
31
|
+
this.#socket = socket;
|
|
32
|
+
this.#fragmentThreshold = config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
|
|
33
|
+
this.#reassembler = new FragmentReassembler({
|
|
34
|
+
timeoutMs: 1e4,
|
|
35
|
+
onTimeout: (frameId) => {
|
|
36
|
+
console.warn(
|
|
37
|
+
`[WebsocketConnection] Fragment batch timed out: ${frameId}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// ==========================================================================
|
|
43
|
+
// INTERNAL API — for adapter use
|
|
44
|
+
// ==========================================================================
|
|
45
|
+
/**
|
|
46
|
+
* Set the channel reference.
|
|
47
|
+
* Called by the adapter when the channel is created.
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
_setChannel(channel) {
|
|
51
|
+
this.#channel = channel;
|
|
52
|
+
}
|
|
53
|
+
// ==========================================================================
|
|
54
|
+
// PUBLIC API
|
|
55
|
+
// ==========================================================================
|
|
56
|
+
/**
|
|
57
|
+
* Start processing messages on this connection.
|
|
58
|
+
*
|
|
59
|
+
* Sets up the message handler on the socket. Must be called after
|
|
60
|
+
* the connection is fully set up (channel assigned, stored in adapter).
|
|
61
|
+
*/
|
|
62
|
+
start() {
|
|
63
|
+
if (this.#started) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.#started = true;
|
|
67
|
+
this.#socket.onMessage((data) => {
|
|
68
|
+
this.#handleMessage(data);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Send a ChannelMsg through the Websocket.
|
|
73
|
+
*
|
|
74
|
+
* Encodes via CBOR codec → frame → fragment if needed → socket.send().
|
|
75
|
+
*/
|
|
76
|
+
send(msg) {
|
|
77
|
+
if (this.#socket.readyState !== "open") {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const frame = encodeComplete(cborCodec, msg);
|
|
81
|
+
if (this.#fragmentThreshold > 0 && frame.length > this.#fragmentThreshold) {
|
|
82
|
+
const fragments = fragmentPayload(frame, this.#fragmentThreshold);
|
|
83
|
+
for (const fragment of fragments) {
|
|
84
|
+
this.#socket.send(fragment);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
this.#socket.send(wrapCompleteMessage(frame));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Send a "ready" signal to the client.
|
|
92
|
+
*
|
|
93
|
+
* This is a transport-level text message that tells the client the
|
|
94
|
+
* server is ready to receive protocol messages. The client creates
|
|
95
|
+
* its channel and sends establish-request after receiving this.
|
|
96
|
+
*/
|
|
97
|
+
sendReady() {
|
|
98
|
+
if (this.#socket.readyState !== "open") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.#socket.send("ready");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Close the connection and clean up resources.
|
|
105
|
+
*/
|
|
106
|
+
close(code, reason) {
|
|
107
|
+
this.#reassembler.dispose();
|
|
108
|
+
this.#socket.close(code, reason);
|
|
109
|
+
}
|
|
110
|
+
// ==========================================================================
|
|
111
|
+
// INTERNAL — message handling
|
|
112
|
+
// ==========================================================================
|
|
113
|
+
/**
|
|
114
|
+
* Handle an incoming message from the Websocket.
|
|
115
|
+
*/
|
|
116
|
+
#handleMessage(data) {
|
|
117
|
+
if (typeof data === "string") {
|
|
118
|
+
this.#handleKeepalive(data);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const result = this.#reassembler.receiveRaw(data);
|
|
122
|
+
if (result.status === "complete") {
|
|
123
|
+
try {
|
|
124
|
+
const frame = decodeBinaryFrame(result.data);
|
|
125
|
+
const messages = cborCodec.decode(frame.content.payload);
|
|
126
|
+
for (const msg of messages) {
|
|
127
|
+
this.#handleChannelMessage(msg);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error("Failed to decode wire message:", error);
|
|
131
|
+
}
|
|
132
|
+
} else if (result.status === "error") {
|
|
133
|
+
console.error("Fragment reassembly error:", result.error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Handle a decoded channel message.
|
|
138
|
+
*
|
|
139
|
+
* Delivers messages synchronously. The Synchronizer's receive queue
|
|
140
|
+
* handles recursion prevention by queuing messages and processing
|
|
141
|
+
* them iteratively.
|
|
142
|
+
*/
|
|
143
|
+
#handleChannelMessage(msg) {
|
|
144
|
+
if (!this.#channel) {
|
|
145
|
+
console.error("Cannot handle message: channel not set");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.#channel.onReceive(msg);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Handle keepalive ping/pong messages.
|
|
152
|
+
*/
|
|
153
|
+
#handleKeepalive(text) {
|
|
154
|
+
if (text === "ping") {
|
|
155
|
+
this.#socket.send("pong");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// src/server-transport.ts
|
|
161
|
+
function generatePeerId() {
|
|
162
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
163
|
+
let result = "ws-";
|
|
164
|
+
for (let i = 0; i < 12; i++) {
|
|
165
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
var WebsocketServerTransport = class extends Transport {
|
|
170
|
+
#connections = /* @__PURE__ */ new Map();
|
|
171
|
+
#fragmentThreshold;
|
|
172
|
+
constructor(options) {
|
|
173
|
+
super({ transportType: "websocket-server" });
|
|
174
|
+
this.#fragmentThreshold = options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
|
|
175
|
+
}
|
|
176
|
+
// ==========================================================================
|
|
177
|
+
// Adapter abstract method implementations
|
|
178
|
+
// ==========================================================================
|
|
179
|
+
generate(peerId) {
|
|
180
|
+
return {
|
|
181
|
+
transportType: this.transportType,
|
|
182
|
+
send: (msg) => {
|
|
183
|
+
const connection = this.#connections.get(peerId);
|
|
184
|
+
if (connection) {
|
|
185
|
+
connection.send(msg);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
stop: () => {
|
|
189
|
+
this.unregisterConnection(peerId);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async onStart() {
|
|
194
|
+
}
|
|
195
|
+
async onStop() {
|
|
196
|
+
for (const connection of this.#connections.values()) {
|
|
197
|
+
connection.close(1001, "Server shutting down");
|
|
198
|
+
}
|
|
199
|
+
this.#connections.clear();
|
|
200
|
+
}
|
|
201
|
+
// ==========================================================================
|
|
202
|
+
// Connection management
|
|
203
|
+
// ==========================================================================
|
|
204
|
+
/**
|
|
205
|
+
* Handle a new Websocket connection.
|
|
206
|
+
*
|
|
207
|
+
* Call this from your framework's Websocket upgrade handler.
|
|
208
|
+
* Returns a connection handle and a `start()` function that begins
|
|
209
|
+
* message processing and sends the "ready" signal.
|
|
210
|
+
*
|
|
211
|
+
* @param options - Connection options including the Socket and optional peer ID
|
|
212
|
+
* @returns A connection handle and start function
|
|
213
|
+
*
|
|
214
|
+
* @example Bun
|
|
215
|
+
* ```typescript
|
|
216
|
+
* const { start } = serverAdapter.handleConnection({
|
|
217
|
+
* socket: wrapBunWebsocket(ws),
|
|
218
|
+
* })
|
|
219
|
+
* start()
|
|
220
|
+
* ```
|
|
221
|
+
*
|
|
222
|
+
* @example Node.js ws
|
|
223
|
+
* ```typescript
|
|
224
|
+
* wss.on("connection", (ws) => {
|
|
225
|
+
* const { start } = serverAdapter.handleConnection({
|
|
226
|
+
* socket: wrapNodeWebsocket(ws),
|
|
227
|
+
* })
|
|
228
|
+
* start()
|
|
229
|
+
* })
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
handleConnection(options) {
|
|
233
|
+
const { socket, peerId: providedPeerId } = options;
|
|
234
|
+
const peerId = providedPeerId ?? generatePeerId();
|
|
235
|
+
const existingConnection = this.#connections.get(peerId);
|
|
236
|
+
if (existingConnection) {
|
|
237
|
+
existingConnection.close(1e3, "Replaced by new connection");
|
|
238
|
+
this.unregisterConnection(peerId);
|
|
239
|
+
}
|
|
240
|
+
const channel = this.addChannel(peerId);
|
|
241
|
+
const connection = new WebsocketConnection(
|
|
242
|
+
peerId,
|
|
243
|
+
channel.channelId,
|
|
244
|
+
socket,
|
|
245
|
+
{
|
|
246
|
+
fragmentThreshold: this.#fragmentThreshold
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
connection._setChannel(channel);
|
|
250
|
+
this.#connections.set(peerId, connection);
|
|
251
|
+
socket.onClose((_code, _reason) => {
|
|
252
|
+
this.unregisterConnection(peerId);
|
|
253
|
+
});
|
|
254
|
+
socket.onError((_error) => {
|
|
255
|
+
this.unregisterConnection(peerId);
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
connection,
|
|
259
|
+
start: () => {
|
|
260
|
+
connection.start();
|
|
261
|
+
connection.sendReady();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get an active connection by peer ID.
|
|
267
|
+
*/
|
|
268
|
+
getConnection(peerId) {
|
|
269
|
+
return this.#connections.get(peerId);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Get all active connections.
|
|
273
|
+
*/
|
|
274
|
+
getAllConnections() {
|
|
275
|
+
return Array.from(this.#connections.values());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Check if a peer is connected.
|
|
279
|
+
*/
|
|
280
|
+
isConnected(peerId) {
|
|
281
|
+
return this.#connections.has(peerId);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Unregister a connection, removing its channel and cleaning up state.
|
|
285
|
+
*/
|
|
286
|
+
unregisterConnection(peerId) {
|
|
287
|
+
const connection = this.#connections.get(peerId);
|
|
288
|
+
if (connection) {
|
|
289
|
+
this.removeChannel(connection.channelId);
|
|
290
|
+
this.#connections.delete(peerId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Broadcast a message to all connected peers.
|
|
295
|
+
*/
|
|
296
|
+
broadcast(msg) {
|
|
297
|
+
for (const connection of this.#connections.values()) {
|
|
298
|
+
connection.send(msg);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Get the number of connected peers.
|
|
303
|
+
*/
|
|
304
|
+
get connectionCount() {
|
|
305
|
+
return this.#connections.size;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
export {
|
|
309
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
310
|
+
WebsocketConnection,
|
|
311
|
+
WebsocketServerTransport,
|
|
312
|
+
wrapNodeWebsocket,
|
|
313
|
+
wrapStandardWebsocket
|
|
314
|
+
};
|
|
315
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +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":[]}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { StateTransition, TransitionListener as TransitionListener$1, PeerId } from '@kyneta/exchange';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Websocket ready states — mirrors the standard WebSocket readyState
|
|
5
|
+
* values as human-readable strings.
|
|
6
|
+
*/
|
|
7
|
+
type SocketReadyState = "connecting" | "open" | "closing" | "closed";
|
|
8
|
+
/**
|
|
9
|
+
* Framework-agnostic Websocket interface.
|
|
10
|
+
*
|
|
11
|
+
* This allows the adapter to work with any Websocket library:
|
|
12
|
+
* - Browser `WebSocket` via `wrapStandardWebsocket()`
|
|
13
|
+
* - Node.js `ws` library via `wrapNodeWebsocket()`
|
|
14
|
+
* - Bun `ServerWebSocket` via `wrapBunWebsocket()`
|
|
15
|
+
*
|
|
16
|
+
* The interface is intentionally minimal — only the operations the
|
|
17
|
+
* adapter needs are exposed.
|
|
18
|
+
*/
|
|
19
|
+
interface Socket {
|
|
20
|
+
/** Send binary or text data through the Websocket. */
|
|
21
|
+
send(data: Uint8Array | string): void;
|
|
22
|
+
/** Close the Websocket connection. */
|
|
23
|
+
close(code?: number, reason?: string): void;
|
|
24
|
+
/** Register a handler for incoming messages (binary or text). */
|
|
25
|
+
onMessage(handler: (data: Uint8Array | string) => void): void;
|
|
26
|
+
/** Register a handler for connection close. */
|
|
27
|
+
onClose(handler: (code: number, reason: string) => void): void;
|
|
28
|
+
/** Register a handler for errors. */
|
|
29
|
+
onError(handler: (error: Error) => void): void;
|
|
30
|
+
/** The current ready state of the Websocket. */
|
|
31
|
+
readonly readyState: SocketReadyState;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Options for handling a new Websocket connection on the server.
|
|
35
|
+
*/
|
|
36
|
+
interface WebsocketConnectionOptions {
|
|
37
|
+
/** The Websocket instance, wrapped in the Socket interface. */
|
|
38
|
+
socket: Socket;
|
|
39
|
+
/** Optional peer ID extracted from the upgrade request. */
|
|
40
|
+
peerId?: PeerId;
|
|
41
|
+
/** Optional authentication token from the upgrade request. */
|
|
42
|
+
authToken?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Handle for an active Websocket connection.
|
|
46
|
+
*/
|
|
47
|
+
interface WebsocketConnectionHandle {
|
|
48
|
+
/** The peer ID for this connection. */
|
|
49
|
+
readonly peerId: PeerId;
|
|
50
|
+
/** The channel ID for this connection. */
|
|
51
|
+
readonly channelId: number;
|
|
52
|
+
/** Close the connection. */
|
|
53
|
+
close(code?: number, reason?: string): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Result of handling a Websocket connection on the server.
|
|
57
|
+
*/
|
|
58
|
+
interface WebsocketConnectionResult {
|
|
59
|
+
/** The connection handle for managing this peer. */
|
|
60
|
+
connection: WebsocketConnectionHandle;
|
|
61
|
+
/** Call this to start processing messages. */
|
|
62
|
+
start(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Discriminated union describing why a Websocket connection was lost.
|
|
66
|
+
*/
|
|
67
|
+
type DisconnectReason = {
|
|
68
|
+
type: "intentional";
|
|
69
|
+
} | {
|
|
70
|
+
type: "error";
|
|
71
|
+
error: Error;
|
|
72
|
+
} | {
|
|
73
|
+
type: "closed";
|
|
74
|
+
code: number;
|
|
75
|
+
reason: string;
|
|
76
|
+
} | {
|
|
77
|
+
type: "max-retries-exceeded";
|
|
78
|
+
attempts: number;
|
|
79
|
+
} | {
|
|
80
|
+
type: "not-started";
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* All possible states of the Websocket client.
|
|
84
|
+
*
|
|
85
|
+
* State machine transitions:
|
|
86
|
+
* ```
|
|
87
|
+
* disconnected → connecting → connected → ready
|
|
88
|
+
* ↓ ↓ ↓
|
|
89
|
+
* reconnecting ← ─ ┴ ─ ─ ─ ─ ┘
|
|
90
|
+
* ↓
|
|
91
|
+
* connecting (retry)
|
|
92
|
+
* ↓
|
|
93
|
+
* disconnected (max retries)
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
type WebsocketClientState = {
|
|
97
|
+
status: "disconnected";
|
|
98
|
+
reason?: DisconnectReason;
|
|
99
|
+
} | {
|
|
100
|
+
status: "connecting";
|
|
101
|
+
attempt: number;
|
|
102
|
+
} | {
|
|
103
|
+
status: "connected";
|
|
104
|
+
} | {
|
|
105
|
+
status: "ready";
|
|
106
|
+
} | {
|
|
107
|
+
status: "reconnecting";
|
|
108
|
+
attempt: number;
|
|
109
|
+
nextAttemptMs: number;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* A state transition event for websocket client states.
|
|
113
|
+
* Specialized from the generic `StateTransition<S>`.
|
|
114
|
+
*/
|
|
115
|
+
type WebsocketClientStateTransition = StateTransition<WebsocketClientState>;
|
|
116
|
+
/**
|
|
117
|
+
* Listener for websocket client state transitions.
|
|
118
|
+
* Specialized from the generic `TransitionListener<S>`.
|
|
119
|
+
*/
|
|
120
|
+
type TransitionListener = TransitionListener$1<WebsocketClientState>;
|
|
121
|
+
/**
|
|
122
|
+
* Wrap a standard `WebSocket` (browser or Node.js `ws` via `ws` package
|
|
123
|
+
* in `WebSocket`-compatible mode) into the `Socket` interface.
|
|
124
|
+
*
|
|
125
|
+
* Handles `ArrayBuffer`, `Blob`, and string messages.
|
|
126
|
+
*/
|
|
127
|
+
declare function wrapStandardWebsocket(ws: WebSocket): Socket;
|
|
128
|
+
/**
|
|
129
|
+
* The minimal interface we need from the Node.js `ws` library's `WebSocket`.
|
|
130
|
+
*
|
|
131
|
+
* Using a structural type rather than importing `ws` — consumers provide
|
|
132
|
+
* the actual `ws` instance, we just need these methods.
|
|
133
|
+
*/
|
|
134
|
+
interface NodeWebsocketLike {
|
|
135
|
+
send(data: Uint8Array | string): void;
|
|
136
|
+
close(code?: number, reason?: string): void;
|
|
137
|
+
on(event: "message", handler: (data: Buffer | ArrayBuffer | string, isBinary: boolean) => void): void;
|
|
138
|
+
on(event: "close", handler: (code: number, reason: Buffer) => void): void;
|
|
139
|
+
on(event: "error", handler: (error: Error) => void): void;
|
|
140
|
+
readyState: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Wrap a Node.js `ws` library WebSocket into the `Socket` interface.
|
|
144
|
+
*
|
|
145
|
+
* Handles `Buffer` → `Uint8Array` conversion for binary messages.
|
|
146
|
+
*/
|
|
147
|
+
declare function wrapNodeWebsocket(ws: NodeWebsocketLike): Socket;
|
|
148
|
+
|
|
149
|
+
export { type DisconnectReason as D, type NodeWebsocketLike as N, type Socket as S, type TransitionListener as T, type WebsocketClientStateTransition as W, type SocketReadyState as a, type WebsocketClientState as b, type WebsocketConnectionOptions as c, type WebsocketConnectionResult as d, type WebsocketConnectionHandle as e, wrapNodeWebsocket as f, wrapStandardWebsocket as w };
|