taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 020770521922a84ab46bbc6be8fa38f2de4593a0
parent 19c30e61b252bec5bfaa3d65fc7900764a628467
Author: Florian Dold <dold@taler.net>
Date:   Tue, 28 Jul 2026 23:07:31 +0200

util: share one HTTP implementation across node, qtart and the browser

The four backends each reimplemented throttling, headers, body encoding,
timeouts and error mapping, and disagreed on all of them; they now only provide
the platform-specific transport. json() is genuinely async everywhere as a
result, hence the added awaits in the harness.

Issue: https://bugs.taler.net/n/9822

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-kyc-challenger.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts | 2+-
Mpackages/taler-harness/src/integrationtests/test-pay-paid.ts | 8++++----
Mpackages/taler-harness/src/integrationtests/test-payment-abort.ts | 4++--
Mpackages/taler-harness/src/integrationtests/test-payment-transient.ts | 8++++----
Mpackages/taler-harness/src/integrationtests/test-paywall-flow.ts | 14+++++++-------
Mpackages/taler-util/src/http-common.ts | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-impl.missing.ts | 23++++++++---------------
Mpackages/taler-util/src/http-impl.node.ts | 279+++++++++++++++++++------------------------------------------------------------
Mpackages/taler-util/src/http-impl.qtart.ts | 180++++++++++++++++---------------------------------------------------------------
Mpackages/taler-util/src/http.ts | 2+-
Mpackages/web-util/src/utils/http-impl.browser.ts | 299+++++++++++++++++++------------------------------------------------------------
Mpackages/web-util/src/utils/http-impl.sw.ts | 281+++----------------------------------------------------------------------------
13 files changed, 546 insertions(+), 892 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-kyc-challenger.ts b/packages/taler-harness/src/integrationtests/test-kyc-challenger.ts @@ -346,7 +346,7 @@ export async function runKycChallengerTest(t: GlobalTestState) { ); logger.info(`kyc-start resp status: ${startResp.status}`); - logger.info(j2s(startResp.json())); + logger.info(j2s(await startResp.json())); // We need to "visit" the KYC proof URL at least once to trigger the exchange // asking for the KYC status. diff --git a/packages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts b/packages/taler-harness/src/integrationtests/test-kyc-form-bad-measure.ts @@ -144,7 +144,7 @@ export async function runKycFormBadMeasureTest(t: GlobalTestState) { ); console.log("resp status", uploadResp.status); - console.log(`resp body: ${j2s(uploadResp.json())}`); + console.log(`resp body: ${j2s(await uploadResp.json())}`); t.assertDeepEqual(uploadResp.status, 409); } diff --git a/packages/taler-harness/src/integrationtests/test-pay-paid.ts b/packages/taler-harness/src/integrationtests/test-pay-paid.ts @@ -118,7 +118,7 @@ export async function runPayPaidTest(t: GlobalTestState) { } let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); console.log(pubUnpaidStatus); @@ -149,7 +149,7 @@ export async function runPayPaidTest(t: GlobalTestState) { } pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, { @@ -161,10 +161,10 @@ export async function runPayPaidTest(t: GlobalTestState) { publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl); - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); if (publicOrderStatusResp.status != 200) { - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); throw Error( `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`, ); diff --git a/packages/taler-harness/src/integrationtests/test-payment-abort.ts b/packages/taler-harness/src/integrationtests/test-payment-abort.ts @@ -102,7 +102,7 @@ export async function runPaymentAbortTest(t: GlobalTestState) { } let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); console.log(pubUnpaidStatus); @@ -131,7 +131,7 @@ export async function runPaymentAbortTest(t: GlobalTestState) { } pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); faultyMerchant.faultProxy.addFault({ diff --git a/packages/taler-harness/src/integrationtests/test-payment-transient.ts b/packages/taler-harness/src/integrationtests/test-payment-transient.ts @@ -104,7 +104,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) { } let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); console.log(pubUnpaidStatus); @@ -135,7 +135,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) { } pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); let injectFault = true; @@ -192,10 +192,10 @@ export async function runPaymentTransientTest(t: GlobalTestState) { console.log("requesting", publicOrderStatusUrl); publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl); - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); if (publicOrderStatusResp.status != 200) { - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); throw Error( `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`, ); diff --git a/packages/taler-harness/src/integrationtests/test-paywall-flow.ts b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts @@ -114,7 +114,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) { } let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); console.log(pubUnpaidStatus); @@ -138,7 +138,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) { console.log("requesting", publicOrderStatusUrl.href); publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href); - console.log("response body", publicOrderStatusResp.json()); + console.log("response body", await publicOrderStatusResp.json()); if (publicOrderStatusResp.status != 402) { throw Error( `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`, @@ -146,7 +146,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) { } pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, { @@ -157,10 +157,10 @@ export async function runPaywallFlowTest(t: GlobalTestState) { t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done); publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href); - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); if (publicOrderStatusResp.status != 200) { - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); throw Error( `expected status 200 (after paying), but got ${publicOrderStatusResp.status}`, ); @@ -316,10 +316,10 @@ export async function runPaywallFlowTest(t: GlobalTestState) { } pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( - publicOrderStatusResp.json(), + await publicOrderStatusResp.json(), ); - console.log(publicOrderStatusResp.json()); + console.log(await publicOrderStatusResp.json()); t.assertTrue(pubUnpaidStatus.already_paid_order_id === firstOrderId); } diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -22,9 +22,11 @@ import { makeErrorDetail, TalerError } from "./errors.js"; import { j2s } from "./helpers.js"; import { Logger } from "./logging.js"; import { openPromise } from "./promises.js"; +import { RequestThrottler } from "./RequestThrottler.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { Duration } from "./time.js"; import { TalerErrorDetail } from "./types-taler-wallet.js"; +import { URL, URLSearchParams } from "./url.js"; const textEncoder = new TextEncoder(); @@ -589,6 +591,340 @@ export function getDefaultHeaders(method: string): Record<string, string> { } /** + * Raw HTTP response, as reported by a platform-specific HttpRawLib. + * + * Deliberately minimal: everything that can be derived from the bytes + * (text, JSON, and the error reporting around them) is handled once in + * HttpLib instead of being redone per platform. + */ +export interface HttpRawResponse { + status: number; + headers: Headers; + bytes(): Promise<Uint8Array>; +} + +/** + * A fully resolved request, as handed to a platform-specific HttpRawLib. + * + * Throttling, the protocol check, header defaults, body encoding and + * compression have already been applied by HttpLib, so an implementation only + * has to move the bytes. + */ +export interface HttpRawRequestOptions { + method: "POST" | "PATCH" | "PUT" | "GET" | "DELETE"; + headers: Record<string, string>; + + /** + * Already encoded and, if requested, already compressed. Undefined for + * methods that carry no body. + */ + body: Uint8Array | undefined; + + /** + * Timeout in milliseconds, or undefined to wait indefinitely. + */ + timeoutMs: number | undefined; + + cancellationToken?: CancellationToken; + redirect?: "follow" | "error" | "manual"; +} + +/** + * Platform-specific part of the HTTP stack. + * + * Implementations report a timeout by throwing RequestTimeoutError and a + * cancellation by throwing RequestCancelledError; any other failure is thrown + * as whatever the platform produced. Translating those into TalerError with + * the request URL, method and status attached is HttpLib's job, so that every + * platform surfaces the same error details. + */ +export interface HttpRawLib { + fetch(url: string, opt: HttpRawRequestOptions): Promise<HttpRawResponse>; + + /** + * Compress a request body. Optional: a platform that cannot compress + * (currently qtart) leaves the body untouched and the request is sent + * without a Content-Encoding header. + */ + compress?(alg: "gzip" | "deflate", body: Uint8Array): Promise<Uint8Array>; +} + +const textDecoder = new TextDecoder(); + +/** + * The HTTP request library used everywhere in the wallet. + * + * All the policy lives here — throttling, TLS enforcement, default headers, + * body encoding and compression, timeout defaults, error mapping, response + * body caching and trace logging — so that the node, qtart and browser + * backends only have to implement HttpRawLib. + */ +export class HttpLib implements HttpRequestLibrary { + private throttle = new RequestThrottler(); + private throttlingEnabled: boolean; + private requireTls: boolean; + private idCounter = 1; + private raw: HttpRawLib; + + constructor(raw: HttpRawLib, args?: HttpLibArgs) { + this.raw = raw; + 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( + requestUrl: string, + opt?: HttpRequestOptions, + ): Promise<HttpResponse> { + const rid = this.idCounter++; + const requestMethod = opt?.method ?? "GET"; + const parsedUrl = new URL(requestUrl); + + if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + { + requestMethod, + requestUrl, + throttleStats: this.throttle.getThrottleStats(requestUrl), + }, + `request ${rid} to ${parsedUrl.origin} was throttled`, + ); + } + + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { + throw Error(`unsupported protocol (${parsedUrl.protocol})`); + } + if (this.requireTls && parsedUrl.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestMethod, + requestUrl, + }, + `request ${rid} to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, + ); + } + + // An absent timeout means the default, an explicit "forever" means no + // timeout at all. + let timeoutMs: number | undefined; + if (opt?.timeout === undefined) { + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + } else if (opt.timeout.d_ms === "forever") { + timeoutMs = undefined; + } else { + timeoutMs = opt.timeout.d_ms; + } + + const headers = getDefaultHeaders(requestMethod); + if (opt?.headers != null) { + for (const [key, value] of Object.entries(opt.headers)) { + if (value === undefined) continue; + headers[key] = value; + } + } + + let body: Uint8Array | undefined; + if ( + requestMethod === "POST" || + requestMethod === "PATCH" || + requestMethod === "PUT" + ) { + body = encodeBody(opt?.body); + } + + if (opt?.body instanceof URLSearchParams) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + } else if ( + typeof opt?.body === "object" && + opt.body.constructor.name === "FormData" + ) { + // The default headers assume JSON; for FormData the browser has to + // generate the content type itself, boundary included. + delete headers["Content-Type"]; + } + + if (body != null && opt?.compress != null) { + if (this.raw.compress != null) { + body = await this.raw.compress(opt.compress, body); + headers["Content-Encoding"] = opt.compress; + } else { + logger.warn( + `request ${rid} asked for ${opt.compress}, but this platform cannot compress; sending uncompressed`, + ); + } + } + + if (logger.shouldLogTrace()) { + const timeoutInfo = + timeoutMs === undefined ? "no timeout" : `timeout ${timeoutMs}ms`; + if (opt?.body != null) { + logger.trace( + `request ${rid} ${requestMethod} ${requestUrl} (${timeoutInfo}): ${j2s( + opt.body, + )}`, + ); + } else { + logger.trace( + `request ${rid} ${requestMethod} ${requestUrl} (${timeoutInfo})`, + ); + } + } + + let resp: HttpRawResponse; + try { + resp = await this.raw.fetch(requestUrl, { + method: requestMethod, + headers, + body, + timeoutMs, + cancellationToken: opt?.cancellationToken, + redirect: opt?.redirect, + }); + } catch (cause) { + throw this.mapRequestError( + cause, + rid, + requestUrl, + requestMethod, + timeoutMs, + ); + } + + logger.trace(`request ${rid} status code ${resp.status}`); + + // The response body gets read more than once in a few places (an error + // path that reports the raw text after JSON parsing failed, for one), so + // the bytes are pulled from the platform exactly once and kept. + let cachedBytes: Uint8Array | undefined; + const readBytes = async (): Promise<Uint8Array> => { + if (cachedBytes === undefined) { + cachedBytes = await resp.bytes(); + } + return cachedBytes; + }; + + const decodeText = async (): Promise<string> => { + const bytes = await readBytes(); + try { + return textDecoder.decode(bytes); + } catch (e) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: resp.status, + validationError: e instanceof Error ? e.message : String(e), + }, + "Invalid text from HTTP response", + ); + } + }; + + const readText = async (): Promise<string> => { + const text = await decodeText(); + if (logger.shouldLogTrace()) { + logger.trace(`request ${rid} TEXT: ${text}`); + } + return text; + }; + + const readJson = async (): Promise<any> => { + const text = await decodeText(); + let json: any; + try { + json = JSON.parse(text); + } catch (e) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + response: text, + httpStatusCode: resp.status, + validationError: e instanceof Error ? e.message : String(e), + }, + e instanceof Error + ? `Invalid JSON from HTTP response: ${e.message}` + : "Invalid JSON from HTTP response", + ); + } + if (logger.shouldLogTrace()) { + logger.trace(`request ${rid} JSON: ${j2s(json)}`); + } + return json; + }; + + return { + requestUrl, + requestMethod, + status: resp.status, + headers: resp.headers, + bytes: readBytes, + text: readText, + json: readJson, + }; + } + + /** + * Turn whatever a raw implementation threw into a TalerError that carries + * the request URL and method. + */ + private mapRequestError( + cause: unknown, + rid: number, + requestUrl: string, + requestMethod: string, + timeoutMs: number | undefined, + ): TalerError { + if (cause instanceof RequestCancelledError) { + logger.trace(`request ${rid} cancelled`); + return TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + { + requestUrl, + requestMethod, + httpStatusCode: 0, + }, + `Request cancelled`, + ); + } + if (cause instanceof RequestTimeoutError) { + logger.trace(`request ${rid} timed out (timeout ${timeoutMs})`); + return TalerError.fromDetail( + TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, + { + requestUrl, + requestMethod, + timeoutMs: timeoutMs ?? 0, + }, + `Request timed out after ${timeoutMs} ms`, + ); + } + const message = cause instanceof Error ? cause.message : String(cause); + logger.trace(`request ${rid} failed with '${message}'`); + return TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestUrl, + requestMethod, + }, + `HTTP request failed: ${message}`, + cause instanceof Error ? cause : undefined, + ); + } +} + +/** * Dummy response, do not use. */ export const dummyHttpResponse: HttpResponse = { diff --git a/packages/taler-util/src/http-impl.missing.ts b/packages/taler-util/src/http-impl.missing.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019 Taler Systems S.A. + (C) 2019-2026 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 @@ -19,20 +19,13 @@ /** * Imports. */ -import { - HttpRequestLibrary, - HttpRequestOptions, - HttpResponse, -} from "./http.js"; +import { HttpRawLib } from "./http-common.js"; /** - * Implementation of the HTTP request library interface for node. + * Stub for platforms that have no HTTP implementation. */ -export class HttpLibImpl implements HttpRequestLibrary { - fetch( - url: string, - opt?: HttpRequestOptions | undefined, - ): Promise<HttpResponse> { - throw new Error("Method not implemented. HttpLibImpl.fetch"); - } -} +export const rawLib: HttpRawLib = { + fetch() { + throw new Error("Method not implemented. HttpRawLib.fetch"); + }, +}; diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019-2025 Taler Systems S.A. + (C) 2019-2026 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 @@ -21,24 +21,16 @@ import followRedirects from "follow-redirects"; import type { ClientRequest, IncomingMessage } from "node:http"; import { RequestOptions } from "node:http"; import * as net from "node:net"; -import { deflateSync } from "node:zlib"; -import { TalerError } from "./errors.js"; -import { HttpLibArgs, encodeBody, getDefaultHeaders } from "./http-common.js"; +import { deflateSync, gzipSync } from "node:zlib"; import { - DEFAULT_REQUEST_TIMEOUT_MS, HeadersImpl, - HttpRequestLibrary, - HttpRequestOptions, - HttpResponse, -} from "./http.js"; -import { - Logger, - RequestThrottler, - TalerErrorCode, - URL, - j2s, - typedArrayConcat, -} from "./index.js"; + HttpRawLib, + HttpRawRequestOptions, + HttpRawResponse, + RequestCancelledError, + RequestTimeoutError, +} from "./http-common.js"; +import { Logger, URL, typedArrayConcat } from "./index.js"; const http = followRedirects.http; const https = followRedirects.https; @@ -65,145 +57,60 @@ 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; - private idCounter: number = 1; - - 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; +function printAsCurl(url: string, opt: HttpRawRequestOptions): void { + const payload = + !opt.body || opt.body.byteLength === 0 + ? undefined + : textDecoder.decode(opt.body); + const headers = Object.entries(opt.headers).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( + `TALER_API_DEBUG: curl -X ${opt.method} "${url}" ${headers} ${ifUndefined( + "-d", + payload, + )}`, + ); +} - async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { - const method = opt?.method?.toUpperCase() ?? "GET"; - const rid = this.idCounter++; - - if (logger.shouldLogTrace()) { - if (opt?.body != null) { - logger.trace( - `request ${rid} ${method} ${url}: ${JSON.stringify(opt.body, undefined, 2)}`, - ); - } else { - logger.trace(`request ${rid} ${method} ${url}`); - } - } - +/** + * Implementation of the raw HTTP layer for node, using follow-redirects on + * top of node's own http/https modules. + */ +export const rawLib: HttpRawLib = { + async compress(alg, body) { + return alg === "gzip" + ? new Uint8Array(gzipSync(body)) + : new Uint8Array(deflateSync(body)); + }, + + fetch(url: string, opt: HttpRawRequestOptions): Promise<HttpRawResponse> { 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 ${rid} to ${parsedUrl.origin} was throttled`, - ); - } - if (this.requireTls && parsedUrl.protocol !== "https:") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestMethod: method, - requestUrl: url, - }, - `request ${rid} 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 ${rid} timeout ${timeoutMs} ms`); - - let reqBody: Uint8Array | undefined; - - if ( - opt?.method == "POST" || - opt?.method == "PATCH" || - opt?.method == "PUT" - ) { - reqBody = encodeBody(opt.body); - } - - if (reqBody && opt?.compress === "deflate") { - reqBody = new Uint8Array(deflateSync(reqBody)); - requestHeadersMap["Content-Encoding"] = "deflate"; - } - - 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<RequestOptions> = { - protocol, + protocol: parsedUrl.protocol, port: parsedUrl.port, host: parsedUrl.hostname, - method: method, + method: opt.method, path, - headers: requestHeadersMap, - timeout: timeoutMs, - followRedirects: opt?.redirect !== "manual", + headers: opt.headers, + timeout: opt.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( - `TALER_API_DEBUG: curl -X ${options.method} "${ - parsedUrl.href - }" ${headers} ${ifUndefined("-d", payload)}`, - ); + printAsCurl(parsedUrl.href, opt); } let timeoutHandle: NodeJS.Timeout | undefined = undefined; @@ -212,13 +119,15 @@ export class HttpLibImpl implements HttpRequestLibrary { const doCleanup = () => { if (timeoutHandle != null) { clearTimeout(timeoutHandle); + timeoutHandle = undefined; } if (cancelCancelledHandler) { cancelCancelledHandler(); + cancelCancelledHandler = undefined; } }; - return new Promise((resolve, reject) => { + return new Promise<HttpRawResponse>((resolve, reject) => { const handler = (res: IncomingMessage) => { res.on("data", (d) => { chunks.push(d); @@ -236,52 +145,24 @@ export class HttpLibImpl implements HttpRequestLibrary { } } 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); - const json = JSON.parse(text); - if (logger.shouldLogTrace()) { - logger.trace(`request ${rid} JSON: ${j2s(json)}`); - } - return json; - }, - async text() { - const text = textDecoder.decode(data); - if (logger.shouldLogTrace()) { - logger.trace(`request ${rid} TEXT: ${text}`); - } - return text; - }, - }; - logger.trace(`request ${rid} status code ${resp.status}`); doCleanup(); if (SHOW_CURL_HTTP_REQUEST) { console.log( `TALER_API_DEBUG: ${res.statusCode} ${textDecoder.decode(data)}`, ); } - resolve(resp); + resolve({ + status: res.statusCode || 0, + headers, + async bytes() { + return data; + }, + }); }); 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); + reject(new Error(`Error in HTTP response handler: ${code}`)); }); }; @@ -294,62 +175,34 @@ export class HttpLibImpl implements HttpRequestLibrary { throw new Error(`unsupported protocol ${options.protocol}`); } - if (timeoutMs != null) { + if (opt.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); + reject(new RequestTimeoutError()); + }, opt.timeoutMs); } - if (opt?.cancellationToken) { + 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); + reject(new RequestCancelledError()); }); } 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); + reject(new Error(`Error in HTTP request: ${code}`)); }); - if (reqBody) { - req.write(new Uint8Array(reqBody)); + if (opt.body) { + req.write(opt.body); } req.end(); }); - } -} + }, +}; diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019 Taler Systems S.A. + (C) 2019-2026 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 @@ -20,156 +20,58 @@ * Imports. */ import { j2s, Logger } from "@gnu-taler/taler-util"; -import { TalerError } from "./errors.js"; import { awaitNativeRequest, - encodeBody, - getDefaultHeaders, - HttpLibArgs, - RequestCancelledError, - RequestTimeoutError, -} from "./http-common.js"; - -// Re-exported for backwards compatibility; defined in http-common. -export { RequestCancelledError, RequestTimeoutError }; -import { HeadersImpl, - HttpRequestLibrary, - HttpRequestOptions, - HttpResponse, -} from "./http.js"; -import { RequestThrottler, TalerErrorCode, URL } from "./index.js"; + HttpRawLib, + HttpRawRequestOptions, + HttpRawResponse, +} from "./http-common.js"; import { QjsHttpResp, qjsOs } from "./qtart.js"; +import { Duration } from "./time.js"; const logger = new Logger("http-impl.qtart.ts"); -const textDecoder = new TextDecoder(); - /** - * Implementation of the HTTP request library interface for node. + * Implementation of the raw HTTP layer for the qtart (QuickJS) runtime used by + * the mobile wallets. + * + * No compress hook: the runtime has no compression primitive, so a request + * that asks for compression is sent uncompressed (and, because the shared + * layer only sets Content-Encoding when compression actually happened, + * correctly labelled). */ -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 ?? "GET").toUpperCase(); - - 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 data: Uint8Array | undefined = undefined; - const requestHeadersMap = getDefaultHeaders(method); - if (opt?.headers) { - Object.entries(opt?.headers).forEach(([key, value]) => { - if (value === undefined) return; - requestHeadersMap[key] = value; - }); - } - let headersList: string[] = []; - for (let headerName of Object.keys(requestHeadersMap)) { - headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`); - } - if (method === "POST") { - data = encodeBody(opt?.body); +export const rawLib: HttpRawLib = { + async fetch( + url: string, + opt: HttpRawRequestOptions, + ): Promise<HttpRawResponse> { + const headersList: string[] = []; + for (const [name, value] of Object.entries(opt.headers)) { + headersList.push(`${name}: ${value}`); } logger.trace(`calling qtart fetchHttp`); const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, { - method, - data, + method: opt.method, + data: opt.body, headers: headersList, }); // awaitNativeRequest applies the timeout and cancellation token, cancels // the native request on either, and releases the timer and the // cancellation listener on every path. - let res: QjsHttpResp; - try { - res = await awaitNativeRequest( - { promise: fetchProm, cancelFn }, - { timeout: opt?.timeout, cancellationToken: opt?.cancellationToken }, - ); - } catch (e) { - logger.trace(`got exception while waiting for qtart http response`); - if (e instanceof RequestCancelledError) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - { - requestUrl: url, - requestMethod: method, - httpStatusCode: 0, - }, - `Request cancelled`, - ); - } - if (e instanceof RequestTimeoutError) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - { - requestUrl: url, - requestMethod: method, - httpStatusCode: 0, - }, - `Request timed out (timeout ${opt?.timeout?.d_ms ?? "none"})`, - ); - } - if ( - e != null && - typeof e === "object" && - "message" in e && - typeof e.message === "string" - ) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - { - requestUrl: url, - requestMethod: method, - httpStatusCode: 0, - }, - e.message, - ); - } else { - // Something weird/unexpected got thrown as exception, re-throw. - throw e; - } - } + const res: QjsHttpResp = await awaitNativeRequest( + { promise: fetchProm, cancelFn }, + { + timeout: + opt.timeoutMs === undefined + ? undefined + : Duration.fromMilliseconds(opt.timeoutMs), + cancellationToken: opt.cancellationToken, + }, + ); if (logger.shouldLogTrace()) { logger.trace(`got qtart http response, status ${res.status}`); @@ -200,21 +102,11 @@ export class HttpLibImpl implements HttpRequestLibrary { } return { - requestMethod: method, + status: res.status, headers, async bytes() { return new Uint8Array(res.data); }, - json() { - const text = textDecoder.decode(res.data); - return JSON.parse(text); - }, - async text() { - const text = textDecoder.decode(res.data); - return text; - }, - requestUrl: url, - status: res.status, }; - } -} + }, +}; diff --git a/packages/taler-util/src/http.ts b/packages/taler-util/src/http.ts @@ -33,5 +33,5 @@ export * from "./http-common.js"; export function createPlatformHttpLib( args?: common.HttpLibArgs, ): common.HttpRequestLibrary { - return new impl.HttpLibImpl(args); + return new common.HttpLib(impl.rawLib, args); } diff --git a/packages/web-util/src/utils/http-impl.browser.ts b/packages/web-util/src/utils/http-impl.browser.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (C) 2022-2026 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 @@ -18,249 +18,92 @@ * Imports. */ import { - Duration, - Logger, - RequestThrottler, - TalerError, - TalerErrorCode, -} from "@gnu-taler/taler-util"; - -import { - DEFAULT_REQUEST_TIMEOUT_MS, HeadersImpl, - HttpLibArgs, - HttpRequestLibrary, - HttpRequestOptions, - HttpResponse, - encodeBody, - getDefaultHeaders, + HttpRawLib, + HttpRawRequestOptions, + HttpRawResponse, + RequestCancelledError, + RequestTimeoutError, } from "@gnu-taler/taler-util/http"; -const logger = new Logger("browserHttpLib"); - /** - * An implementation of the [[HttpRequestLibrary]] using the - * browser's XMLHttpRequest. - * - * @deprecated use BrowserFetchHttpLib + * Implementation of the raw HTTP layer for the browser, using the fetch API. */ -export class BrowserHttpLibDepreacted 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; - } +export const browserRawLib: HttpRawLib = { + compress, async fetch( - requestUrl: string, - options?: HttpRequestOptions, - ): Promise<HttpResponse> { - const requestMethod = options?.method ?? "GET"; - const requestBody = options?.body; - const requestHeader = options?.headers; - const requestTimeout = - options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); + url: string, + opt: HttpRawRequestOptions, + ): Promise<HttpRawResponse> { + const controller = new AbortController(); + let timedOut = false; + let cancelled = false; + let timeoutId: ReturnType<typeof setTimeout> | undefined; + let unregisterCancel: (() => void) | undefined; + + const cleanup = (): void => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (unregisterCancel !== undefined) { + unregisterCancel(); + unregisterCancel = undefined; + } + }; - const parsedUrl = new URL(requestUrl); - if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, - { - requestMethod, - requestUrl, - throttleStats: this.throttle.getThrottleStats(requestUrl), - }, - `request to origin ${parsedUrl.origin} was throttled`, - ); - } - if (this.requireTls && parsedUrl.protocol !== "https:") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestMethod: requestMethod, - requestUrl: requestUrl, - }, - `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, - ); + if (opt.timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, opt.timeoutMs); } - - const encodedBody: Uint8Array | undefined = - requestMethod === "POST" || - requestMethod === "PUT" || - requestMethod === "PATCH" - ? encodeBody(requestBody) - : undefined; - - const myBody: Uint8Array | undefined = - !options?.compress || !encodedBody - ? encodedBody - : await compress(options.compress, encodedBody); - - const requestHeadersMap = getDefaultHeaders(requestMethod); - if (requestHeader) { - Object.entries(requestHeader).forEach(([key, value]) => { - if (value === undefined) return; - requestHeadersMap[key] = value; + if (opt.cancellationToken) { + unregisterCancel = opt.cancellationToken.onCancelled(() => { + cancelled = true; + controller.abort(); }); } - if (options?.compress) { - requestHeadersMap["Content-Encoding"] = options.compress; - } - - return new Promise<HttpResponse>((resolve, reject) => { - const myRequest = new XMLHttpRequest(); - - myRequest.onerror = (e) => { - logger.error("http request error"); - reject( - TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestUrl, - requestMethod, - }, - "Could not make request", - ), - ); - }; - - myRequest.open(requestMethod, requestUrl); - - let timeoutId: any | undefined; - if (requestTimeout.d_ms !== "forever") { - timeoutId = setTimeout(() => { - myRequest.abort(); - reject( - TalerError.fromDetail( - TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, - { - requestUrl, - requestMethod, - timeoutMs: - requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms, - }, - `request to ${requestUrl} timed out`, - ), - ); - }, requestTimeout.d_ms); - } - - Object.keys(requestHeadersMap).forEach((headerName) => { - myRequest.setRequestHeader(headerName, requestHeadersMap[headerName]); - }); - - myRequest.responseType = "arraybuffer"; - myRequest.send(myBody != null ? new Uint8Array(myBody) : undefined); - - myRequest.addEventListener("readystatechange", (e) => { - if (myRequest.readyState === XMLHttpRequest.DONE) { - if (myRequest.status === 0) { - const exc = TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestUrl, - requestMethod, - }, - "HTTP request failed (status 0, maybe URI scheme was wrong?)", - ); - reject(exc); - return; - } - const makeText = async (): Promise<string> => { - const td = new TextDecoder(); - return td.decode(myRequest.response); - }; - let responseJson: unknown = undefined; - const makeJson = async (): Promise<any> => { - if (responseJson === undefined) { - try { - const td = new TextDecoder(); - const responseString = td.decode(myRequest.response); - responseJson = JSON.parse(responseString); - } catch (e) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: myRequest.status, - }, - "Invalid JSON from HTTP response", - ); - } - } - if (responseJson === null || typeof responseJson !== "object") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: myRequest.status, - }, - "Invalid JSON from HTTP response", - ); - } - return responseJson; - }; - - const headers = myRequest.getAllResponseHeaders(); - const arr = headers.trim().split(/[\r\n]+/); - - // Create a map of header names to values - const headerMap: HeadersImpl = new HeadersImpl(); - arr.forEach(function (line) { - const parts = line.split(": "); - const headerName = parts.shift(); - if (!headerName) { - logger.warn("skipping invalid header"); - return; - } - const value = parts.join(": "); - headerMap.set(headerName, value); - }); - const resp: HttpResponse = { - requestUrl: requestUrl, - status: myRequest.status, - headers: headerMap, - requestMethod: requestMethod, - json: makeJson, - text: makeText, - bytes: async () => myRequest.response, - }; - resolve(resp); - } + let response: Response; + try { + response = await globalThis.fetch(url, { + headers: opt.headers, + body: opt.body != null ? new Uint8Array(opt.body) : undefined, + method: opt.method, + signal: controller.signal, + redirect: opt.redirect, }); - }); - } - - get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { - return this.fetch(url, { - method: "GET", - ...opt, - }); - } + } catch (e) { + // The abort reason is tracked separately: an AbortError does not say + // whether we timed out or the caller cancelled. + if (timedOut) { + throw new RequestTimeoutError(); + } + if (cancelled) { + throw new RequestCancelledError(); + } + throw e; + } finally { + cleanup(); + } - postJson( - url: string, - body: any, - opt?: HttpRequestOptions, - ): Promise<HttpResponse> { - return this.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - ...opt, + const headers = new HeadersImpl(); + response.headers.forEach((value, key) => { + headers.set(key, value); }); - } - stop(): void { - // Nothing to do - } -} + return { + status: response.status, + headers, + async bytes() { + const buf = await response.arrayBuffer(); + return new Uint8Array(buf); + }, + }; + }, +}; export async function compress( alg: "gzip" | "deflate" | undefined, diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (C) 2022-2026 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 @@ -17,280 +17,17 @@ /** * Imports. */ -import { - Duration, - RequestThrottler, - TalerError, - TalerErrorCode, -} from "@gnu-taler/taler-util"; - -import { - DEFAULT_REQUEST_TIMEOUT_MS, - HeadersImpl, - HttpLibArgs, - HttpRequestLibrary, - HttpRequestOptions, - encodeBody, - getDefaultHeaders, -} from "@gnu-taler/taler-util/http"; -import { compress } from "./http-impl.browser.js"; +import { HttpLib, HttpLibArgs } from "@gnu-taler/taler-util/http"; +import { browserRawLib } from "./http-impl.browser.js"; /** - * An implementation of the [[HttpRequestLibrary]] using the - * browser's XMLHttpRequest. + * The HTTP request library for the browser. + * + * Everything except moving the bytes lives in HttpLib, shared with the node + * and qtart backends; this only binds it to the browser's fetch API. */ -export class BrowserFetchHttpLib implements HttpRequestLibrary { - private throttle = new RequestThrottler(); - private throttlingEnabled = true; - private requireTls = false; - +export class BrowserFetchHttpLib extends HttpLib { public constructor(args?: HttpLibArgs) { - this.throttlingEnabled = args?.enableThrottling ?? true; - this.requireTls = args?.requireTls ?? false; + super(browserRawLib, args); } - - async fetch(requestUrl: string, options?: HttpRequestOptions) { - const requestMethod = options?.method ?? "GET"; - const requestBody = options?.body; - const requestHeader = options?.headers; - const requestTimeout = - options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); - const requestCancel = options?.cancellationToken; - const requestRedirect = options?.redirect; - - const parsedUrl = new URL(requestUrl); - if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, - { - requestMethod, - requestUrl, - throttleStats: this.throttle.getThrottleStats(requestUrl), - }, - `request to origin ${parsedUrl.origin} was throttled`, - ); - } - if (this.requireTls && parsedUrl.protocol !== "https:") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestMethod: requestMethod, - requestUrl: requestUrl, - }, - `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, - ); - } - - const encodedBody: Uint8Array | undefined = - requestMethod === "POST" || - requestMethod === "PUT" || - requestMethod === "PATCH" - ? encodeBody(requestBody) - : undefined; - - const myBody = - !options?.compress || !encodedBody - ? encodedBody - : await compress(options.compress, encodedBody); - - const requestHeadersMap = getDefaultHeaders(requestMethod); - if (requestHeader) { - Object.entries(requestHeader).forEach(([key, value]) => { - if (value === undefined) return; - requestHeadersMap[key] = value; - }); - } - - if (options?.compress) { - requestHeadersMap["Content-Encoding"] = options.compress; - } - /** - * default header assume everything is json - * in case of formData the content-type will be - * auto generated - */ - if (requestBody instanceof FormData) { - delete requestHeadersMap["Content-Type"]; - } else if (requestBody instanceof URLSearchParams) { - requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded"; - } - - const controller = new AbortController(); - let timeoutId: ReturnType<typeof setTimeout> | undefined; - if (requestTimeout.d_ms !== "forever") { - timeoutId = setTimeout(() => { - controller.abort(TalerErrorCode.GENERIC_TIMEOUT); - }, requestTimeout.d_ms); - } - if (requestCancel) { - requestCancel.onCancelled((reason) => { - controller.abort(reason); - }); - } - - try { - const response = await fetch(requestUrl, { - headers: requestHeadersMap, - body: myBody != null ? new Uint8Array(myBody) : undefined, - method: requestMethod, - signal: controller.signal, - redirect: requestRedirect, - }); - - if (timeoutId) { - clearTimeout(timeoutId); - } - - const headerMap = new HeadersImpl(); - response.headers.forEach((value, key) => { - headerMap.set(key, value); - }); - const text = makeTextHandler(response, requestUrl, requestMethod); - const json = makeJsonHandler(response, requestUrl, requestMethod, text); - return { - headers: headerMap, - status: response.status, - requestMethod, - requestUrl, - json, - text, - bytes: async () => { - const blob = await response.blob(); - const buf = await blob.arrayBuffer(); - return new Uint8Array(buf); - }, - }; - } catch (e: unknown) { - if (!(e instanceof Error)) { - throw e; - } - if (controller.signal.aborted) { - throw TalerError.fromDetail( - controller.signal.reason, - { - requestUrl, - requestMethod, - timeoutMs: - requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms, - }, - `HTTP request aborted: ${e.message}`, - ); - } else { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_NETWORK_ERROR, - { - requestUrl, - requestMethod, - }, - `HTTP request failed: ${e.message}`, - ); - } - } - } -} - -function makeTextHandler( - response: Response, - requestUrl: string, - requestMethod: string, -) { - let firstTime = true; - let respText: string; - let error: TalerError | undefined; - return async function getTextFromResponse(): Promise<string> { - if (firstTime) { - firstTime = false; - try { - respText = await response.text(); - } catch (e) { - error = TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: response.status, - validationError: e instanceof Error ? e.message : String(e), - }, - "Invalid text from HTTP response", - ); - } - } - if (error !== undefined) { - throw error; - } - return respText; - }; -} - -function makeJsonHandler( - response: Response, - requestUrl: string, - requestMethod: string, - readTextHandler: () => Promise<string>, -) { - let firstTime = true; - let responseJson: string | undefined = undefined; - let error: TalerError | undefined; - return async function getJsonFromResponse(): Promise<any> { - if (firstTime) { - let responseText: string; - try { - responseText = await readTextHandler(); - } catch (e) { - const message = - e instanceof Error - ? `Couldn't read HTTP response: ${e.message}` - : "Couldn't read HTTP response"; - error = TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: response.status, - validationError: e instanceof Error ? e.message : String(e), - }, - message, - ); - } - if (!error) { - try { - // @ts-expect-error no error then text is initialized - responseJson = JSON.parse(responseText); - } catch (e) { - const message = - e instanceof Error - ? `Invalid JSON from HTTP response: ${e.message}` - : "Invalid JSON from HTTP response"; - error = TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - // @ts-expect-error no error then text is initialized - response: responseText, - httpStatusCode: response.status, - validationError: e instanceof Error ? e.message : String(e), - }, - message, - ); - } - if (responseJson === null || typeof responseJson !== "object") { - error = TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - response: JSON.stringify(responseJson), - httpStatusCode: response.status, - }, - "Invalid JSON from HTTP response: null or not object", - ); - } - } - } - if (error !== undefined) { - throw error; - } - return responseJson; - }; }