import { EventEmitter } from "node:events";
import { ErrorAborted, TimeoutError, fetch, isValidHttpUrl, retry } from "@lodestar/utils";
import { encodeJwtToken } from "./jwt.js";
export { JsonRpcHttpClientEvent };
var JsonRpcHttpClientEvent;
(function (JsonRpcHttpClientEvent) {
    /**
     * When registered this event will be emitted before the client throws an error.
     * This is useful for defining the error behavior in a common place at the time of declaration of the client.
     */
    JsonRpcHttpClientEvent["ERROR"] = "jsonRpcHttpClient:error";
    /**
     * When registered this event will be emitted before the client returns the valid response to the caller.
     * This is useful for defining some common behavior for each request/response cycle
     */
    JsonRpcHttpClientEvent["RESPONSE"] = "jsonRpcHttpClient:response";
})(JsonRpcHttpClientEvent || (JsonRpcHttpClientEvent = {}));
export class JsonRpcHttpClientEventEmitter extends EventEmitter {
}
/**
 * Limits the amount of response text printed with RPC or parsing errors
 */
const maxStringLengthToPrint = 500;
const REQUEST_TIMEOUT = 30 * 1000;
export class JsonRpcHttpClient {
    urls;
    opts;
    id = 1;
    /**
     * Optional: If provided, use this jwt secret to HS256 encode and add a jwt token in the
     * request header which can be authenticated by the RPC server to provide access.
     * A fresh token is generated on each requests as EL spec mandates the ELs to check
     * the token freshness +-5 seconds (via `iat` property of the token claim)
     */
    jwtSecret;
    jwtId;
    jwtVersion;
    metrics;
    emitter = new JsonRpcHttpClientEventEmitter();
    constructor(urls, opts) {
        this.urls = urls;
        this.opts = opts;
        // Sanity check for all URLs to be properly defined. Otherwise it will error in loop on fetch
        if (urls.length === 0) {
            throw Error("No urls provided to JsonRpcHttpClient");
        }
        for (const [i, url] of urls.entries()) {
            if (!url) {
                throw Error(`JsonRpcHttpClient.urls[${i}] is empty or undefined: ${url}`);
            }
            if (!isValidHttpUrl(url)) {
                throw Error(`JsonRpcHttpClient.urls[${i}] must be a valid URL: ${url}`);
            }
        }
        this.jwtSecret = opts?.jwtSecret;
        this.jwtId = opts?.jwtId;
        this.jwtVersion = opts?.jwtVersion;
        this.metrics = opts?.metrics ?? null;
        this.metrics?.configUrlsCount.set(urls.length);
    }
    /**
     * Perform RPC request
     */
    async fetch(payload, opts) {
        return this.wrapWithEvents(async () => {
            const res = await this.fetchJson({ jsonrpc: "2.0", id: this.id++, ...payload }, opts);
            return parseRpcResponse(res, payload);
        }, { payload });
    }
    /**
     * Perform RPC request with retry
     */
    async fetchWithRetries(payload, opts) {
        return this.wrapWithEvents(async () => {
            const routeId = opts?.routeId ?? "unknown";
            const res = await retry(async (_attempt) => {
                return this.fetchJson({ jsonrpc: "2.0", id: this.id++, ...payload }, opts);
            }, {
                retries: opts?.retries ?? this.opts?.retries ?? 0,
                retryDelay: opts?.retryDelay ?? this.opts?.retryDelay,
                shouldRetry: opts?.shouldRetry,
                signal: this.opts?.signal,
                onRetry: () => {
                    this.opts?.metrics?.retryCount.inc({ routeId });
                },
            });
            return parseRpcResponse(res, payload);
        }, payload);
    }
    /**
     * Perform RPC batched request
     * Type-wise assumes all requests results have the same type
     */
    async fetchBatch(rpcPayloadArr, opts) {
        return this.wrapWithEvents(async () => {
            if (rpcPayloadArr.length === 0)
                return [];
            const resArr = await this.fetchJson(rpcPayloadArr.map(({ method, params }) => ({ jsonrpc: "2.0", method, params, id: this.id++ })), opts);
            if (!Array.isArray(resArr)) {
                // Nethermind may reply to batch request with a JSON RPC error
                if (resArr.error !== undefined) {
                    throw new ErrorJsonRpcResponse(resArr, "batch");
                }
                throw Error(`expected array of results, got ${resArr} - ${jsonSerializeTry(resArr)}`);
            }
            return resArr.map((res, i) => parseRpcResponse(res, rpcPayloadArr[i]));
        }, rpcPayloadArr);
    }
    async wrapWithEvents(func, payload) {
        try {
            const response = await func();
            this.emitter.emit(JsonRpcHttpClientEvent.RESPONSE, { payload, response });
            return response;
        }
        catch (error) {
            this.emitter.emit(JsonRpcHttpClientEvent.ERROR, { payload, error: error });
            throw error;
        }
    }
    async fetchJson(json, opts) {
        if (this.urls.length === 0)
            throw Error("No url provided");
        const routeId = opts?.routeId ?? "unknown";
        let lastError = null;
        for (let i = 0; i < this.urls.length; i++) {
            if (i > 0) {
                this.metrics?.requestUsedFallbackUrl.inc({ routeId });
            }
            try {
                return await this.fetchJsonOneUrl(this.urls[i], json, opts);
            }
            catch (e) {
                lastError = e;
                if (this.opts?.shouldNotFallback?.(e)) {
                    break;
                }
            }
        }
        throw lastError ?? Error("Unknown error");
    }
    /**
     * Fetches JSON and throws detailed errors in case the HTTP request is not ok
     */
    async fetchJsonOneUrl(url, json, opts) {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT);
        const onParentSignalAbort = () => controller.abort();
        this.opts?.signal?.addEventListener("abort", onParentSignalAbort, { once: true });
        // Default to "unknown" to prevent mixing metrics with others.
        const routeId = opts?.routeId ?? "unknown";
        const timer = this.metrics?.requestTime.startTimer({ routeId });
        this.metrics?.activeRequests.inc({ routeId }, 1);
        try {
            const headers = { "Content-Type": "application/json" };
            if (this.jwtSecret) {
                /**
                 * ELs have a tight +-5 second freshness check on token's iat i.e. issued at
                 * so its better to generate a new token each time. Currently iat is the only claim
                 * we are encoding but potentially we can encode more claims.
                 * Also currently the algorithm for the token generation is mandated to HS256
                 *
                 * Jwt auth spec: https://github.com/ethereum/execution-apis/pull/167
                 */
                const jwtClaim = {
                    iat: Math.floor(Date.now() / 1000),
                    id: this.jwtId,
                    clv: this.jwtVersion,
                };
                const token = encodeJwtToken(jwtClaim, this.jwtSecret);
                headers.Authorization = `Bearer ${token}`;
            }
            const res = await fetch(url, {
                method: "post",
                body: JSON.stringify(json),
                headers,
                signal: controller.signal,
            });
            const streamTimer = this.metrics?.streamTime.startTimer({ routeId });
            const bodyText = await res.text();
            if (!res.ok) {
                // Infura errors:
                // - No project ID: Forbidden: {"jsonrpc":"2.0","id":0,"error":{"code":-32600,"message":"project ID is required","data":{"reason":"project ID not provided","see":"https://infura.io/dashboard"}}}
                throw new HttpRpcError(res.status, `${res.statusText}: ${bodyText.slice(0, maxStringLengthToPrint)}`);
            }
            const bodyJson = parseJson(bodyText);
            streamTimer?.();
            return bodyJson;
        }
        catch (e) {
            this.metrics?.requestErrors.inc({ routeId });
            if (controller.signal.aborted) {
                // controller will abort on both parent signal abort + timeout of this specific request
                if (this.opts?.signal?.aborted) {
                    throw new ErrorAborted("request");
                }
                throw new TimeoutError("request");
            }
            throw e;
        }
        finally {
            timer?.();
            this.metrics?.activeRequests.dec({ routeId }, 1);
            clearTimeout(timeout);
            this.opts?.signal?.removeEventListener("abort", onParentSignalAbort);
        }
    }
}
function parseRpcResponse(res, payload) {
    if (res.result !== undefined) {
        return res.result;
    }
    if (res.error !== undefined) {
        throw new ErrorJsonRpcResponse(res, payload.method);
    }
    throw Error(`Invalid JSON RPC response, no result or error property: ${jsonSerializeTry(res)}`);
}
/**
 * Util: Parse JSON but display the original source string in case of error
 * Helps debug instances where an API returns a plain text instead of JSON,
 * such as getting an HTML page due to a wrong API URL
 */
