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

actly

v1.1.0

Published

A reliability primitive for executing async actions safely.

Downloads

486

Readme

actly

Reliability primitive for async functions.
Retry. Timeout. Dedupe. Cache. Composable, typed, zero-throw.

npm install actly

Requires Node 18+. Ships ESM + CJS. Zero dependencies.


The problem

Every async call can fail. Networks blip. Services time out. The same UI mounts three times and fires the same fetch in parallel. You need retry logic, but not for 4xx errors. You need timeouts, but not the kind that let retry loops run forever.

These patterns are solved the same way every time. actly solves them once.


Quick start

import { act } from 'actly'

const result = await act('user:42', () => fetchUser(42))

if (result.ok) {
  console.log(result.value)    // T
  console.log(result.source)   // 'fresh' | 'cache'
  console.log(result.attempts) // number
} else {
  console.error(result.error)  // unknown — never throws
}

act always resolves. It never rejects. You check .ok and move on.


API

act<T>(key: string, fn: () => Promise<T>, options?: ActOptions): Promise<ActResult<T>>

| Argument | Type | Description | |---|---|---| | key | string | Stable, unique identifier. Scopes dedupe and cache state. | | fn | () => Promise<T> | The async function to execute. | | options | ActOptions | All optional. See policies below. |

Result shape

type ActResult<T> =
  | { ok: true;  value: T; source: 'fresh' | 'cache'; attempts: number }
  | { ok: false; error: unknown;                       attempts: number }

Policies

All policies are optional and compose freely. The execution order is fixed:

totalTimeout → cache → dedupe → retry → timeout

Retry

const result = await act('payments:charge', () => chargeCard(payload), {
  retry: {
    attempts: 3,            // total attempts (including first)
    delayMs:  200,          // base delay between attempts (ms)
    backoff:  'exponential' // 'none' | 'linear' | 'exponential'
  },
})

result.attempts tells you how many tries it took.

Selective retry with shouldRetry

By default, every error triggers a retry. Use shouldRetry to skip retrying permanent errors — a 404 will never recover, a 503 might.

const result = await act('api:resource', () => fetchResource(id), {
  retry: {
    attempts: 4,
    delayMs:  150,
    backoff:  'exponential',
    shouldRetry: (err, attempt) => {
      if (err instanceof HttpError && err.status < 500) return false
      return true
    },
  },
})

shouldRetry(error, attempt) receives the error and the 1-based attempt number. Return false to surface the error immediately. Not called on the final attempt — that always surfaces.


Timeout

Per-attempt deadline. Each retry gets a fresh clock.

import { act, TimeoutError } from 'actly'

const result = await act('geo:lookup', () => lookupCoords(ip), {
  timeout: { ms: 3_000 },
})

if (!result.ok && result.error instanceof TimeoutError) {
  console.log(`timed out after ${result.error.ms}ms`)
}

Total timeout

Hard budget over the entire operation — all attempts, delays, and per-attempt timeouts combined. The per-attempt timeout resets on retry; totalTimeout does not.

import { act, TimeoutError, TotalTimeoutError } from 'actly'

const result = await act('search:query', () => runQuery(q), {
  retry:        { attempts: 5, delayMs: 100, backoff: 'linear' },
  timeout:      { ms: 2_000 },  // per attempt
  totalTimeout: { ms: 8_000 },  // whole operation
})

if (!result.ok) {
  if (result.error instanceof TotalTimeoutError) {
    console.log(`budget exhausted after ${result.error.ms}ms`)
  } else if (result.error instanceof TimeoutError) {
    console.log(`last attempt timed out after ${result.error.ms}ms`)
  }
}

TotalTimeoutError and TimeoutError are separate classes — instanceof distinguishes which deadline fired.


Dedupe

Concurrent calls with the same key collapse into one in-flight Promise. The first caller executes; the rest wait and receive the same result. After settlement, the next call starts fresh.

const result = await act('config:load', () => loadRemoteConfig(), {
  dedupe: true,
})

dedupe: { enabled: true } is also valid — object form for forward compatibility.

Note: Deduped callers (those that joined an in-flight Promise) always receive attempts: 1 in their result, because the retry counter belongs to the originating call. This is a known trade-off.


Cache

Stores successful results for ttl milliseconds. Failures are never cached. A cache hit short-circuits everything — dedupe, retry, and timeout are all skipped.

const result = await act('flags:all', () => fetchFeatureFlags(), {
  cache: { ttl: 60_000 },
})

console.log(result.source) // 'fresh' on miss, 'cache' on hit

Combining policies

const result = await act('product:detail', () => fetchProduct(id), {
  totalTimeout: { ms: 10_000 },  // outermost wall
  cache:        { ttl: 30_000 }, // short-circuits on hit
  dedupe:       true,            // collapses concurrent calls
  retry:        { attempts: 3, delayMs: 100, backoff: 'linear' },
  timeout:      { ms: 3_000 },   // per attempt
})

Options reference

interface ActOptions {
  retry?:        RetryOptions
  timeout?:      TimeoutOptions      // per-attempt
  totalTimeout?: TimeoutOptions      // entire operation
  dedupe?:       boolean | DedupeOptions
  cache?:        CacheOptions
}

interface RetryOptions {
  attempts:     number
  delayMs?:     number
  backoff?:     'none' | 'linear' | 'exponential'
  shouldRetry?: (error: unknown, attempt: number) => boolean
}

