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

@tduylib/core

v0.0.8

Published

Shared primitives for one ecosystem: state, cache, forms, async, events, and a chunked request protocol. Each util is small, called directly, and composable with the others. The package has no framework dependency. The public API is `src/index.ts`.

Readme

@tduylib/core

Shared primitives for one ecosystem: state, cache, forms, async, events, and a chunked request protocol. Each util is small, called directly, and composable with the others. The package has no framework dependency. The public API is src/index.ts.

The goal is one place for work that repeats across client, server, and UI: holding state, caching requests, forms, concurrency limits, retries, waiting on a condition, emitting events, and sending a request that waits for its response. The same call style, and each API is small enough to use on its own.

import { Store, persist, CacheApi, FormStore, retry, Emitter, RequestClient } from "@tduylib/core";

| Area | Main APIs | | --- | --- | | State | Store, persist | | Cache | Cache, CacheClient, CacheApi | | Form | FormStore, zodValidator | | Concurrency | Semaphore, Mutex, RateLimiter, TaskPool | | Waiter | WaiterClient, WaiterApi | | Async | delay, retry, withTimeout, waitUntil, once, waitFor, deferred | | Emitter | Emitter, EmitterApi | | Protocol | RequestClient, ResponseClient | | Equality | shallowEqual, deepEqual |

Run tests with pnpm -F @tduylib/core test.

Store

set replaces the whole state. update merges a patch. Both accept a value or a function. subscribe returns an unsubscribe function.

const store = new Store({ count: 0 });

const stop = store.subscribe(() => {
  console.log(store.get().count);
});

store.update({ count: 1 });
store.set((state) => ({ count: state.count + 1 }));

Unchanged state does not notify. set compares with Object.is. update compares one level of keys with shallowEqual. Every current listener runs. One failing listener throws that error. Several failing listeners throw an AggregateError.

persist

Attaches storage to a Store. Hydration finishes before subscribe, so the first read does not write back.

const stop = persist(store, {
  name: "counter",
  version: 1,
  partialize: (state) => ({ count: state.count }),
  migrations: {
    1: (state) => ({ count: (state as { n: number }).n }),
  },
});
  • A migration key is the destination version. 1 means from 0 to 1.
  • Broken JSON, a non-finite version, or the wrong shape removes the key. In-memory state stays as it is. A failing remove is ignored.
  • A payload newer than the code skips hydration and does not overwrite storage.
  • With no storage argument and no localStorage, the function returns a no-op. defaultStorage() returns that localStorage adapter, or null when it is missing.
  • stop() stops writes. The default storage is localStorage.
  • partialize chooses the saved slice. equalityFn defaults to deepEqual and skips a write when the slice is unchanged. serialize and deserialize default to JSON.stringify and JSON.parse.

Cache

Cache is one entry. CacheClient keeps entries by key. CacheApi is one shared CacheClient. configure replaces that client, so call it when the app starts.

const cache = new CacheClient({ staleTime: 5_000 });

const user = await cache.run("user:1", () => fetchUser("1"));

cache.get<User>("user:1");
cache.invalidate("user:1");
cache.remove("user:1");
CacheApi.configure({ staleTime: 5_000 });

await CacheApi.run("user:1", () => fetchUser("1"));
await CacheApi.sgf("checkout", () => submitOrder());

A snapshot has key, status (pending | success | error), fetchStatus (idle | fetching), data, hasData, error, updatedAt, staleTime, and isInvalidated.

get returns data only while isFresh() is true. staleTime of 0 only dedupes in-flight requests. staleTime greater than 0 is fresh while age < staleTime. A failed first request leaves the entry without data. A failed refetch keeps the previous data, sets status to error, and the next run fetches again because the entry is not fresh.

run notifies once when a request starts (fetchStatus: "fetching") and once when it settles. The settled snapshot includes the new status and fetchStatus: "idle" together.

run accepts { func, staleTime }, or a function plus an optional staleTime: run(key, func, staleTime). An existing key keeps its staleTime. run replaces the function used on the next fetch. getOrCreate returns the existing entry and does not replace its function.

| API | Methods | | --- | --- | | Cache | getSnapshot, subscribe, updateFunc, isFresh, run, setData, invalidate, reset | | CacheClient | get, getCache, getOrCreate, run, setData, invalidate, reset, remove, clear, has | | CacheApi | configure, get, run, setData, invalidate, reset, remove, clear, has, sgf |

setData, invalidate, and reset throw when the key is missing. remove deletes the entry. reset clears that entry and keeps the key. invalidate keeps the data and marks it stale. setData writes data and ignores a later write from a request that was already in flight. The original caller still receives that request's result.

sgf dedupes the in-flight request, then removes the entry when it settles, including when the task fails.

onListenerError receives listener errors. A listener error does not change the request result. subscribe returns an unsubscribe function.

Form

FormStore holds values, errors, touched, and submit state. A path is a dot string to a leaf field, including nested objects. validationMode is onChange, onBlur, or onSubmit. The default is onSubmit.