function parseJson(json) {
    try {
        return JSON.parse(json);
    }
    catch (e) {
        throw new ErrorParseJson(json, e);
    }
}
export class ErrorParseJson extends Error {
    constructor(json, e) {
        super(`Error parsing JSON: ${e.message}\n${json.slice(0, maxStringLengthToPrint)}`);
    }
}
/** JSON RPC endpoint returned status code == 200, but with error property set */
export class ErrorJsonRpcResponse extends Error {
    response;
    constructor(res, payloadMethod) {
        const errorMessage = typeof res.error === "object"
            ? typeof res.error.message === "string"
                ? res.error.message
                : typeof res.error.code === "number"
                    ? parseJsonRpcErrorCode(res.error.code)
                    : JSON.stringify(res.error)
            : String(res.error);
        super(`JSON RPC error: ${errorMessage}, ${payloadMethod}`);
        this.response = res;
    }
}
/** JSON RPC endpoint returned status code != 200 */
export class HttpRpcError extends Error {
    status;
    constructor(status, message) {
        super(message);
        this.status = status;
    }
}
/**
 * JSON RPC spec errors https://www.jsonrpc.org/specification#response_object
 */
export function parseJsonRpcErrorCode(code) {
    if (code === -32700)
        return "Parse request error";
    if (code === -32600)
        return "Invalid request object";
    if (code === -32601)
        return "Method not found";
    if (code === -32602)
        return "Invalid params";
    if (code === -32603)
        return "Internal error";
    if (code <= -32000 && code >= -32099)
        return "Server error";
    return `Unknown error code ${code}`;
}
function jsonSerializeTry(obj) {
    try {
        return JSON.stringify(obj);
    }
    catch (e) {
        return `Unable to serialize ${String(obj)}: ${e.message}`;
    }
}
//# sourceMappingURL=jsonRpcHttpClient.js.map