Skip to content

Commit 75d95b1

Browse files
committed
Fix delay + echo endpoint chain
1 parent e9b5b5c commit 75d95b1

File tree

6 files changed

+211
-56
lines changed

6 files changed

+211
-56
lines changed

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
},
3131
"homepage": "https://github.com/httptoolkit/testserver#readme",
3232
"dependencies": {
33-
"@httptoolkit/util": "^0.1.2",
33+
"@httptoolkit/util": "^0.1.9",
3434
"@peculiar/asn1-ocsp": "^2.6.0",
3535
"@peculiar/asn1-schema": "^2.6.0",
3636
"@peculiar/asn1-x509": "^2.6.0",

src/endpoints/http-index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ export type HttpHandler = (
1111
options: {
1212
path: string;
1313
query: URLSearchParams;
14-
handleRequest: (req: HttpRequest, res: HttpResponse) => void;
1514
}
1615
) => MaybePromise<void>;
1716

1817
export interface HttpEndpoint {
1918
matchPath: (path: string, hostnamePrefix: string | undefined) => boolean;
2019
handle: HttpHandler;
21-
/** If true, raw connection data will be captured for this endpoint (e.g. for echo) */
2220
needsRawData?: boolean;
21+
getRemainingPath?: (path: string) => string | undefined;
2322
}
2423

2524
export * from './http/echo.js';

src/endpoints/http/delay.ts

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

55
const matchPath = (path: string) => path.startsWith('/delay/');
66

7+
const getRemainingPath = (path: string): string | undefined => {
8+
const idx = path.indexOf('/', '/delay/'.length);
9+
return idx !== -1 ? path.slice(idx) : undefined;
10+
};
11+
12+
const parseDelaySeconds = (path: string): number => {
13+
const idx = path.indexOf('/', '/delay/'.length);
14+
const end = idx !== -1 ? idx : path.length;
15+
return parseFloat(path.slice('/delay/'.length, end));
16+
};
17+
718
const defaultAnythingEndpoint = buildHttpBinAnythingEndpoint({
819
fieldFilter: ["url", "args", "form", "data", "origin", "headers", "files"]
920
});
1021

11-
const handle: HttpHandler = async (req, res, { path, handleRequest }) => {
12-
const followingSlashIndex = path.indexOf('/', '/delay/'.length);
13-
const followingUrl = followingSlashIndex !== -1 ? path.slice(followingSlashIndex) : '';
14-
const endOfDelay = followingSlashIndex === -1 ? path.length : followingSlashIndex;
15-
const delayMs = parseFloat(path.slice('/delay/'.length, endOfDelay));
22+
const handle: HttpHandler = async (req, res, { path }) => {
23+
const delaySeconds = parseDelaySeconds(path);
1624

17-
if (isNaN(delayMs)) {
25+
if (isNaN(delaySeconds)) {
1826
res.writeHead(400);
1927
res.end('Invalid delay duration');
28+
return;
2029
}
2130

22-
await delay(Math.min(delayMs, 10) * 1000); // 10s max
31+
const cappedDelayMs = Math.min(delaySeconds, 10) * 1000;
32+
await delay(cappedDelayMs);
2333

24-
if (followingUrl) {
25-
req.url = followingUrl;
26-
handleRequest(req, res);
27-
return;
28-
} else {
29-
return defaultAnythingEndpoint(req, res);
34+
if (getRemainingPath(path)) {
35+
return; // Handler continues to next endpoint in chain
3036
}
31-
}
3237

33-
export const delayEndpoint = {
38+
return defaultAnythingEndpoint(req, res);
39+
};
40+
41+
export const delayEndpoint: HttpEndpoint = {
3442
matchPath,
35-
handle
36-
};
43+
handle,
44+
getRemainingPath
45+
};

src/http-handler.ts

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,48 @@
11
import { TLSSocket } from 'tls';
22
import * as http from 'http';
33
import * as http2 from 'http2';
4-
import { MaybePromise } from '@httptoolkit/util';
4+
import { MaybePromise, StatusError } from '@httptoolkit/util';
55

66
import { httpEndpoints } from './endpoints/endpoint-index.js';
77
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
88

9+
const MAX_CHAIN_DEPTH = 10;
10+
11+
function resolveEndpointChain(initialPath: string, hostnamePrefix: string | undefined) {
12+
const entries: Array<{ endpoint: typeof httpEndpoints[number]; path: string }> = [];
13+
let needsRawData = false;
14+
let path: string | undefined = initialPath;
15+
16+
while (path && entries.length <= MAX_CHAIN_DEPTH) {
17+
const endpoint = httpEndpoints.find(ep => ep.matchPath(path!, hostnamePrefix));
18+
if (!endpoint) break;
19+
20+
entries.push({ endpoint, path });
21+
needsRawData ||= !!endpoint.needsRawData;
22+
path = endpoint.getRemainingPath?.(path);
23+
}
24+
25+
if (path) {
26+
throw new StatusError(400, `Endpoint chain exceeded maximum depth with path: ${initialPath}`);
27+
}
28+
29+
return { entries, needsRawData };
30+
}
31+
32+
function stopRawDataCapture(req: HttpRequest): void {
33+
if (req.httpVersion === '2.0') {
34+
const stream = (req as any).stream;
35+
const session = stream?.session;
36+
const capturingStream = session?.socket?.stream;
37+
const streamId = stream?.id as number | undefined;
38+
if (streamId !== undefined) {
39+
capturingStream?.stopCapturingStream?.(streamId);
40+
}
41+
} else {
42+
req.socket.receivedData = undefined;
43+
}
44+
}
45+
946
const allowCORS = (req: HttpRequest, res: HttpResponse) => {
1047
const origin = req.headers['origin'];
1148
if (!origin) return;
@@ -90,49 +127,37 @@ function createHttpRequestHandler(options: {
90127
}
91128

92129
// Now we begin the various test endpoints themselves:
130+
93131
allowCORS(req, res);
94132

95133
if (req.method === 'OPTIONS') {
96134
// Handle preflight CORS requests for everything
97135
res.writeHead(200);
98136
res.end();
137+
return;
99138
}
100139

101-
const matchingEndpoint = httpEndpoints.find((endpoint) =>
102-
endpoint.matchPath(path, hostnamePrefix)
103-
);
104-
105-
// Stop data capturing unless the endpoint needs it
106-
if (!matchingEndpoint || !matchingEndpoint.needsRawData) {
107-
if (req.httpVersion === '2.0') {
108-
const stream = (req as any).stream;
109-
const session = stream?.session;
110-
const capturingStream = session?.socket?.stream;
111-
const streamId = stream?.id as number | undefined;
112-
if (streamId !== undefined) {
113-
capturingStream?.stopCapturingStream?.(streamId);
114-
}
115-
} else {
116-
// HTTP/1: stop capturing and clear buffer
117-
req.socket.receivedData = undefined;
118-
}
140+
const { entries, needsRawData } = resolveEndpointChain(path, hostnamePrefix);
141+
142+
if (!needsRawData) {
143+
stopRawDataCapture(req);
119144
}
120145

121-
if (matchingEndpoint) {
122-
console.log(`Request to ${path}${
123-
hostnamePrefix
124-
? ` ('${hostnamePrefix}' prefix)`
125-
: ` (${options.rootDomain})`
126-
} matched endpoint ${matchingEndpoint.name}`);
127-
await matchingEndpoint.handle(req, res, {
128-
path,
129-
query: url.searchParams,
130-
handleRequest
131-
});
132-
} else {
146+
if (entries.length === 0) {
133147
console.log(`Request to ${path} matched no endpoints`);
134148
res.writeHead(404);
135149
res.end(`No handler for ${req.url}`);
150+
return;
151+
}
152+
153+
const endpointNames = entries.map(e => e.endpoint.name).join(' → ');
154+
console.log(`Request to ${path}${
155+
hostnamePrefix ? ` ('${hostnamePrefix}' prefix)` : ` (${options.rootDomain})`
156+
} matched: ${endpointNames}`);
157+
158+
for (const { endpoint, path } of entries) {
159+
if (res.writableEnded) return;
160+
await endpoint.handle(req, res, { path, query: url.searchParams });
136161
}
137162
}
138163
}
@@ -159,6 +184,9 @@ export function createHttp1Handler(options: {
159184
if (res.closed) return;
160185
else if (res.headersSent) {
161186
res.destroy();
187+
} else if (e instanceof StatusError) {
188+
res.writeHead(e.statusCode);
189+
res.end(e.message);
162190
} else {
163191
res.writeHead(500);
164192
res.end('HTTP handler failed');

test/delay.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as net from 'net';
2+
import * as http2 from 'http2';
3+
import { expect } from 'chai';
4+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
5+
6+
import { createServer } from '../src/server.js';
7+
8+
describe("Delay 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("delays then returns httpbin-style response", async () => {
26+
const start = Date.now();
27+
const response = await fetch(`http://localhost:${serverPort}/delay/0.1`);
28+
const elapsed = Date.now() - start;
29+
30+
expect(response.status).to.equal(200);
31+
expect(elapsed).to.be.greaterThan(90);
32+
33+
const body = await response.json();
34+
expect(body).to.have.property('url');
35+
expect(body).to.have.property('headers');
36+
});
37+
38+
it("rejects invalid delay values", async () => {
39+
const response = await fetch(`http://localhost:${serverPort}/delay/notanumber`);
40+
expect(response.status).to.equal(400);
41+
});
42+
43+
it("forwards to /status endpoint", async () => {
44+
const response = await fetch(`http://localhost:${serverPort}/delay/0.05/status/201`);
45+
expect(response.status).to.equal(201);
46+
});
47+
48+
it("rejects excessively deep chains", async () => {
49+
const deepPath = '/delay/0.001'.repeat(15) + '/status/200';
50+
const response = await fetch(`http://localhost:${serverPort}${deepPath}`);
51+
expect(response.status).to.equal(400);
52+
});
53+
54+
describe("forwarding to /echo", () => {
55+
56+
it("echoes HTTP/1 request data after delay", async () => {
57+
const response = await fetch(`http://localhost:${serverPort}/delay/0.05/echo`, {
58+
headers: { 'test-header': 'test-value' }
59+
});
60+
61+
expect(response.status).to.equal(200);
62+
63+
const rawBody = await response.text();
64+
// Raw data reflects what was actually sent by the client
65+
expect(rawBody).to.include('GET /delay/0.05/echo HTTP/1.1');
66+
expect(rawBody).to.include('test-header: test-value');
67+
});
68+
69+
it("echoes HTTP/2 request data after delay", async () => {
70+
const client = http2.connect(`http://localhost:${serverPort}`);
71+
72+
const req = client.request({
73+
':path': '/delay/0.05/echo',
74+
':method': 'GET',
75+
'test-header': 'test-value'
76+
});
77+
78+
const [headers, body] = await new Promise<[http2.IncomingHttpHeaders, string]>((resolve, reject) => {
79+
let headers: http2.IncomingHttpHeaders;
80+
const chunks: Buffer[] = [];
81+
82+
req.on('response', (h) => { headers = h; });
83+
req.on('data', (chunk) => chunks.push(chunk));
84+
req.on('end', () => resolve([headers, Buffer.concat(chunks).toString()]));
85+
req.on('error', reject);
86+
});
87+
88+
client.close();
89+
90+
expect(headers[':status']).to.equal(200);
91+
92+
const lines = body.trim().split('\n');
93+
const frames = lines.map(line => JSON.parse(line));
94+
95+
const headersFrames = frames.filter((f: any) => f.type === 'HEADERS' && f.decoded_headers);
96+
expect(headersFrames.length).to.be.greaterThan(0);
97+
expect(headersFrames[0].decoded_headers).to.have.property('test-header', 'test-value');
98+
});
99+
100+
it("chains multiple delays before /echo with raw data preserved", async () => {
101+
const start = Date.now();
102+
103+
const response = await fetch(`http://localhost:${serverPort}/delay/0.1/delay/0.1/echo`, {
104+
headers: { 'chain-test': 'multi-delay' }
105+
});
106+
107+
expect(response.status).to.equal(200);
108+
expect(Date.now() - start).to.be.greaterThan(200);
109+
110+
const rawBody = await response.text();
111+
// Raw data reflects what was actually sent by the client
112+
expect(rawBody).to.include('GET /delay/0.1/delay/0.1/echo HTTP/1.1');
113+
expect(rawBody).to.include('chain-test: multi-delay');
114+
});
115+
116+
});
117+
118+
});

0 commit comments

Comments
 (0)