/* 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 allowHttp = false; constructor(args?: HttpLibArgs) { this.throttlingEnabled = args?.enableThrottling ?? false; this.allowHttp = args?.allowHttp ?? 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.allowHttp && 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, }); } }