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

@hey-amanthakur/coord-box

v1.0.1

Published

Coord-Box — distributed coordination primitives for Node.js: counting semaphores and an idempotency-key registry. Zero runtime dependencies. Pluggable backends (in-memory, Redis) and framework adapters.

Readme

Coord-Box

A production-grade, framework-agnostic distributed semaphore & idempotency-key library for Node.js

npm version npm downloads license types dependencies node

Zero runtime dependencies · Pluggable backends · Counting semaphore · TTL leases · Auto-renewal · Idempotency keys · Single-flight dedup · Express · Fastify · Koa · NestJS


Overview

Coord-Box is a lightweight, framework-agnostic coordination toolkit for Node.js. It ships two primitives:

  • Distributed semaphore — an "at most N concurrent workers" limit shared across processes and machines.
  • Idempotency registry — exactly-once execution for side-effecting operations, keyed by a client-supplied idempotency key.

Both sit on a clean backend interface so you can run them in-process or on real distributed storage (Redis ships out of the box), and both plug into Express, Fastify, Koa, and NestJS with zero wiring.

Highlights

| | | | --- | --- | | Zero dependencies | No transitive supply-chain risk; nothing to audit beyond Node itself. | | Pluggable backends | In-memory for single-process apps and tests, or the Redis adapter for real distributed coordination. Bring your own via the SemaphoreBackend / IdempotencyBackend interfaces. | | Counting semaphore | Reserve 1..N permits per operation; the backend guarantees the total never exceeds your maxPermits. | | TTL leases | Every permit is a lease: if a holder crashes, its slots free themselves after ttlMs. No stuck permits. | | Auto-renewal | Background lease extension so long jobs never lose their reservation mid-flight. | | Blocking acquire | withPermits/acquire wait (poll) until permits are free or a timeout elapses. tryAcquire never blocks. | | Exactly-once idempotency | The first caller runs; concurrent and later duplicates wait for or replay the stored outcome. | | Observability hooks | onAcquire/onRelease/onRenew/onLost for the semaphore; onCreated/onCompleted/onReplayed/onFailed for idempotency — throwing hooks never break the flow. | | Framework adapters | Drop-in middleware for Express, Fastify, Koa, and decorators + guard/interceptor for NestJS. | | Dual ESM + CommonJS | Ships both module formats with full TypeScript type definitions. |


When to use it

Semaphore

Coord-Box is for any resource that allows at most N actors at a time:

  • Worker pools / queues — limit in-flight jobs to the pool size across every replica.
  • External API rate limits — reserve a fixed concurrency budget for a downstream service.
  • Batch downloads — cap parallel chunk transfers.
  • Resource-shared jobs — bound memory/connection pressure by limiting concurrent heavy jobs.

For mutual exclusion (exactly one actor), use Lock-Box instead.

Idempotency

Coord-Box deduplicates operations that must run exactly once:

  • Payment / checkout flows — the same charge request retried (or sent twice) charges once.
  • Webhook delivery — duplicate webhook events are handled once, but return the stored response.
  • RPC/queue consumers — at-least-once delivery with exactly-once processing.
  • Report generation — concurrent triggers of the same report wait for and share the single run.

Table of Contents


Installation

npm install @hey-amanthakur/coord-box
pnpm add    @hey-amanthakur/coord-box
yarn add    @hey-amanthakur/coord-box

Framework and backend packages are optional peer dependencies — install only the ones you use:

npm install ioredis                 # Redis backend only
npm install express                 # Express adapter only
npm install fastify                 # Fastify adapter only
npm install koa                     # Koa adapter only
npm install @nestjs/common @nestjs/core reflect-metadata rxjs   # NestJS only

Quick start

import {
  DistributedSemaphore,
  IdempotencyRegistry,
  MemoryIdempotencyBackend,
  MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';

// At most 4 slow jobs run at once.
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend(), {
  wait: { maxWaitMs: 5_000 },
});
await semaphore.withPermits('slow-jobs', 1, async () => {
  await runSlowJob();
});