const form = new FormStore({
  initialValues: { email: "", profile: { name: "" } },
  validationMode: "onBlur",
});

form.setValue("profile.name", "An");
form.registerFieldValidator("email", (value) =>
  value.includes("@") ? undefined : "Invalid email",
);

const ok = await form.handleSubmit(async (values) => {
  await save(values);
});

zodValidator turns a Zod schema into a validator for the whole form. It keeps the first issue on each path. An issue with an empty path is skipped.

form.setValidator(zodValidator(schema));

| Method | What it does | | --- | --- | | getState, getValues, getValue, getFieldState, getFieldRevision | Read the form or one path. getFieldState returns value, error, touched, and isDirty. | | subscribe, subscribeField | Listen to every change, or to one path. Both return an unsubscribe function. | | setValue, setValues | Write one path or replace all values. The same value is ignored. Nested writes copy the path and leave the previous object in place. | | setFieldTouched, setFieldError | Set touched or one error. The same error is ignored. undefined removes the error. | | setValidator, setValidationMode, registerFieldValidator | Replace the form validator, change the mode, or register one field. registerFieldValidator returns an unregister function. A later registration wins. | | validateField, validate | Run validators. A newer validate discards an older run. A field validator overrides the form error for that path. A passing field validator keeps the form error when a form validator exists. | | handleSubmit | Validates, then calls the handler. Returns false when invalid or when a newer submit has started. A thrown handler rejects and clears isSubmitting. | | reset | Restores values and clears errors, touched, and submit state. A new argument becomes the next initial value. |

onSubmit does not validate on change or blur. onChange validates the edited field, or the whole form when a form validator is set. onBlur does the same when the field is marked touched. setValues in onChange mode validates registered fields whose values changed, unless a form validator is set.

Concurrency

Semaphore limits how many tasks run at once. Mutex is a semaphore with one slot. run releases the permit when the task settles. acquire returns a release function. Calling release more than once releases only once. Semaphore exposes activeCount and pendingCount. Mutex exposes isLocked and pendingCount. RateLimiter exposes activeCount, pendingCount, and size. TaskPool exposes concurrency, activeCount, pendingCount, size, and isPaused.

const gate = new Semaphore(3);
await gate.run(async () => fetchPage());

const lock = new Mutex();
await lock.run(async () => writeFile());

RateLimiter limits how many tasks may start within an interval.

const limiter = new RateLimiter({ limit: 10, interval: 1_000 });
await limiter.run(async () => callApi());

TaskPool queues work and runs it at a fixed concurrency. pause holds new tasks. resume continues them. clear drops queued tasks and rejects them with TaskPoolClearedError. onIdle resolves when the queue and the running work are both done.

const pool = new TaskPool({ concurrency: 4 });

await pool.add(() => work());
await pool.onIdle();

Semaphore, Mutex, RateLimiter, and TaskPool accept an AbortSignal. An aborted signal removes the caller that is still waiting in the queue.

Waiter

WaiterClient connects one wait to one result inside the same process. Each key has one waiter. WaiterApi is one shared WaiterClient. Call configure when the app starts. It replaces that client, so a wait already started stays on the previous client and is no longer reachable through WaiterApi.

const waiter = new WaiterClient();
const pending = waiter.wait<User>("user:1");
waiter.resolve("user:1", user);

await WaiterApi.request<User>("user:1", () => askForUser("1"));

wait defaults to a 30 second timeout. A blank key throws. A timeout that is not a positive finite number throws. resolve and reject return false when nobody is waiting on that key. request waits, runs send, and rejects that wait when send throws. A second wait on a key that already has a waiter throws. After the wait settles, the same key can be used again. Two WaiterClient instances do not share keys. Neither client takes an AbortSignal.

Async

await delay(200, { signal });

await retry(() => fetchUser(id), {
  retries: 3,
  delayTime: 500,
  strategy: "exponential",
  shouldRetry: (error) => error instanceof NetworkError,
  signal,
});

await withTimeout((signal) => fetchUser(id, { signal }), { timeout: 2_000 });

await waitUntil(() => ready, { timeout: 5_000, interval: 100 });

await once(emitter, "open", { timeout: 5_000 });
await waitFor(emitter, "tick", () => connected, { timeout: 5_000 });

const pending = deferred<User>();
pending.resolve(user);

retry defaults to 3 retries, a 1000ms delay, and the fixed strategy. retry(fn, retries, delayTime) is the same call with positional arguments. The total number of calls is retries + 1. shouldRetry(error, attempt) is asked only while retries remain. attempt starts at 0. backoffFactor defaults to 2. maxDelayTime defaults to 30 seconds. getDelay replaces the strategy delay when it is set.

waitFor and waitUntil default to a 5000ms timeout. once waits for exactly one event. deferred creates a promise that can be resolved from outside. resolve and reject after the promise has settled are ignored.

