-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathaws-https.ts
More file actions
221 lines (198 loc) · 7.79 KB
/
aws-https.ts
File metadata and controls
221 lines (198 loc) · 7.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
/* eslint-disable @typescript-eslint/no-unused-expressions */
import * as AWS from "aws-sdk";
import { Credentials, CredentialsOptions } from "aws-sdk/lib/credentials";
import * as aws4 from "aws4";
import * as http from "http";
import * as https from "https";
import { Logger } from "@sailplane/logger";
import { URL } from "url";
const logger = new Logger("aws-https");
/**
* Same options as https://nodejs.org/api/http.html#http_http_request_options_callback
* with the addition of optional body to send with POST, PUT, or PATCH
* and option to AWS Sig4 sign the request.
*/
export type AwsHttpsOptions = aws4.Request & {
/** Body content of HTTP POST, PUT or PATCH */
body?: string;
/** If true, apply AWS Signature v4 to the request */
awsSign?: boolean;
};
/**
* Light-weight utility for making HTTPS requests in AWS environments.
*/
export class AwsHttps {
/** Resolves when credentials are available - shared by all instances */
private static credentialsInitializedPromise: Promise<void> | undefined = undefined;
/** Credentials to use in this instance */
private awsCredentials?: Credentials | CredentialsOptions;
/**
* Constructor.
* @param verbose true to log everything, false for silence,
* undefined (default) for normal logging.
* @param credentials
* If not defined, credentials will be obtained by default SDK behavior for the runtime environment.
* This happens once and then is cached; good for Lambdas.
* If `true`, clear cached to obtain fresh credentials from SDK.
* Good for longer running containers that rotate credentials.
* If an object with accessKeyId, secretAccessKey, and sessionToken,
* use these credentials for this instance.
*/
constructor(
private readonly verbose?: boolean,
credentials?: boolean | Credentials | CredentialsOptions,
) {
if (credentials) {
AwsHttps.credentialsInitializedPromise = undefined;
if (typeof credentials === "object" && credentials.accessKeyId) {
this.awsCredentials = credentials;
}
}
}
/**
* Perform an HTTPS request and return the JSON body of the result.
*
* @params options https request options, with optional body and awsSign
* @returns parsed JSON content, or null if none.
* @throws {Error{message,status,statusCode}} error if HTTP result is not 2xx or unable
* to parse response. Compatible with http-errors package.
*/
async request(options: AwsHttpsOptions): Promise<any | null> {
let requestOptions = options;
if (options.awsSign === true) {
requestOptions = await this.awsSign(requestOptions);
}
this.verbose === true && logger.debug("HTTPS Request: ", requestOptions);
return new Promise<any | null>((resolve, reject) => {
// eslint-disable-next-line prefer-const -- eslint is simply wrong about this
let timeoutHandle: any | undefined;
const request = https.request(requestOptions, (response: http.IncomingMessage) => {
this.verbose !== false && logger.info("Status: " + response.statusCode);
const body: Array<Buffer | string> = [];
// Save each chunk of response data
response.on("data", (chunk) => body.push(chunk));
// End of response - process it
response.on("end", () => {
clearTimeout(timeoutHandle!);
const content = body.join("");
if (!response.statusCode || response.statusCode < 200 || response.statusCode > 299) {
// HTTP status indicates failure. Throw http-errors compatible error.
const err: any = new Error(
"Failed to load content, status code: " + response.statusCode,
);
err.status = err.statusCode = response.statusCode || 0;
this.verbose !== false && logger.warn(err.message + " ", content);
reject(err);
} else if (content) {
this.verbose === true && logger.debug("HTTP response content: " + content);
try {
resolve(JSON.parse(content));
} catch (someError) {
if (
someError &&
typeof someError === "object" &&
"message" in someError &&
typeof someError.message === "string"
) {
const err = someError as any;
logger.warn(err.message, content);
err.status = err.statusCode = 400;
reject(err);
} else {
logger.warn("Unexpected error:", someError);
reject(someError);
}
}
} else {
this.verbose === true && logger.debug("HTTP response " + response.statusCode);
resolve(null);
}
});
// Theoretically this should be called based on requestOptions#timeout.
// It doesn't, but leaving this code here in case in ever gets fixed by Node.js.
/* istanbul ignore next */
response.on("timeout", () => {
logger.warn(
`Request timeout from ${options.protocol}://${options.hostname}:${options.port}`,
);
request.destroy();
});
});
// Communication timeout - The HttpRequest setTimeout and requestOptions#timeout
// aren't reliable across various Node.js versions, so timing out this way.
timeoutHandle = setTimeout(() => {
logger.warn(
`Request timeout from ${options.protocol}://${options.hostname}:${options.port}`,
);
request.destroy();
}, options.timeout || 120000);
// Connection error
request.on("error", (err) => {
logger.warn(
`Error response from ${options.protocol}://${options.hostname}:${options.port}: ${err.message}`,
);
reject(err);
});
if (options.body) request.write(options.body);
request.end();
});
}
/**
* Helper to build a starter AwsHttpsOptions object from a URL.
*
* @param method an HTTP method/verb
* @param url the URL to request from
* @param connectTimeout (default 5000) milliseconds to wait for connection to establish
* @returns an AwsHttpsOptions object, which may be further modified before use.
*/
buildOptions(
method: "DELETE" | "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH",
url: URL,
connectTimeout = 5000,
): AwsHttpsOptions {
return {
protocol: url.protocol,
method: method,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + (url.search || ""),
timeout: connectTimeout,
};
}
/**
* Helper for signing AWS requests
* @param request to make
* @return signed version of the request.
*/
private async awsSign(request: AwsHttpsOptions): Promise<AwsHttpsOptions> {
if (!this.awsCredentials) {
if (!AwsHttps.credentialsInitializedPromise) {
// Prepare process-wide AWS credentials
AwsHttps.credentialsInitializedPromise = new Promise((resolve, reject) => {
AWS.config.getCredentials((err) => {
if (err) {
logger.error("Unable to load AWS credentials", err);
reject(err);
} else {
resolve();
}
});
});
}
// Wait for process-wide AWS credentials to be available
await AwsHttps.credentialsInitializedPromise;
this.awsCredentials = AWS.config.credentials!;
}
// Sign the request
const signCreds = {
accessKeyId: this.awsCredentials.accessKeyId,
secretAccessKey: this.awsCredentials.secretAccessKey,
sessionToken: this.awsCredentials.sessionToken,
};
const awsRequest = aws4.sign({ ...request, host: request.host ?? undefined }, signCreds);
return {
...awsRequest,
body: awsRequest.body?.toString(),
};
}
}