summaryrefslogtreecommitdiff
path: root/packages/taler-util/src/http-impl.node.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-util/src/http-impl.node.ts')
-rw-r--r--packages/taler-util/src/http-impl.node.ts324
1 files changed, 324 insertions, 0 deletions
diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts
new file mode 100644
index 000000000..45a12c258
--- /dev/null
+++ b/packages/taler-util/src/http-impl.node.ts
@@ -0,0 +1,324 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+
+ 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<HttpResponse> {
+ 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);
+ }
+
+ 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<RequestOptions> = {
+ 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<T>(arg: string, v: undefined | T): string {
+ if (v === undefined) return "";
+ return arg + " '" + String(v) + "'";
+ }
+ console.log(
+ `curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined(
+ "-d",
+ payload,
+ )}`,
+ );
+ }
+
+ let timeoutHandle: NodeJS.Timer | 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();
+ 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<ClientRequest, IncomingMessage>;
+ 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();
+ });
+ }
+}