Skip to content

Commit 3172d35

Browse files
committed
Add HTTP/2 support for everything except /echo
1 parent cead31b commit 3172d35

10 files changed

Lines changed: 141 additions & 25 deletions

File tree

src/endpoints/http-index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { MaybePromise } from '@httptoolkit/util';
22
import * as http from 'http';
3+
import * as http2 from 'http2';
4+
5+
export type HttpRequest = http.IncomingMessage | http2.Http2ServerRequest;
6+
export type HttpResponse = http.ServerResponse | http2.Http2ServerResponse;
37

48
export type HttpHandler = (
5-
req: http.IncomingMessage,
6-
res: http.ServerResponse,
9+
req: HttpRequest,
10+
res: HttpResponse,
711
options: {
812
path: string;
913
query: URLSearchParams;
10-
handleRequest: (req: http.IncomingMessage, res: http.ServerResponse) => void;
14+
handleRequest: (req: HttpRequest, res: HttpResponse) => void;
1115
}
1216
) => MaybePromise<void>;
1317

src/endpoints/http/cookies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const setCookies: HttpEndpoint = {
4242
})
4343
];
4444
})).flat()
45-
]).end();
45+
] as any).end(); // Any because H2 types don't include string array
4646
}
4747
}
4848

@@ -57,6 +57,6 @@ export const deleteCookies: HttpEndpoint = {
5757
'set-cookie',
5858
`${key}=; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/`
5959
]).flat()
60-
]).end();
60+
] as any).end(); // Any because H2 types don't include string array
6161
}
6262
}

src/endpoints/http/echo.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
import * as http from 'http';
21
import * as streamConsumers from 'stream/consumers';
32

4-
import { HttpEndpoint } from '../http-index.js';
3+
import {
4+
HttpEndpoint,
5+
HttpRequest,
6+
HttpResponse
7+
} from '../http-index.js';
58

69
const matchPath = ((path: string) => path === '/echo');
710

