Skip to content

Commit bdb5a8b

Browse files
committed
Add support for websockets
1 parent a32bbe0 commit bdb5a8b

8 files changed

Lines changed: 323 additions & 3 deletions

File tree

package-lock.json

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

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@
4040
"lodash": "^4.17.23",
4141
"parse-multipart-data": "^1.5.0",
4242
"read-tls-client-hello": "^1.1.0",
43-
"tsx": "^4.19.3"
43+
"tsx": "^4.19.3",
44+
"ws": "^8.19.0"
4445
},
4546
"devDependencies": {
4647
"@types/chai": "^4.3.14",
4748
"@types/lodash": "^4.17.0",
4849
"@types/mocha": "^10.0.6",
4950
"@types/node": "^22.15.30",
51+
"@types/ws": "^8.18.1",
5052
"chai": "^5.1.0",
5153
"destroyable-server": "^1.0.1",
5254
"mocha": "^10.8.2",

src/endpoints/endpoint-index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import * as httpIndex from './http-index.js';
22
import * as tlsIndex from './tls-index.js';
3+
import * as wsIndex from './ws-index.js';
34

45
export const httpEndpoints: Array<httpIndex.HttpEndpoint & { name: string }> = Object.entries(httpIndex)
56
.map(([key, value]) => ({ ...value, name: key }));
67

78
export const tlsEndpoints: Array<tlsIndex.TlsEndpoint & { name: string }> = Object.entries(tlsIndex)
8-
.map(([key, value]) => ({ ...value, name: key }));
9+
.map(([key, value]) => ({ ...value, name: key }));
10+
11+
export const wsEndpoints: Array<wsIndex.WebSocketEndpoint & { name: string }> = Object.entries(wsIndex)
12+
.map(([key, value]) => ({ ...(value as wsIndex.WebSocketEndpoint), name: key }));

src/endpoints/ws-index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { WebSocket } from 'ws';
2+
import { IncomingMessage } from 'http';
3+
4+
export interface WebSocketEndpoint {
5+
matchPath: (path: string, hostnamePrefix?: string) => boolean;
6+
handle: (ws: WebSocket, req: IncomingMessage, options: {
7+
path: string;
8+
query: URLSearchParams;
9+
}) => void;
10+
}
11+
12+
export * from './ws/echo.js';

src/endpoints/ws/echo.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { WebSocketEndpoint } from '../ws-index.js';
2+
3+
export const wsEchoEndpoint: WebSocketEndpoint = {
4+
matchPath: (path) => path === '/ws/echo',
5+
handle: (ws) => {
6+
ws.on('message', (data, isBinary) => {
7+
if (ws.readyState === ws.OPEN) {
8+
ws.send(data, { binary: isBinary });
9+
}
10+
});
11+
}
12+
};

src/http-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MaybePromise, StatusError } from '@httptoolkit/util';
55

66
import { httpEndpoints } from './endpoints/endpoint-index.js';
77
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
8+
import { handleWebSocketUpgrade } from './ws-handler.js';
89

910
const MAX_CHAIN_DEPTH = 10;
1011

