/*
This file is part of GNU Taler
(C) 2019 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
SPDX-License-Identifier: AGPL3.0-or-later
*/
/**
* Imports.
*/
import * as http from "node:http";
import * as https from "node:https";
import * as net from "node:net";
import { RequestOptions } from "node:http";
import { TalerError } from "./errors.js";
import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
Headers,
HttpRequestLibrary,
HttpRequestOptions,
HttpResponse,
} from "./http.js";
import {
Logger,
RequestThrottler,
TalerErrorCode,
typedArrayConcat,
URL,
} from "./index.js";
// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed
// in v20.3.0.
// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
// Safe to remove once support for Node v20 is dropped.
if (
// check for `node` in case we want to use this in "exotic" JS envs
process.versions.node &&
process.versions.node.match(/20\.[0-2]\.0/)
) {
//@ts-ignore
net.setDefaultAutoSelectFamily(false);
}
const logger = new Logger("http-impl.node.ts");
const textDecoder = new TextDecoder();
/**
* Implementation of the HTTP request library interface for node.
*/
export class HttpLibImpl implements HttpRequestLibrary {
private throttle = new RequestThrottler();
private throttlingEnabled = true;
private requireTls = false;
constructor(args?: HttpLibArgs) {
this.throttlingEnabled = args?.enableThrottling ?? false;
this.requireTls = args?.requireTls ?? false;
}
/**
* Set whether requests should be throttled.
*/
setThrottling(enabled: boolean): void {
this.throttlingEnabled = enabled;
}
async fetch(url: string, opt?: HttpRequestOptions): Promise {
const method = opt?.method?.toUpperCase() ?? "GET";
logger.trace(`Requesting ${method} ${url}`);
const parsedUrl = new URL(url);
if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
{
requestMethod: method,
requestUrl: url,
throttleStats: this.throttle.getThrottleStats(url),
},
`request to origin ${parsedUrl.origin} was throttled`,
);
}
if (this.requireTls && parsedUrl.protocol !== "https:") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_NETWORK_ERROR,
{
requestMethod: method,
requestUrl: url,
},
`request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
);
}
let timeoutMs: number | undefined;
if (typeof opt?.timeout?.d_ms === "number") {
timeoutMs = opt.timeout.d_ms;
} else {
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
}
const requestHeadersMap = { ...getDefaultHeaders(method), ...opt?.headers };
let reqBody: ArrayBuffer | undefined;
if (opt?.method == "POST") {
reqBody = encodeBody(opt.body);
}
let path = parsedUrl.pathname;
if (parsedUrl.search != null) {
path += parsedUrl.search;
}
let protocol: string;
if (parsedUrl.protocol === "https:") {
protocol = "https:";
} else if (parsedUrl.protocol === "http:") {
protocol = "http:";
} else {
throw Error(`unsupported protocol (${parsedUrl.protocol})`);
}
const options: RequestOptions = {
protocol,
port: parsedUrl.port,
host: parsedUrl.hostname,
method: method,
path,
headers: requestHeadersMap,
timeout: timeoutMs,
};
const chunks: Uint8Array[] = [];
return new Promise((resolve, reject) => {
const handler = (res: http.IncomingMessage) => {
res.on("data", (d) => {
chunks.push(d);
});
res.on("end", () => {
const headers: Headers = new Headers();
for (const [k, v] of Object.entries(res.headers)) {
if (!v) {
continue;
}
if (typeof v === "string") {
headers.set(k, v);
} else {
headers.set(k, v.join(", "));
}
}
const data = typedArrayConcat(chunks);
const resp: HttpResponse = {
requestMethod: method,
requestUrl: parsedUrl.href,
status: res.statusCode || 0,
headers,
async bytes() {
return data;
},
json() {
const text = textDecoder.decode(data);
return JSON.parse(text);
},
async text() {
const text = textDecoder.decode(data);
return text;
},
};
resolve(resp);
});
res.on("error", (e) => {
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP response handler: ${e.message}`,
);
reject(err);
});
};
let req: http.ClientRequest;
if (options.protocol === "http:") {
req = http.request(options, handler);
} else if (options.protocol === "https:") {
req = https.request(options, handler);
} else {
throw new Error(`unsupported protocol ${options.protocol}`);
}
req.on("error", (e: Error) => {
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP request: ${e.message}`,
);
reject(err);
});
if (reqBody) {
req.write(new Uint8Array(reqBody));
}
req.end();
});
}
async get(url: string, opt?: HttpRequestOptions): Promise {
return this.fetch(url, {
method: "GET",
...opt,
});
}
async postJson(
url: string,
body: any,
opt?: HttpRequestOptions,
): Promise {
return this.fetch(url, {
method: "POST",
body,
...opt,
});
}
}