/* 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(); }); } }