Initial Commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/currency-provider-coinmarketcap - CoinMarketCap crypto-rates provider for @suaveplan/currency — sibling of @suaveplan/currency-providers. Authenticates via X-CMC_PRO_API_KEY header against the /cryptocurrency/quotes/latest endpoint. Universal (no node:/bun:/DOM imports).
|
||||
|
||||
CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One
|
||||
of the nine `@suaveplan/currency-provider-<name>` sibling packages spun
|
||||
out of the 2026-05-17 slim refactor of `@suaveplan/currency-providers`.
|
||||
Consumes the slim core's `CurrencyProviderError` registry, `ApiKeyConfig`
|
||||
type, and shared `httpRequest()` HTTP helper; talks to
|
||||
`pro-api.coinmarketcap.com` via the `X-CMC_PRO_API_KEY` header.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
|
||||
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency-provider-coinmarketcap @suaveplan/currency-providers @suaveplan/currency
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(
|
||||
new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" })
|
||||
);
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("coinmarketcap"),
|
||||
});
|
||||
|
||||
const rate = await converter.getRate("BTC", "USD");
|
||||
```
|
||||
|
||||
Direct instantiation works equally well:
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" }),
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `CurrencyProvider` (`name = "coinmarketcap"`,
|
||||
`supportsCrypto = true`, `supportsFiat = false`)
|
||||
- `ApiKeyConfig` constructor — `apiKey` required, `baseUrl` optional
|
||||
(defaults to `https://pro-api.coinmarketcap.com/v1`), injectable
|
||||
`FetchLike` from `@suaveplan/types`
|
||||
- Authenticated via the upstream `X-CMC_PRO_API_KEY` header; `Accept`
|
||||
set to `application/json` on every request
|
||||
- Default target list covers the top 10 crypto by market cap
|
||||
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); override via
|
||||
`options.targets`
|
||||
- Default `base` is `USD`; any fiat conversion currency works via
|
||||
`options.base`
|
||||
- Prices are inverted (`1 / price`) into the converter's expected
|
||||
`1 fiat = X crypto` form
|
||||
- Typed errors via `CurrencyProviderError` —
|
||||
`MISSING_CONFIG` (empty `apiKey`), `HTTP_ERROR` (non-2xx),
|
||||
`PARSE_ERROR` (invalid JSON / missing data), `UPSTREAM_ERROR`
|
||||
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no symbol
|
||||
resolved). Every error carries `providerName: "coinmarketcap"`
|
||||
- HTTP plumbing (abort-signal composition, timeout, non-2xx mapping)
|
||||
delegated to the slim core's `httpRequest()` — uniform with every
|
||||
other sibling provider
|
||||
- Universal (`sideEffects: false`); 100/100/100/100 istanbul
|
||||
coverage; 2 subpath exports
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/coinmarketcap/coinmarketcap.md`](./src/coinmarketcap/coinmarketcap.md)
|
||||
— `CoinMarketCapProvider`
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` (pre-graduation). Sibling provider landed alongside the
|
||||
2026-05-17 slim refactor of `@suaveplan/currency-providers`. See
|
||||
the design proposal for
|
||||
the change record.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@suaveplan/currency-provider-coinmarketcap",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "CoinMarketCap crypto-rates provider for @suaveplan/currency — sibling of @suaveplan/currency-providers. Authenticates via X-CMC_PRO_API_KEY header against the /cryptocurrency/quotes/latest endpoint. Universal (no node:/bun:/DOM imports).",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./coinmarketcap/coinmarketcap": {
|
||||
"import": {
|
||||
"types": "./dist/coinmarketcap/coinmarketcap.d.ts",
|
||||
"default": "./dist/coinmarketcap/coinmarketcap.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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# `CoinMarketCapProvider` — crypto-rates sibling provider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CoinMarketCapProvider` is the
|
||||
`@suaveplan/currency-providers` sibling that fetches cryptocurrency
|
||||
spot prices from the CoinMarketCap professional REST API
|
||||
(`pro-api.coinmarketcap.com`). It implements `CurrencyProvider` from
|
||||
`@suaveplan/currency` so it slots directly into `CurrencyConverter`
|
||||
either by direct instantiation (`new CurrencyConverter({ provider: new
|
||||
CoinMarketCapProvider(...) })`) or by registration with
|
||||
`CurrencyProviderRegistry`. The class exists so consumers that need
|
||||
CoinMarketCap as a primary or fallback rate source install exactly one
|
||||
provider package instead of pulling in the eight other sibling
|
||||
providers they do not wire in.
|
||||
|
||||
The class is crypto-only — `supportsCrypto = true`, `supportsFiat =
|
||||
false` — because the upstream `/cryptocurrency/quotes/latest` endpoint
|
||||
is a crypto quotes feed; the request still accepts any fiat conversion
|
||||
currency via the `convert` query parameter, so a USD-base, EUR-base, or
|
||||
GBP-base call is equally valid. Consumers that need fiat rates should
|
||||
register a fiat sibling alongside (`currency-provider-ecb`,
|
||||
`currency-provider-fixer`, `currency-provider-open-exchange-rates`).
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `CurrencyProvider` with stable identity (`name =
|
||||
"coinmarketcap"`, `supportsCrypto = true`, `supportsFiat = false`).
|
||||
- Constructor accepts `ApiKeyConfig` (`apiKey`, optional `baseUrl`,
|
||||
optional injectable `FetchLike`); empty / whitespace `apiKey`
|
||||
immediately raises `CurrencyProviderError(MISSING_CONFIG)`.
|
||||
- `X-CMC_PRO_API_KEY` + `Accept: application/json` headers carried on
|
||||
every request through the shared `httpRequest()` helper, so abort
|
||||
signals, timeouts, and HTTP error mapping are uniform with every
|
||||
other sibling provider.
|
||||
- Default target list covers the top 10 cryptocurrencies by market cap
|
||||
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); callers can override
|
||||
via `options.targets` to query any supported symbol set.
|
||||
- Default `base` is `USD`; any fiat conversion currency supported by
|
||||
CoinMarketCap is accepted via `options.base`.
|
||||
- Prices are inverted from CoinMarketCap's `1 crypto = X fiat` form to
|
||||
the converter's expected `1 fiat = (1 / X) crypto` form before being
|
||||
written into `RateFetchResult.rates`.
|
||||
- Typed error mapping — `HTTP_ERROR` (non-2xx), `PARSE_ERROR` (invalid
|
||||
JSON / missing `data` field / non-object data), `UPSTREAM_ERROR`
|
||||
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no requested
|
||||
symbol resolved), `MISSING_CONFIG` (empty `apiKey`) — every error
|
||||
carries `providerName: "coinmarketcap"`.
|
||||
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
|
||||
under Node 18+, Bun, modern browsers, and Web Workers via the
|
||||
injectable `FetchLike` seam.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
|
||||
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: process.env.CMC_API_KEY ?? "",
|
||||
});
|
||||
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
const rate = await converter.getRate("BTC", "USD");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a `FetchLike` for testing, point at the CoinMarketCap sandbox,
|
||||
restrict the target list, and forward an `AbortSignal` for cancellation:
|
||||
|
||||
```ts
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
|
||||
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
console.log("→", url);
|
||||
return await fetch(url, init);
|
||||
};
|
||||
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: process.env.CMC_SANDBOX_KEY ?? "",
|
||||
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
|
||||
fetch: recordingFetch,
|
||||
});
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(provider);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2_000);
|
||||
try {
|
||||
const result = await registry.get("coinmarketcap").fetchRates({
|
||||
base: "EUR",
|
||||
targets: ["BTC", "ETH", "SOL"],
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.log(result.rates);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class CoinMarketCapProvider implements CurrencyProvider`
|
||||
|
||||
- `readonly name: "coinmarketcap"`
|
||||
- `readonly supportsCrypto: true`
|
||||
- `readonly supportsFiat: false`
|
||||
- `constructor(config: ApiKeyConfig)` — `config.apiKey` is required;
|
||||
whitespace-only and empty strings raise
|
||||
`CurrencyProviderError(MISSING_CONFIG)`. `config.baseUrl` defaults to
|
||||
`https://pro-api.coinmarketcap.com/v1`. `config.fetch` defaults to
|
||||
`globalThis.fetch`.
|
||||
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
|
||||
issues `GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<csv>&convert=<base>`
|
||||
through `@suaveplan/currency-providers`'s `httpRequest()`. Returns
|
||||
`{ base, rates, timestamp }` with rates inverted into base-per-unit
|
||||
form. Forwards `options.signal` to the underlying fetch.
|
||||
|
||||
### Error codes (all from `CURRENCY_PROVIDER_CODES`)
|
||||
|
||||
- `MISSING_CONFIG` — empty / whitespace `apiKey` at construction.
|
||||
- `HTTP_ERROR` — non-2xx HTTP response (raised by `httpRequest()`).
|
||||
- `PARSE_ERROR` — invalid JSON, non-object payload, or missing `data`.
|
||||
- `UPSTREAM_ERROR` — payload `status.error_code` populated and non-zero.
|
||||
- `UNKNOWN_CURRENCY` — none of the requested symbols resolved.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default target list (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`)
|
||||
mirrors the upstream `../suaveplan/...coinmarketcap.ts` source —
|
||||
these are the top ten cryptocurrencies by market cap and are the
|
||||
same set every consumer of the upstream package received by default.
|
||||
- Price inversion (`1 / price`) is applied per-symbol so a symbol with
|
||||
a missing, non-numeric, or non-positive `price` is silently dropped
|
||||
from the result. If the upstream returns `{ data: {} }` or every
|
||||
symbol fails the numeric guard, the whole call raises
|
||||
`UNKNOWN_CURRENCY` — the converter then falls back to its next
|
||||
registered provider rather than caching a zero-entry result.
|
||||
- `status.error_code` is treated as "present and non-zero" — many
|
||||
CoinMarketCap success responses carry `status.error_code: 0`, which
|
||||
is not a real error. The dedicated `assertNoUpstreamError` guard
|
||||
preserves that distinction.
|
||||
- The `parsePayload` step is intentionally lenient about the order of
|
||||
validation: when both `status.error_code` and a missing `data` field
|
||||
are present, the upstream error wins so consumers see the actionable
|
||||
`UPSTREAM_ERROR` (and `upstreamMessage`) rather than a generic
|
||||
`PARSE_ERROR`.
|
||||
- All HTTP plumbing — abort-signal composition, timeout derivation,
|
||||
non-2xx mapping to `HTTP_ERROR` — is delegated to
|
||||
`@suaveplan/currency-providers`'s `httpRequest()`. The provider
|
||||
never calls the injected `FetchLike` directly, so timeout / abort
|
||||
behaviour is identical to every other sibling.
|
||||
@@ -0,0 +1,533 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { CoinMarketCapProvider } from "./coinmarketcap.js";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
handler: (call: FetchCall) => Promise<Response> | Response
|
||||
): { fetch: FetchLike; calls: FetchCall[] } {
|
||||
const calls: FetchCall[] = [];
|
||||
const fetchImpl: FetchLike = async (
|
||||
url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const call: FetchCall = { url: String(url), init };
|
||||
calls.push(call);
|
||||
return await handler(call);
|
||||
};
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
const API_KEY = "test-key";
|
||||
|
||||
describe("CoinMarketCapProvider — identity", () => {
|
||||
it("exposes stable identity fields", { timeout: 5000 }, () => {
|
||||
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
|
||||
expect(provider.name).toBe("coinmarketcap");
|
||||
expect(provider.supportsCrypto).toBe(true);
|
||||
expect(provider.supportsFiat).toBe(false);
|
||||
expect(
|
||||
(provider as CoinMarketCapProvider & { supportsVirtual?: boolean })
|
||||
.supportsVirtual
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoinMarketCapProvider — constructor validation", () => {
|
||||
it("raises MISSING_CONFIG when apiKey is empty", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new CoinMarketCapProvider({ apiKey: "" });
|
||||
expect.fail("constructor should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.MISSING_CONFIG);
|
||||
expect(err.providerName).toBe("coinmarketcap");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises MISSING_CONFIG when apiKey is whitespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new CoinMarketCapProvider({ apiKey: " " })).toThrow(
|
||||
CurrencyProviderError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoinMarketCapProvider — fetchRates request shape", () => {
|
||||
it("issues an authenticated GET to /cryptocurrency/quotes/latest", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
BTC: { quote: { EUR: { price: 40000 } } },
|
||||
ETH: { quote: { EUR: { price: 2000 } } },
|
||||
},
|
||||
status: { error_code: 0 },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
|
||||
const result = await provider.fetchRates({
|
||||
base: "EUR",
|
||||
targets: ["BTC", "ETH"],
|
||||
});
|
||||
|
||||
expect(result.base).toBe("EUR");
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 40000);
|
||||
expect(result.rates.ETH).toBeCloseTo(1 / 2000);
|
||||
expect(typeof result.timestamp).toBe("number");
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
const call = calls[0];
|
||||
expect(call?.url).toBe(
|
||||
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH&convert=EUR"
|
||||
);
|
||||
expect(call?.init?.method).toBe("GET");
|
||||
const headers = call?.init?.headers as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(headers?.["X-CMC_PRO_API_KEY"]).toBe(API_KEY);
|
||||
expect(headers?.Accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("defaults to USD + top-10 symbols when no options are given", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: { BTC: { quote: { USD: { price: 50000 } } } },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
|
||||
await provider.fetchRates();
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT&convert=USD"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to top-10 symbols when targets is an empty array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: { BTC: { quote: { USD: { price: 50000 } } } },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
|
||||
await provider.fetchRates({ targets: [] });
|
||||
|
||||
expect(calls[0]?.url).toContain(
|
||||
"symbol=BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT"
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the caller AbortSignal to the injected fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchImpl: FetchLike = async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(
|
||||
init?.signal?.reason ??
|
||||
new DOMException("aborted", "AbortError")
|
||||
);
|
||||
});
|
||||
});
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const promise = provider.fetchRates({
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("caller cancelled"));
|
||||
await expect(promise).rejects.toThrow(/caller cancelled/);
|
||||
});
|
||||
|
||||
it("honours a custom baseUrl", { timeout: 5000 }, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: { BTC: { quote: { USD: { price: 50000 } } } },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
|
||||
fetch,
|
||||
});
|
||||
|
||||
await provider.fetchRates({ targets: ["BTC"] });
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoinMarketCapProvider — globalThis.fetch fallback", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let stubCalled: { url: string } | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
stubCalled = undefined;
|
||||
globalThis.fetch = (async (
|
||||
url: string | URL | Request,
|
||||
_init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
stubCalled = { url: String(url) };
|
||||
return jsonResponse({
|
||||
data: { BTC: { quote: { USD: { price: 50000 } } } },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("falls back to globalThis.fetch when fetch is not injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CoinMarketCapProvider({ apiKey: API_KEY });
|
||||
const result = await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect(stubCalled?.url).toContain(
|
||||
"/cryptocurrency/quotes/latest?symbol=BTC&convert=USD"
|
||||
);
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoinMarketCapProvider — error mapping", () => {
|
||||
it("surfaces non-2xx responses as HTTP_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response("nope", {
|
||||
status: 503,
|
||||
statusText: "Down",
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
|
||||
expect(err.status).toBe(503);
|
||||
expect(err.providerName).toBe("coinmarketcap");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the body is not valid JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the body is JSON null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response("null", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
await expect(
|
||||
provider.fetchRates({ targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the data field is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ status: { error_code: null } })
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
await expect(
|
||||
provider.fetchRates({ targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the data field is JSON null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse({ data: null }));
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
await expect(
|
||||
provider.fetchRates({ targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the data field is not an object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({ data: "not-an-object" })
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
await expect(
|
||||
provider.fetchRates({ targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR when status.error_code is populated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: {},
|
||||
status: {
|
||||
error_code: 1006,
|
||||
error_message: "Insufficient credits",
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
|
||||
expect(err.upstreamMessage).toBe("Insufficient credits");
|
||||
expect(err.providerName).toBe("coinmarketcap");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR over PARSE_ERROR when data is missing AND status has an error_code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
status: { error_code: 401 },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
await expect(
|
||||
provider.fetchRates({ targets: ["BTC"] })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises UPSTREAM_ERROR without an upstreamMessage when error_message is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: {},
|
||||
status: { error_code: 500 },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
|
||||
expect(err.upstreamMessage).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats status.error_code === 0 as success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: { BTC: { quote: { USD: { price: 50000 } } } },
|
||||
status: { error_code: 0, error_message: null },
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
const result = await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_CURRENCY when no requested symbol resolved", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => jsonResponse({ data: {} }));
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["DOESNOTEXIST"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
expect((error as CurrencyProviderError).code).toBe(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoinMarketCapProvider — rate filtering", () => {
|
||||
it("skips entries with missing, non-numeric, NaN, or non-positive prices", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
BTC: { quote: { USD: { price: 50000 } } },
|
||||
ETH: { quote: { USD: { price: "2000" } } }, // string — dropped
|
||||
SOL: { quote: { USD: { price: 0 } } }, // zero — dropped
|
||||
XRP: { quote: { USD: { price: -1 } } }, // negative — dropped
|
||||
ADA: { quote: { USD: { price: Number.NaN } } }, // NaN — dropped
|
||||
DOT: { quote: { EUR: { price: 7 } } }, // wrong base — dropped
|
||||
DOGE: { quote: {} }, // missing base — dropped
|
||||
BNB: undefined, // undefined entry — dropped
|
||||
USDT: { quote: { USD: { price: 1 } } },
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: [
|
||||
"BTC",
|
||||
"ETH",
|
||||
"SOL",
|
||||
"XRP",
|
||||
"ADA",
|
||||
"DOT",
|
||||
"DOGE",
|
||||
"BNB",
|
||||
"USDT",
|
||||
],
|
||||
});
|
||||
expect(Object.keys(result.rates).sort()).toEqual(["BTC", "USDT"]);
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 50000);
|
||||
expect(result.rates.USDT).toBe(1);
|
||||
});
|
||||
|
||||
it("skips entries whose quote field is missing entirely", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse({
|
||||
data: {
|
||||
BTC: { quote: { USD: { price: 50000 } } },
|
||||
LTC: {}, // entry exists but no quote at all
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new CoinMarketCapProvider({
|
||||
apiKey: API_KEY,
|
||||
fetch,
|
||||
});
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["BTC", "LTC"],
|
||||
});
|
||||
expect(Object.keys(result.rates)).toEqual(["BTC"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import {
|
||||
type ApiKeyConfig,
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
httpRequest,
|
||||
} from "@suaveplan/currency-providers";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
const PROVIDER_NAME = "coinmarketcap";
|
||||
const DEFAULT_BASE_URL = "https://pro-api.coinmarketcap.com/v1";
|
||||
const DEFAULT_BASE = "USD";
|
||||
const DEFAULT_TARGETS: readonly string[] = [
|
||||
"BTC",
|
||||
"ETH",
|
||||
"SOL",
|
||||
"USDT",
|
||||
"USDC",
|
||||
"BNB",
|
||||
"XRP",
|
||||
"ADA",
|
||||
"DOGE",
|
||||
"DOT",
|
||||
];
|
||||
|
||||
interface CoinMarketCapQuote {
|
||||
readonly price?: unknown;
|
||||
}
|
||||
|
||||
interface CoinMarketCapEntry {
|
||||
readonly quote?: Record<string, CoinMarketCapQuote | undefined>;
|
||||
}
|
||||
|
||||
interface CoinMarketCapPayload {
|
||||
readonly data?: Record<string, CoinMarketCapEntry | undefined>;
|
||||
readonly status?: CoinMarketCapStatus;
|
||||
}
|
||||
|
||||
interface CoinMarketCapStatus {
|
||||
readonly error_code?: unknown;
|
||||
readonly error_message?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* CoinMarketCap crypto-rates provider for `@suaveplan/currency`.
|
||||
*
|
||||
* Sibling of `@suaveplan/currency-providers`. Implements `CurrencyProvider`
|
||||
* by issuing an authenticated `GET /cryptocurrency/quotes/latest` against
|
||||
* `pro-api.coinmarketcap.com`. The `X-CMC_PRO_API_KEY` header carries
|
||||
* `config.apiKey`. Identity flags are crypto-only (`supportsCrypto: true`,
|
||||
* `supportsFiat: false`).
|
||||
*/
|
||||
export class CoinMarketCapProvider implements CurrencyProvider {
|
||||
public readonly name = PROVIDER_NAME;
|
||||
public readonly supportsCrypto = true;
|
||||
public readonly supportsFiat = false;
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: FetchLike | undefined;
|
||||
|
||||
public constructor(config: ApiKeyConfig) {
|
||||
if (config.apiKey.trim() === "") {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.MISSING_CONFIG,
|
||||
"coinmarketcap: apiKey is required",
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
this.fetchImpl = config.fetch;
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
const base = options?.base ?? DEFAULT_BASE;
|
||||
const targets =
|
||||
options?.targets && options.targets.length > 0
|
||||
? options.targets
|
||||
: DEFAULT_TARGETS;
|
||||
const symbolList = targets.join(",");
|
||||
const url = `${this.baseUrl}/cryptocurrency/quotes/latest?symbol=${symbolList}&convert=${base}`;
|
||||
|
||||
const fetchImpl: FetchLike = this.fetchImpl ?? globalThis.fetch;
|
||||
const response = await httpRequest({
|
||||
url,
|
||||
fetch: fetchImpl,
|
||||
providerName: PROVIDER_NAME,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-CMC_PRO_API_KEY": this.apiKey,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
...(options?.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const payload = await parsePayload(response);
|
||||
assertNoUpstreamError(payload.status);
|
||||
const rates = collectRates(payload, base);
|
||||
if (Object.keys(rates).length === 0) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`coinmarketcap: no rates resolved for base ${base}`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
return { base, rates, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
async function parsePayload(response: Response): Promise<CoinMarketCapPayload> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch (cause) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
"coinmarketcap: response body is not valid JSON",
|
||||
{ providerName: PROVIDER_NAME, cause }
|
||||
);
|
||||
}
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
"coinmarketcap: response body is not a JSON object",
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
const payload = raw as CoinMarketCapPayload;
|
||||
if (!isValidDataMap(payload.data)) {
|
||||
// Allow the upstream status check to raise UPSTREAM_ERROR first;
|
||||
// only raise PARSE_ERROR when there is no usable upstream signal.
|
||||
if (!hasUpstreamErrorCode(payload.status)) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
"coinmarketcap: response missing or malformed data field",
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isValidDataMap(data: unknown): data is Record<string, unknown> {
|
||||
return typeof data === "object" && data !== null;
|
||||
}
|
||||
|
||||
function hasUpstreamErrorCode(
|
||||
status: CoinMarketCapStatus | undefined
|
||||
): boolean {
|
||||
const code = status?.error_code;
|
||||
return typeof code === "number" && code !== 0;
|
||||
}
|
||||
|
||||
function assertNoUpstreamError(status: CoinMarketCapStatus | undefined): void {
|
||||
if (!hasUpstreamErrorCode(status)) return;
|
||||
const upstreamMessage =
|
||||
typeof status?.error_message === "string"
|
||||
? status.error_message
|
||||
: undefined;
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
`coinmarketcap: upstream error_code ${String(status?.error_code)}`,
|
||||
{
|
||||
providerName: PROVIDER_NAME,
|
||||
...(upstreamMessage !== undefined ? { upstreamMessage } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function collectRates(
|
||||
payload: CoinMarketCapPayload,
|
||||
base: string
|
||||
): Record<string, number> {
|
||||
const rates: Record<string, number> = {};
|
||||
// By the time we reach here, `parsePayload` + `assertNoUpstreamError`
|
||||
// have guaranteed `payload.data` is a non-null object — payloads with a
|
||||
// missing `data` raise either `PARSE_ERROR` (no upstream signal) or
|
||||
// `UPSTREAM_ERROR` (upstream signalled the failure).
|
||||
const data = payload.data as Record<string, CoinMarketCapEntry | undefined>;
|
||||
for (const [symbol, entry] of Object.entries(data)) {
|
||||
const price = entry?.quote?.[base]?.price;
|
||||
if (typeof price === "number" && Number.isFinite(price) && price > 0) {
|
||||
rates[symbol] = 1 / price;
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CoinMarketCapProvider } from "./coinmarketcap.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { CoinMarketCapProvider } from "./coinmarketcap/coinmarketcap.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
// Polyfills for test environment — none required. The package is
|
||||
// universal and uses only `globalThis.fetch` (overridable via
|
||||
// `FetchLike` injection), `Object.entries`, `Number.isFinite`, and
|
||||
// `Date.now`, all available in Node 18+, Bun, browsers, and Web
|
||||
// Workers. This file exists to give vitest a stable setup-file path
|
||||
// mirroring the sibling-package layout.
|
||||
export { };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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-coinmarketcap",
|
||||
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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user