npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@codefusion-cc/exchange-rates

v0.1.0

Published

Approximate prices in other currencies for a shop that charges in its own: daily exchange rates from a source (the Polish central bank's table A first, any source behind one interface) kept in D1 with a guard against absurd jumps, a refresh for the app's

Readme

@codefusion-cc/exchange-rates

Approximate prices in other currencies for a shop that charges in its own. The shop shows "≈ €23.26" next to its price of 100 zł, from a rate updated once a day. Payment stays in the shop's currency: the other price is for display and is never an amount to charge. It is not commerce: any app with a D1 database can use it. No dependencies (React is an optional peer).

npm install @codefusion-cc/exchange-rates

| Entry | Runs in | Holds | | --- | --- | --- | | @codefusion-cc/exchange-rates | Workers | refreshRates, latestRates, ratesResponse, nbpTableA and the source interface, approximate | | @codefusion-cc/exchange-rates/browser | the page | createCurrencyChoice, createRatesClient, formatMoney, formatApproximate, approximate | | @codefusion-cc/exchange-rates/react | the page | ExchangeRatesProvider, ApproxPrice, useApproxPrice, useCurrencyChoice | | @codefusion-cc/exchange-rates/migrations | Node | syncMigrations, behind the codefusion-exchange-rates-migrations command | | @codefusion-cc/exchange-rates/testing | tests | EXAMPLE_RATES, fakeSource, fakeFetch, nbpAnswer, failedTable |

What a shop does

  1. Install and migrate. "postinstall": "codefusion-exchange-rates-migrations" copies the package's migration into migrations/0000_exchange_rates/ (add it to .gitignore); the D1 binding needs "migrations_pattern": "migrations/**/*.sql", as for @codefusion-cc/mail. The deploy applies it. One table, exchange_rates.

  2. A cron trigger and the refresh. The NBP publishes around noon Warsaw time on business days; run twice a day so one failure does not cost a day:

    "triggers": { "crons": ["15 11 * * *", "15 14 * * *"] }   // UTC
    import { nbpTableA, refreshRates } from '@codefusion-cc/exchange-rates'
    
    export const RATES = { base: 'PLN', source: nbpTableA(), currencies: ['EUR', 'USD', 'GBP', 'CZK'] } as const
    
    async scheduled(_event, env) {
      const result = await refreshRates(env.DB, { base: RATES.base, source: RATES.source, require: RATES.currencies })
      if (result.status === 'failed') console.error('exchange rates', result.code, result.detail) // the last good rates stay
    }
  3. A route (GET /api/rates): return ratesResponse(request, env.DB, { base: 'PLN', source: RATES.source, currencies: RATES.currencies }). The answer is { base, date, stale, rates }, cacheable for an hour; null when no rates are stored (cached for a minute); a database that fails is a 503 that is not cached. The route only reads the database: no visitor can make the Worker fetch or write.

  4. The page makes the choice and the client once and wraps the app:

    const choice = createCurrencyChoice({ base: 'PLN', offered: ['EUR', 'USD', 'GBP'], languageToCurrency: { en: 'EUR', de: 'EUR', 'en-GB': 'GBP' } })
    const rates = createRatesClient({ url: '/api/rates' })
    
    <ExchangeRatesProvider choice={choice} rates={rates} locale={language}>…</ExchangeRatesProvider>
    
    <ApproxPrice amountMinor={product.priceGrosze} from="PLN" label={({ spoken }) => t('approx.spoken', { amount: spoken })} />

    useCurrencyChoice() returns [currency, set, choice] for a picker; choice.choices is the list (the shop's own currency first: choosing it means "show no approximate price").

Where ApproxPrice goes

  • A list or grid: under the price, in a line of fixed height, for every product card. One request serves all cards.
  • A product page: next to the large price, and for each variant's price when it changes.
  • The cart: next to each line's price and to the total.
  • The checkout: next to the total, and this sentence, which the app writes in its languages, with the date from useApproxPrice(...).date (or the route's date): "You pay in zloty (PLN). The price in euro is approximate, by the rate of 9 October 2026." The payment button shows the amount in the shop's currency only. When stale is true, either say the rate is old or hide the approximate price.

The page does not jump

ApproxPrice renders nothing for the shop's own currency, without rates or without a rate for the currency. While the rates load it holds the line with an empty, aria-hidden box one line high (or your fallback, a skeleton as wide as the price), so the rates arriving do not move what is below. A price that turns out to have no rate leaves a gap, and the first paint of a visitor who chose another currency comes after hydration, so also give the container a one-line min-height (min-height: 1lh) where a gap would show. Rates are fetched only when a visitor chose another currency, so most visitors cost no request. On the server the choice is always the shop's currency, so the HTML never differs from the first client render.

Words and screen readers

The package holds no copy. The visible text is prefix (default "≈ ") plus the amount in the visitor's locale (Intl.NumberFormat: "€12.40", "12,40 €"). That is aria-hidden; a visually hidden sentence from the app's label is read instead, with the currency in words: label={({ spoken }) => about ${spoken}} reads "about 12.40 euros".

The calculation

approximate(10000, 'PLN', 'EUR', rates) // { ok: true, amountMinor: 2284, currency: 'EUR' }

Amounts are in the smallest unit (grosze, cents, yen) as safe integers or BigInts; rates are decimal texts ("4.3775", at most 12+12 digits) and everything is BigInt arithmetic with one rounding at the end, half up to the target's own minor unit (yen 0 digits, dinar 3, most 2; ISO 4217, not the browser's opinion). No float is ever in between. Anything wrong is a result: unknown-currency, missing-rate, invalid-amount, overflow (the answer would pass 2^53-1). A rate is "units of the base per one unit of the currency", the base is always 1.

The page shows no more precision than a daily mid rate knows: approximateFor (and so ApproxPrice) rounds the answer to whole units, half up, for a currency whose unit is worth less than a tenth of the base (the forint, the rupee, the hryvnia, the rupiah), where its cents are noise; the euro, the franc or the dinar keep their minor digits. NBP quotes every currency per one unit (the forint as 0.011999, the yen as 0.024681), so there is no "per 100" to convert. A price above zero that rounds to zero in the chosen currency is not shown (it would read as free); a price of zero shows zero.

Where rates come from, and what can go wrong

refreshRates(db, options) returns { status: 'updated' | 'unchanged' | 'failed' } and throws only for options that are a mistake. unchanged: the day is stored (a weekend, when the source repeats Friday; a second run; a run that lost a race), or older than the newest stored day. failed has a code and stores nothing, so the last good rates stay:

| code | meaning | | --- | --- | | network, timeout | no answer within timeoutMs (8 s); retried up to retries (2) with a doubling pause | | http | a status other than 200; 429, 408 and 5xx are retried | | malformed | no JSON, truncated, wrong type, an empty table, a duplicate code, a rate of 0, negative or NaN, a rate outside rateRange (0.0000001 to 10000000: what the jump guard cannot see on the very first table), a larger answer than 256 KB, a redirect (not followed), a day that is no day or more than a day ahead | | incomplete | the table lacks a required currency, or the base | | jump | a rate of a required currency (all, with no require) moved by more than maxJump (1.5) against the newest stored day, up or down; the whole table is refused. After a real revaluation, or an outage so long that a real move passed 1.5, run once with maxJump: Infinity | | storage | D1 refused the read or the write |

latestRates(db, { base, source }) returns { base, date, rates, stale } | null. stale is true when the date is more than staleAfterDays (4: a long weekend) calendar days (UTC) old. null means nothing is stored, or D1 failed: the shop shows only its own price. The answer is kept in the isolate for cacheMs (60 s). Old days stay (keepDays 400, pruned by the refresh itself, relative to the newest day), so a shop may show the date of the rate it used.

What the scheduled handler does with failed

The shop keeps its last good rates, so a failed run needs no reaction for the visitor. For staff, log it as an error (console.error('exchange rates', result.code, result.detail)), where the console's log search finds it. network, timeout and http are mostly transient: the second cron run of the day tries again. malformed, incomplete, jump and storage are not: the same table will fail again, someone has to look. Nothing is stored for a refused table, so rates then grow old and stale turns true after staleAfterDays; a shop that hides stale rates hides them then. A long holiday stretch (Christmas, with a Friday table and no Monday) can pass 4 days: raise staleAfterDays for it, or accept the mark.

Adding a source

A source is { id, fetchTable({ signal }) } returning { ok: true, table: { base, date, rates } } or a failure (sourceFailure('http', …)), where rates[X] is the base currency's units per one X. fetchJson does the request, the size limit and the failure codes. The ECB's daily table quotes "units of X per 1 EUR": a source for it is a parser that passes the quotes to invertQuotes('EUR', date, quotes), which returns exactly that. refreshRates rebases any table to the shop's base (rebase), so the ECB's EUR table serves a zloty shop without the caller changing; only source does. Rows are kept by source.id, so two sources never mix.

Tests in an app

@codefusion-cc/exchange-rates/testing: EXAMPLE_RATES (a RateTable), fakeSource([…]) for refreshRates, fakeFetch(json, status) for createRatesClient, nbpAnswer(date, mids) for a fake NBP.