interface TimeoutOptions  { ms: number }
interface DedupeOptions   { enabled: boolean }
interface CacheOptions    { ttl: number }

Exported values

import {
  act,                       // primary function
  InMemoryStore,             // isolated store for SSR / tests
  TimeoutError,              // per-attempt deadline (.ms)
  TotalTimeoutError,         // total budget exhausted (.ms)
} from 'actly'

Exported types

import type {
  ActResult, ActSuccess, ActFailure, ActSource,
  ActOptions, ActFn,
  RetryOptions, TimeoutOptions, DedupeOptions, CacheOptions,
  // v1.1 — store interfaces
  SyncStateStore,
  AsyncStateStore,
  InMemoryStoreOptions,
  // v1.0 alias — still valid, zero migration needed
  StateStore,
} from 'actly'

Custom store

By default act uses a module-level InMemoryStore. For SSR request isolation or test control, instantiate your own:

import { InMemoryStore } from 'actly'

// Basic
const store = new InMemoryStore()

// With background cleanup (useful for long-lived server-side stores)
const store = new InMemoryStore({
  autoCleanup: true,
  cleanupIntervalMs: 60_000, // sweep every 60s (default: 30s)
})

// Always call destroy() when done to prevent timer leaks
store.destroy()

InMemoryStore satisfies SyncStateStore. Use it with execute() directly for full control.

Async store (v1.1+)

For external cache backends (Redis, Upstash, etc.), implement AsyncStateStore:

import type { AsyncStateStore } from 'actly'

class RedisStore implements AsyncStateStore {
  readonly _sync = false as const

  async get<T>(key: string): Promise<T | undefined> { /* ... */ }
  async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { /* ... */ }
  async delete(key: string): Promise<void> { /* ... */ }
  async has(key: string): Promise<boolean> { /* ... */ }
  async clear(): Promise<void> { /* ... */ }
  async size(): Promise<number> { /* ... */ }
}

AsyncStateStore is compatible with cache only. Using it with dedupe is a TypeScript error and a runtime error — dedupe requires synchronous store access. See SyncStateStore docs for the reason.


Store interfaces

interface SyncStateStore {
  readonly _sync: true
  get<T>(key: string): T | undefined
  set<T>(key: string, value: T, ttlMs?: number): void
  delete(key: string): void
  has(key: string): boolean
  clear(): void
  size(): number
}

interface AsyncStateStore {
  readonly _sync: false
  get<T>(key: string): Promise<T | undefined>
  set<T>(key: string, value: T, ttlMs?: number): Promise<void>
  delete(key: string): Promise<void>
  has(key: string): Promise<boolean>
  clear(): Promise<void>
  size(): Promise<number>
}

The _sync discriminant is read at runtime by the executor to enforce the dedupe constraint. Set it as a readonly literal — true as const or false as const.


Zero-throw contract

act() always resolves. Under any condition — network error, thrown exception, timeout, total budget exhaustion — the result is an ActFailure, not a rejected promise. This contract holds across all versions.


Philosophy

One function. One return type. No exceptions in userland.

The key is your responsibility. Make it stable and specific. user:42 is good. fetch is not.

Policies are composable but intentionally constrained. There is no builder API, no middleware system, no hooks. If you need something act doesn't do, write a wrapper around it — that's the right boundary.


Changelog

v1.1.0 — 2026-06-01

New features

  • AsyncStateStore interface — plug in any async key-value backend (Redis, Upstash, Cloudflare KV). Compatible with cache policy. Using with dedupe is a compile-time and runtime error by design.
  • SyncStateStore interface — the canonical public name for what was previously the internal store shape. StateStore is preserved as an alias with zero breakage for existing code.
  • InMemoryStore is now a public export — construct isolated stores for SSR request isolation, per-test control, or multi-tenant scenarios. Accepts InMemoryStoreOptions.
  • InMemoryStoreOptions — opt-in autoCleanup with configurable cleanupIntervalMs. Call destroy() to stop the background timer and prevent leaks.
  • TotalTimeoutError — new error class thrown when totalTimeout fires. Distinct from TimeoutError (per-attempt) so instanceof tells you which deadline fired.
  • totalTimeout option — hard wall-clock budget over the entire operation including all retry attempts and delays. Use alongside timeout to express both per-attempt and total constraints.
  • dedupe: true shorthand — equivalent to dedupe: { enabled: true }. Object form still valid for forward compatibility.
  • isSyncStore() / isAsyncStore() type guards — exported from actly for consumers building custom policy chains or store adapters.

Internal improvements

  • Executor validates sync-store requirement at chain-build time — async store + dedupePolicy throws immediately with a clear message instead of producing silent correctness failures.
  • dedupePolicy tagged with REQUIRES_SYNC_STORE symbol — allows executor to detect the constraint without importing the policy module (avoids circular deps).
  • cachePolicy now branches sync/async paths — fast synchronous path for InMemoryStore, async-await path for external stores.

Non-breaking changes

  • StateStore preserved as type alias for SyncStateStore — all v1.0 code compiles without changes.
  • All new exports are purely additive.

v1.0.1

  • Initial stable release.
  • act(), retry, timeout, totalTimeout, dedupe, cache.
  • TimeoutError exported for instanceof checks.
  • Zero dependencies. ESM + CJS. Node 18+.

License

MIT