@@ -209,6 +210,10 @@ export function createHttp1Handler(options: {
209210

210211
handler.on('error', (err) => console.error('HTTP handler error', err));
211212

213+
handler.on('upgrade', (req, socket, head) => {
214+
handleWebSocketUpgrade(req, socket, head, options);
215+
});
216+
212217
return handler;
213218
}
214219

src/ws-handler.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { IncomingMessage } from 'http';
2+
import { Duplex } from 'stream';
3+
import { WebSocketServer } from 'ws';
4+
5+
import { wsEndpoints } from './endpoints/endpoint-index.js';
6+
7+
const wss = new WebSocketServer({ noServer: true });
8+
9+
export function handleWebSocketUpgrade(
10+
req: IncomingMessage,
11+
socket: Duplex,
12+
head: Buffer,
13+
options: { rootDomain: string }
14+
) {
15+
const url = new URL(req.url!, `http://${req.headers.host}`);
16+
const path = url.pathname;
17+
18+
const hostnamePrefix = url.hostname.endsWith(options.rootDomain)
19+
? url.hostname.slice(0, -options.rootDomain.length - 1)
20+
: undefined;
21+
22+
const endpoint = wsEndpoints.find(ep => ep.matchPath(path, hostnamePrefix));
23+
24+
if (!endpoint) {
25+
console.log(`WebSocket upgrade to ${path} matched no endpoints`);
26+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
27+
socket.destroy();
28+
return;
29+
}
30+
31+
console.log(`WebSocket upgrade to ${path}${
32+
hostnamePrefix ? ` ('${hostnamePrefix}' prefix)` : ''
33+
} matched: ${endpoint.name}`);
34+
35+
socket.on('error', (err) => {
36+
console.log('WebSocket upgrade socket error:', err.message);
37+
});
38+
39+
wss.handleUpgrade(req, socket, head, (ws) => {
40+
ws.on('error', (err) => {
41+
console.log(`WebSocket error on ${path}:`, err.message);
42+
});
43+
44+
try {
45+
endpoint.handle(ws, req, {
46+
path,
47+
query: url.searchParams
48+
});
49+
} catch (err) {
50+
console.log(`WebSocket handler error on ${path}:`, err);
51+
ws.close(1011, 'Internal error');
52+
}
53+
});
54+
}

test/ws-echo.spec.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import * as net from 'net';
2+
import { expect } from 'chai';
3+
import { WebSocket } from 'ws';
4+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
5+
6+
import { createServer } from '../src/server.js';
7+
8+
describe("WebSocket Echo endpoint", () => {
9+
10+
let server: DestroyableServer;
11+
let serverPort: number;
12+
13+
beforeEach(async () => {
14+
server = makeDestroyable(await createServer({
15+
domain: 'localhost'
16+
}));
17+
await new Promise<void>((resolve) => server.listen(resolve));
18+
serverPort = (server.address() as net.AddressInfo).port;
19+
});
20+
21+
afterEach(async () => {
22+
await server.destroy();
23+
});
24+
25+
it("echoes text messages", async () => {
26+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/echo`);
27+
28+
await new Promise<void>((resolve, reject) => {
29+
ws.on('open', resolve);
30+
ws.on('error', reject);
31+
});
32+
33+
ws.send('Hello, WebSocket!');
34+
35+
const receivedMessage = await new Promise<string>((resolve, reject) => {
36+
ws.on('message', (data, isBinary) => {
37+
expect(isBinary).to.equal(false);
38+
resolve(data.toString());
39+
});
40+
ws.on('error', reject);
41+
});
42+
43+
expect(receivedMessage).to.equal('Hello, WebSocket!');
44+
ws.close();
45+
});
46+
47+
it("echoes binary messages", async () => {
48+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/echo`);
49+
50+
await new Promise<void>((resolve, reject) => {
51+
ws.on('open', resolve);
52+
ws.on('error', reject);
53+
});
54+
55+
const testData = Buffer.from([0x01, 0x02, 0x03, 0xFF, 0xFE, 0x00]);
56+
ws.send(testData);
57+
58+
const receivedData = await new Promise<{ data: Buffer; isBinary: boolean }>((resolve, reject) => {
59+
ws.on('message', (data, isBinary) => {
60+
resolve({ data: data as Buffer, isBinary });
61+
});
62+
ws.on('error', reject);
63+
});
64+
65+
expect(receivedData.isBinary).to.equal(true);
66+
expect(Buffer.compare(receivedData.data, testData)).to.equal(0);
67+
ws.close();
68+
});
69+
70+
it("echoes multiple messages in sequence", async () => {
71+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/echo`);
72+
73+
await new Promise<void>((resolve, reject) => {
74+
ws.on('open', resolve);
75+
ws.on('error', reject);
76+
});
77+
78+
const messages = ['first', 'second', 'third'];
79+
const received: string[] = [];
80+
81+
for (const msg of messages) {
82+
ws.send(msg);
83+
}
84+
85+
await new Promise<void>((resolve, reject) => {
86+
ws.on('message', (data) => {
87+
received.push(data.toString());
88+
if (received.length === messages.length) {
89+
resolve();
90+
}
91+
});
92+
ws.on('error', reject);
93+
});
94+
95+
expect(received).to.deep.equal(messages);
96+
ws.close();
97+
});
98+
99+
it("returns 404 for unknown WebSocket paths", async () => {
100+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/unknown`);
101+
102+
const result = await new Promise<{ statusCode: number }>((resolve, reject) => {
103+
ws.on('open', () => {
104+
reject(new Error('Should not have connected'));
105+
});
106+
ws.on('unexpected-response', (req, res) => {
107+
resolve({ statusCode: res.statusCode! });
108+
});
109+
ws.on('error', reject);
110+
});
111+
112+
expect(result.statusCode).to.equal(404);
113+
});
114+
115+
it("returns 404 for non-WebSocket paths", async () => {
116+
const ws = new WebSocket(`ws://localhost:${serverPort}/status/200`);
117+
118+
const result = await new Promise<{ statusCode: number }>((resolve, reject) => {
119+
ws.on('open', () => {
120+
reject(new Error('Should not have connected'));
121+
});
122+
ws.on('unexpected-response', (req, res) => {
123+
resolve({ statusCode: res.statusCode! });
124+
});
125+
ws.on('error', reject);
126+
});
127+
128+
expect(result.statusCode).to.equal(404);
129+
});
130+
131+
it("works over HTTPS (wss://)", async () => {
132+
const ws = new WebSocket(`wss://localhost:${serverPort}/ws/echo`, {
133+
rejectUnauthorized: false
134+
});
135+
136+
await new Promise<void>((resolve, reject) => {
137+
ws.on('open', resolve);
138+
ws.on('error', reject);
139+
});
140+
141+
ws.send('Secure WebSocket!');
142+
143+
const receivedMessage = await new Promise<string>((resolve, reject) => {
144+
ws.on('message', (data) => {
145+
resolve(data.toString());
146+
});
147+
ws.on('error', reject);
148+
});
149+
150+
expect(receivedMessage).to.equal('Secure WebSocket!');
151+
ws.close();
152+
});
153+
154+
it("handles empty messages", async () => {
155+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/echo`);
156+
157+
await new Promise<void>((resolve, reject) => {
158+
ws.on('open', resolve);
159+
ws.on('error', reject);
160+
});
161+
162+
ws.send('');
163+
164+
const receivedMessage = await new Promise<string>((resolve, reject) => {
165+
ws.on('message', (data) => {
166+
resolve(data.toString());
167+
});
168+
ws.on('error', reject);
169+
});
170+
171+
expect(receivedMessage).to.equal('');
172+
ws.close();
173+
});
174+
175+
it("handles large messages", async () => {
176+
const ws = new WebSocket(`ws://localhost:${serverPort}/ws/echo`);
177+
178+
await new Promise<void>((resolve, reject) => {
179+
ws.on('open', resolve);
180+
ws.on('error', reject);
181+
});
182+
183+
const largeMessage = 'x'.repeat(100000);
184+
ws.send(largeMessage);
185+
186+
const receivedMessage = await new Promise<string>((resolve, reject) => {
187+
ws.on('message', (data) => {
188+
resolve(data.toString());
189+
});
190+
ws.on('error', reject);
191+
});
192+
193+
expect(receivedMessage).to.equal(largeMessage);
194+
expect(receivedMessage.length).to.equal(100000);
195+
ws.close();
196+
});
197+
198+
});

0 commit comments

Comments
 (0)