// The same operation never runs twice for the same key.
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
const { record, replayed } = await registry.execute('charge:tok_123', charge);

The default permit lease is 30 seconds. A holder that crashes simply frees its slots when the lease expires — no stuck permits. Idempotency records live for 24 hours by default.


Core concepts

  • Backend decides where coordination state lives and makes every transition atomic.
  • Permit is one unit of capacity; a reservation holds permits distinct permit slots.
  • Lease (TTL) bounds how long a permit is held — the safety net for crashed holders.
  • Renewal keeps a reservation alive while a long operation runs, and detects a lost reservation.
  • Idempotency key names an operation; the registry ensures the operation runs exactly once per key within its TTL.

Semaphore API

DistributedSemaphore

import {
  DistributedSemaphore,
  MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';

const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend(), {
  defaultTtlMs: 10_000,                        // permit lease duration (default 30s)
  wait: { maxWaitMs: 5_000, intervalMs: 100 }, // blocking-acquire defaults
  hooks: {
    onAcquire: ({ key, permits }) => console.log('acquired', key, permits),
    onRelease: ({ key, permits }) => console.log('released', key, permits),
    onLost: ({ key, reason }) => console.warn('permits lost', key, reason),
  },
});

tryAcquire / acquire / withPermits

| Method | Behavior | | --- | --- | | tryAcquire(key, permits?, opts?) | One attempt. Returns AcquiredPermits or null when at capacity. | | acquire(key, permits?, opts?) | Waits (polls) until permits are free or maxWaitMs elapses; throws SemaphoreWaitTimeoutError. | | withPermits(key, permits, fn, opts?) | acquire → run fn(permits) → release in finally (even if fn throws). | | count(key) | Current number of active permits for a key. |

permits defaults to 1. When acquiring multiple permits, all are reserved in a single logical transaction — a partial failure rolls the reservation back.

const slot = await semaphore.tryAcquire('drain-queue', 1);
if (slot === null) {
  // at capacity — skip this run
}

const guard = await semaphore.acquire('downloads:heavy', 4, { maxWaitMs: 2_000 });

const result = await semaphore.withPermits('drain-queue', 1, async (held) => {
  return processNext();
});

AcquiredPermits

export interface AcquiredPermits {
  readonly key: string;
  readonly permits: number;      // slots reserved
  readonly ids: string[];        // per-slot unique identifiers
  readonly autoRenew: boolean;
  readonly ended: Promise<{ reason: 'released' | 'expired' | 'aborted'; at: number }>;
  isHeld(): Promise<boolean>;    // live check against the backend
  extend(ttlMs?: number): Promise<void>; // refresh every permit; throws if lost
  release(): Promise<boolean>;   // idempotent; true if it released active permits
}

Leases & auto-renewal

Every permit is a lease: the backend frees it after ttlMs even if nobody releases it. If your operation may run longer than the lease, enable auto-renewal:

await semaphore.withPermits('long-job', 1, async () => {
  // every permit's lease is refreshed every ttlMs/3 in the background
}, { ttlMs: 10_000, renew: true });

If a renewal fails (the reservation was lost), onLost fires and ended resolves with { reason: 'expired' }. A lost reservation is never silently assumed to be held: check await reservation.isHeld() before committing side effects, or use renewal to detect loss.

Cancellation

const controller = new AbortController();

const job = semaphore.withPermits('x', 1, async () => { /* ... */ }, {
  signal: controller.signal,
});
setTimeout(() => controller.abort(), 1_000);
  • Waiting for permits: aborts with SemaphoreAbortError.
  • Already holding: the reservation is released and ended resolves with { reason: 'aborted' }.

Hooks

const semaphore = new DistributedSemaphore(backend, {
  hooks: {
    onAcquire: ({ key, permits }) => {},
    onRelease: ({ key, permits }) => {},
    onRenew: ({ key, permits }) => {},
    onLost: ({ key, reason }) => {}, // 'expired' | 'aborted'
  },
});

