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

@dirstack/utils

v3.1.0

Published

A lightweight set of utilities

Downloads

7,936

Readme

@dirstack/utils

A lightweight, dependency-light set of TypeScript utilities shared across projects. Tree-shakeable, ESM-only, and fully typed.

Install

bun add @dirstack/utils
# or: npm install @dirstack/utils

Requires Node.js 22.12 or later. The package ships ES modules only, and CommonJS code can require() it on these versions.

Usage

Everything is exported from the package root:

import { chunk, clamp, formatBytes, slugify, tryCatch } from "@dirstack/utils"

chunk([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
clamp(12, 0, 10) // 10
formatBytes(1536, 1) // "1.5 KB"
slugify("Hello World") // "hello-world"

const { data, error } = await tryCatch(fetch("/api"))

Each module is also available on its own path, such as @dirstack/utils/array or @dirstack/utils/time. Bundlers remove unused code from the root import either way, because the build keeps one file per module.

What's inside

| Module | Highlights | | --- | --- | | array | uniq, uniqBy, chunk, groupBy, keyBy, countBy, compact, sortBy, sum, sumBy, range | | batch | processBatch, processBatchSettled (ordered, concurrency-limited, with onProgress) | | errors | getErrorMessage, toError | | files | formatBytes, isMimeTypeMatch, toBase64, toDataUrl | | format | formatNumber, formatCurrency, formatIntervalAmount, currencyExponent, minorUnits | | helpers | sleep, isTruthy, tryCatch, retry, withTimeout | | http | isValidUrl, addProtocol, removeProtocol, getDomain, isExternalUrl, joinUrlPaths, setQueryParams, removeQueryParams, isValidImageSrc, isHostname, normalizeHostname, stripWildcard, isWithinDomain, isIp, isIpInRange, isCloudflareIp, getClientIp, CLOUDFLARE_IP_RANGES | | numbers | clamp, preciseRound | | objects | pick, omit, isEmptyObject, isKeyInObject | | parsers | serialize | | random | getRandomString, getRandomDigits, getRandomNumber, getRandomElement | | string | ucFirst, lcFirst, truncate, slugify, getInitials, joinAsSentence | | time | formatDate, formatTime, formatDateTime, formatDateRange, getReadTime, dayKey, shiftDayKey, dayIn, fromUnix, toUnix, SECOND_MS, MINUTE_MS, HOUR_MS, DAY_MS, MINUTE_SECONDS, HOUR_SECONDS, DAY_SECONDS | | ui | getBalancedColumns, isLightColor, getCurrentPage, getPagination, getPageLink | | types | WithOptional, WithRequired, plus the types in helper signatures, such as Result (from tryCatch), Timestamp and ProcessBatchOptions |

Dates and time zones

The date helpers accept any Intl.DateTimeFormat option plus a locale (defaults to "en-US"). Without a timeZone, they format in the time zone of the machine that runs them. On server-rendered pages, the server (often UTC) and the browser (the visitor's zone) can then produce different text, which causes React hydration errors. Pass a fixed timeZone to get the same output in both places:

import { formatDate, formatDateRange } from "@dirstack/utils"

formatDate("2026-10-05T23:30:00Z", { dateStyle: "long", timeZone: "UTC", locale: "en-GB" }) // "5 October 2026"
formatDate("2026-10-05", { day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" }) // "Oct 05, 2026"
formatDateRange("2026-10-05", "2026-10-09", { timeZone: "UTC" }) // "Oct 5 – 9, 2026"

Date-only strings such as "2026-10-05" parse as UTC midnight, so format them with timeZone: "UTC" to keep the same calendar day everywhere.

Client IP behind a proxy

getClientIp reads the visitor's address from request headers set by a reverse proxy such as Traefik. The first x-forwarded-for entry comes from the client and can be forged, so it returns the last entry, which the proxy appends as the TCP peer. It falls back to x-real-ip, and returns undefined when no header holds a valid IP address:

import { getClientIp } from "@dirstack/utils"

getClientIp(request.headers) ?? "127.0.0.1"

Behind proxied Cloudflare records, the peer is a Cloudflare server. Pass trust: "cloudflare" to return cf-connecting-ip instead. The header is read only when the peer is in CLOUDFLARE_IP_RANGES, because a request sent straight to the origin can set it to anything:

getClientIp(request.headers, { trust: "cloudflare" })

The IP helpers parse IPv4 and IPv6 in plain TypeScript, without node:net, so they also run in browsers and edge runtimes.

Migrating from v2

v3 leaves one way to call each helper. Each removed alias or call form below has a direct replacement.

Package format

The CommonJS/UMD build (dist/index.umd.cjs) is gone. The package is ESM-only and needs Node.js 22.12 or later.

// Before: CommonJS on any Node.js version
const { slugify } = require("@dirstack/utils")
// After: an import, or require() on Node.js 22.12+
import { slugify } from "@dirstack/utils"

Removed aliases

// Before                                    // After
splitArrayIntoChunks(items, 10)              chunk(items, 10)
keepNumberInRange(value, 0, 100)             clamp(value, 0, 100)
pickFromObject(user, ["id", "name"])         pick(user, ["id", "name"])

Date formatters take an options object only

// Before                                                   // After
formatDate(date, "long", "en-GB")                           formatDate(date, { dateStyle: "long", locale: "en-GB" })
formatTime(date, "short", "pl")                             formatTime(date, { timeStyle: "short", locale: "pl" })
formatDateTime(date, "long", "short", "de")                 formatDateTime(date, { dateStyle: "long", timeStyle: "short", locale: "de" })
formatDateRange(start, end, "short", "es")                  formatDateRange(start, end, { dateStyle: "short", locale: "es" })

sortBy takes an options object

// Before                                  // After
sortBy(posts, post => post.date, "desc")   sortBy(posts, post => post.date, { order: "desc" })

Removed helpers

No project used these, so v3 drops them. Each has a short native or library replacement.

| Removed | Use instead | | --- | --- | | getElementPosition | element.getBoundingClientRect() or element.scrollIntoView() | | setInputValue | Copy the five lines into the project that needs them | | subscribe, unsubscribe, publish | document.addEventListener, removeEventListener, dispatchEvent(new CustomEvent(name, { detail })) | | publishEscape | document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) | | isErrorWithMessage, toErrorWithMessage, ErrorWithMessage | getErrorMessage(error) or toError(error) | | formatMimeType | mimeType.split("/")[1]?.toUpperCase() | | formatToDecimals | formatNumber(value, { maximumFractionDigits: 2, useGrouping: false }) | | debounce, throttle | useDebouncedCallback and useThrottledCallback from @mantine/hooks | | getBaseUrl | new URL(url).origin | | getQueryParams | Object.fromEntries(new URL(url).searchParams) | | checkUrlAvailability | fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) }) | | normalizeUrl, isLocalhostUrl | Still used inside the package, no longer exported | | parseNumericValue | Number.parseFloat(value) with a Number.isNaN check, or z.coerce.number() | | sortObject | Object.fromEntries(Object.entries(source).sort(([a], [b]) => a.localeCompare(b))) | | sortObjectKeys | A comparator in the project that needs it | | nullsToUndefined, ReplaceNullWithUndefined | Map the fields that need it | | maybeParseJson, maybeStringifyJson, deserialize | JSON.parse and JSON.stringify | | getRandomColor | Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, "0") | | getRandomProperty | getRandomElement(Object.values(source)) | | isCuid | isCuid from @paralleldrive/cuid2 | | formatDateOrTime | formatDate, formatTime or formatDateTime | | getShortcutLabel | A <Kbd> component that knows the platform's modifier key | | DeepIdx, DeepIndex, ValidatePath | Path and PathValue from react-hook-form, or Get and Paths from type-fest | | NestedPartial, NestedRequired | PartialDeep and RequiredDeep from type-fest |

