Skip to content

Commit 5e2e577

Browse files
committed
Add a selection of chainable websocket endpoints
1 parent bdb5a8b commit 5e2e577

12 files changed

Lines changed: 495 additions & 71 deletions

File tree

src/endpoint-chain.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { StatusError } from '@httptoolkit/util';
2+
3+
const MAX_CHAIN_DEPTH = 10;
4+
5+
export interface ChainableEndpoint {
6+
matchPath: (path: string, hostnamePrefix?: string) => boolean;
7+
getRemainingPath?: (path: string) => string | undefined;
8+
}
9+
10+
export function resolveEndpointChain<T extends ChainableEndpoint>(
11+
endpoints: Array<T & { name: string }>,
12+
initialPath: string,
13+
hostnamePrefix?: string
14+
): Array<{ endpoint: T & { name: string }; path: string }> {
15+
const entries: Array<{ endpoint: T & { name: string }; path: string }> = [];
16+
let path: string | undefined = initialPath;
17+
18+
while (path && entries.length <= MAX_CHAIN_DEPTH) {
19+
// matchPath may throw StatusError for invalid parameters
20+
const endpoint = endpoints.find(ep => ep.matchPath(path!, hostnamePrefix));
21+
if (!endpoint) {
22+
throw new StatusError(404, `Could not match endpoint for ${initialPath}${
23+
hostnamePrefix ? ` (${hostnamePrefix})` : ''
24+
}`);
25+
}
26+
27+
entries.push({ endpoint, path });
28+
path = endpoint.getRemainingPath?.(path);
29+
}
30+
31+
if (path) {
32+
throw new StatusError(400, `Endpoint chain exceeded maximum depth for ${initialPath}`);
33+
}
34+
35+
return entries;
36+
}

src/endpoints/http/delay.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { delay } from '@httptoolkit/util';
1+
import { delay, StatusError } from '@httptoolkit/util';
22
import { HttpEndpoint, HttpHandler } from '../http-index.js';
33
import { buildHttpBinAnythingEndpoint } from '../../httpbin-compat.js';
44

