Skip to content

Commit 88124d2

Browse files
committed
Add WebSocket client SDK and split out shared methods into ws-utils, add tests
1 parent 161631d commit 88124d2

7 files changed

Lines changed: 861 additions & 103 deletions

File tree

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
"types": "./dist/http-stream.d.ts",
3434
"default": "./dist/http-stream.js"
3535
},
36+
"./ws-client": {
37+
"types": "./dist/ws-stream.d.ts",
38+
"default": "./dist/ws-stream.js"
39+
},
3640
"./server": {
3741
"types": "./dist/server.d.ts",
3842
"default": "./dist/server.js"
@@ -69,6 +73,7 @@
6973
"@eslint/js": "^10.0.1",
7074
"@hey-api/openapi-ts": "^0.97.0",
7175
"@types/node": "^25.5.0",
76+
"@types/ws": "^8.5.13",
7277
"@typescript-eslint/eslint-plugin": "^8.57.1",
7378
"@typescript-eslint/parser": "^8.57.1",
7479
"concurrently": "^9.2.1",
@@ -82,6 +87,7 @@
8287
"typedoc-github-theme": "^0.4.0",
8388
"typescript": "^6.0.2",
8489
"vitest": "^4.1.0",
90+
"ws": "^8.18.0",
8591
"zod": "^3.25.0 || ^4.0.0"
8692
}
8793
}

src/test-support/test-http-server.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import http from "node:http";
2+
import { WebSocketServer } from "ws";
23

34
import { AcpServer } from "../server.js";
45
import { createNodeHttpHandler } from "../node-adapter.js";
@@ -9,6 +10,7 @@ import type { Agent, AgentSideConnection } from "../acp.js";
910

1011
export interface TestHttpServer {
1112
readonly url: string;
13+
readonly wsUrl: string;
1214
readonly close: () => Promise<void>;
1315
}
1416

@@ -19,6 +21,13 @@ export async function startTestServer(
1921
): Promise<TestHttpServer> {
2022
const acpServer = new AcpServer({ createAgent: agentFactory });
2123
const httpServer = http.createServer(createNodeHttpHandler(acpServer));
24+
const webSocketServer = new WebSocketServer({ noServer: true });
25+
26+
httpServer.on("upgrade", (req, socket, head) => {
27+
webSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
28+
acpServer.handleWebSocket(webSocket);
29+
});
30+
});
2231

2332
await listen(httpServer, options.port ?? 0);
2433

@@ -30,8 +39,14 @@ export async function startTestServer(
3039

3140
return {
3241
url: `http://127.0.0.1:${address.port}`,
42+
wsUrl: `ws://127.0.0.1:${address.port}`,
3343
close: async () => {
34-
await Promise.all([acpServer.close(), closeHttpServer(httpServer)]);
44+
terminateWebSockets(webSocketServer);
45+
await Promise.all([
46+
acpServer.close(),
47+
closeWebSocketServer(webSocketServer),
48+
closeHttpServer(httpServer),
49+
]);
3550
},
3651
};
3752
}
@@ -54,6 +69,25 @@ function listen(server: http.Server, port: number): Promise<void> {
5469
});
5570
}
5671

