@dirstack/utils

中文文档
3.3.0发布于 2026/11/10MITTypeScriptES Module构建来源已验证近 30 天下载 8918

A lightweight set of utilities

原始使用文档
文档目录20 节
  1. Install
  2. Usage
  3. What's inside
  4. Dates and time zones
  5. Client IP behind a proxy
  6. Retrying with a time budget
  7. Migrating from v2
  8. Package format
  9. Removed aliases
  10. Date formatters take an options object only
  11. sortBy takes an options object
  12. Removed helpers
  13. processBatchWithErrorHandling is now processBatchSettled
  14. toBase64 returns plain Base64, and toDataUrl returns the da…
  15. slugify takes an options object
  16. getExcerpt is now truncate
  17. getPageParams is now getPagination
  18. joinAsSentence takes an options object
  19. Development
  20. License

@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

ModuleHighlights
arrayuniq, uniqBy, chunk, groupBy, keyBy, countBy, compact, sortBy, sum, sumBy, range
batchprocessBatch, processBatchSettled (ordered, concurrency-limited, with onProgress)
errorsgetErrorMessage, toError
filesformatBytes, isMimeTypeMatch, toBase64, toDataUrl
formatformatNumber, formatCurrency, formatIntervalAmount, currencyExponent, minorUnits
helperssleep, isTruthy, tryCatch, retry, withTimeout
httpisValidUrl, addProtocol, removeProtocol, getDomain, isExternalUrl, joinUrlPaths, setQueryParams, removeQueryParams, isValidImageSrc, isHostname, normalizeHostname, stripWildcard, isWithinDomain, isIp, isIpInRange, isCloudflareIp, getClientIp, CLOUDFLARE_IP_RANGES
numbersclamp, preciseRound
objectspick, omit, isEmptyObject, isKeyInObject
parsersserialize
randomgetRandomString, getRandomDigits, getRandomNumber, getRandomElement
stringucFirst, lcFirst, truncate, slugify, getInitials, joinAsSentence
timeformatDate, formatTime, formatDateTime, formatDateRange, getReadTime, dayKey, shiftDayKey, dayIn, fromUnix, toUnix, SECOND_MS, MINUTE_MS, HOUR_MS, DAY_MS, MINUTE_SECONDS, HOUR_SECONDS, DAY_SECONDS
uigetBalancedColumns, isLightColor, getCurrentPage, getPagination, getPageCount, isPageOutOfRange, getPageLink
typesWithOptional, 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.

Retrying with a time budget

retry runs an async function again when it throws. By default it makes 3 retries with no wait between them. delay and factor add exponential backoff, and maxDelay caps each wait:

import { retry } from "@dirstack/utils"

// Waits 100ms, 200ms, 400ms, 800ms, then 1s between attempts, for at most 15 seconds.
const rows = await retry(() => client.query(sql), {
  retries: Infinity,
  delay: 100,
  maxDelay: 1000,
  timeout: 15_000,
  shouldRetry: isConnectionError,
})

timeout counts milliseconds from the call to retry. deadline takes an epoch timestamp instead, so several calls can share one budget. When both are set, the earlier one applies. retry never starts a wait that would end past the budget: it rethrows the last error instead. retries still defaults to 3 with a budget set, so pass retries: Infinity to let the budget alone end the loop.

const deadline = Date.now() + 15_000
const user = await retry(() => findUser(id), { retries: Infinity, delay: 100, deadline })
await retry(() => insertEvent(user), { retries: Infinity, delay: 100, deadline })

signal takes an AbortSignal. Once it aborts, retry stops, even in the middle of a wait, and rejects with signal.reason. Neither the budget nor the signal interrupts an attempt that is already running. Wrap the callback in withTimeout when a single attempt can hang.

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.

RemovedUse instead
getElementPositionelement.getBoundingClientRect() or element.scrollIntoView()
setInputValueCopy the five lines into the project that needs them
subscribe, unsubscribe, publishdocument.addEventListener, removeEventListener, dispatchEvent(new CustomEvent(name, { detail }))
publishEscapedocument.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
isErrorWithMessage, toErrorWithMessage, ErrorWithMessagegetErrorMessage(error) or toError(error)
formatMimeTypemimeType.split("/")[1]?.toUpperCase()
formatToDecimalsformatNumber(value, { maximumFractionDigits: 2, useGrouping: false })
debounce, throttleuseDebouncedCallback and useThrottledCallback from @mantine/hooks
getBaseUrlnew URL(url).origin
getQueryParamsObject.fromEntries(new URL(url).searchParams)
checkUrlAvailabilityfetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) })
normalizeUrl, isLocalhostUrlStill used inside the package, no longer exported
parseNumericValueNumber.parseFloat(value) with a Number.isNaN check, or z.coerce.number()
sortObjectObject.fromEntries(Object.entries(source).sort(([a], [b]) => a.localeCompare(b)))
sortObjectKeysA comparator in the project that needs it
nullsToUndefined, ReplaceNullWithUndefinedMap the fields that need it
maybeParseJson, maybeStringifyJson, deserializeJSON.parse and JSON.stringify
getRandomColorMath.floor(Math.random() * 0x1000000).toString(16).padStart(6, "0")
getRandomPropertygetRandomElement(Object.values(source))
isCuidisCuid from @paralleldrive/cuid2
formatDateOrTimeformatDate, formatTime or formatDateTime
getShortcutLabelA <Kbd> component that knows the platform's modifier key
DeepIdx, DeepIndex, ValidatePathPath and PathValue from react-hook-form, or Get and Paths from type-fest
NestedPartial, NestedRequiredPartialDeep 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

版本与发布记录

@dirstack/utils 共发布 20 个版本,首次发布于 2026/03/02;过去 12 个月发布了 20 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.3.0latest2026/11/102 天前
3.2.02026/10/2320 天前
3.1.02026/10/1626 天前
3.0.02026/10/081 个月前
2.3.02026/10/061 个月前
2.2.02026/10/061 个月前

关于 @dirstack/utils 的常见问题

@dirstack/utils 是什么?

A lightweight set of utilities

如何安装 @dirstack/utils?

在项目目录运行 npm install @dirstack/utils,或使用 pnpm add / yarn add / bun add。

@dirstack/utils 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dirstack/utils 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@dirstack/utils 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@dirstack/utils 可以商用吗?

@dirstack/utils 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dirstack/utils 的下载量有多少?

根据 npm 官方下载统计,@dirstack/utils 在 2026/10/13 – 2026/11/11 这 30 天内被下载 8,918 次,日均约 297 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dirstack/utils 还在维护吗?

最新版本 3.3.0 发布于 2026/11/10(2 天前);至今共发布 20 个版本,其中过去 12 个月发布了 20 个;首次发布于 2026/03/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。