5-
const matchPath = (path: string) => path.startsWith('/delay/');
6-
75
const getRemainingPath = (path: string): string | undefined => {
86
const idx = path.indexOf('/', '/delay/'.length);
97
return idx !== -1 ? path.slice(idx) : undefined;
@@ -21,13 +19,6 @@ const defaultAnythingEndpoint = buildHttpBinAnythingEndpoint({
2119

2220
const handle: HttpHandler = async (req, res, { path }) => {
2321
const delaySeconds = parseDelaySeconds(path);
24-
25-
if (isNaN(delaySeconds)) {
26-
res.writeHead(400);
27-
res.end('Invalid delay duration');
28-
return;
29-
}
30-
3122
const cappedDelayMs = Math.min(delaySeconds, 10) * 1000;
3223
await delay(cappedDelayMs);
3324

@@ -39,7 +30,14 @@ const handle: HttpHandler = async (req, res, { path }) => {
3930
};
4031

4132
export const delayEndpoint: HttpEndpoint = {
42-
matchPath,
33+
matchPath: (path) => {
34+
if (!path.startsWith('/delay/')) return false;
35+
const delaySeconds = parseDelaySeconds(path);
36+
if (isNaN(delaySeconds)) {
37+
throw new StatusError(400, `Invalid delay duration in ${path}`);
38+
}
39+
return true;
40+
},
4341
handle,
4442
getRemainingPath
4543
};

src/endpoints/http/status.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1+
import { StatusError } from '@httptoolkit/util';
12
import { HttpEndpoint, HttpHandler } from '../http-index.js';
23

3-
const matchPath = (path: string) => path.startsWith('/status/');
4+
const parseStatusCode = (path: string): number => {
5+
return parseInt(path.slice('/status/'.length), 10);
6+
};
47

58
const handle: HttpHandler = (_req, res, { path }) => {
6-
const statusCode = parseInt(path.slice('/status/'.length), 10);
7-
if (isNaN(statusCode)) {
8-
res.writeHead(400);
9-
res.end('Invalid status code');
10-
} else {
11-
res.writeHead(statusCode);
12-
res.end();
13-
}
9+
const statusCode = parseStatusCode(path);
10+
res.writeHead(statusCode);
11+
res.end();
1412
}
1513

1614
export const status: HttpEndpoint = {
17-
matchPath,
15+
matchPath: (path) => {
16+
if (!path.startsWith('/status/')) return false;
17+
const statusCode = parseStatusCode(path);
18+
if (isNaN(statusCode)) {
19+
throw new StatusError(400, `Invalid status code in ${path}`);
20+
}
21+
return true;
22+
},
1823
handle
1924
}

src/endpoints/ws-index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ import { WebSocket } from 'ws';
22
import { IncomingMessage } from 'http';
33

44
export interface WebSocketEndpoint {
5+
/** Return true to match, false to skip, or throw StatusError for invalid params. */
56
matchPath: (path: string, hostnamePrefix?: string) => boolean;
7+
getRemainingPath?: (path: string) => string | undefined;
68
handle: (ws: WebSocket, req: IncomingMessage, options: {
79
path: string;
810
query: URLSearchParams;
9-
}) => void;
11+
}) => void | Promise<void>;
1012
}
1113

1214
export * from './ws/echo.js';
15+
export * from './ws/delay.js';
16+
export * from './ws/close.js';
17+
export * from './ws/message.js';

src/endpoints/ws/close.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { StatusError } from '@httptoolkit/util';
2+
import { WebSocketEndpoint } from '../ws-index.js';
3+
4+
const parseCloseCode = (path: string): number => {
5+
if (path === '/ws/close') return 1000;
6+
return parseInt(path.slice('/ws/close/'.length), 10);
7+
};
8+
9+
export const wsCloseEndpoint: WebSocketEndpoint = {
10+
matchPath: (path) => {
11+
if (!path.startsWith('/ws/close/') && path !== '/ws/close') return false;
12+
const code = parseCloseCode(path);
13+
if (isNaN(code) || code < 1000 || code > 4999) {
14+
throw new StatusError(400, `Invalid WebSocket close code: ${code}`);
15+
}
16+
return true;
17+
},
18+
handle: (ws, req, { path, query }) => {
19+
const code = parseCloseCode(path);
20+
const reason = query.get('reason') ?? undefined;
21+
ws.close(code, reason);
22+
}
23+
};

src/endpoints/ws/delay.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { delay, StatusError } from '@httptoolkit/util';
2+
import { WebSocketEndpoint } from '../ws-index.js';
3+
4+
const getRemainingPath = (path: string): string | undefined => {
5+
const idx = path.indexOf('/', '/ws/delay/'.length);
6+
return idx !== -1 ? '/ws' + path.slice(idx) : undefined;
7+
};
8+
9+
const parseDelaySeconds = (path: string): number => {
10+
const idx = path.indexOf('/', '/ws/delay/'.length);
11+
const end = idx !== -1 ? idx : path.length;
12+
return parseFloat(path.slice('/ws/delay/'.length, end));
13+
};
14+
15+
export const wsDelayEndpoint: WebSocketEndpoint = {
16+
matchPath: (path) => {
17+
if (!path.startsWith('/ws/delay/')) return false;
18+
const delaySeconds = parseDelaySeconds(path);
19+
if (isNaN(delaySeconds)) {
20+
throw new StatusError(400, `Invalid delay duration in ${path}`);
21+
}
22+
return true;
23+
},
24+
getRemainingPath,
25+
handle: async (ws, req, { path }) => {
26+
const delaySeconds = parseDelaySeconds(path);
27+
const cappedDelayMs = Math.min(delaySeconds, 10) * 1000;
28+
await delay(cappedDelayMs);
29+
}
30+
};

src/endpoints/ws/message.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { WebSocketEndpoint } from '../ws-index.js';
2+
3+
const matchPath = (path: string) => path.startsWith('/ws/message/');
4+
5+
const getRemainingPath = (path: string): string | undefined => {
6+
const idx = path.indexOf('/', '/ws/message/'.length);
7+
return idx !== -1 ? '/ws' + path.slice(idx) : undefined;
8+
};
9+
10+
const parseMessage = (path: string): string => {
11+
const idx = path.indexOf('/', '/ws/message/'.length);
12+
const end = idx !== -1 ? idx : path.length;
13+
return decodeURIComponent(path.slice('/ws/message/'.length, end));
14+
};
15+
16+
export const wsMessageEndpoint: WebSocketEndpoint = {
17+
matchPath,
18+
getRemainingPath,
19+
handle: (ws, req, { path }) => {
20+
const message = parseMessage(path);
21+
if (ws.readyState === ws.OPEN) {
22+
ws.send(message);
23+
}
24+
}
25+
};

src/http-handler.ts

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,7 @@ import { MaybePromise, StatusError } from '@httptoolkit/util';
66
import { httpEndpoints } from './endpoints/endpoint-index.js';
77
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
88
import { handleWebSocketUpgrade } from './ws-handler.js';
9-
10-
const MAX_CHAIN_DEPTH = 10;
11-
12-
function resolveEndpointChain(initialPath: string, hostnamePrefix?: string) {
13-
const entries: Array<{ endpoint: typeof httpEndpoints[number]; path: string }> = [];
14-
let needsRawData = false;
15-
let path: string | undefined = initialPath;
16-
17-
while (path && entries.length <= MAX_CHAIN_DEPTH) {
18-
const endpoint = httpEndpoints.find(ep => ep.matchPath(path!, hostnamePrefix));
19-
if (!endpoint) {
20-
throw new StatusError(404, `Could not match endpoint for ${initialPath}${
21-
hostnamePrefix
22-
? ` (${hostnamePrefix})`
23-
: ''
24-
}`);
25-
}
26-
27-
entries.push({ endpoint, path });
28-
needsRawData ||= !!endpoint.needsRawData;
29-
path = endpoint.getRemainingPath?.(path);
30-
}
31-
32-
if (path) {
33-
throw new StatusError(400, `Endpoint chain exceeded maximum depth with path: ${initialPath}`);
34-
}
35-
36-
return { entries, needsRawData };
37-
}
9+
import { resolveEndpointChain } from './endpoint-chain.js';
3810

3911
function stopRawDataCapture(req: HttpRequest): void {
4012
if (req.httpVersion === '2.0') {
@@ -144,19 +116,13 @@ function createHttpRequestHandler(options: {
144116
return;
145117
}
146118

147-
const { entries, needsRawData } = resolveEndpointChain(path, hostnamePrefix);
119+
const entries = resolveEndpointChain(httpEndpoints, path, hostnamePrefix);
120+
const needsRawData = entries.some(e => e.endpoint.needsRawData);
148121

149122
if (!needsRawData) {
150123
stopRawDataCapture(req);
151124
}
152125

153-
if (entries.length === 0) {
154-
console.log(`Request to ${path} matched no endpoints`);
155-
res.writeHead(404);
156-
res.end(`No handler for ${req.url}`);
157-
return;
158-
}
159-
160126
const endpointNames = entries.map(e => e.endpoint.name).join(' → ');
161127
console.log(`Request to ${path}${
162128
hostnamePrefix ? ` ('${hostnamePrefix}' prefix)` : ` (${options.rootDomain})`

src/ws-handler.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { IncomingMessage } from 'http';
22
import { Duplex } from 'stream';
33
import { WebSocketServer } from 'ws';
4+
import { StatusError } from '@httptoolkit/util';
45

56
import { wsEndpoints } from './endpoints/endpoint-index.js';
7+
import { resolveEndpointChain } from './endpoint-chain.js';
68

79
const wss = new WebSocketServer({ noServer: true });
810

@@ -19,36 +21,48 @@ export function handleWebSocketUpgrade(
1921
? url.hostname.slice(0, -options.rootDomain.length - 1)
2022
: undefined;
2123

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');
24+
let entries: typeof wsEndpoints extends Array<infer T> ? Array<{ endpoint: T; path: string }> : never;
25+
try {
26+
entries = resolveEndpointChain(wsEndpoints, path, hostnamePrefix);
27+
} catch (err) {
28+
if (err instanceof StatusError) {
29+
console.log(`WebSocket upgrade to ${path}: ${err.message}`);
30+
socket.write(`HTTP/1.1 ${err.statusCode} ${err.statusCode === 404 ? 'Not Found' : 'Bad Request'}\r\n\r\n`);
31+
} else {
32+
console.log(`WebSocket upgrade to ${path}: unexpected error`, err);
33+
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
34+
}
2735
socket.destroy();
2836
return;
2937
}
3038

39+
const endpointNames = entries.map(e => e.endpoint.name).join(' → ');
3140
console.log(`WebSocket upgrade to ${path}${
3241
hostnamePrefix ? ` ('${hostnamePrefix}' prefix)` : ''
33-
} matched: ${endpoint.name}`);
42+
} matched: ${endpointNames}`);
3443

3544
socket.on('error', (err) => {
3645
console.log('WebSocket upgrade socket error:', err.message);
3746
});
3847

39-
wss.handleUpgrade(req, socket, head, (ws) => {
48+
wss.handleUpgrade(req, socket, head, async (ws) => {
4049
ws.on('error', (err) => {
4150
console.log(`WebSocket error on ${path}:`, err.message);
4251
});
4352

4453
try {
45-
endpoint.handle(ws, req, {
46-
path,
47-
query: url.searchParams
48-
});
54+
for (const { endpoint, path: entryPath } of entries) {
55+
if (ws.readyState !== ws.OPEN) return;
56+
await endpoint.handle(ws, req, {
57+
path: entryPath,
58+
query: url.searchParams
59+
});
60+
}
4961
} catch (err) {
5062
console.log(`WebSocket handler error on ${path}:`, err);
51-
ws.close(1011, 'Internal error');
63+
if (ws.readyState === ws.OPEN) {
64+
ws.close(1011, 'Internal error');
65+
}
5266
}
5367
});
5468
}

0 commit comments

Comments
 (0)