Skip to content

Commit 778be15

Browse files
committed
Add support for HTTP/2 in /echo as NDJSON frames
1 parent 7fccd7e commit 778be15

6 files changed

Lines changed: 456 additions & 21 deletions

File tree

src/endpoints/http-index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type HttpHandler = (
1818
export interface HttpEndpoint {
1919
matchPath: (path: string, hostnamePrefix: string | undefined) => boolean;
2020
handle: HttpHandler;
21+
/** If true, raw connection data will be captured for this endpoint (e.g. for echo) */
22+
needsRawData?: boolean;
2123
}
2224

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

src/endpoints/http/echo.ts

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,149 @@ import {
88

99
const matchPath = ((path: string) => path === '/echo');
1010

11+
interface ParsedFrame {
12+
stream_id: number;
13+
type: string;
14+
flags: string[];
15+
length: number;
16+
payload_hex: string;
17+
}
18+
19+
interface FrameOutput extends ParsedFrame {
20+
decoded_headers?: { [key: string]: string | string[] };
21+
}
22+
23+
function frameToOutput(frameBuffer: Buffer): ParsedFrame {
24+
const length = (frameBuffer[0] << 16) | (frameBuffer[1] << 8) | frameBuffer[2];
25+
const typeNum = frameBuffer[3];
26+
const flagsByte = frameBuffer[4];
27+
const streamId = ((frameBuffer[5] & 0x7f) << 24) | (frameBuffer[6] << 16) |
28+
(frameBuffer[7] << 8) | frameBuffer[8];
29+
30+
const FRAME_TYPES: { [key: number]: string } = {
31+
0x0: 'DATA', 0x1: 'HEADERS', 0x2: 'PRIORITY', 0x3: 'RST_STREAM',
32+
0x4: 'SETTINGS', 0x5: 'PUSH_PROMISE', 0x6: 'PING',
33+
0x7: 'GOAWAY', 0x8: 'WINDOW_UPDATE', 0x9: 'CONTINUATION'
34+
};
35+
36+
const FRAME_FLAGS: { [type: number]: { [flag: number]: string } } = {
37+
0x0: { 0x1: 'END_STREAM', 0x8: 'PADDED' },
38+
0x1: { 0x1: 'END_STREAM', 0x4: 'END_HEADERS', 0x8: 'PADDED', 0x20: 'PRIORITY' },
39+
0x4: { 0x1: 'ACK' },
40+
0x5: { 0x4: 'END_HEADERS', 0x8: 'PADDED' },
41+
0x6: { 0x1: 'ACK' },
42+
0x9: { 0x4: 'END_HEADERS' }
43+
};
44+
45+
const flags: string[] = [];
46+
const flagDefs = FRAME_FLAGS[typeNum] || {};
47+
for (const [flagValue, flagName] of Object.entries(flagDefs)) {
48+
if (flagsByte & parseInt(flagValue)) {
49+
flags.push(flagName);
50+
}
51+
}
52+
53+
const payload = frameBuffer.subarray(9, 9 + length);
54+
55+
return {
56+
stream_id: streamId,
57+
type: FRAME_TYPES[typeNum] || `UNKNOWN_${typeNum}`,
58+
flags,
59+
length,
60+
payload_hex: payload.toString('hex')
61+
};
62+
}
63+
64+
function writeFrame(res: HttpResponse, frame: ParsedFrame, req?: HttpRequest) {
65+
const output: FrameOutput = { ...frame };
66+
67+
if (req && (frame.type === 'HEADERS' || frame.type === 'CONTINUATION')) {
68+
if (frame.flags.includes('END_HEADERS')) {
69+
output.decoded_headers = req.headers as { [key: string]: string | string[] };
70+
}
71+
}
72+
73+
(res as any).write(JSON.stringify(output) + '\n');
74+
}
75+
1176
async function handle(req: HttpRequest, res: HttpResponse) {
1277
if (req.httpVersion === '2.0') {
13-
res.writeHead(400);
14-
res.end('Echo endpoint does not yet support HTTP/2');
78+
const stream = (req as any).stream;
79+
const session = stream?.session;
80+
const jsStreamSocket = session?.socket;
81+
const capturingStream = (jsStreamSocket as any)?.stream;
82+
83+
if (!capturingStream?.addStreamCallback) {
84+
res.writeHead(500);
85+
res.end('Echo endpoint requires data capturing stream');
86+
return;
87+
}
88+
89+
const requestStreamId = stream?.id as number | undefined;
90+
if (requestStreamId === undefined) {
91+
res.writeHead(500);
92+
res.end('Could not determine stream ID');
93+
return;
94+
}
95+
96+
res.writeHead(200, { 'Content-Type': 'text/plain' });
97+
98+
let responseEnded = false;
99+
100+
const writeFrameBuffer = (frameBuffer: Buffer, isGlobal: boolean) => {
101+
if (responseEnded) return;
102+
const frame = frameToOutput(frameBuffer);
103+
writeFrame(res, frame, isGlobal ? undefined : req);
104+
};
105+
106+
const { globalFrames, streamFrames } = capturingStream.addStreamCallback(
107+
requestStreamId,
108+
(frameBuffer: Buffer) => {
109+
const frame = frameToOutput(frameBuffer);
110+
if (frame.stream_id === 0) {
111+
writeFrame(res, frame);
112+
} else if (frame.stream_id === requestStreamId) {
113+
writeFrame(res, frame, req);
114+
}
115+
}
116+
);
117+
118+
for (const frameBuffer of globalFrames) {
119+
writeFrameBuffer(frameBuffer, true);
120+
}
121+
122+
for (const frameBuffer of streamFrames) {
123+
writeFrameBuffer(frameBuffer, false);
124+
}
125+
126+
req.resume();
127+
128+
req.on('end', () => {
129+
responseEnded = true;
130+
capturingStream.removeStreamCallback(requestStreamId);
131+
res.end();
132+
});
133+
134+
req.on('error', () => {
135+
responseEnded = true;
136+
capturingStream.removeStreamCallback(requestStreamId);
137+
res.end();
138+
});
139+
15140
return;
16141
}
17-
await streamConsumers.buffer(req); // Wait for all request data
18-
const input = Buffer.concat(req.socket.receivedData ?? []);
142+
143+
// HTTP/1.x: return raw request data
144+
await streamConsumers.buffer(req);
145+
const rawData = Buffer.concat(req.socket.receivedData ?? []);
19146
res.writeHead(200, {
20-
'Content-Length': Buffer.byteLength(input)
147+
'Content-Length': Buffer.byteLength(rawData)
21148
});
22-
res.end(input);
149+
res.end(rawData);
23150
}
24151

25152
export const echo: HttpEndpoint = {
26153
matchPath,
27-
handle
28-
}
154+
handle,
155+
needsRawData: true
156+
}

src/http-handler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ function createHttpRequestHandler(options: {
106106
endpoint.matchPath(path, hostnamePrefix)
107107
);
108108

109+
// For HTTP/2, stop data capturing for this stream unless the endpoint needs it
110+
// This prevents unbounded buffering of large request bodies
111+
if (req.httpVersion === '2.0' && (!matchingEndpoint || !matchingEndpoint.needsRawData)) {
112+
const stream = (req as any).stream;
113+
const session = stream?.session;
114+
const capturingStream = session?.socket?.stream;
115+
const streamId = stream?.id as number | undefined;
116+
if (streamId !== undefined) {
117+
capturingStream?.stopCapturingStream?.(streamId);
118+
}
119+
}
120+
109121
if (matchingEndpoint) {
110122
console.log(`Request to ${path}${
111123
hostnamePrefix

src/httpbin-compat.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export const buildHttpBinAnythingEndpoint = (options: {
7777

7878
const contentType = req.headers['content-type'];
7979

80-
const origin = req.socket.remoteAddress?.replace(/^::ffff:/, '') // Drop IPv6 wrapper of IPv4 addresses
80+
// For HTTP/2 over wrapped streams, remoteAddress is on .stream (the underlying wrapper)
81+
const origin = (req.socket.remoteAddress ?? (req.socket as any).stream?.remoteAddress)
82+
?.replace(/^::ffff:/, ''); // Drop IPv6 wrapper of IPv4 addresses
8183

8284
let result: {} = {
8385
args: getUrlArgs(url),

0 commit comments

Comments
 (0)