Question
Why am I getting an error of an unsupported request when running this code in Node.js v18.4.0, but it succeeds in v16.16.0?
Code
The direct code that is returning an error is my unit tests for the library I'm making for my company internally. Pardon some exclusions of the code, but I've tried to tidy it up as much as possible for posting publicly.
// ~/http/tests/Client.spec.ts
import { HttpClient } from '../src';
describe('Standard HTTP Client', () => {
const { request: http } = HttpClient;
it('should save the response when a state is given', async () => {
const response = await http({ method: 'GET', url: 'https://www.google.com' });
expect(response).toBeDefined();
});
});
The result in v16 is a success, but the result in v18 is:
Error: unsupported
at configSecureContext (node:internal/tls/secure-context:282:15)
at Object.createSecureContext (node:_tls_common:117:3)
at Object.connect (node:_tls_wrap:1624:48)
at Agent.createConnection (node:https:150:22)
at Agent.createSocket (node:_http_agent:346:26)
at Agent.addRequest (node:_http_agent:297:10)
at new ClientRequest (node:_http_client:322:16)
at Client.request (node:https:360:10)
at Client.promiseHandler (/home/user/projects/node/http/src/Client.ts:72:25)
at new Promise (<anonymous>)
Supporting Code
Here is the HttpClient class I've implemented. If some statements appear verbose, it's likely because there was logging in the original implementation that I have stripped out for the purposes of this post.
Note: comment added above line 72 for the error message, in the private method promiseHandler
.
Another note, the original code was found on StackOverflow when I started this effort months ago. I can't be sure of the original post I saw, but this is a likely answer for my inspiration. I expanded it from the original implementation to an object-oriented approach, and formalized the data declarations of a request body and response object, since we have some expected behavior about logging information at runtime.
// ~/http/src/Client.ts
import { request as http, ClientRequest, IncomingMessage } from 'http';
import { request as https } from 'https';
import './extensions';
import { PromiseExecutor } from './common';
import { IHttpResponse, IRequestArgs } from './interfaces';
import { Request as HttpRequest } from './Request';
import { Response as HttpResponse } from './Response';
type RequestMethod = typeof http | typeof https;
export class Client {
private redirects: Map<URL, number>;
private request?: ClientRequest;
private readonly requestOptions: HttpRequest;
private response?: HttpResponse;
private reject: PromiseExecutor;
private resolve: PromiseExecutor;
constructor(request: IRequestArgs) {
this.requestOptions = new HttpRequest(request);
this.redirects = new Map();
this.redirects.set(this.requestOptions.url as URL, 1);
this.reject = () => void 0;
this.resolve = () => void 0;
}
private get boundPromiseHandler() {
return this.promiseHandler.bind(this);
}
private get boundRequestHandler() {
return this.requestHandler.bind(this);
}
private get boundRequestErrorHandler() {
return this.requestErrorHandler.bind(this);
}
handleRedirect(this: Client, response: IHttpResponse): Promise<IHttpResponse> {
const { headers } = response;
if(!headers || !headers.has('location')) {
return Promise.resolve(response);
}
const location = headers.get('location')!;
const url = new URL(location);
const redirects = this.redirects.get(url) ?? 0;
this.redirects.set(url, redirects + 1);
this.requestOptions.setRequestUrl(url);
if(this.redirects.get(url)! < 2) {
return Client.request(this.requestOptions as IRequestArgs, this);
}
return Promise.resolve(response);
}
get isSecure() {
return [ 'ssh:', 'sftp:', 'https:' ].includes(this.requestOptions.protocol ?? 'http');
}
get promise(): Promise<IHttpResponse> {
return new Promise(this.boundPromiseHandler) as Promise<IHttpResponse>;
}
private promiseHandler(resolve: PromiseExecutor, reject: PromiseExecutor): void {
const { requestOptions: { body, hasPayload = false, ...rest } } = Object.assign(this, { resolve, reject }) as this;
// This is line #72, position 25 is the dot (.) before `requestMethod`
this.request = this.requestMethod(rest, this.boundRequestHandler).on('error', this.boundRequestErrorHandler);
// Data payloads must be written to the request stream
if(hasPayload) {
this.request.write(body);
}
this.request.end();
}
static async request(options: IRequestArgs, client?: Client): Promise<IHttpResponse> {
client = client ?? new Client(options);
const response = await client.promise;
if(Number.between(response.statusCode ?? 0, 300, 399)) {
return client.handleRedirect(response);
}
return response;
}
get requestMethod(): RequestMethod {
if(this.isSecure) {
return https;
}
return http;
}
private requestHandler(response: IncomingMessage) {
this.response = new HttpResponse(response, this.resolve, this.reject);
}
private requestErrorHandler(error?: Error) {
if(error) {
return this.reject(error);
}
return this.reject(new Error('An error occurred while handling the HTTP Response'));
}
toJSON() {
return {
request: this.requestOptions,
response: this.response,
}
}
toString() {
return JSON.stringify(this, null, 2);
}
}
Misc
I can add additional supporting code, but I felt the question was already fairly verbose. The Request
class is just an enumeration of the RequestOptions
interfaces from node:http
and node:https
, with some common defaults like method = options.method ?? 'GET'
. The Response
class has a little more meat to it, but it's just responsible for concatenating data chunks received on the data
event, and then attempting to parse the response body as JSON.
The full code can be seen in a GitHub repo I just created for this question.