Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# @suaveplan/currency-provider-cryptocompare - Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.
Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`.
Sibling provider package on top of the slim `@suaveplan/currency-providers`
core (`httpRequest`, `CurrencyProviderError`, `NoKeyConfig`). Optional API
key — CryptoCompare's free tier works fully keyless; supplying a key boosts
the upstream rate limit and is sent on every request via the
`authorization: Apikey <key>` header.
<!-- begin:auto-docs -->
## Documentation tree
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-cryptocompare \
@suaveplan/currency \
@suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY, // optional
});
const converter = new CurrencyConverter({
provider,
staleDurationMs: 60_000,
});
const conversion = await converter.convert(amount, "BTC");
```
Or register through the slim-core registry:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(new CryptoCompareProvider());
const provider = registry.get("cryptocompare");
```
## Modules
- [`cryptocompare/cryptocompare`](./src/cryptocompare/cryptocompare.md) —
`CryptoCompareProvider` class, `CryptoCompareConfig` interface, error
mapping for `HTTP_ERROR` / `PARSE_ERROR` / `UPSTREAM_ERROR` /
`UNKNOWN_CURRENCY`.
## Errors
Every failure path raises `CurrencyProviderError` from the slim core:
| Code | Raised when |
|---|---|
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx HTTP response (shared `httpRequest`) |
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body not valid JSON, or payload not a JSON object |
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Payload carries `Response: "Error"` |
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | No requested symbol produced a finite positive quote |
Dispatch on `error.code`, not the message string. The instance also
carries `providerName: "cryptocompare"`, optional `status` (for HTTP_ERROR),
and optional `upstreamMessage` (for UPSTREAM_ERROR).

View File

@@ -0,0 +1,68 @@
{
"name": "@suaveplan/currency-provider-cryptocompare",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./cryptocompare/cryptocompare": {
"import": {
"types": "./dist/cryptocompare/cryptocompare.d.ts",
"default": "./dist/cryptocompare/cryptocompare.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/currency": "workspace:^",
"@suaveplan/currency-providers": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,146 @@
# `cryptocompare` — CryptoCompare crypto-rates provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CryptoCompareProvider` implements the `CurrencyProvider` contract from
`@suaveplan/currency` on top of CryptoCompare's public
`min-api.cryptocompare.com/data/pricemulti` endpoint. It returns inverted
spot prices (`1 / price`) for a configurable set of crypto symbols against
a fiat or crypto base currency, suitable for direct registration with
`CurrencyProviderRegistry` or instantiation into `CurrencyConverter`.
The provider is sibling to the slim `@suaveplan/currency-providers` core:
all HTTP plumbing (timeout / abort composition / typed-error mapping) lives
in the shared `httpRequest` helper, the typed error class and code registry
come from `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES`, and the
config shape extends `NoKeyConfig` with one extra optional field. The
provider is universal — no Node, DOM, or framework dependencies — so
it runs untouched in browsers, Workers, Bun, and Node 18+.
## Features
- Sibling crypto-rates provider — `name: "cryptocompare"`,
`supportsCrypto: true`, `supportsFiat: false`
- Optional `apiKey` — free tier works fully keyless; key boosts the
upstream rate limit and is sent via `authorization: Apikey <key>` header
- 15 sensible default crypto symbols (BTC, ETH, SOL, USDT, USDC, BNB, XRP,
ADA, DOGE, DOT, AVAX, LINK, UNI, ATOM, LTC) when callers omit `targets`
- Injectable `FetchLike` from `@suaveplan/types` — tests pass a controlled
closure without `vi.stubGlobal('fetch', ...)`
- Typed errors via the slim core registry: `HTTP_ERROR`, `PARSE_ERROR`,
`UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — every failure path carries
`providerName: "cryptocompare"` for log dispatch
- Caller `AbortSignal` from `RateFetchOptions.signal` is composed through
`httpRequest`; whitespace-only `apiKey` strings are treated as absent
## Usage: Basic Example
```ts
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider();
const result = await provider.fetchRates({ base: "USD" });
console.log(result.rates.BTC); // 1 / spot price of BTC in USD
```
## Usage: Advanced Example
Register the provider with `CurrencyProviderRegistry`, use the keyed
upstream for higher rate limits, and dispatch on typed errors:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
CurrencyProviderRegistry,
} from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(
new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY,
baseUrl: "https://min-api.cryptocompare.com/data",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("cryptocompare"),
staleDurationMs: 60_000,
});
try {
const conversion = await converter.convert(amount, "BTC");
console.log(conversion);
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
) {
console.warn("CryptoCompare upstream:", error.upstreamMessage);
} else {
throw error;
}
}
```
## API Reference
- `interface CryptoCompareConfig extends NoKeyConfig`
- `apiKey?: string` — optional API key. Sent on every request via
`authorization: Apikey <key>` when present and non-whitespace.
Whitespace-only strings are treated as absent.
- `baseUrl?: string` — overrides the default
`https://min-api.cryptocompare.com/data` endpoint.
- `fetch?: FetchLike` — injectable WHATWG-shape fetch. Defaults to
`globalThis.fetch`.
- `class CryptoCompareProvider implements CurrencyProvider`
- `name: "cryptocompare"`, `supportsCrypto: true`,
`supportsFiat: false`.
- `constructor(config?: CryptoCompareConfig)` — every config field is
optional. Constructing without a config wires `globalThis.fetch`, the
default base URL, and no API key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues a single `GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>`,
inverts each price, and returns `{ base, rates, timestamp }`.
`options.base` defaults to `"USD"`; `options.targets` defaults to the
15 built-in symbols when omitted or empty; `options.signal` is
forwarded into the shared `httpRequest` helper.
- Raised `CurrencyProviderError` codes:
- `HTTP_ERROR` — non-2xx response (raised by shared `httpRequest`).
- `PARSE_ERROR` — body is not valid JSON, or the parsed payload is not
a JSON object.
- `UPSTREAM_ERROR` — payload carries `Response: "Error"`; the
`Message` field (or `"unknown upstream error"` when absent) is copied
to `error.upstreamMessage`.
- `UNKNOWN_CURRENCY` — no requested symbol produced a finite positive
quote in the requested base currency.
## Implementation Notes
- CryptoCompare's `pricemulti` endpoint returns HTTP 200 even for
per-symbol upstream errors — the payload carries `Response: "Error"` and
a human-readable `Message`. The provider distinguishes that from a
successful (possibly empty) quote map and surfaces it as
`UPSTREAM_ERROR` rather than masquerading as `HTTP_ERROR`.
- The free tier permits ~100,000 calls per month without a key; supplying
an `apiKey` boosts that quota. The provider authenticates via the
`authorization: Apikey <key>` header per CryptoCompare's documentation
(Apikey scheme, not Bearer). Empty or whitespace-only keys are silently
treated as absent so callers can pass `process.env.CRYPTOCOMPARE_API_KEY`
directly without a guard.
- Prices are inverted (`1 / price`) so the result map answers "one unit of
`base` is worth X units of `symbol`" — consistent with every other
sibling provider. Non-finite or non-positive quotes are skipped.
- The default symbol list covers the top 15 crypto assets by market cap at
package-publish time; callers wanting a different set always pass
`targets` explicitly. The defaults exist only as a developer-friendly
fallback for ad-hoc usage.
- The provider does not wrap retry, backoff, or rate-limit semantics —
compose `@suaveplan/retry` / future `RateLimitedProvider` wrappers at a
higher layer, exactly the same way as every other sibling provider.