delay, retry, withTimeout, waitUntil, once, and waitFor all accept an AbortSignal. The async helper once waits for one event on an emitter-like object. Emitter.once registers a one-shot listener on this package's event bus.

Emitter

Emitter is one event bus. EmitterApi is one shared Emitter. Call configure when the app starts. It replaces that emitter, so listeners already registered stay on the previous emitter and are no longer reachable through EmitterApi.

type Events = {
  message: [text: string];
  ping: [];
};

const bus = new Emitter<Events>();

const stop = bus.on("message", (text) => {
  console.log(text);
});

bus.emit("message", "hello");
bus.once("ping", () => {});
stop();
EmitterApi.configure();
const stop = EmitterApi.on<Events, "message">("message", (text) => {
  console.log(text);
});

EmitterApi.emit<Events, "message">("message", "hello");
stop();

An event name is a string or a symbol. Listeners for one event run in registration order. Two Emitter instances do not share listeners.

| API | Methods | | --- | --- | | Emitter | on, once, off, emit, removeAllListeners, listenerCount, eventNames | | EmitterApi | configure, on, once, off, emit, removeAllListeners, listenerCount, eventNames |

on and once return an unsubscribe function. Calling it again does nothing. off removes every registration of that function for the event. On Emitter, off and removeAllListeners return the emitter. On EmitterApi, both return void. emit returns false when the event has no listeners. A listener added during emit waits until the next emit. A listener removed by an earlier listener in the same emit is skipped. once is removed before it runs, including when it throws. A thrown listener stops the rest of that emit. Those later listeners stay subscribed. removeAllListeners() clears every event. removeAllListeners(event) clears one event. The emit already in progress still finishes the snapshot it took. eventNames follows registration order.

Protocol

RequestClient sends one request and waits for the matching response. ResponseClient runs the handler for that method. Messages are a string or a Uint8Array. Serialization defaults to JSON. A payload larger than chunkSize is split. String chunks are counted in UTF-16 code units and keep a surrogate pair in one chunk. maxMessageSize counts UTF-8 bytes.

const requests = new RequestClient({
  request: {
    send: async (data) => channel.send(data),
  },
  response: {
    register: async () => channel,
  },
});

const responses = new ResponseClient({
  request: {
    register: async () => channel,
  },
  response: {
    send: async (data) => channel.send(data),
  },
});

responses.register("user.get", (payload) => loadUser(payload));
await responses.ready();

const user = await requests.request<User>("user.get", { id: "1" });

The register callback returns { on, onClose? }. on receives each incoming message. onClose runs when that connection closes. A missing listener, or a listener without on, rejects the call that needed it.

| | Default | | --- | --- | | chunkSize | 1024 | | timeout (RequestClient) | 30 seconds | | maxMessageSize | 8 MiB | | maxAssemblies | 128 | | assemblyTimeout | 30 seconds | | maxConcurrentHandlers | 64 | | handlerTimeout | 30 seconds |

request(method, payload, options) uses options.timeout, then defaultOptions.timeout, then 30 seconds. A timeout that is not a finite number of 0 or more throws. chunkSize, and every limit, must be a positive safe integer.

A request message is { header: { id, method, chunk? }, body }. chunk is { total, index }. A response message is { header: { id, method, ok, chunk? }, body }. The response is applied only when id and method match a pending request. A method mismatch rejects that request. ok: false carries { code, message }, is not chunked, and rejects immediately. code is a non-empty string or a finite number. Duplicate chunks and a total that changes reject the request. A payload or serialized message over maxMessageSize fails before it is sent. An assembled response over that limit rejects the request.

ResponseClient.register(method, handler) replaces the handler for that method and returns an unregister function. The unregister function removes the handler only when it is still the one that was registered. ready attaches the request listener. close aborts in-flight handlers. A handler is (payload, header, context) => result. context.signal aborts when the handler times out. A handler that is still running after that timeout keeps its concurrency slot until it settles. These answers use ok: false:

| code | When | | --- | --- | | METHOD_NOT_FOUND | no handler for the method | | HANDLER_ERROR | the handler throws | | HANDLER_TIMEOUT | the handler exceeds handlerTimeout | | SERVER_BUSY | running handlers are already at maxConcurrentHandlers | | INVALID_PAYLOAD | the payload does not deserialize | | INVALID_CHUNK | the chunk is inconsistent | | MESSAGE_TOO_LARGE | the message exceeds maxMessageSize | | SERIALIZATION_ERROR | the handler result does not serialize |

ResponseErrorCode exports those string values. When the response listener closes, RequestClient rejects pending requests, and the next request registers a listener again. ResponseClient.close() aborts handlers that are still running. The next ready() registers a listener again.

Equality

shallowEqual compares one level of keys with Object.is. Date, Map, Set, and RegExp with different references are not equal.

deepEqual compares values, including nested objects, cycles, and shared references. Map compares keys with SameValueZero. Set ignores order.