Hook exceptions are isolated by design — a throwing hook never breaks acquisition or release.

Errors

| Error | When | | --- | --- | | SemaphoreWaitTimeoutError | Couldn't acquire within maxWaitMs. Carries key, permits, and waitedMs. | | SemaphoreEndedError | extend() on a reservation that already ended. | | SemaphoreAbortError | Acquisition cancelled via signal. name === 'AbortError'. | | SemaphoreError | Base class for all semaphore errors. |

Custom backends

Any object satisfying SemaphoreBackend works:

import type { SemaphoreBackend } from '@hey-amanthakur/coord-box';

const backend: SemaphoreBackend = {
  async tryAcquire(key, id, maxPermits, ttlMs) { /* atomic: reserve one slot if capacity remains */ },
  async release(key, id) { /* atomic delete-if-owns */ },
  async renew(key, id, ttlMs) { /* atomic refresh-if-still-owned-and-unexpired */ },
  async isHeld(key, id) { /* does this id still own an unexpired slot? */ },
  async count(key) { /* number of unexpired slots */ },
};

Each operation must be atomic — that is the entire contract of a distributed semaphore. (For reference, see the Redis Lua scripts in src/redis/index.ts.)


Idempotency API

IdempotencyRegistry

import {
  IdempotencyRegistry,
  MemoryIdempotencyBackend,
} from '@hey-amanthakur/coord-box';

const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend(), {
  defaultTtlMs: 24 * 60 * 60 * 1000, // records live 24h (default)
  storeErrors: false,                // do not cache failed outcomes (default)
});

const { record, replayed } = await registry.execute(
  'charge:tok_123',
  async () => {
    await charge();
    return { id: 'charge-1' };
  },
  { ttlMs: 60 * 60 * 1000, waitMs: 30_000, signal, metadata: { amount: 100 } },
);
  • The first caller for a key becomes the runner and stores its result.
  • Concurrent callers with the same key wait for the runner (default 30s) and then replay its result — single-flight.
  • Later duplicates replay the stored result instantly.
  • result.record is the stored IdempotencyRecord; result.replayed is true when the outcome came from storage.

Single-flight semantics

const results = await Promise.all([
  registry.execute('webhook:evt_123', run),
  registry.execute('webhook:evt_123', run), // waits for the runner, then replays
  registry.execute('webhook:evt_123', run), // replays instantly after completion
]);
// run() executed exactly once; the other two results are replayed.

Concurrent losers poll the backend until the winner settles or waitMs elapses (then throw IdempotencyWaitTimeoutError). Pass an AbortSignal to cancel the wait (IdempotencyAbortError).

Failed executions

By default a failed execution deletes its record, so a retry of the same key re-runs the operation. Set storeErrors: true (globally or per-execution via option on the registry) to instead cache failures and replay them — duplicates then throw the denormalized error without re-running.

Hooks

const registry = new IdempotencyRegistry(backend, {
  hooks: {
    onCreated: ({ key }) => {},
    onCompleted: ({ key }) => {},
    onReplayed: ({ key }) => {},
    onFailed: ({ key, error }) => {},
  },
});

Also available: registry.getRecord(key) to read a stored record and registry.deleteRecord(key) to clear it so the operation can run again.

Errors

| Error | When | | --- | --- | | IdempotencyWaitTimeoutError | A concurrent caller waited waitMs for the winner to settle. Carries key and waitedMs. | | IdempotencyAbortError | The wait was cancelled via signal. name === 'AbortError'. | | IdempotencyError | Base class for all idempotency errors. |

Custom backends

Any object satisfying IdempotencyBackend works:

import type { IdempotencyBackend } from '@hey-amanthakur/coord-box';

const backend: IdempotencyBackend = {
  async create(key, record) { /* atomic set-if-absent */ },
  async get(key) { /* record or undefined */ },
  async update(key, record) { /* atomic overwrite + reset TTL */ },
  async expire(key, ttlMs) { /* delete (0) or reset TTL */ },
};

