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

unluck

v0.1.1

Published

Deterministic simulation testing for Node. Finds bugs that only happen under unlucky timing, then replays them exactly.

Readme

unluck

Deterministic simulation testing for Node.

unluck finds bugs that only happen under unlucky timing — a lost reply, a retry that lands twice, a request that beats its own response — and then replays the exact failing run, forever. The code under test imports nothing from unluck. It keeps using fetch, setTimeout, Date.now, Math.random and crypto.randomUUID; unluck controls what those return.

The problem

Your integration test calls the service once, over a healthy network, and passes. Production runs the same code a million times over a network that drops 0.4% of replies, and the retry path — the one your test never entered — applies the charge twice. When it does show up in CI it shows up as a flaky test, one run in thirty, and it gets muted rather than fixed.

60 seconds

One file. The top half is an ordinary retrying HTTP client and the service it talks to. The bottom half is the test.

// billing.sim.mjs
import { pathToFileURL } from 'node:url';
import { scenario, search, shrink, replay } from 'unluck';

// ---- the application: ordinary Node, imports nothing from unluck -----------

function createBilling() {
  const applied = new Map();          // id -> times we actually charged
  const acked = new Set();            // every id we answered OK to
  return { applied, acked, handle({ id, amount }) {
    acked.add(id);
    applied.set(id, (applied.get(id) ?? 0) + 1);
    return { ok: true, id };
  } };
}

async function chargeCustomers({ count = 5, attempts = 3 } = {}) {
  for (let i = 0; i < count; i++) {
    const id = `charge-${i}-${crypto.randomUUID().slice(0, 8)}`;
    for (let n = 1; n <= attempts; n++) {
      await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 10) + 1));
      try {
        const res = await fetch('http://billing.internal/charge', {
          method: 'POST', body: JSON.stringify({ id, amount: 10 }),
        });
        if (res.ok) break;
      } catch { /* timed out; retry with the same id */ }
    }
  }
}

// ---- the test -------------------------------------------------------------

const billing = scenario({
  name: 'billing/retry-is-not-idempotent',
  url: import.meta.url,                       // lets replay re-import this file
  setup: () => ({ svc: createBilling() }),
  services: (s) => ({ 'billing.internal': (body) => s.svc.handle(body) }),
  run: () => chargeCustomers({ count: 5 }),
  invariant: (s) => {
    for (const id of s.svc.acked) {
      const n = s.svc.applied.get(id) ?? 0;
      if (n !== 1) return { ok: false, detail: `${id} was acknowledged but charged ${n} times` };
    }
    return { ok: true };
  },
});
export default billing;

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  const faults = { dropRequest: 0.004, dropResponse: 0.004, duplicateRequest: 0.002,
                   minLatency: 1, maxLatency: 12, requestTimeoutMs: 60 };

  const found = await search(billing, { seeds: 500, faults, stopOnFirst: false });
  console.log(`search  ${found.failures}/${found.searched} seeds failed (${(found.rate * 100).toFixed(1)}%) in ${found.elapsedMs}ms`);
  console.log(`        seed ${found.counterexample.seed}: ${found.counterexample.detail}`);

  const small = await shrink(billing, found.counterexample);
  console.log(`shrink  ${small.before.faults} faults -> ${small.after.faults}  (minimal: ${small.minimal})`);
  console.log(`        ${small.faults.join(', ')}`);

  const r = await replay(billing, small.counterexample);
  console.log(`replay  fresh process: ${r.isolated}   reproduced: ${r.reproduced}`);
  console.log(`        digest ${r.digest} == recorded ${r.expectedDigest}, ${r.tapeHits} tape hits, ${r.tapeMisses} PRNG draws`);
}

node billing.sim.mjs:

search  17/500 seeds failed (3.4%) in 222ms
        seed 54: charge-1-5ba8cef6 was acknowledged but charged 2 times
shrink  11 faults -> 1  (minimal: true)
        net.drop-response:charge-1#1
replay  fresh process: true   reproduced: true
        digest b3493a0a798e623a == recorded b3493a0a798e623a, 40 tape hits, 0 PRNG draws

Read the three lines in order.

3.4% of seeds. That is the flaky test. Roughly one run in thirty, which is exactly the rate at which a failure gets re-run instead of investigated.

