hanzio 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ ---
2
+ name: hanzio-api-wrapper
3
+ description: Building typed HTTP API wrappers with createApiClient and Zod in hanzio/api-wrapper. Covers folder layout, index factory, endpoint files with defineEndpoint, shared types, pagination with paginate, and common REST API integration patterns.
4
+ ---
5
+
6
+ # AI Agent Guide: Building API Wrappers
7
+
8
+ This guide is for AI assistants to follow when building API wrappers using the
9
+ `createApiClient` utility from `hanzio/api-wrapper`. The API reference
10
+ (configuration, errors, actions, retries, pagination) lives in
11
+ [README.md](./README.md).
12
+
13
+ ## Folder Structure
14
+
15
+ When creating a new API wrapper, follow this structure:
16
+
17
+ ```txt
18
+ src/api-wrapper/{api-name}/
19
+ ├── index.ts
20
+ ├── types.ts
21
+ ├── {endpointName}.ts
22
+ ├── {anotherEndpoint}.ts
23
+ └── utils/
24
+ ├── {utilName}.ts
25
+ └── ...
26
+ ```
27
+
28
+ ### Naming Conventions
29
+
30
+ - Folder name: lowercase, kebab-case, for example `github-api`, `stripe`, `template-persona`.
31
+ - Endpoint files: camelCase and action-oriented, for example `topicsGet.ts`, `usersCreate.ts`, `ordersUpdate.ts`.
32
+ - Utility files: camelCase and descriptive, for example `paginateResults.ts`, `templateGetAll.ts`.
33
+
34
+ ## Step-By-Step Process
35
+
36
+ ### Step 1: Gather Information
37
+
38
+ The user will typically provide one or more of:
39
+
40
+ 1. cURL command: extract method, URL, headers, and body.
41
+ 2. Request/response examples: use them to build Zod schemas.
42
+ 3. Chrome DevTools Network logs: extract request and response details.
43
+ 4. API documentation: reference schemas, auth, pagination, and endpoints.
44
+
45
+ ### Step 2: Create The Folder
46
+
47
+ ```bash
48
+ mkdir -p src/api-wrapper/{api-name}/utils
49
+ ```
50
+
51
+ ### Step 3: Create The Index File
52
+
53
+ The index file creates and exports the API client. When the token comes from
54
+ a secret store, create the client at module level and resolve the header
55
+ lazily with a `defaultHeaders` function; take the token as a parameter only
56
+ when callers supply it per instance.
57
+
58
+ ```ts
59
+ import { createApiClient } from 'hanzio/api-wrapper'
60
+ import { endpointOne } from './endpointOne'
61
+ import { endpointTwo } from './endpointTwo'
62
+
63
+ export const exampleApi = createApiClient({
64
+ name: 'exampleApi',
65
+ baseApiUrls: {
66
+ default: 'https://api.example.com'
67
+ },
68
+ defaultHeaders: () => ({
69
+ Authorization: `Bearer ${getSecret('EXAMPLE_API_TOKEN')}`
70
+ }),
71
+ endpoints: {
72
+ endpointOne,
73
+ endpointTwo
74
+ }
75
+ })
76
+ ```
77
+
78
+ If the project uses OpenTelemetry, add
79
+ `use: [otelMiddleware(trace.getTracer('app'))]`; for other shared middleware
80
+ (metrics), pass it with `use: [...]` instead of wrapping `fetch`. Pass a
81
+ `logger` so failures are logged with their category (server, input or output
82
+ validation). If the API returns HTTP 200 with an
83
+ error envelope (e.g. `{ success: false, errors }`), add a `checkResponse` that
84
+ throws on failure rather than checking after every call. Endpoints without the
85
+ envelope set `checkResponse: false` (or their own check). When the result is in
86
+ response headers (e.g. a `Location`), read them in `resFormatter(body, headers)`.
87
+
88
+ ### Step 4: Create Shared Types
89
+
90
+ If multiple endpoints share schemas, create a `types.ts` file.
91
+
92
+ ```ts
93
+ import { z } from 'zod'
94
+
95
+ export const ListQueryInput = z.object({
96
+ page: z.number().optional(),
97
+ page_size: z.number().optional()
98
+ })
99
+ export type ListQueryInput = z.infer<typeof ListQueryInput>
100
+
101
+ export const ListQueryOutput = <T extends z.ZodType>(itemSchema: T) =>
102
+ z.object({
103
+ data: z.array(itemSchema),
104
+ current_page: z.number(),
105
+ last_page: z.number(),
106
+ per_page: z.number(),
107
+ total: z.number()
108
+ })
109
+
110
+ export type ListQueryOutput<T> = {
111
+ data: T[]
112
+ current_page: number
113
+ last_page: number
114
+ per_page: number
115
+ total: number
116
+ }
117
+ ```
118
+
119
+ ### Step 5: Create Endpoint Files
120
+
121
+ Each endpoint gets its own file with request/response schemas and endpoint
122
+ configuration.
123
+
124
+ ```ts
125
+ import { defineEndpoint } from 'hanzio/api-wrapper'
126
+ import { z } from 'zod'
127
+
128
+ export const UserResponse = z.object({
129
+ id: z.number(),
130
+ name: z.string(),
131
+ email: z.string()
132
+ })
133
+
134
+ export const usersGet = defineEndpoint({
135
+ method: 'GET',
136
+ path: '/users/:userId',
137
+ reqQuerySchema: z.object({ includeInactive: z.boolean().optional() }),
138
+ resSchema: UserResponse
139
+ })
140
+ ```
141
+
142
+ `defineEndpoint` types `reqParams` from the `:params` in `path`, so add a
143
+ `reqParamsSchema` only when params need validation or coercion.
144
+
145
+ ### Step 6: Create Utility Functions
146
+
147
+ Place reusable helpers in `utils/`, e.g. response normalization or rate-limit
148
+ handling. For pagination, use the built-in `paginate` instead of a hand-written
149
+ loop:
150
+
151
+ ```ts
152
+ import { paginate } from 'hanzio/api-wrapper'
153
+ import type { GetExampleApi } from '../index'
154
+
155
+ export const usersGetAll = (api: GetExampleApi) =>
156
+ Array.fromAsync(
157
+ paginate(
158
+ (page: number = 1) => api.usersList({ reqQuery: { page } }),
159
+ (res) =>
160
+ res.data.current_page < res.data.last_page
161
+ ? res.data.current_page + 1
162
+ : null
163
+ )
164
+ )
165
+ ```
166
+
167
+ ## Extracting Information From User Input
168
+
169
+ ### From cURL Commands
170
+
171
+ ```bash
172
+ curl -X POST 'https://api.example.com/users' \
173
+ -H 'Authorization: Bearer token123' \
174
+ -H 'Content-Type: application/json' \
175
+ -d '{"name": "John", "email": "john@example.com"}'
176
+ ```
177
+
178
+ Extract:
179
+
180
+ - Method: `POST`
181
+ - Base URL: `https://api.example.com`
182
+ - Path: `/users`
183
+ - Headers: `Authorization`, `Content-Type`
184
+ - Body schema: `{ name: string, email: string }`
185
+
186
+ ### From Chrome DevTools
187
+
188
+ Look for:
189
+
190
+ 1. Request URL: base URL plus path.
191
+ 2. Request method: HTTP method.
192
+ 3. Request headers: auth and custom headers.
193
+ 4. Request payload: body schema.
194
+ 5. Response body: response schema.
195
+ 6. Query string parameters: query schema.
196
+
197
+ ### Building Zod Schemas From JSON
198
+
199
+ Given this response:
200
+
201
+ ```json
202
+ {
203
+ "id": 123,
204
+ "name": "John Doe",
205
+ "email": "john@example.com",
206
+ "created_at": "2024-01-15T10:30:00Z",
207
+ "roles": ["admin", "user"],
208
+ "profile": {
209
+ "avatar_url": "https://example.com/avatar.png",
210
+ "bio": "Developer"
211
+ },
212
+ "is_active": true
213
+ }
214
+ ```
215
+
216
+ Create this schema:
217
+
218
+ ```ts
219
+ const UserResponse = z.object({
220
+ id: z.number(),
221
+ name: z.string(),
222
+ email: z.string(),
223
+ created_at: z.string(),
224
+ roles: z.array(z.string()),
225
+ profile: z.object({
226
+ avatar_url: z.string(),
227
+ bio: z.string()
228
+ }),
229
+ is_active: z.boolean()
230
+ })
231
+ ```
232
+
233
+ ## Checklist
234
+
235
+ - [ ] Create folder with API name in kebab-case.
236
+ - [ ] Create `index.ts` with a `createApiClient` call.
237
+ - [ ] Set `baseApiUrls` and `defaultHeaders` (a function when the token comes from a secret store).
238
+ - [ ] Add `checkResponse` if the API returns errors inside 200 responses.
239
+ - [ ] Create `types.ts` for shared schemas when needed.
240
+ - [ ] Create one file per endpoint.
241
+ - [ ] Define Zod schemas for all request and response data.
242
+ - [ ] Use `defineEndpoint` for endpoint definitions.
243
+ - [ ] Create helpers in `utils/` when needed.
244
+ - [ ] Export the API type, for example `export type ExampleApi = typeof exampleApi`.
245
+ - [ ] Test with `createMockFetch` from `hanzio/api-wrapper/testing` passed as `fetch`; don't replace `globalThis.fetch`.
246
+
247
+ ## Tips
248
+
249
+ 1. Always use `defineEndpoint` for endpoint definitions (it checks `resFormatter` and types path params).
250
+ 2. Import Zod from `zod`.
251
+ 3. Be strict with schemas and mark unstable fields as `.optional()` or `.nullish()`.
252
+ 4. Use descriptive endpoint names like `usersGet`, `usersCreate`, and `ordersListByStatus`.
253
+ 5. Keep files small by using one endpoint per file.
254
+ 6. Extract schemas to `types.ts` when used by two or more endpoints.
255
+ 7. Use `paginate` when APIs use pagination.
256
+ 8. Ask for sample responses if the user only provides cURL commands.
@@ -256,7 +256,8 @@ function createApiClientImpl<
256
256
  responseSizeMb: result.validatedData.responseSizeBytes / (1024 * 1024),
