Skip to content

Commit 1493cc3

Browse files
committed
feat: [WIP] Node.js HTTP/2 Handler in smithy-codegen (#414)
1 parent d75c620 commit 1493cc3

File tree

8 files changed

+379
-43
lines changed

8 files changed

+379
-43
lines changed
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { HeaderBag } from "@aws-sdk/types";
2+
import { IncomingHttpHeaders } from "http2";
3+
4+
const getTransformedHeaders = (headers: IncomingHttpHeaders) => {
5+
const transformedHeaders: HeaderBag = {};
6+
7+
for (let name of Object.keys(headers)) {
8+
let headerValues = <string>headers[name];
9+
transformedHeaders[name] = Array.isArray(headerValues)
10+
? headerValues.join(",")
11+
: headerValues;
12+
}
13+
14+
return transformedHeaders;
15+
};
16+
17+
export { getTransformedHeaders };

Diff for: packages/node-http-handler/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./node-http-handler";
2+
export * from "./node-http2-handler";

Diff for: packages/node-http-handler/src/node-http-handler.ts

+19-41
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import * as https from "https";
22
import * as http from "http";
3-
import { Readable } from "stream";
43
import { buildQueryString } from "@aws-sdk/querystring-builder";
5-
import { HeaderBag, HttpOptions, NodeHttpOptions } from "@aws-sdk/types";
4+
import { HttpOptions, NodeHttpOptions } from "@aws-sdk/types";
65
import { HttpHandler, HttpRequest, HttpResponse } from "@aws-sdk/protocol-http";
76
import { setConnectionTimeout } from "./set-connection-timeout";
87
import { setSocketTimeout } from "./set-socket-timeout";
98
import { writeRequestBody } from "./write-request-body";
9+
import { getTransformedHeaders } from "./get-transformed-headers";
1010

1111
export class NodeHttpHandler implements HttpHandler {
1212
private readonly httpAgent: http.Agent;
@@ -25,33 +25,9 @@ export class NodeHttpHandler implements HttpHandler {
2525

2626
handle(
2727
request: HttpRequest,
28-
options: HttpOptions
28+
{ abortSignal }: HttpOptions
2929
): Promise<{ response: HttpResponse }> {
30-
// determine which http(s) client to use
31-
const isSSL = request.protocol === "https:";
32-
const httpClient = isSSL ? https : http;
33-
34-
let path = request.path;
35-
if (request.query) {
36-
const queryString = buildQueryString(request.query);
37-
if (queryString) {
38-
path += `?${queryString}`;
39-
}
40-
}
41-
42-
const nodeHttpsOptions: https.RequestOptions = {
43-
headers: request.headers,
44-
host: request.hostname,
45-
method: request.method,
46-
path: path,
47-
port: request.port,
48-
agent: isSSL ? this.httpsAgent : this.httpAgent
49-
};
50-
5130
return new Promise((resolve, reject) => {
52-
const abortSignal = options && options.abortSignal;
53-
const { connectionTimeout, socketTimeout } = this.httpOptions;
54-
5531
// if the request was already aborted, prevent doing extra work
5632
if (abortSignal && abortSignal.aborted) {
5733
const abortError = new Error("Request aborted");
@@ -60,21 +36,23 @@ export class NodeHttpHandler implements HttpHandler {
6036
return;
6137
}
6238

63-
// create the http request
64-
const req = (httpClient as typeof http).request(nodeHttpsOptions, res => {
65-
const httpHeaders = res.headers;
66-
const transformedHeaders: HeaderBag = {};
67-
68-
for (let name of Object.keys(httpHeaders)) {
69-
let headerValues = <string>httpHeaders[name];
70-
transformedHeaders[name] = Array.isArray(headerValues)
71-
? headerValues.join(",")
72-
: headerValues;
73-
}
39+
// determine which http(s) client to use
40+
const isSSL = request.protocol === "https:";
41+
const queryString = buildQueryString(request.query || {});
42+
const nodeHttpsOptions: https.RequestOptions = {
43+
headers: request.headers,
44+
host: request.hostname,
45+
method: request.method,
46+
path: queryString ? `${request.path}?${queryString}` : request.path,
47+
port: request.port,
48+
agent: isSSL ? this.httpsAgent : this.httpAgent
49+
};
7450

51+
// create the http request
52+
const req = (isSSL ? https : http).request(nodeHttpsOptions, res => {
7553
const httpResponse = new HttpResponse({
7654
statusCode: res.statusCode || -1,
77-
headers: transformedHeaders,
55+
headers: getTransformedHeaders(res.headers),
7856
body: res
7957
});
8058
resolve({ response: httpResponse });
@@ -83,8 +61,8 @@ export class NodeHttpHandler implements HttpHandler {
8361
req.on("error", reject);
8462

8563
// wire-up any timeout logic
86-
setConnectionTimeout(req, reject, connectionTimeout);
87-
setSocketTimeout(req, reject, socketTimeout);
64+
setConnectionTimeout(req, reject, this.httpOptions.connectionTimeout);
65+
setSocketTimeout(req, reject, this.httpOptions.socketTimeout);
8866

8967
// wire-up abort logic
9068
if (abortSignal) {
+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { NodeHttp2Handler } from "./node-http2-handler";
2+
import { HttpRequest } from "@aws-sdk/protocol-http";
3+
import { createMockHttp2Server, createResponseFunction } from "./server.mock";
4+
import { AbortController } from "@aws-sdk/abort-controller";
5+
6+
describe("NodeHttp2Handler", () => {
7+
let nodeH2Handler: NodeHttp2Handler;
8+
9+
const protocol = "http:";
10+
const hostname = "localhost";
11+
const port = 45321;
12+
const mockH2Server = createMockHttp2Server().listen(port);
13+
const getMockReqOptions = () => ({
14+
protocol,
15+
hostname,
16+
port,
17+
method: "GET",
18+
path: "/",
19+
headers: {}
20+
});
21+
22+
const mockResponse = {
23+
statusCode: 200,
24+
headers: {},
25+
body: "test"
26+
};
27+
28+
beforeEach(() => {
29+
nodeH2Handler = new NodeHttp2Handler();
30+
mockH2Server.on("request", createResponseFunction(mockResponse));
31+
});
32+
33+
afterEach(() => {
34+
mockH2Server.removeAllListeners("request");
35+
// @ts-ignore: access private property
36+
const connectionPool = nodeH2Handler.connectionPool;
37+
for (const [, session] of connectionPool) {
38+
session.destroy();
39+
}
40+
connectionPool.clear();
41+
});
42+
43+
afterAll(() => {
44+
mockH2Server.close();
45+
});
46+
47+
describe("connectionPool", () => {
48+
it("is empty on initialization", () => {
49+
// @ts-ignore: access private property
50+
expect(nodeH2Handler.connectionPool.size).toBe(0);
51+
});
52+
53+
it("creates and stores session when request is made", async () => {
54+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
55+
56+
// @ts-ignore: access private property
57+
expect(nodeH2Handler.connectionPool.size).toBe(1);
58+
expect(
59+
// @ts-ignore: access private property
60+
nodeH2Handler.connectionPool.get(`${protocol}//${hostname}:${port}`)
61+
).toBeDefined();
62+
});
63+
64+
it("reuses existing session if request is made on same authority again", async () => {
65+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
66+
// @ts-ignore: access private property
67+
expect(nodeH2Handler.connectionPool.size).toBe(1);
68+
69+
// @ts-ignore: access private property
70+
const session: ClientHttp2Session = nodeH2Handler.connectionPool.get(
71+
`${protocol}//${hostname}:${port}`
72+
);
73+
const requestSpy = jest.spyOn(session, "request");
74+
75+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
76+
// @ts-ignore: access private property
77+
expect(nodeH2Handler.connectionPool.size).toBe(1);
78+
expect(requestSpy.mock.calls.length).toBe(1);
79+
});
80+
81+
it("creates new session if request is made on new authority", async () => {
82+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
83+
// @ts-ignore: access private property
84+
expect(nodeH2Handler.connectionPool.size).toBe(1);
85+
86+
const port2 = port + 1;
87+
const mockH2Server2 = createMockHttp2Server().listen(port2);
88+
mockH2Server2.on("request", createResponseFunction(mockResponse));
89+
90+
await nodeH2Handler.handle(
91+
new HttpRequest({ ...getMockReqOptions(), port: port2 }),
92+
{}
93+
);
94+
// @ts-ignore: access private property
95+
expect(nodeH2Handler.connectionPool.size).toBe(2);
96+
expect(
97+
// @ts-ignore: access private property
98+
nodeH2Handler.connectionPool.get(`${protocol}//${hostname}:${port2}`)
99+
).toBeDefined();
100+
101+
mockH2Server2.close();
102+
});
103+
104+
it("closes and removes session on sessionTimeout", async done => {
105+
const sessionTimeout = 500;
106+
nodeH2Handler = new NodeHttp2Handler({ sessionTimeout });
107+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
108+
109+
const authority = `${protocol}//${hostname}:${port}`;
110+
// @ts-ignore: access private property
111+
const session: ClientHttp2Session = nodeH2Handler.connectionPool.get(
112+
authority
113+
);
114+
expect(session.closed).toBe(false);
115+
setTimeout(() => {
116+
expect(session.closed).toBe(true);
117+
// @ts-ignore: access private property
118+
expect(nodeH2Handler.connectionPool.get(authority)).not.toBeDefined();
119+
done();
120+
}, sessionTimeout + 100);
121+
});
122+
});
123+
124+
describe("destroy", () => {
125+
it("destroys sessions and clears connectionPool", async () => {
126+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
127+
128+
// @ts-ignore: access private property
129+
const session: ClientHttp2Session = nodeH2Handler.connectionPool.get(
130+
`${protocol}//${hostname}:${port}`
131+
);
132+
133+
// @ts-ignore: access private property
134+
expect(nodeH2Handler.connectionPool.size).toBe(1);
135+
expect(session.destroyed).toBe(false);
136+
nodeH2Handler.destroy();
137+
// @ts-ignore: access private property
138+
expect(nodeH2Handler.connectionPool.size).toBe(0);
139+
expect(session.destroyed).toBe(true);
140+
});
141+
});
142+
143+
describe("abortSignal", () => {
144+
it("will not create session if request already aborted", async () => {
145+
// @ts-ignore: access private property
146+
expect(nodeH2Handler.connectionPool.size).toBe(0);
147+
await expect(
148+
nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {
149+
abortSignal: {
150+
aborted: true
151+
}
152+
})
153+
).rejects.toHaveProperty("name", "AbortError");
154+
// @ts-ignore: access private property
155+
expect(nodeH2Handler.connectionPool.size).toBe(0);
156+
});
157+
158+
it("will not create request on session if request already aborted", async () => {
159+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
160+
161+
// @ts-ignore: access private property
162+
const session: ClientHttp2Session = nodeH2Handler.connectionPool.get(
163+
`${protocol}//${hostname}:${port}`
164+
);
165+
const requestSpy = jest.spyOn(session, "request");
166+
167+
await expect(
168+
nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {
169+
abortSignal: {
170+
aborted: true
171+
}
172+
})
173+
).rejects.toHaveProperty("name", "AbortError");
174+
expect(requestSpy.mock.calls.length).toBe(0);
175+
});
176+
177+
it("will close request on session when aborted", async () => {
178+
await nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {});
179+
180+
// @ts-ignore: access private property
181+
const session: ClientHttp2Session = nodeH2Handler.connectionPool.get(
182+
`${protocol}//${hostname}:${port}`
183+
);
184+
const requestSpy = jest.spyOn(session, "request");
185+
186+
const abortController = new AbortController();
187+
// Delay response so that onabort is called earlier
188+
setTimeout(() => {
189+
abortController.abort();
190+
}, 0);
191+
mockH2Server.on(
192+
"request",
193+
async () =>
194+
new Promise(resolve => {
195+
setTimeout(() => {
196+
resolve(createResponseFunction(mockResponse));
197+
}, 1000);
198+
})
199+
);
200+
201+
await expect(
202+
nodeH2Handler.handle(new HttpRequest(getMockReqOptions()), {
203+
abortSignal: abortController.signal
204+
})
205+
).rejects.toHaveProperty("name", "AbortError");
206+
expect(requestSpy.mock.calls.length).toBe(1);
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)