11 faults down to 1. Seed 54 injected eleven separate perturbations. Ten of them were irrelevant. minimal: true is not a claim — unluck removed each surviving fault in turn and required the case to pass. One dropped reply, on the second charge, is the entire bug.

0 PRNG draws. The replay was driven by the recorded choice tape, not by re-rolling the seed, in a process that had never run the scenario before. The digest matched the recorded one, so this is the same run byte for byte — not merely "it broke again".

Install

npm install --save-dev unluck

Node 22 or newer. No runtime dependencies.

What it intercepts

Replaced for the duration of a run, with no change to the code under test. Rows marked † need the loader hook (--import unluck/register, or the CLI, which installs it for you); everything else works with no setup.

| | | |---|---| | setTimeout / clearTimeout | real Timeout objects with ref/unref/refresh, on a virtual clock | | setInterval / clearInterval | same | | fetch | routed to your services handlers; drops, duplicates, latency and timeouts injected | | † node:http / node:https | simulated — requires --import unluck/register or the CLI, since the builtin must be redirected before your modules link. Covers got and axios's default adapter | | crypto.subtle digest / HMAC | backed by Node's synchronous crypto so completion is scheduled; byte-identical results | | Date.now() and new Date() | the virtual clock | | performance.now() | the virtual clock | | Math.random() | seeded, and recorded on the tape | | crypto.randomUUID() | seeded, and recorded on the tape | | † node:timers/promises, named node:crypto imports | seeded and virtual; a named builtin import is invisible to a global patch |

The virtual clock starts at 1767225600000 (2026-01-01T00:00:00Z) rather than 0, because timestamp 0 is an "unset" sentinel in real libraries and hangs them. Delays are clamped to at least 1ms, as Node does.

Sixteen popular packages were exercised under interception: 13 work unchanged, 2 (nanoid, uuid.v7) work only when the replay is process-isolated, and 1 (piscina) does not work. See docs/limitations.md.

Three ways in

  • The library API — scenario / search / shrink / replay, as above. See docs/api.md.
  • Inside your test runner — simulate() / regression() drop into node:test or vitest and throw a shrunk, readable report when a seed falsifies your invariant. See docs/quickstart.md.
  • The CLI — unluck search, replay, shrink, check. Use it when the code under test does import { setTimeout } from 'node:timers/promises'; the CLI is the only entry point that installs the loader hook that reaches named builtin imports.

Limitations, briefly

It interleaves tasks at simulated I/O only. A race whose window contains no simulated call — two tasks racing across an in-process mutex, say — is not explored at all, and you will get a clean run over any number of seeds. Measured both sides of that boundary in docs/limitations.md; read it before trusting a green result.

unluck simulates the network at the request/response level, not the wire. It does not speak socket or binary protocols, so redis and mysql2 are outside it. pg is simulated at the client's own API (Client/Pool/query), not over a socket — so transactions, lost COMMIT acknowledgements and pool exhaustion are reachable, while COPY, LISTEN/NOTIFY, cursors and real unique constraints are not.

setImmediate is not scheduled by the simulator and will hang a run. Real I/O (DNS, real sockets, child processes) never completes inside a run. Worker threads work, but piscina does not: it hands each worker a MessagePort in a transferList and then blocks on Atomics.wait, which cannot work when every simulated thread shares one real thread.

Two of those fail by leaving the run unfinished rather than by throwing, so read docs/limitations.md before you trust a clean run. It lists what fails silently and the specific tell for each.

Documentation

| | | |---|---| | docs/quickstart.md | install to found-and-replayed bug, in your test runner | | docs/api.md | every public export, with signatures and examples | | docs/recipes.md | retrying clients, webhooks, job queues, transactions, worker pools | | docs/limitations.md | what it cannot see, and how to notice | | docs/how-it-works.md | the scheduler, the tape, swarm testing, and the evidence | | PRIOR-ART.md | the fourteen in-house builds and three Rust projects this came from |

Verifying the build

npm test        # 42 checks

Each check calibrates in both directions: it plants the defect and requires the harness to catch it, then removes the defect and requires a clean run. On this machine the whole gate takes about 20s.

License: MIT.