247 lines
6.7 KiB
TypeScript
247 lines
6.7 KiB
TypeScript
/*
|
|
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 * 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<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), ...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<HttpResponse> {
|
|
return this.fetch(url, {
|
|
method: "GET",
|
|
...opt,
|
|
});
|
|
}
|
|
|
|
async postJson(
|
|
url: string,
|
|
body: any,
|
|
opt?: HttpRequestOptions,
|
|
): Promise<HttpResponse> {
|
|
return this.fetch(url, {
|
|
method: "POST",
|
|
body,
|
|
...opt,
|
|
});
|
|
}
|
|
}
|