/*
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 type { FollowOptions, RedirectableRequest } from "follow-redirects";
import followRedirects from "follow-redirects";
import type { ClientRequest, IncomingMessage } from "node:http";
import { RequestOptions } from "node:http";
import * as net from "node:net";
import { TalerError } from "./errors.js";
import { HttpLibArgs, encodeBody, getDefaultHeaders } from "./http-common.js";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
Headers,
HttpRequestLibrary,
HttpRequestOptions,
HttpResponse,
} from "./http.js";
import {
Logger,
RequestThrottler,
TalerErrorCode,
URL,
typedArrayConcat,
} from "./index.js";
const http = followRedirects.http;
const https = followRedirects.https;
// 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();
let SHOW_CURL_HTTP_REQUEST = false;
export function setPrintHttpRequestAsCurl(b: boolean) {
SHOW_CURL_HTTP_REQUEST = b;
}
/**
* 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 ?? true;
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);
if (opt?.headers) {
Object.entries(opt?.headers).forEach(([key, value]) => {
if (value === undefined) return;
requestHeadersMap[key] = value;
});
}
logger.trace(`request timeout ${timeoutMs} ms`);
let reqBody: ArrayBuffer | undefined;
if (
opt?.method == "POST" ||
opt?.method == "PATCH" ||
opt?.method == "PUT"
) {
reqBody = encodeBody(opt.body);
}
if (opt?.body instanceof URLSearchParams) {
requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded"
}
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 & FollowOptions = {
protocol,
port: parsedUrl.port,
host: parsedUrl.hostname,
method: method,
path,
headers: requestHeadersMap,
timeout: timeoutMs,
followRedirects: opt?.redirect !== "manual",
};
const chunks: Uint8Array[] = [];
if (SHOW_CURL_HTTP_REQUEST) {
const payload =
!reqBody || reqBody.byteLength === 0
? undefined
: textDecoder.decode(reqBody);
const headers = Object.entries(requestHeadersMap).reduce(
(prev, [key, value]) => {
return `${prev} -H "${key}: ${value}"`;
},
"",
);
function ifUndefined(arg: string, v: undefined | T): string {
if (v === undefined) return "";
return arg + " '" + String(v) + "'";
}
console.log(
`TALER_API_DEBUG: curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined(
"-d",
payload,
)}`,
);
}
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let cancelCancelledHandler: (() => void) | undefined = undefined;
const doCleanup = () => {
if (timeoutHandle != null) {
clearTimeout(timeoutHandle);
}
if (cancelCancelledHandler) {
cancelCancelledHandler();
}
};
return new Promise((resolve, reject) => {
const handler = (res: 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;
},
};
doCleanup();
if (SHOW_CURL_HTTP_REQUEST) {
console.log(`TALER_API_DEBUG: ${res.statusCode} ${textDecoder.decode(data)}`)
}
resolve(resp);
});
res.on("error", (e) => {
const code = "code" in e ? e.code : "unknown";
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP response handler: ${code}`,
);
doCleanup();
reject(err);
});
};
let req: RedirectableRequest;
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}`);
}
if (timeoutMs != null) {
timeoutHandle = setTimeout(() => {
logger.info(`request to ${url} timed out`);
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Request timed out after ${timeoutMs} ms`,
);
timeoutHandle = undefined;
req.destroy();
doCleanup();
reject(err);
req.destroy();
}, timeoutMs);
}
if (opt?.cancellationToken) {
cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Request cancelled`,
);
req.destroy();
doCleanup();
reject(err);
});
}
req.on("error", (e: Error) => {
const code = "code" in e ? e.code : "unknown";
const err = TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
{
requestUrl: url,
requestMethod: method,
httpStatusCode: 0,
},
`Error in HTTP request: ${code}`,
);
doCleanup();
reject(err);
});
if (reqBody) {
req.write(new Uint8Array(reqBody));
}
req.end();
});
}
}