257
257
  responseTimeMs: elapsed(),
258
258
  httpStatus: result.httpStatus,
259
- retryCount: result.retryCount
259
+ retryCount: result.retryCount,
260
+ headers: result.validatedData.headers
260
261
  }
261
262
  }
262
263
 
@@ -265,9 +266,14 @@ function createApiClientImpl<
265
266
  httpStatus: number,
266
267
  attempt: number
267
268
  ) => {
268
- if (!apiConfig.checkResponse) return
269
+ // An endpoint's own check (or `false`) replaces the client's.
270
+ const checkResponse =
271
+ endpoint.checkResponse === undefined
272
+ ? apiConfig.checkResponse
273
+ : endpoint.checkResponse
274
+ if (!checkResponse) return
269
275
  try {
270
- await apiConfig.checkResponse(data, {
276
+ await checkResponse(data, {
271
277
  endpoint: endpointName,
272
278
  method: endpoint.method,
273
279
  url: fullUrl,
@@ -1,11 +1,12 @@
1
1
  import type { z } from 'zod'
2
- import type { ApiEndpoint } from './types'
2
+ import type { ApiEndpoint, CheckResponseContext } from './types'
3
3
 
4
4
  /**
5
5
  * Defines an endpoint with full inference, as an alternative to
6
6
  * `satisfies ApiEndpoint`: `resFormatter` must return what `resSchema`
7
- * accepts, and a literal `path` types its `:params` (making `reqParams`
8
- * required when there's no `reqParamsSchema`).
7
+ * accepts, `checkResponse` receives the parsed `resSchema` output, and a
8
+ * literal `path` types its `:params` (making `reqParams` required when
9
+ * there's no `reqParamsSchema`).
9
10
  *
10
11
  * @example
11
12
  * export const usersGet = defineEndpoint({
@@ -14,12 +15,25 @@ import type { ApiEndpoint } from './types'
14
15
  * resSchema: User
15
16
  * })
16
17
  */
17
- export function defineEndpoint<const TEndpoint extends ApiEndpoint>(
18
+ // `TRes` is inferred from `resSchema` before the callbacks are typed, and the
19
+ // constraint omits `checkResponse` so its untyped `ApiEndpoint` signature
20
+ // doesn't override the one below.
21
+ export function defineEndpoint<
22
+ const TEndpoint extends Omit<ApiEndpoint, 'checkResponse'>,
23
+ TRes extends z.ZodType = TEndpoint['resSchema']
24
+ >(
18
25
  endpoint: TEndpoint & {
26
+ resSchema: TRes
19
27
  resFormatter?: (
20
28
  data: unknown,
21
29
  headers: Record<string, string>
22
- ) => z.input<TEndpoint['resSchema']>
30
+ ) => z.input<TRes>
31
+ checkResponse?:
32
+ | ((
33
+ data: z.output<TRes>,
34
+ context: CheckResponseContext
35
+ ) => void | Promise<void>)
36
+ | false
23
37
  }
24
38
  ): TEndpoint {
25
39
  return endpoint
@@ -303,7 +303,11 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
303
303
  >,
304
304
  response: Response,
305
305
  ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
306
- ): Promise<{ data: z.infer<T>; responseSizeBytes: number }> {
306
+ ): Promise<{
307
+ data: z.infer<T>
308
+ responseSizeBytes: number
309
+ headers: Record<string, string>
310
+ }> {
307
311
  const responseHeaders = getHeadersAsObject(response.headers)
308
312
  const text = await response.text()
309
313
  const responseSizeBytes = new TextEncoder().encode(text).length
@@ -327,7 +331,7 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
327
331
  : responseData
328
332
 
329
333
  const data = await validateResponse(endpoint.resSchema, transformedData, ctx)
330
- return { data, responseSizeBytes }
334
+ return { data, responseSizeBytes, headers: responseHeaders }
331
335
  }
332
336
 
333
337
  export function defaultShouldRetry(ctx: RetryContext): boolean {
@@ -35,6 +35,17 @@ export interface ApiEndpoint<
35
35
  ) => z.input<TResponse>
36
36
  reqDefaultQueryParams?: QueryParams
37
37
  doNotEncodeQueryParams?: boolean
38
+ /**
39
+ * Replaces the client's `checkResponse` for this endpoint; `false` skips it.
40
+ * `defineEndpoint` types `data` as the parsed `resSchema` output.
41
+ */
42
+ checkResponse?:
43
+ | ((
44
+ // biome-ignore lint/suspicious/noExplicitAny: typed per endpoint by defineEndpoint
45
+ data: any,
46
+ context: CheckResponseContext
47
+ ) => void | Promise<void>)
48
+ | false
38
49
  }
39
50
 
40
51
  export interface ApiWrapperResponse<T> {
@@ -44,6 +55,8 @@ export interface ApiWrapperResponse<T> {
44
55
  responseTimeMs: number
45
56
  httpStatus: number
46
57
  retryCount: number
58
+ /** Response headers of the final attempt, keyed by lowercase name. */
59
+ headers: Record<string, string>
47
60
  }
48
61
 
49
62
  export type RetryContext = {
@@ -0,0 +1,144 @@
1
+ ---
2
+ description: Web Crypto helpers - base64/hex encoding, constant-time compare, random tokens, SHA-256, HMAC and webhook signatures, AES-GCM sealing.
3
+ ---
4
+
5
+ # hanzio/crypto
6
+
7
+ Built only on `globalThis.crypto` (Web Crypto), so it runs in browsers, Workers,
8
+ Bun and Node ≥ 19. On Node 18, `globalThis.crypto` may be missing; fix that with
9
+ `globalThis.crypto ??= (await import('node:crypto')).webcrypto`. Nothing here
10
+ imports `node:crypto`. Hashing and HMAC are async because Web Crypto is; keep
11
+ synchronous Node hashing (`createHash`) in apps that need it.
12
+
13
+ ```ts
14
+ import {
15
+ createSealer,
16
+ randomToken,
17
+ safeEqual,
18
+ sha256Hex,
19
+ verifyHmacSignature,
20
+ verifySvixSignature
21
+ } from 'hanzio/crypto'
22
+ ```
23
+
24
+ ## Encoding
25
+
26
+ | Function | Notes |
27
+ | --- | --- |
28
+ | `encodeBase64(bytes)` / `decodeBase64(value)` | standard alphabet, padded output; decoding accepts missing padding |
29
+ | `encodeBase64Url(bytes)` / `decodeBase64Url(value)` | URL-safe, unpadded output; decoding accepts padding |
30
+ | `bytesToHex(bytes)` / `hexToBytes(value)` | lowercase output; decoding accepts either case |
31
+ | `utf8Encode(string)` / `utf8Decode(bytes)` | invalid UTF-8 decodes to U+FFFD |
32
+
33
+ `bytes` may be a `Uint8Array` or an `ArrayBuffer`. Decoders throw a `TypeError` on
34
+ invalid input, and the message never contains the input.
35
+
36
+ ## Compare and random
37
+
38
+ ```ts
39
+ safeEqual(providedToken, expectedToken) // string | Uint8Array, strings compared as UTF-8
40
+ randomToken() // 32 random bytes as base64url (43 chars)
41
+ randomToken(16) // 22 chars
42
+ randomBytes(12) // Uint8Array
43
+ ```
44
+
45
+ `safeEqual` always loops over the longer input and never exits early, so timing
46
+ can reveal the **lengths** of the inputs but not where they differ. That is fine
47
+ for comparing against fixed-length secrets or digests. If the length itself is
48
+ sensitive, hash both sides first (`safeEqual(await sha256(a), await sha256(b))`).
49
+
50
+ ## Hashing and HMAC
51
+
52
+ ```ts
53
+ await sha256('abc') // Uint8Array
54
+ await sha256Hex('abc') // 'ba7816bf…'
55
+
56
+ await hmacSign(secret, body) // hex HMAC-SHA-256
57
+ await hmacSign(secret, body, { algorithm: 'SHA-512', encoding: 'base64url' })
58
+
59
+ // Meta / GitHub style: `x-hub-signature-256: sha256=<hex>`
60
+ await verifyHmacSignature(appSecret, rawBody, request.headers.get('x-hub-signature-256') ?? '', {
61
+ prefix: 'sha256='
62
+ })
63
+ ```
64
+
65
+ | Option | Default | Values |
66
+ | --- | --- | --- |
67
+ | `algorithm` | `'SHA-256'` | `'SHA-256' \| 'SHA-384' \| 'SHA-512'` |
68
+ | `encoding` | `'hex'` | `'hex' \| 'base64' \| 'base64url'` |
69
+ | `prefix` (verify only) | none | stripped before decoding; if it is missing, the result is `false` |
70
+
71
+ `verifyHmacSignature` decodes the signature and checks it with
72
+ `crypto.subtle.verify`, which compares in constant time. A malformed, truncated
73
+ or wrongly prefixed signature returns `false`. Configuration errors throw: an
74
+ empty secret, or an unknown algorithm or encoding.
75
+
76
+ ## Svix / Standard Webhooks
77
+
78
+ Resend, Clerk and other Svix-based providers sign
79
+ `${id}.${timestamp}.${body}` with HMAC-SHA-256, using the base64 key inside the
80
+ `whsec_` secret.
81
+
82
+ ```ts
83
+ const ok = await verifySvixSignature({
84
+ secret: env.RESEND_WEBHOOK_SECRET, // 'whsec_…'
85
+ payload: rawBody, // the exact raw body, not re-serialized JSON
86
+ headers: request.headers, // Headers or a plain record
87
+ toleranceSec: 300, // default
88
+ now: Date.now() // default, in milliseconds
89
+ })
90
+ ```
91
+
92
+ - Reads the `svix-id`, `svix-timestamp` and `svix-signature` headers. If they are
93
+ missing, it reads `webhook-id`, `webhook-timestamp` and `webhook-signature`.
94
+ Header lookup ignores case, also for plain records.
95
+ - The signature header is a space-separated list. Every `v1,<base64>` entry is
96
+ checked, so verification keeps working while the provider rotates the secret.
97
+ - Returns `false` for missing headers, a non-integer timestamp, a timestamp more
98
+ than `toleranceSec` from `now` in either direction, or no matching signature.
99
+ An invalid secret throws a `TypeError`.
100
+
101
+ ## Sealing (AES-256-GCM)
102
+
103
+ ```ts
104
+ const sealer = createSealer({
105
+ secret: env.APP_SECRET,
106
+ info: 'myapp.oauth-tokens.v1' // use a different value for each purpose
107
+ })
108
+
109
+ const sealed = await sealer.sealJson({ accessToken }) // 'enc:v1:…'
110
+ const tokens = await sealer.openJson<{ accessToken: string }>(sealed)
111
+
112
+ await sealer.seal('plain text')
113
+ await sealer.open(sealed)
114
+ ```
115
+
116
+ - The key is derived from `secret` with HKDF-SHA-256 (`salt`, default empty;
117
+ `info`, default `'hanzio.sealer.aes-256-gcm.v1'`). Derivation runs once per
118
+ sealer, and the resulting `CryptoKey` cannot be exported.
119
+ - Each value gets a random 96-bit IV.
120
+ - The envelope is `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`.
121
+ - `open` throws a `SealerError` with one of these codes:
122
+ - `'malformed'`
123
+ - `'unsupported_version'`, for `enc:vN:` values other than v1
124
+ - `'decrypt_failed'`, for a wrong key, salt or info, or tampered data
125
+
126
+ Error messages never contain the secret or the plaintext.
127
+ - `sealJson` stores `JSON.stringify(value)`. It throws for `undefined` and for
128
+ functions.
129
+
130
+ ### Migrating lullu `secret-json.ts`
131
+
132
+ The envelope and key derivation match `apps/backend/src/lib/crypto/secret-json.ts`,
133
+ so values already stored keep opening and don't need to be re-encrypted:
134
+
135
+ ```ts
136
+ const sealer = createSealer({
137
+ secret: getSecret('APP_SECRET'),
138
+ salt: 'lullu.secret-json.salt.v1',
139
+ info: 'lullu.secret-json.aes-256-gcm.v1'
140
+ })
141
+ ```
142
+
143
+ lullu's `openSecretJson` returns non-sealed values unchanged. `open` throws on
144
+ them instead, so keep the `value.startsWith('enc:')` check in the app.
@@ -0,0 +1,120 @@
1
+ # hanzio/format
2
+
3
+ Locale-aware formatting on top of `Intl`, with no dependencies. Every function
4
+ takes the locale (a BCP-47 tag such as `'en-US'` or `'da-DK'`) explicitly, and
5
+ every function that reads the clock accepts a `now` for tests.
6
+
7
+ ```ts
8
+ import { formatRelativeTime, formatBytes } from 'hanzio/format'
9
+ ```
10
+
11
+ ## Cached formatters
12
+
13
+ Creating an `Intl` formatter is slow; these return one shared instance per
14
+ (locale, options), keyed on a stable JSON of the options (key order does not
15
+ matter). Each cache holds the 100 most recently used formatters.
16
+
17
+ ```ts
18
+ getNumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(5) // '€5.00'
19
+ getDateTimeFormat('en-GB', { dateStyle: 'medium' }).format(date) // '28 Sep 2026'
20
+ getRelativeTimeFormat('en-US', { numeric: 'auto' }).format(-1, 'day') // 'yesterday'
21
+ getListFormat('en-US', { type: 'disjunction' }).format(['a', 'b']) // 'a or b'
22
+ getPluralRules('en-US').select(1) // 'one'
23
+ ```
24
+
25
+ ## Numbers
26
+
27
+ ```ts
28
+ formatNumber(1234567.891, 'da-DK') // '1.234.567,891'
29
+ formatPercent(0.25, 'en-US') // '25%' (takes a fraction, not 25)
30
+ formatCurrency(12.5, 'USD', 'en-US') // '$12.50' (whole units, not cents)
31
+ formatCurrency(2500, 'DKK', 'da-DK', { maximumFractionDigits: 0 }) // '2.500 kr.'
32
+ formatCompactNumber(1234, 'en-US') // '1.2K'
33
+ ```
34
+
35
+ ### `formatBytes(bytes, { locale?, decimals?, binary? })`
36
+
37
+ Unit labels (B, KB, MB, GB, TB, PB) are not localized; the number is. 1 KB is
38
+ 1024 B by default (`binary: false` for 1000). Whole numbers up to KB, one
39
+ decimal from MB up, unless `decimals` is set. `locale` defaults to `'en-US'`.
40
+
41
+ ```ts
42
+ formatBytes(3.45 * 1024 * 1024) // '3.5 MB'
43
+ ```
44
+
45
+ ## Durations
46
+
47
+ ```ts
48
+ splitDuration(93_784_005) // { days: 1, hours: 2, minutes: 3, seconds: 4, milliseconds: 5 }
49
+ formatClockDuration(3_909_000) // '1:05:09' ('5:09' under an hour)
50
+ ```
51
+
52
+ ### `formatDuration(ms, locale, { style?, maxUnits? })`
53
+
54
+ Truncates, never rounds up. Shows up to `maxUnits` (default 2) adjacent units
55
+ starting at the largest non-zero one, so 1 day 0 h 5 min reads `'1 day'`.
56
+ `style` is `'short'` by default.
57
+
58
+ ```ts
59
+ formatDuration(3_930_000, 'en-US') // '1 hr, 5 min'
60
+ formatDuration(3_930_000, 'en-US', { style: 'narrow' }) // '1h 5m'
61
+ formatDuration(3_930_000, 'en-US', { style: 'long' }) // '1 hour, 5 minutes'
62
+ ```
63
+
64
+ ## Relative time
65
+
66
+ ### `formatRelativeTime(date, locale, { now?, numeric?, style?, withSuffix? })`
67
+
68
+ Picks seconds, minutes, hours, days (< 30), months (30-day) or years.
69
+ `numeric` defaults to `'auto'` ("yesterday"). `withSuffix: false` returns the
70
+ bare amount ("5 minutes") for use next to a label that already says when.
71
+ Unparseable input returns `''`.
72
+
73
+ ```ts
74
+ formatRelativeTime('2026-09-28T11:00:00Z', 'en-US', { now: new Date('2026-09-28T12:00:00Z') }) // '1 hour ago'
75
+ ```
76
+
77
+ ### `formatWait(value, locale, { now? })`
78
+
79
+ Compact "how long has this waited" for a status pill: floors, never below
80
+ `1 min`, a future timestamp counts as no waiting. A `number` is a duration in
81
+ milliseconds; a `Date` or string is a start time measured against `now`.
82
+
83
+ ```ts
84
+ formatWait(14 * 60_000 + 50_000, 'en-US') // '14 min'
85
+ ```
86
+
87
+ ## Dates and lists
88
+
89
+ ### `formatDateRange(start, end, locale, options?)`
90
+
91
+ Uses `Intl.DateTimeFormat#formatRange`, which collapses the shared parts. When
92
+ `options` has no date/time fields, it uses day + short month + year (and still
93
+ applies `timeZone` and similar options).
94
+
95
+ ```ts
96
+ formatDateRange('2026-08-24', '2026-08-30', 'en-GB', { timeZone: 'UTC' }) // '24–30 Aug 2026'
97
+ ```
98
+
99
+ ### `formatList(items, locale, options?)`
100
+
101
+ ```ts
102
+ formatList(['a', 'b', 'c'], 'en-US') // 'a, b, and c'
103
+ ```
104
+
105
+ ## Counts
106
+
107
+ ```ts
108
+ formatCappedCount(150) // '99+' (a negative number or NaN gives '0')
109
+ formatCappedCount(12, 9) // '9+'
110
+ pluralize(1234, { one: '{count} item', other: '{count} items' }, 'en-US') // '1,234 items'
111
+ ```
112
+
113
+ `pluralize` picks the form with `Intl.PluralRules` and falls back to `other`.
114
+
115
+ ## Runtime support
116
+
117
+ Works in browsers, Node, Bun, Workers and React Native. On Hermes, polyfill
118
+ `Intl.RelativeTimeFormat` and `Intl.PluralRules` if your Hermes build does not
119
+ have them. `formatList`, `formatDuration` and `formatDateRange` fall back to
120
+ plain separators when `Intl.ListFormat` or `formatRange` is missing.