72+
function terminateWebSockets(server: WebSocketServer): void {
73+
for (const client of server.clients) {
74+
client.terminate();
75+
}
76+
}
77+
78+
function closeWebSocketServer(server: WebSocketServer): Promise<void> {
79+
return new Promise((resolve, reject) => {
80+
server.close((error) => {
81+
if (error) {
82+
reject(error);
83+
return;
84+
}
85+
86+
resolve();
87+
});
88+
});
89+
}
90+
5791
function closeHttpServer(server: http.Server): Promise<void> {
5892
return new Promise((resolve, reject) => {
5993
server.close((error) => {

src/ws-server.ts

Lines changed: 28 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import {
88
messageIdKey,
99
sessionIdFromParams,
1010
} from "./protocol.js";
11-
11+
import { onWebSocket, webSocketMessageToString } from "./ws-utils.js";
1212
import type { Agent, AgentSideConnection } from "./acp.js";
1313
import type {
1414
ConnectionRegistry,
1515
ConnectionState,
1616
ResponseRoute,
1717
} from "./connection.js";
1818
import type { AnyMessage, AnyRequest } from "./jsonrpc.js";
19+
import type { WebSocketLike } from "./ws-utils.js";
20+
21+
export type WebSocketServerSocket = WebSocketLike;
1922

2023
type ForwardResult =
2124
| {
@@ -26,27 +29,13 @@ type ForwardResult =
2629
message: string;
2730
};
2831

29-
export interface WebSocketServerSocket {
30-
readonly readyState?: number;
31-
send(data: string): void;
32-
close(code?: number, reason?: string): void;
33-
addEventListener?(type: string, listener: (event: unknown) => void): void;
34-
removeEventListener?(type: string, listener: (event: unknown) => void): void;
35-
on?(type: string, listener: (...args: unknown[]) => void): unknown;
36-
off?(type: string, listener: (...args: unknown[]) => void): unknown;
37-
removeListener?(
38-
type: string,
39-
listener: (...args: unknown[]) => void,
40-
): unknown;
41-
}
42-
4332
export interface WebSocketConnectionOptions {
4433
readonly registry: ConnectionRegistry;
4534
readonly createAgent: (conn: AgentSideConnection) => Agent;
4635
}
4736

4837
export function handleWebSocketConnection(
49-
socket: WebSocketServerSocket,
38+
socket: WebSocketLike,
5039
options: WebSocketConnectionOptions,
5140
): void {
5241
const session = new WebSocketServerSession(socket, options);
@@ -56,29 +45,30 @@ export function handleWebSocketConnection(
5645
class WebSocketServerSession {
5746
private connection: ConnectionState | undefined;
5847
private outboundReader: ReadableStreamDefaultReader<AnyMessage> | undefined;
48+
private inboundWriteChain: Promise<void> = Promise.resolve();
5949
private isClosed = false;
6050
private readonly detachListeners: Array<() => void> = [];
6151

6252
constructor(
63-
private readonly socket: WebSocketServerSocket,
53+
private readonly socket: WebSocketLike,
6454
private readonly options: WebSocketConnectionOptions,
6555
) {}
6656

6757
start(): void {
6858
this.detachListeners.push(
69-
onSocket(this.socket, "message", (...args) => {
59+
onWebSocket(this.socket, "message", (...args) => {
7060
void this.handleSocketMessage(args);
7161
}),
7262
);
7363

7464
this.detachListeners.push(
75-
onSocket(this.socket, "close", () => {
65+
onWebSocket(this.socket, "close", () => {
7666
void this.closeSession();
7767
}),
7868
);
7969

8070
this.detachListeners.push(
81-
onSocket(this.socket, "error", () => {
71+
onWebSocket(this.socket, "error", () => {
8272
void this.shutdown(1011, "WebSocket error");
8373
}),
8474
);
@@ -89,7 +79,7 @@ class WebSocketServerSession {
8979
return;
9080
}
9181

92-
const text = socketMessageToString(args);
82+
const text = webSocketMessageToString(args);
9383
if (text === undefined) {
9484
console.warn("Ignoring non-text ACP WebSocket frame");
9585
return;
@@ -202,19 +192,33 @@ class WebSocketServerSession {
202192
connection.pendingRoutes.set(key, route);
203193
}
204194

205-
await writeInbound(connection, message);
195+
await this.writeInbound(message);
206196
return { ok: true };
207197
}
208198

209199
if (isResponseMessage(message)) {
210-
await writeInbound(connection, message);
200+
await this.writeInbound(message);
211201
return { ok: true };
212202
}
213203

214-
await writeInbound(connection, message);
204+
await this.writeInbound(message);
215205
return { ok: true };
216206
}
217207

208+
private async writeInbound(message: AnyMessage): Promise<void> {
209+
const connection = this.connection;
210+
211+
if (!connection) {
212+
throw new Error("ACP WebSocket connection is not initialized");
213+
}
214+
215+
const write = this.inboundWriteChain.then(() =>
216+
writeInbound(connection, message),
217+
);
218+
this.inboundWriteChain = write.catch(() => undefined);
219+
await write;
220+
}
221+
218222
private startOutboundPump(connection: ConnectionState): void {
219223
const subscription = connection.allOutbound.subscribe();
220224
const reader = subscription.stream.getReader();
@@ -345,81 +349,3 @@ function determineWebSocketRoute(message: AnyRequest): ResponseRoute {
345349

346350
return "connection";
347351
}
348-
349-
function onSocket(
350-
socket: WebSocketServerSocket,
351-
type: string,
352-
listener: (...args: unknown[]) => void,
353-
): () => void {
354-
if (socket.addEventListener) {
355-
const eventListener = (event: unknown) => listener(event);
356-
socket.addEventListener(type, eventListener);
357-
358-
return () => {
359-
socket.removeEventListener?.(type, eventListener);
360-
};
361-
}
362-
363-
if (socket.on) {
364-
socket.on(type, listener);
365-
366-
return () => {
367-
if (socket.off) {
368-
socket.off(type, listener);
369-
return;
370-
}
371-
372-
socket.removeListener?.(type, listener);
373-
};
374-
}
375-
376-
throw new Error("WebSocket object does not support event listeners");
377-
}
378-
379-
function socketMessageToString(args: unknown[]): string | undefined {
380-
const data = extractMessageData(args);
381-
382-
if (typeof data === "string") {
383-
return data;
384-
}
385-
386-
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
387-
return new TextDecoder().decode(data);
388-
}
389-
390-
if (Array.isArray(data) && data.every(ArrayBuffer.isView)) {
391-
return decodeArrayBufferViews(data);
392-
}
393-
394-
return undefined;
395-
}
396-
397-
function extractMessageData(args: unknown[]): unknown {
398-
const [first] = args;
399-
400-
if (isMessageEventLike(first)) {
401-
return first.data;
402-
}
403-
404-
return first;
405-
}
406-
407-
function isMessageEventLike(value: unknown): value is { data: unknown } {
408-
return typeof value === "object" && value !== null && "data" in value;
409-
}
410-
411-
function decodeArrayBufferViews(views: ArrayBufferView[]): string {
412-
const totalLength = views.reduce((sum, view) => sum + view.byteLength, 0);
413-
const combined = new Uint8Array(totalLength);
414-
let offset = 0;
415-
416-
for (const view of views) {
417-
combined.set(
418-
new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
419-
offset,
420-
);
421-
offset += view.byteLength;
422-
}
423-
424-
return new TextDecoder().decode(combined);
425-
}

0 commit comments

Comments
 (0)