IdempotencyRecord has { key, state: 'in-flight' | 'completed' | 'failed', attempts, createdAt, lastUpdatedAt, expiresAt, result?, error?, metadata? }. normalizeError/denormalizeError are exported for storing/restoring thrown errors.


Backends

In-memory

import {
  DistributedSemaphore,
  IdempotencyRegistry,
  MemoryIdempotencyBackend,
  MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';

const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());

Correct within a single Node process. Swap in Redis (or your own backends) the moment coordination must span processes or machines.

Redis

import Redis from 'ioredis';
import {
  createRedisIdempotencyRegistry,
  createRedisSemaphore,
} from '@hey-amanthakur/coord-box/redis';

const redis = new Redis({ host: '127.0.0.1' });
const semaphore = createRedisSemaphore(redis, { defaultTtlMs: 10_000 });
const registry = createRedisIdempotencyRegistry(redis, { defaultTtlMs: 24 * 60 * 60 * 1000 });

Semaphore operations are atomic Lua scripts: capacity check + slot insert (ZCOUNT/ZADD), and token-verified lease refresh. Permits live in a sorted set keyed by expiry, so crashed holders' slots expire on their own. Idempotency records are stored with a PX expiry. Safe across any number of processes.

const semaphoreBackend = createRedisSemaphoreBackend(redis);   // just the backends, if you prefer
const idempotencyBackend = createRedisIdempotencyBackend(redis);

Framework adapters

Adapters reserve permits for the duration of the request (released when the response finishes or the connection closes) and/or deduplicate requests by idempotency key.

Express

import express from 'express';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { expressSemaphore } from '@hey-amanthakur/coord-box/express';

const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
const app = express();

app.post(
  '/payments',
  expressSemaphore({
    semaphore,
    permits: 1,
    key: (req) => `payments:${req.body.accountId}`,
    maxPermits: 10, // the concurrency cap for that key
  }),
  (req, res) => {
    res.locals.coordBoxPermits; // the AcquiredPermits, if you need it
    res.json({ ok: true });
  },
);

Idempotency dedup is a sibling middleware:

import { IdempotencyRegistry, MemoryIdempotencyBackend } from '@hey-amanthakur/coord-box';
import { expressIdempotency } from '@hey-amanthakur/coord-box/express';

const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
app.post(
  '/payments',
  expressIdempotency(registry, { headerName: 'idempotency-key' }),
  (req, res) => res.json({ ok: true }),
);
// Duplicate requests replay the first response's status + body.

Note: idempotency adapters capture the response via res.send/res.json. Handlers that stream (res.write) or write the response directly are not captured.

Fastify

import Fastify from 'fastify';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { fastifySemaphorePlugin } from '@hey-amanthakur/coord-box/fastify';

const app = Fastify();
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());

app.register((instance, _opts, done) => {
  instance.register(fastifySemaphorePlugin({
    semaphore,
    permits: 1,
    key: (req) => `payments:${req.body.accountId}`,
    maxPermits: 10,
  }));
  instance.register(fastifyIdempotencyPlugin(registry));
  done();
});

The reservation is exposed as request.coordBoxPermits. A per-route fastifySemaphoreRoute(options) handler is also exported.

Koa

import Koa from 'koa';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { koaSemaphore } from '@hey-amanthakur/coord-box/koa';

const app = new Koa();
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());

app.use(koaSemaphore({
  semaphore,
  permits: 1,
  key: (ctx) => `payments:${ctx.request.body.accountId}`,
  maxPermits: 10,
}));
app.use(koaIdempotency(registry));

The reservation is exposed as ctx.state.coordBoxPermits. koaIdempotency replays ctx.status/ctx.body.

NestJS

import { Controller, Get, UseGuards } from '@nestjs/common';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { createSemaphoreGuard, Permits } from '@hey-amanthakur/coord-box/nestjs';