processBatchWithErrorHandling is now processBatchSettled

Like Promise.allSettled, each result now says whether its item succeeded and carries the item, instead of mixing values and Error objects in one array. Options, including onError, are unchanged.

// Before
const results = await processBatchWithErrorHandling(items, processor, { batchSize: 10 })
const failed = results.filter(result => result instanceof Error)

// After
const results = await processBatchSettled(items, processor, { batchSize: 10 })
const failed = results.filter(result => result.status === "rejected") // { item, error }

toBase64 returns plain Base64, and toDataUrl returns the data URL

toBase64 used to return a data URL (data:image/png;base64,…), so most callers stripped the prefix. It now returns the Base64 string alone, and toDataUrl returns the full data URL. Both accept any Blob and work in Node.js too.

// Before                                                          // After
(await toBase64(file)).replace(/^data:[^;]+;base64,/, "")          await toBase64(file)
await toBase64(file) // when the data URL was wanted                await toDataUrl(file)

slugify takes an options object

// Before                         // After
slugify(title, true)              slugify(title, { decamelize: true })

getExcerpt is now truncate

Every caller passed plain text, so truncate drops the HTML stripping (which also deleted any text after a <). It cuts between words, never splits an emoji, and keeps the result within length including the ….

// Before                              // After
getExcerpt(description, 125)           truncate(description, 125)

Three details changed: the ellipsis is … instead of ..., it counts toward length, and empty input returns "" instead of null. stripHtml and convertNewlines are removed too.

getPageParams is now getPagination

getPageParams read the page from a URL and returned the other query parameters under an unchecked type. getPagination only does the math: parse the page with your router or nuqs first.

// Before                                    // After
getPageParams<Filters>(url, 20)              getPagination({ page, perPage: 20 }) // { skip, take }

joinAsSentence takes an options object

// Before                                       // After
joinAsSentence(names)                           joinAsSentence(names, { limit: 3, locale: "en-GB" })
joinAsSentence(names, 5)                        joinAsSentence(names, { limit: 5, locale: "en-GB" })
joinAsSentence(names, undefined, t("and"))      joinAsSentence(names, { locale })
joinAsSentence(names, undefined, "or")          joinAsSentence(names, { type: "disjunction", locale: "en-GB" })

joinAsSentence now formats with Intl.ListFormat, which changes its output in three ways:

  • It has no default limit. A limit collapses the rest into one trailing item ("a, b, and 2 more") instead of dropping it, and never hides a single item.
  • The default en-US locale adds a serial comma: "a, b, and c". Pass locale: "en-GB" for "a, b and c", as in v2.
  • The locale sets the conjunction and punctuation. Pass the active locale instead of a translated "and".

Development

bun install        # install dependencies
bun test           # run the test suite on Bun
bun run test:node  # run the same suite on Node.js with Vitest
bun run typecheck
bun run lint
bun run build      # build with tsdown, then check the package with publint and attw

License

MIT © Piotr Kulpinski