@olvnck/helper
v2.0.2
Published
A simple helper library with common stuff
Readme
@olvnck/helper
A simple TypeScript helper library with common stuff: array/object/string/number/time utilities, an id-based promise resolver, a prioritized task queue, coalescing async queues, an event bus, a color class, plus browser-only download helpers and Vue composables.
- ESM-only — the package ships
.mjs+.d.mtsonly; there is no CommonJS build (require()will not work). This is the breaking change behind the2.xmajor. - Modern baseline — Node.js ≥ 24 (enforced via
engines) and evergreen browsers. The library deliberately contains no helpers that ES2024/ES2025 already provides natively (Object.fromEntries,Array.prototype.toSorted,Set.prototype.difference,Number.isInteger, …). - Fully typed — declarations are bundled, no
@typespackage needed. - Three entry points — core helpers, browser-only helpers, and Vue composables are separate bundles so you only import what your environment supports.
| Entry point | Import path | Environment |
| --- | --- | --- |
| Core | @olvnck/helper | Node.js ≥ 24, Bun, browsers, workers |
| Browser | @olvnck/helper/browser | Browsers only (needs document/window) |
| Vue | @olvnck/helper/vue | Vue 3 apps (requires the vue peer dependency) |
Installation
npm install @olvnck/helper
# or
bun add @olvnck/helperIf you want to use the Vue composables, your app must have vue >= 3 installed — it is an optional peer dependency and is never bundled into this package.
Usage from Node.js
Everything in the core entry is environment-agnostic and works in Node.js, Bun, and browsers. Import named helpers directly from the package root:
import { addDays, dateToString, formatBytes, sleep } from '@olvnck/helper';
// time helpers — add*/sub* return a NEW Date, the input is never mutated
console.log(dateToString(addDays(new Date(), 7))); // "2026-06-19 14:05:33"
console.log(formatBytes(1536)); // "1.50 KiB"
await sleep(250);Formatting notes: formatBytes uses binary (1024-based) units with KiB/MiB-style labels; formatBitsPerSecond takes bytes as input and outputs decimal (1000-based) bit-rate units (kbit/s, Mbit/s, …) per networking convention. ltrim/rtrim truncate long strings with a ... ellipsis — they are not whitespace trimmers (use native trimStart/trimEnd for that).
Error conventions
All async helpers reject with Error objects (never bare strings). The messages to match on: 'timeout' (resolver timeout), 'canceled' (resolver clear()), 'limit reached' (runLimited over limit), 'cancelled' (task queue clear()), 'queue limit reached' (task queue per-id limit).
Resolver — resolve promises by id
useCustomResolver creates promises you can settle later from somewhere else, matched by an auto-generated id. Typical use: request/response over a WebSocket, where the reply arrives in a different handler than the request was sent from.
import { useCustomResolver } from '@olvnck/helper';
const resolver = useCustomResolver();
// somewhere: send a request, keep the promise
const reply = resolver.run(
(id) => {
socket.send(JSON.stringify({ id, type: 'getStatus' }));
},
{ timeout: 5000 }, // optional: rejects with Error('timeout') after 5s
);
// elsewhere: the response handler settles it by id
socket.on('message', (msg) => {
const { id, data } = JSON.parse(msg);
resolver.resolve(id, data);
});
const status = await reply;Ids default to an incrementing number; pass your own generator to control them: useCustomResolver<string>((prev) => crypto.randomUUID()). Other methods: runLimited(fn, type, maxLimit, opts) rejects immediately when too many promises of the same type are already pending, reject(id, err) settles with an error, has(id) / count() inspect pending promises, and clear() cancels everything (rejecting with Error('canceled') unless you pass false) and resets all runLimited counters.
Task queue — run async tasks one at a time, by priority
A lower prio value means higher priority. The queue starts processing immediately by default — add() kicks off the first task synchronously, so priority only reorders tasks that are still waiting. To batch tasks up front and let priority decide the full order, create the queue paused:
import { useTaskQueue } from '@olvnck/helper';
const queue = useTaskQueue<string>(false); // false = start paused
const a = queue.add({ id: 'thumbnail', prio: 2, fn: () => renderThumbnail() });
const b = queue.add({ id: 'export', prio: 1, fn: () => exportVideo() });
queue.resume();
await Promise.all([a, b]); // tasks ran sequentially, 'export' first
queue.pause(); // stop processing (resume() to continue)
queue.clear(); // rejects everything still queued with Error('cancelled')add(task, limit) also accepts a per-id limit: if limit tasks with the same id are already queued, the returned promise rejects with Error('queue limit reached'). A task whose fn rejects passes its error through to the caller — the queue itself keeps running. Introspection: has() (anything queued), length(), print() (debug dump).
Coalescing queue — run the latest value, skip the rest
Where useTaskQueue runs every task you add, a coalescing queue runs one async operation at a time and keeps only the most recent value enqueued while that operation is in flight — intermediate values are discarded. It is the right tool for "persist the newest state", "re-fetch with the latest params", or "re-render from the freshest input", where running on a stale intermediate value would be wasted work.
import { CoalescingQueue } from '@olvnck/helper';
// only the most recent document state is ever saved; states that arrive
// while a save is in flight collapse into a single follow-up save
const saver = new CoalescingQueue<DocState>(async (state) => {
await api.save(state);
});
editor.on('change', (state) => saver.enqueue(state)); // fire-and-forgetCoalescingKeyedQueue<K, V> is the same idea but with an independent in-flight slot per key — latest-wins within each key, and the key's internal state is dropped once it goes idle, so it is safe to use with an unbounded/growing key set:
import { CoalescingKeyedQueue } from '@olvnck/helper';
// one in-flight save per document id; the newest state for each id wins
const saver = new CoalescingKeyedQueue<string, DocState>(async (id, state) => {
await api.save(id, state);
});
saver.enqueue(docId, state);enqueue returns nothing and never rejects — the runner is expected to own its errors. A runner that rejects surfaces as an unhandled rejection rather than failing a specific caller, so wrap fallible work in try/catch inside the runner.
Event bus — decoupled pub/sub by channel id
import { useEventBus } from '@olvnck/helper';
type AppEvent = 'login' | 'logout';
const bus = useEventBus<AppEvent, { user: string }>('app');
const off = bus.on((event, data) => console.log(event, data.user));
bus.emit('login', { user: 'olivier' });
off(); // unsubscribe this listener
bus.reset(); // drop ALL listeners on this busBuses are singletons per id — calling useEventBus('app') anywhere in the process returns the same bus.
Event handler — collect cleanup functions
useEventHandler is a registry of teardown callbacks (socket closers, unsubscribers, …). Note that remove(fn) and clear() invoke each function before dropping it — it is a disposer, not a passive list:
import { useEventHandler } from '@olvnck/helper';
const cleanup = useEventHandler(); // or useEventHandler('shared-id') for a per-id singleton
cleanup.add(bus.on(onEvent), () => socket.close());
cleanup.clear(); // runs every registered fn, then empties the registryUsage in the browser
The core entry (everything above) works in the browser as-is. The /browser entry adds helpers that require the DOM, mainly for triggering file downloads from in-memory data:
import { downloadAsBinary, downloadAsJson, downloadAsText, downloadVideoSnapshot } from '@olvnck/helper/browser';
// offer an object as a .json file download
downloadAsJson('report.json', { generated: Date.now(), items });
// plain text and binary variants
downloadAsText('notes.txt', 'hello');
downloadAsBinary('data.bin', [byteArray]);
// grab a frame from a <video> element as an image blob URL,
// optionally downloading it as a file at the same time.
// resolves to null if the frame could not be captured (canvas.toBlob returned null).
const blobUrl = await downloadVideoSnapshot(videoEl, { width: 1280, height: 720 }, { type: 'image/jpeg', quality: 0.8, downloadTo: 'snapshot.jpg' });
if (blobUrl) URL.revokeObjectURL(blobUrl); // the returned URL is yours to revoke when doneImport these only in code that runs in a browser — they touch document and window at call time, so using them under Node.js throws.
| Export | Purpose |
| --- | --- |
| downloadAsJson(file, obj) | download any object as pretty-printed JSON |
| downloadAsText(file, str) | download a string as a text file |
| downloadAsBinary(file, parts) | download BlobPart[] as a binary file |
| downloadVideoSnapshot(video, res, opts?) | canvas-grab a video frame; resolves to an object URL, or null if capture fails (caller revokes) |
| Resolution, SnapShotOpts | option types for the snapshot helper |
Usage with Vue
The /vue entry contains Vue 3 composables. vue is a peer dependency: it is resolved from your app and never bundled, so there is no risk of duplicate Vue instances.
delayedRef — a ref that applies certain values only after a delay
Each entry in the config maps a value to the delay (in ms) it must "survive" before the ref actually changes. The classic use case is a loading spinner that should only appear if loading takes noticeably long, but must disappear immediately:
<script setup lang="ts">
import { delayedRef } from '@olvnck/helper/vue';
// `true` is applied after 300ms, `false` immediately.
// If loading finishes within 300ms, the spinner never flashes.
const showSpinner = delayedRef(false, [
{ v: true, delay: 300 },
{ v: false, delay: 0 },
]);
async function load() {
showSpinner.value = true;
try {
await fetchData();
} finally {
showSpinner.value = false;
}
}
</script>
<template>
<Spinner v-if="showSpinner" />
</template>Setting a value that is already pending is a no-op, and setting a new value cancels the previous pending one. The options argument supports { ignoreInitial: true } to apply the very first write immediately regardless of its configured delay. Exported types: DelayedRef<T> (config entry), DelayedRefOptions.
useEventHandlerVue — scope-aware cleanup registry
The same disposer registry as the core useEventHandler ({ add, remove, clear }, returning EventHandlerReturn), but it runs clear() automatically when the current Vue effect scope is disposed — so you never write onUnmounted(() => cleanup.clear()):
<script setup lang="ts">
import { useEventHandlerVue } from '@olvnck/helper/vue';
const cleanup = useEventHandlerVue();
cleanup.add(bus.on(onEvent), () => socket.close());
// every registered fn runs on unmount — no onUnmounted boilerplate
</script>It wraps an anonymous (non-shared) handler and registers teardown via Vue's native onScopeDispose, so it adds no VueUse dependency. Called outside an active effect scope it is a no-op registry — you would call clear() yourself, exactly like the core helper.
API overview (core entry)
| Module | Exports |
| --- | --- |
| Array | getId, arrayToMap (composite-key Map builder) |
| Object | compareObj, compareObjFn (array diffing by unique keys → added/updated/unchanged/removed) |
| String | formatBytes, formatBitsPerSecond, randomStr (crypto-based), ltrim, rtrim (ellipsis truncation) |
| Number | gcd, getReadableAspect, minMax (clamp) |
| Primitives | isNumeric |
| Time | convertDateType, parseDate, parseDateToString, dateToString (+ dateToStringFile/dateToStringWithMs/dateToStringWithMsFile), addDays/subDays (+ months/years/hours/minutes/seconds), getDayOfYear, getDaysDiff (to − from), durationToString, formatDuration (colon-separated dd:hh:mm:ss, configurable depth/units/ms), sleep |
| Functions | useTimeoutFn, customDebounceFn (promise-returning debounce), runOnChanged |
| Async | useCustomResolver, useTaskQueue, CoalescingQueue, CoalescingKeyedQueue |
| Events | useEventBus, useEventHandler |
| Enums | enumHelper (numeric enums, including flag/pow2 enums) |
| Colors | Colors (named-color lookup, hex/rgb/hsl/hwb conversions, lighten/darken/mix/rotate/…) |
| Misc | createSingleton, utility types (MyFn, MyAwaitableFn, MyMutable, MyNullable, MyKeyValuePair) |
Number/date inputs accepted by all time helpers (DateType): a Date, a parseable string, or a unix timestamp (seconds or milliseconds — values below 10^10 are treated as seconds).
Exported types
Beyond the runtime helpers, the core entry also exports these supporting types so you can annotate variables that hold helper options or results:
| Type | Used with | Shape |
| --- | --- | --- |
| DateType | every time helper (input) | Date \| string \| number |
| FormatTime | parseDate (return) | numeric { year, month, day, hour, min, sec, ms, dow } |
| FormatTimeString | parseDateToString (return) | same fields zero-padded to strings (dow stays numeric) |
| FormatDuration | formatDuration (options, pass as Partial) | { ms: boolean, digits: number, units: boolean } — digits 1–5 picks how many units to show, units appends s/min/h/d, ms appends .mmm |
| ResolverOptions<T> | useCustomResolver run/runLimited | { timeout, rejectOnTimeout?, onTimeout? } |
| IdGenerator<T> | resolver custom id generator | (prev: T \| null) => T |
| Task<T> | queue.add (input) | { id: string, prio: number, fn: () => T \| Promise<T> } |
| UseTaskQueue | useTaskQueue (return) | the queue handle (add, pause, resume, clear, has, length, print) |
| EventBusReturn<T, K> | useEventBus (return) | { on, emit, reset } |
| EventHandlerReturn | useEventHandler (return) | { add, remove, clear } |
| EnumKeyVal | enumHelper entries | { idx, v, isCombi, isPow2 } |
| EnumHelperType | enumHelper (return) | { keyValues, toPrint, toArray, toNumber } |
| TimeOutFnOptions | useTimeoutFn options | { immediate? } |
| CustomDebounceFnArgs | customDebounceFn input fn | (...args) => Return |
| SingletonFactory<T> | createSingleton (return) | (...args) => T |
The general-purpose utility types MyFn, MyAwaitableFn, MyMutable, MyNullable, and MyKeyValuePair (see the Misc row above) are exported too.
Development
Bun is the package manager and script runner.
bun install # install dependencies
bun run build # bundle all three entries to ESM + .d.mts in dist/ (vp pack → tsdown)
bun run dev # same build in watch mode
bun run typecheck # tsc --noEmit
bun run check # Biome format + lint + import organization (add --write to fix)
bun run test # manual smoke script (test/src/index.ts), not an assertion suite
bun run pub:dry # preview the npm tarball
bun run pub # publish to npmjs.orgSource lives in lib/: shared helpers in lib/lib/, browser-only code in lib/browser/, Vue composables in lib/vue/. To add a helper, create lib/lib/<name>.ts and re-export it from the matching entry file (lib/index.ts, lib/browser.ts, or lib/vue.ts).