const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());

@Controller('payments')
class PaymentsController {
  @Get()
  @UseGuards(createSemaphoreGuard({ semaphore }))
  @Permits((req) => `payments:${req.query.accountId}`, { permits: 2, wait: { maxWaitMs: 5_000 } })
  pay() {
    return 'ok';
  }
}

The guard acquires the semaphore (using the @Permits metadata), exposes it as request.coordBoxPermits, and releases it when the response finishes.

Idempotency dedup uses a decorator + interceptor:

import { IdempotencyRegistry, MemoryIdempotencyBackend } from '@hey-amanthakur/coord-box';
import { createIdempotencyInterceptor, Idempotency } from '@hey-amanthakur/coord-box/nestjs';

const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
// register createIdempotencyInterceptor({ registry }) as an interceptor, then:
@Idempotency() // or @Idempotency({ ttlMs, waitMs })
@Post('charge')
charge() {
  return 'ok';
}

Duplicates replay the stored value (and captured status) without re-running the handler.


Configuration reference

interface DistributedSemaphoreOptions {
  /** Default permit lease duration in ms. Default 30_000. */
  defaultTtlMs?: number;
  /** Wait defaults for acquire/withPermits. Default { maxWaitMs: 30_000, intervalMs: 200 }. */
  wait?: { maxWaitMs?: number; intervalMs?: number };
  hooks?: SemaphoreHooks;
}

interface SemaphoreOptions {
  ttlMs?: number;                             // permit lease duration
  renew?: boolean | { intervalMs?: number };  // background lease renewal
  signal?: AbortSignal;                       // cancel wait / release held permits
  metadata?: unknown;                         // arbitrary, available to hooks
}

interface SemaphoreWaitOptions extends SemaphoreOptions {
  maxWaitMs?: number;   // stop waiting after this; Infinity waits forever
  intervalMs?: number;  // poll interval (jittered)
}

interface IdempotencyRegistryOptions {
  defaultTtlMs?: number;   // record lifetime. Default 24h.
  pollIntervalMs?: number; // concurrent-contender poll interval. Default 100.
  storeErrors?: boolean;   // cache failed outcomes. Default false.
  hooks?: IdempotencyHooks;
}

interface IdempotencyExecuteOptions {
  ttlMs?: number;   // record TTL for this execution
  waitMs?: number;  // how long a concurrent loser waits. Default 30_000.
  signal?: AbortSignal;
  metadata?: unknown;
}

Examples

Runnable examples live in examples/ — run any of them with npx tsx:

npx tsx examples/basic.ts       # semaphore withPermits, tryAcquire
npx tsx examples/idempotency.ts # exactly-once execution + replay
npx tsx examples/redis.ts       # Redis backends (requires a local Redis)
npx tsx examples/adapters.ts    # Express / Fastify / Koa

Node.js support

| Node line | Status | Supported | | --- | --- | :---: | | 20.x | EOL ~Apr 2026, still widely deployed | ✅ | | 22.x | Active LTS | ✅ | | 24.x | Active LTS (newest LTS) | ✅ | | 26.x | Current | ✅ |

engines.node: ">=20.19.0".


Testing

npm test          # run unit + integration tests with tsx + node:test
npm run test:coverage  # coverage with gates (lines/functions/statements >= 90, branches >= 85)
npm run typecheck # tsc --noEmit
npm run build     # tsup dual ESM/CJS + .d.ts
npm run verify    # lint + typecheck + test + build

Redis adapter tests run against ioredis-mock — no live Redis needed.


Contributing

Contributions are welcome and appreciated. Please read the Contributing Guidelines before opening a pull request.

  • Bug reports & feature requestsopen an issue
  • Pull requests → target the main branch; include tests for any new behavior
  • Discussions & questionsstart a discussion

By contributing, you agree that your contributions will be licensed under the MIT License.


License

MIT © 2026 Aman Thakur