View File

@@ -0,0 +1,316 @@
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FetchLike } from "@suaveplan/types";
import { CryptoCompareProvider } from "./cryptocompare.js";
interface Call {
url: string;
init: RequestInit | undefined;
}
function recordingFetch(
respond: (call: Call) => Response | Promise<Response>
): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const fetch: FetchLike = async (input, init) => {
const url = typeof input === "string" ? input : input.toString();
const call: Call = { url, init };
calls.push(call);
return respond(call);
};
return { fetch, calls };
}
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("CryptoCompareProvider", () => {
it("reports stable metadata identifying the provider", {
timeout: 5000,
}, () => {
const provider = new CryptoCompareProvider();
expect(provider.name).toBe("cryptocompare");
expect(provider.supportsCrypto).toBe(true);
expect(provider.supportsFiat).toBe(false);
});
it("inverts pricemulti payload, skips zero prices, and defaults base to USD", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({
BTC: { USD: 67000 },
ETH: { USD: 3500 },
FAKE: { USD: 0 },
})
);
const provider = new CryptoCompareProvider({ fetch });
const result = await provider.fetchRates();
expect(result.base).toBe("USD");
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 12);
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 12);
expect(result.rates.FAKE).toBeUndefined();
expect(typeof result.timestamp).toBe("number");
expect(calls[0]?.url).toContain("/pricemulti?fsyms=");
expect(calls[0]?.url).toContain("&tsyms=USD");
});
it("sends authorization header only when an apiKey is provided", {
timeout: 5000,
}, async () => {
const { fetch: keylessFetch, calls: keylessCalls } = recordingFetch(
() => jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({ fetch: keylessFetch }).fetchRates({
base: "USD",
targets: ["BTC"],
});
expect(keylessCalls[0]?.init?.headers).toBeUndefined();
const { fetch: keyedFetch, calls: keyedCalls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({
fetch: keyedFetch,
apiKey: "secret-key",
}).fetchRates({ base: "USD", targets: ["BTC"] });
const headers = keyedCalls[0]?.init?.headers as
| Record<string, string>
| undefined;
expect(headers?.authorization).toBe("Apikey secret-key");
});
it("treats whitespace-only apiKey as absent and omits the header", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({
fetch,
apiKey: " ",
}).fetchRates({ base: "USD", targets: ["BTC"] });
expect(calls[0]?.init?.headers).toBeUndefined();
});
it("honours custom baseUrl and explicit targets list", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { EUR: 60000 }, ETH: { EUR: 3000 } })
);
const provider = new CryptoCompareProvider({
fetch,
baseUrl: "https://staging.example.com/data",
});
const result = await provider.fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
});
expect(calls[0]?.url).toBe(
"https://staging.example.com/data/pricemulti?fsyms=BTC,ETH&tsyms=EUR"
);
expect(result.base).toBe("EUR");
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "ETH"]);
});
it("falls back to defaults when targets array is empty", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse({ BTC: { USD: 50000 } })
);
await new CryptoCompareProvider({ fetch }).fetchRates({
base: "USD",
targets: [],
});
expect(calls[0]?.url).toContain("fsyms=BTC,ETH,SOL,USDT");
});
it("raises HTTP_ERROR on non-2xx response", { timeout: 5000 }, async () => {
const fetch: FetchLike = async () =>
new Response("", {
status: 502,
statusText: "Bad Gateway",
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD" })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.HTTP_ERROR,
providerName: "cryptocompare",
status: 502,
});
});
it("raises PARSE_ERROR when the response body is not valid JSON", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
new Response("<<not json>>", {
status: 200,
headers: { "content-type": "application/json" },
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD" })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
providerName: "cryptocompare",
});
});
it("raises PARSE_ERROR when the parsed payload is not an object", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
new Response("null", {
status: 200,
headers: { "content-type": "application/json" },
});
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
});
it("raises UPSTREAM_ERROR when payload carries Response='Error' with Message", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({
Response: "Error",
Message: "tsym XYZ is not available",
});
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "XYZ" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
);
expect((error as CurrencyProviderError).upstreamMessage).toContain(
"tsym XYZ"
);
});
it("raises UPSTREAM_ERROR with default message when Message field is absent", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({ Response: "Error" });
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
);
expect((error as CurrencyProviderError).upstreamMessage).toBe(
"unknown upstream error"
);
});
it("raises UNKNOWN_CURRENCY when no symbols resolve to a quote", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () => jsonResponse({});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({ base: "USD", targets: ["BTC"] })
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
providerName: "cryptocompare",
});
});
it("raises UNKNOWN_CURRENCY when every quote is non-finite or non-positive", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () =>
jsonResponse({
BTC: { USD: 0 },
ETH: { USD: Number.NaN },
SOL: { USD: -1 },
});
const provider = new CryptoCompareProvider({ fetch });
await expect(
provider.fetchRates({
base: "USD",
targets: ["BTC", "ETH", "SOL"],
})
).rejects.toMatchObject({
code: CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
});
});
it("preserves PARSE_ERROR when the response body throws a non-Error value", {
timeout: 5000,
}, async () => {
const fetch: FetchLike = async () => {
const response = new Response("ignored", {
status: 200,
headers: { "content-type": "application/json" },
});
// Replace `.json()` with a closure that rejects with a non-Error.
Object.defineProperty(response, "json", {
value: async () => {
const failure: unknown = "string-thrown";
throw failure;
},
});
return response;
};
const provider = new CryptoCompareProvider({ fetch });
const error = await provider
.fetchRates({ base: "USD" })
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(CurrencyProviderError);
expect((error as CurrencyProviderError).code).toBe(
CURRENCY_PROVIDER_CODES.PARSE_ERROR
);
expect((error as CurrencyProviderError).cause).toBeUndefined();
});
it("forwards a caller AbortSignal through httpRequest", {
timeout: 5000,
}, async () => {
const seen: { signal: AbortSignal | null | undefined }[] = [];
const fetch: FetchLike = async (_url, init) => {
seen.push({ signal: init?.signal });
return jsonResponse({ BTC: { USD: 50000 } });
};
const controller = new AbortController();
await new CryptoCompareProvider({ fetch }).fetchRates({
base: "USD",
targets: ["BTC"],
signal: controller.signal,
});
expect(seen[0]?.signal).toBe(controller.signal);
});
});