8-
async function handle(req: http.IncomingMessage, res: http.ServerResponse) {
11+
async function handle(req: HttpRequest, res: HttpResponse) {
12+
if (req.httpVersion === '2.0') {
13+
res.writeHead(400);
14+
res.end('Echo endpoint does not yet support HTTP/2');
15+
return;
16+
}
917
await streamConsumers.buffer(req); // Wait for all request data
1018
const input = Buffer.concat(req.socket.receivedData ?? []);
1119
res.writeHead(200, {

src/http-handler.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import * as http from 'http';
2+
import * as http2 from 'http2';
23

34
import { clearArray } from './util.js';
45

56
import { httpEndpoints } from './endpoints/endpoint-index.js';
7+
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
68

7-
const allowCORS = (req: http.IncomingMessage, res: http.ServerResponse) => {
9+
const allowCORS = (req: HttpRequest, res: HttpResponse) => {
810
const origin = req.headers['origin'];
911
if (!origin) return;
1012

@@ -24,10 +26,15 @@ const allowCORS = (req: http.IncomingMessage, res: http.ServerResponse) => {
2426
}
2527
}
2628

27-
export function createHttpHandler(options: {
29+
type RequestHandler = (
30+
req: HttpRequest,
31+
res: HttpResponse
32+
) => Promise<void>;
33+
34+
function createHttpRequestHandler(options: {
2835
acmeChallengeCallback: (token: string) => string | undefined
29-
}) {
30-
async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
36+
}): RequestHandler {
37+
return async function handleRequest(req, res) {
3138
const url = new URL(req.url!, `http://${req.headers.host}`);
3239
const path = url.pathname;
3340

@@ -84,10 +91,44 @@ export function createHttpHandler(options: {
8491
res.end(`No handler for ${req.url}`);
8592
}
8693
}
94+
}
8795

96+
export function createHttp1Handler(options: {
97+
acmeChallengeCallback: (token: string) => string | undefined
98+
}) {
99+
const handleRequest = createHttpRequestHandler(options);
88100
const handler = new http.Server(async (req, res) => {
89101
try {
90-
console.log(`Handling request to ${req.url}`);
102+
console.log(`Handling H1 request to ${req.url}`);
103+
await handleRequest(req, res);
104+
} catch (e) {
105+
console.error(e);
106+
107+
if (res.closed) return;
108+
else if (res.headersSent) {
109+
res.destroy();
110+
} else {
111+
res.writeHead(500);
112+
res.end('HTTP handler failed');
113+
}
114+
} finally {
115+
// We have to clear this, as we might get multiple requests on the same
116+
// socket with keep-alive etc.
117+
clearArray(req.socket.receivedData);
118+
}
119+
});
120+
121+
handler.on('error', (err) => console.error('HTTP handler error', err));
122+
123+
return handler;
124+
}
125+
126+
export function createHttp2Handler(options: {
127+
acmeChallengeCallback: (token: string) => string | undefined
128+
}) {
129+
const handleRequest = createHttpRequestHandler(options);
130+
const handler = http2.createServer(async (req, res) => {
131+
try {
91132
await handleRequest(req, res);
92133
} catch (e) {
93134
console.error(e);

src/httpbin-compat.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import _ from 'lodash';
2-
import * as http from 'http';
32
import * as streamConsumers from 'stream/consumers';
43

54
import * as querystring from 'querystring';
65
import * as multipart from 'parse-multipart-data';
76

87
import { TLSSocket } from 'tls';
98
import { serializeJson } from './util.js';
9+
import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
1010

1111
const utf8Decoder = new TextDecoder('utf8', { fatal: true });
1212

@@ -40,7 +40,7 @@ const entriesToMultidict = (entries: Array<[string, string]>) =>
4040
// Matches Flask's req.args multi-dict values
4141
const getUrlArgs = (url: URL) => entriesToMultidict([...url.searchParams.entries()]);
4242

43-
const getFiles = (body: Buffer, req: http.IncomingMessage) => {
43+
const getFiles = (body: Buffer, req: HttpRequest) => {
4444
const contentType = req.headers["content-type"];
4545
if (!contentType?.includes("multipart/form-data")) return {};
4646

@@ -62,11 +62,13 @@ const getFiles = (body: Buffer, req: http.IncomingMessage) => {
6262
// interoperability since this is widely used (and it's generally a reasonable format for this).
6363
export const buildHttpBinAnythingEndpoint = (options: {
6464
fieldFilter?: string[]
65-
}) => async (req: http.IncomingMessage, res: http.ServerResponse) => {
65+
}) => async (req: HttpRequest, res: HttpResponse) => {
6666
const input = await streamConsumers.buffer(req); // Wait for all request data
6767

6868
const isHTTPS = req.socket instanceof TLSSocket;
69-
const url = new URL(req.url as string, `${isHTTPS ? 'https' : 'http'}://${req.headers.host}`);
69+
const url = new URL(req.url as string, `${isHTTPS ? 'https' : 'http'}://${
70+
req.headers[':authority'] || req.headers.host
71+
}`);
7072

7173
let jsonValue: any = null;
7274
try {

src/process-connection.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import * as http from 'http';
55
const TLS_HANDSHAKE_BYTE = 0x16; // SSLv3+ or TLS handshake
66
const isTLS = (initialData: Uint8Array) => initialData[0] === TLS_HANDSHAKE_BYTE;
77

8+
const HTTP2_PREFACE = 'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n';
9+
const HTTP2_PREFACE_BUFFER = Buffer.from(HTTP2_PREFACE);
10+
const HTTP2_PREFACE_LENGTH = HTTP2_PREFACE_BUFFER.byteLength;
11+
const isHTTP2 = (initialData: Uint8Array) => {
12+
const comparisonLength = Math.min(HTTP2_PREFACE_LENGTH, initialData.length);
13+
return Buffer.from(initialData.subarray(0, comparisonLength))
14+
.equals(HTTP2_PREFACE_BUFFER.subarray(0, comparisonLength));
15+
};
16+
817
// We guess at HTTP by checking the initial bytes match a known HTTP method + following space.
918
// Not super precise, but generally pretty good (rules out TLS, proxy protocol, etc).
1019
const METHOD_PREFIXES = http.METHODS.map(m => m + ' ');
@@ -28,7 +37,8 @@ export class ConnectionProcessor {
2837

2938
constructor(
3039
private tlsHandler: ConnectionHandler,
31-
private httpHandler: ConnectionHandler
40+
private httpHandler: ConnectionHandler,
41+
private http2Handler: ConnectionHandler
3242
) {}
3343

3444
readonly processConnection = (connection: stream.Duplex) => {
@@ -38,7 +48,7 @@ export class ConnectionProcessor {
3848

3949
const initialData: Buffer | null = connection.read();
4050
if (initialData === null) {
41-
// Wait until this is actually readable
51+
// Wait until we have more bytes available (at least 3 to differentiate H2 & H1):
4252
connection.once('readable', () => this.processConnection(connection));
4353
return;
4454
} else {
@@ -56,8 +66,9 @@ export class ConnectionProcessor {
5666

5767
if (isTLS(initialData)) {
5868
this.tlsHandler(connection);
69+
} else if (isHTTP2(initialData)) {
70+
this.http2Handler(connection);
5971
} else if (couldBeHttp(initialData)) {
60-
// Assume it's otherwise HTTP (for now)
6172
this.httpHandler(connection);
6273
} else {
6374
console.error('Got unrecognized connection data:', initialData);

src/server.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as net from 'net';
22

3-
import { createHttpHandler } from './http-handler.js';
3+
import { createHttp1Handler, createHttp2Handler } from './http-handler.js';
44
import { createTlsHandler } from './tls-handler.js';
55
import { ConnectionProcessor } from './process-connection.js';
66

@@ -81,16 +81,23 @@ async function generateTlsConfig(options: ServerOptions) {
8181

8282
const createTcpHandler = async (options: ServerOptions = {}) => {
8383
const connProcessor = new ConnectionProcessor(
84-
(conn) => tlsHandler.emit('connection', conn),
85-
(conn) => httpHandler.emit('connection', conn)
84+
(conn) => {
85+
conn.pause();
86+
tlsHandler.emit('connection', conn);
87+
},
88+
(conn) => httpHandler.emit('connection', conn),
89+
(conn) => http2Handler.emit('connection', conn)
8690
);
8791

8892
const tlsConfig = await generateTlsConfig(options);
8993
const tlsHandler = await createTlsHandler(tlsConfig, connProcessor);
9094

91-
const httpHandler = createHttpHandler({
95+
const httpConfig = {
9296
acmeChallengeCallback: tlsConfig.acmeChallenge
93-
});
97+
};
98+
99+
const httpHandler = createHttp1Handler(httpConfig);
100+
const http2Handler = createHttp2Handler(httpConfig);
94101

95102
return (conn: net.Socket) => {
96103
try {

src/tls-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export async function createTlsHandler(
2121
key: tlsConfig.key,
2222
cert: tlsConfig.cert,
2323
ca: [tlsConfig.ca],
24+
ALPNProtocols: ['h2', 'http/1.1'],
2425
SNICallback: (domain: string, cb: Function) => {
2526
try {
2627
const generatedCert = tlsConfig.generateCertificate(domain);

test/anything.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as net from 'net';
2+
import * as http2 from 'http2';
3+
import * as streamConsumers from 'stream/consumers';
24
import { expect } from 'chai';
35
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
46

@@ -93,4 +95,42 @@ describe("Anything endpoint", () => {
9395
});
9496
});
9597

98+
it("returns request details for HTTP/2", async () => {
99+
const client = http2.connect(`http://localhost:${serverPort}`);
100+
const request = client.request({
101+
':path': '/anything?&&&&',
102+
':method': 'PUT',
103+
'test-HEADER': 'abc',
104+
'content-type': 'application/json'
105+
});
106+
request.end(JSON.stringify({ "hello": "world" }));
107+
const response = await new Promise<
108+
http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader
109+
>((resolve) =>
110+
request.on('response', resolve)
111+
);
112+
113+
expect(response[':status']).to.equal(200);
114+
115+
const body: any = await streamConsumers.json(request);
116+
expect(body).to.deep.equal({
117+
args: {},
118+
data: '{"hello":"world"}',
119+
files: {},
120+
form: {},
121+
headers: {
122+
":authority": `localhost:${serverPort}`,
123+
":method": "PUT",
124+
":path": "/anything?&&&&",
125+
":scheme": "http",
126+
"Content-Type": "application/json",
127+
"Test-Header": "abc",
128+
},
129+
json: { "hello": "world" },
130+
method: "PUT",
131+
origin: body.origin ?? 'fail', // Skip testing the exact value since it varies a lot
132+
url: `http://localhost:${serverPort}/anything?&&&&`
133+
});
134+
});
135+
96136
});

test/status.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as net from 'net';
2+
import * as http2 from 'http2';
3+
24
import { expect } from 'chai';
35
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
46

0 commit comments

Comments
 (0)