@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.
1means 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 failingremoveis ignored. - A payload newer than the code skips hydration and does not overwrite storage.
- With no
storageargument and nolocalStorage, the function returns a no-op.defaultStorage()returns thatlocalStorageadapter, ornullwhen it is missing. stop()stops writes. The default storage islocalStorage.partializechooses the saved slice.equalityFndefaults todeepEqualand skips a write when the slice is unchanged.serializeanddeserializedefault toJSON.stringifyandJSON.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.