View File

@@ -0,0 +1,168 @@
import type {
CurrencyProvider,
RateFetchOptions,
RateFetchResult,
} from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const DEFAULT_URL = "https://min-api.cryptocompare.com/data";
const NAME = "cryptocompare";
const DEFAULT_BASE = "USD";
const DEFAULT_SYMBOLS = [
"BTC",
"ETH",
"SOL",
"USDT",
"USDC",
"BNB",
"XRP",
"ADA",
"DOGE",
"DOT",
"AVAX",
"LINK",
"UNI",
"ATOM",
"LTC",
] as const;
/**
* Configuration for {@link CryptoCompareProvider}. Extends the shared
* `NoKeyConfig` (`baseUrl?` + injectable `fetch?`) with an **optional**
* `apiKey`. CryptoCompare's free tier works fully keyless; supplying a key
* boosts the rate limit and is sent on every request via the
* `authorization: Apikey <key>` header.
*/
export interface CryptoCompareConfig extends NoKeyConfig {
/** Optional CryptoCompare API key. Free tier works keyless. */
readonly apiKey?: string;
}
interface PriceMultiPayload {
readonly [symbol: string]: Readonly<Record<string, number>>;
}
interface ErrorPayload {
readonly Response?: string;
readonly Message?: string;
}
/**
* CryptoCompare crypto-rates provider. Hits the public
* `min-api.cryptocompare.com/data/pricemulti` endpoint and returns the
* inverted price map (`1 / price`) per requested symbol. Crypto-only —
* `supportsCrypto: true`, `supportsFiat: false`.
*/
export class CryptoCompareProvider implements CurrencyProvider {
public readonly name = NAME;
public readonly supportsCrypto = true;
public readonly supportsFiat = false;
private readonly baseUrl: string;
private readonly apiKey: string | undefined;
private readonly fetchImpl: FetchLike;
public constructor(config?: CryptoCompareConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.apiKey = trimOrUndefined(config?.apiKey);
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
public async fetchRates(
options?: RateFetchOptions
): Promise<RateFetchResult> {
const base = options?.base ?? DEFAULT_BASE;
const symbols =
options?.targets !== undefined && options.targets.length > 0
? options.targets
: DEFAULT_SYMBOLS;
const url = `${this.baseUrl}/pricemulti?fsyms=${symbols.join(",")}&tsyms=${base}`;
const init: RequestInit =
this.apiKey !== undefined
? { headers: { authorization: `Apikey ${this.apiKey}` } }
: {};
const response = await httpRequest({
url,
fetch: this.fetchImpl,
providerName: NAME,
init,
...(options?.signal !== undefined
? { signal: options.signal }
: {}),
});
const payload = await parseJson(response);
assertNotErrorPayload(payload);
const data = payload as PriceMultiPayload;
const rates: Record<string, number> = {};
for (const [symbol, prices] of Object.entries(data)) {
const price = prices?.[base];
if (
typeof price === "number" &&
Number.isFinite(price) &&
price > 0
) {
rates[symbol] = 1 / price;
}
}
if (Object.keys(rates).length === 0) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
`${NAME}: no quotes resolved for base ${base} from symbols ${symbols.join(",")}`,
{ providerName: NAME }
);
}
return { base, rates, timestamp: Date.now() };
}
}
function trimOrUndefined(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
async function parseJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (cause) {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${NAME}: failed to parse JSON response`,
{
providerName: NAME,
cause: cause instanceof Error ? cause : undefined,
}
);
}
}
function assertNotErrorPayload(payload: unknown): void {
if (payload === null || typeof payload !== "object") {
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
`${NAME}: unexpected payload shape (expected object)`,
{ providerName: NAME }
);
}
const candidate = payload as ErrorPayload;
if (candidate.Response === "Error") {
const message = candidate.Message ?? "unknown upstream error";
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
`${NAME}: upstream error — ${message}`,
{ providerName: NAME, upstreamMessage: message }
);
}
}

View File

@@ -0,0 +1,2 @@
export type { CryptoCompareConfig } from "./cryptocompare.js";
export { CryptoCompareProvider } from "./cryptocompare.js";

View File

@@ -0,0 +1,4 @@
export {
type CryptoCompareConfig,
CryptoCompareProvider,
} from "./cryptocompare/cryptocompare.js";

View File

@@ -0,0 +1,7 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `globalThis.fetch` (overridable via FetchLike injection),
// `URLSearchParams`, `Response`, `Object.entries`, `Date.now`, and standard
// `Error` semantics — available in every supported runtime (Node 18+, Bun,
// browsers, Web Workers). This file exists to give vitest a stable setup file
// path mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/currency-provider-cryptocompare",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/currency",
"@suaveplan/currency-providers",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});