hanzio 1.2.0 → 2.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.
Files changed (145) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-181e6vfw.js +29 -0
  36. package/dist/index-8r2vdzjg.js +87 -0
  37. package/dist/index-hy7kbb99.js +157 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-mnkcfyx8.js +78 -0
  42. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  43. package/dist/index-rp5pqghz.js +157 -0
  44. package/dist/index-srt06xd2.js +15 -0
  45. package/dist/index-wjyb9bsd.js +263 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/sitemap/index.d.ts +48 -6
  55. package/dist/src/api-wrapper/index.js +60 -1130
  56. package/dist/src/api-wrapper/testing.js +20 -0
  57. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  58. package/dist/src/crypto/index.js +195 -0
  59. package/dist/src/format/index.js +211 -0
  60. package/dist/src/i18n/index.js +36 -0
  61. package/dist/src/index.js +739 -3145
  62. package/dist/src/jwt/index.js +138 -92
  63. package/dist/src/p-queue/index.js +9 -437
  64. package/dist/src/secrets/index.js +44 -1428
  65. package/dist/src/secrets/vite.js +6 -39
  66. package/dist/src/secrets/worker.js +10 -273
  67. package/dist/src/sitemap/index.js +183 -511
  68. package/dist/src/storage/index.js +119 -0
  69. package/dist/src/zod/index.js +340 -167
  70. package/dist/state/index.d.ts +21 -0
  71. package/dist/storage/index.d.ts +58 -0
  72. package/dist/string/index.d.ts +75 -2
  73. package/dist/typedswitch/index.d.ts +31 -24
  74. package/dist/types/index.d.ts +11 -0
  75. package/dist/url/index.d.ts +50 -1
  76. package/dist/zod/index.d.ts +32 -3
  77. package/dist/zod/introspect.d.ts +19 -0
  78. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  79. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  80. package/dist/zod/zodToTypeString.d.ts +8 -3
  81. package/package.json +78 -7
  82. package/src/api-wrapper/actions-impl.ts +11 -72
  83. package/src/api-wrapper/builders.ts +32 -25
  84. package/src/api-wrapper/create-client.ts +171 -41
  85. package/src/api-wrapper/describe.ts +157 -0
  86. package/src/api-wrapper/endpoint.ts +26 -0
  87. package/src/api-wrapper/error-reporting.ts +7 -1
  88. package/src/api-wrapper/errors.ts +107 -2
  89. package/src/api-wrapper/headers.ts +11 -6
  90. package/src/api-wrapper/index.ts +5 -0
  91. package/src/api-wrapper/otel.ts +139 -0
  92. package/src/api-wrapper/paginate.ts +28 -0
  93. package/src/api-wrapper/redaction.ts +42 -0
  94. package/src/api-wrapper/shared.ts +21 -2
  95. package/src/api-wrapper/testing.ts +48 -0
  96. package/src/api-wrapper/transport.ts +41 -43
  97. package/src/api-wrapper/types.ts +185 -18
  98. package/src/array/index.ts +195 -42
  99. package/src/cache/createCache.ts +157 -0
  100. package/src/cache/index.ts +132 -147
  101. package/src/color/contrast.ts +163 -0
  102. package/src/color/index.ts +22 -0
  103. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  104. package/src/cool-console-log/index.ts +128 -41
  105. package/src/crypto/encoding.ts +83 -0
  106. package/src/crypto/hash.ts +170 -0
  107. package/src/crypto/index.ts +26 -0
  108. package/src/crypto/sealer.ts +168 -0
  109. package/src/crypto/webhook.ts +86 -0
  110. package/src/date/index.ts +109 -34
  111. package/src/emitter/index.ts +82 -0
  112. package/src/error/index.ts +63 -0
  113. package/src/format/index.ts +389 -0
  114. package/src/function/index.ts +201 -0
  115. package/src/guard/index.ts +80 -0
  116. package/src/i18n/index.ts +125 -0
  117. package/src/index.ts +11 -7
  118. package/src/jwt/index.ts +199 -125
  119. package/src/math/index.ts +5 -10
  120. package/src/math/linearRegressionTrend.ts +33 -8
  121. package/src/math/scale.ts +17 -0
  122. package/src/object/index.ts +99 -0
  123. package/src/p-queue/index.ts +2 -0
  124. package/src/promise/index.ts +304 -9
  125. package/src/sitemap/index.ts +293 -130
  126. package/src/state/index.ts +133 -81
  127. package/src/storage/index.ts +187 -0
  128. package/src/string/index.ts +261 -23
  129. package/src/typedswitch/index.ts +58 -38
  130. package/src/types/index.ts +14 -0
  131. package/src/url/index.ts +146 -1
  132. package/src/zod/index.ts +105 -9
  133. package/src/zod/introspect.ts +103 -0
  134. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  135. package/src/zod/zodMinMaxFilter.ts +37 -13
  136. package/src/zod/zodToTypeString.ts +184 -181
  137. package/dist/api-wrapper/index.js +0 -250
  138. package/dist/gzip/index.d.ts +0 -2
  139. package/dist/gzip/index.js +0 -16
  140. package/dist/index.js +0 -1823
  141. package/dist/jwt/index.js +0 -19
  142. package/dist/sitemap/index.js +0 -552
  143. package/dist/src/gzip/index.js +0 -16
  144. package/dist/src/secrets/loaders.js +0 -1529
  145. package/dist/zod/index.js +0 -210
package/README.md CHANGED
@@ -1,42 +1,84 @@
1
1
  # hanzio
2
2
 
3
3
  A small TypeScript-first utility library for common application code: arrays,
4
- strings, promises, caching, state machines, dates, URLs, Zod helpers, API clients,
5
- JWTs, sitemap crawling, queues, secrets, and colorful terminal logging.
4
+ strings, promises, caching, state machines, dates, URLs, colors, Zod helpers,
5
+ API clients, JWTs, sitemap crawling, queues, secrets, and colorful terminal
6
+ logging.
6
7
 
7
8
  ## Install
8
9
 
9
10
  ```bash
10
- bun add hanzio zod
11
+ bun add hanzio
12
+ # only needed for hanzio/zod, hanzio/api-wrapper and hanzio/secrets
13
+ bun add zod
11
14
  ```
12
15
 
13
16
  The package is ESM and Bun-friendly. Bun consumers resolve the `bun` export to
14
17
  the source TypeScript files, while Node/bundler consumers resolve built files
15
- from `dist`.
16
- `zod` is a peer dependency because the Zod helpers and API wrapper share the
17
- consumer application's Zod instance.
18
+ from `dist`. `zod` is an optional peer dependency: the Zod helpers, the API
19
+ wrapper and the secrets loader share the consumer application's Zod instance,
20
+ while the root entry never loads it.
18
21
 
19
22
  ## Imports
20
23
 
21
- Most lightweight utilities are available from the root export:
24
+ The root export contains only dependency-free, runtime-neutral helpers (array,
25
+ string, promise, cache, state, typedSwitch, date, url, math, color, errors,
26
+ type guards, objects, functions, events and shared types):
22
27
 
23
28
  ```ts
24
29
  import { groupBy, typedSwitch, tryCatch } from 'hanzio'
25
30
  ```
26
31
 
27
- Heavier or dependency-specific modules are also available as subpath exports:
28
-
29
- ```ts
30
- import { createZId } from 'hanzio/zod'
31
- import { jwtSign } from 'hanzio/jwt'
32
- import { getDomainSitemap } from 'hanzio/sitemap'
33
- import PQueue from 'hanzio/p-queue'
34
- import { createCoolLogger } from 'hanzio/cool-console-log'
35
- import { defineSecretSet } from 'hanzio/secrets'
36
- ```
37
-
38
- The package currently publishes `src/**/*.ts` intentionally so Bun can consume
39
- the source entrypoints directly. Tests are excluded from the published package.
32
+ Everything that needs Zod, the network, or a specific runtime has its own
33
+ subpath export:
34
+
35
+ | Import | Contents |
36
+ | --- | --- |
37
+ | `hanzio/zod` | Branded IDs, JSON schemas, schema introspection and defaults, schema-to-type rendering, min/max filters |
38
+ | `hanzio/api-wrapper` | Typed HTTP clients ([docs](src/api-wrapper/README.md)) |
39
+ | `hanzio/api-wrapper/testing` | `createMockFetch` / `jsonResponse` for testing API clients |
40
+ | `hanzio/jwt` | HS256/384/512 JWTs via Web Crypto ([docs](src/jwt/README.md)) |
41
+ | `hanzio/sitemap` | Sitemap discovery and crawling ([docs](src/sitemap/README.md)) |
42
+ | `hanzio/p-queue` | Promise queue ([docs](src/p-queue/README.md)) |
43
+ | `hanzio/cool-console-log` | Colored terminal logging |
44
+ | `hanzio/format` | Cached `Intl` formatters: numbers, currency, bytes, durations, relative time ([docs](src/format/README.md)) |
45
+ | `hanzio/crypto` | Web Crypto helpers: constant-time compare, HMAC/webhook verification, sealing, tokens ([docs](src/crypto/README.md)) |
46
+ | `hanzio/storage` | Typed localStorage/AsyncStorage items that never throw ([docs](src/storage/README.md)) |
47
+ | `hanzio/i18n` | Typed `{placeholder}` interpolation and translators ([docs](src/i18n/README.md)) |
48
+ | `hanzio/secrets` | Typed secret sets from env/Infisical ([docs](src/secrets/README.md)) |
49
+ | `hanzio/secrets/vite` | Vite `define` helpers for secret sets |
50
+ | `hanzio/secrets/worker` | Secret sets for Cloudflare Workers |
51
+
52
+ `scripts/verify-exports.mjs` imports every entry above from the packed tarball
53
+ in CI, so this table and `package.json#exports` can't drift apart.
54
+
55
+ ## Migrating to 2.0
56
+
57
+ - Subpath-only modules are no longer re-exported from `hanzio`. Change
58
+ `import { createApiClient } from 'hanzio'` to `from 'hanzio/api-wrapper'`, and
59
+ likewise for zod, jwt, sitemap, p-queue, cool-console-log and secrets.
60
+ - `Result` has an `ok` discriminant: `{ ok: true, data, error: null }` or
61
+ `{ ok: false, data: null, error }`. Code that builds results by hand must add
62
+ `ok`; `isSuccess`/`isFailure` now check it, so `throw null` is a failure.
63
+ - Color helpers moved from math to color. They're still on the root export.
64
+ - `groupBy` and `countBy` return `Partial<Record<K, …>>`, because not every key
65
+ has a group.
66
+ - `extractNumber` reads thousands separators (`'1,234.56'` → `1234.56`, was
67
+ `1`). Pass `{ decimalSeparator: ',' }` for European formats.
68
+ - `slugify` transliterates `æ`→`ae`, `œ`→`oe`, `ß`→`ss` (previously single letters).
69
+ - API wrapper retries default to exponential backoff with jitter instead of a
70
+ fixed 300ms, and array query values serialize as `a=1&a=2` instead of `a=1,2`.
71
+ - `cool-console-log` only colors TTY output and honors `NO_COLOR`/`FORCE_COLOR`.
72
+ - API wrapper request inputs are typed with the schemas' input types
73
+ (`z.input`): `.default()` fields become optional and transformed fields take
74
+ their pre-transform type. Calls that passed post-transform values now fail
75
+ to compile, as they already failed validation at runtime.
76
+ - API wrapper object and array bodies are sent as JSON when no `Content-Type`
77
+ is set (previously a `ConfigError`), and endpoints/actions can't be named
78
+ `request`, `cache` or `safe`.
79
+ - The API wrapper logs failures as `logger.error(summary, attributes)` instead
80
+ of `logger.error(error)`.
81
+ - `normalizeSingleOrArray` (zod) is deprecated in favor of `toArray`.
40
82
 
41
83
  ## Utilities
42
84
 
@@ -51,85 +93,80 @@ const pages = chunkArray([1, 2, 3, 4, 5], 2)
51
93
  // [[1, 2], [3, 4], [5]]
52
94
  ```
53
95
 
54
- `findArrayDifferenceByKey` compares two arrays by a shared key and returns
55
- `new`, `upsert`, and `delete` groups.
96
+ `findArrayDifferenceByKey` compares two arrays by a key name or key function and
97
+ returns `new`, `upsert`, and `delete` groups. Pass `isEqual` to also split
98
+ `upsert` into `update` and `unchanged`.
56
99
 
57
100
  ```ts
58
101
  import { findArrayDifferenceByKey } from 'hanzio'
59
102
 
60
103
  const diff = findArrayDifferenceByKey(
61
- [{ id: 1, name: 'old' }],
62
- [{ id: 1, name: 'new' }, { id: 2, name: 'added' }],
63
- 'id'
104
+ [{ id: 1, name: 'old' }, { id: 3, name: 'same' }],
105
+ [{ id: 1, name: 'new' }, { id: 2, name: 'added' }, { id: 3, name: 'same' }],
106
+ 'id',
107
+ { isEqual: (current, next) => current.name === next.name }
64
108
  )
109
+ // diff.new: [id 2], diff.update: [id 1], diff.unchanged: [id 3], diff.delete: []
65
110
  ```
66
111
 
67
- `getUniqueValues` keeps the first unique serialized value.
112
+ `getUniqueValues` keeps the first unique value, comparing with
113
+ `stableStringify` (object key order doesn't matter). `getUniqueValuesByKey`
114
+ keeps the first item per key name or key function.
68
115
 
69
116
  ```ts
70
- import { getUniqueValues } from 'hanzio'
117
+ import { getUniqueValues, getUniqueValuesByKey } from 'hanzio'
71
118
 
72
- const values = getUniqueValues([{ id: 1 }, { id: 1 }, { id: 2 }])
73
- // [{ id: 1 }, { id: 2 }]
119
+ getUniqueValues([{ a: 1, b: 2 }, { b: 2, a: 1 }])
120
+ // [{ a: 1, b: 2 }]
121
+ getUniqueValuesByKey(users, (user) => user.email.toLowerCase())
74
122
  ```
75
123
 
76
- `compact` removes `null` and `undefined` while narrowing the returned type.
124
+ `compact` removes `null` and `undefined`; `toArray` normalizes a single value,
125
+ an array, or nullish input to an array.
77
126
 
78
127
  ```ts
79
- import { compact } from 'hanzio'
128
+ import { compact, toArray } from 'hanzio'
80
129
 
81
- const names = compact(['Ada', null, 'Grace', undefined])
82
- // string[]
130
+ compact(['Ada', null, 'Grace', undefined]) // string[]
131
+ toArray('tag') // ['tag']
132
+ toArray(undefined) // []
83
133
  ```
84
134
 
85
- `getUniqueValuesByKey` keeps the first item for each key value.
135
+ `groupBy`, `keyBy` and `countBy` index items by a property or callback and
136
+ preserve literal key unions when possible.
86
137
 
87
138
  ```ts
88
- import { getUniqueValuesByKey } from 'hanzio'
139
+ import { countBy, groupBy, keyBy } from 'hanzio'
89
140
 
90
- const users = getUniqueValuesByKey(
91
- [{ id: 'a', role: 'admin' }, { id: 'a', role: 'user' }],
92
- 'id'
93
- )
94
- ```
95
-
96
- `groupBy` groups items by a property or callback and preserves literal key unions
97
- when possible.
98
-
99
- ```ts
100
- import { groupBy } from 'hanzio'
141
+ const items = [
142
+ { type: 'fruit', name: 'apple' },
143
+ { type: 'veg', name: 'carrot' }
144
+ ] as const
101
145
 
102
- const byType = groupBy(
103
- [
104
- { type: 'fruit', name: 'apple' },
105
- { type: 'veg', name: 'carrot' }
106
- ] as const,
107
- 'type'
108
- )
146
+ const byType = groupBy(items, 'type') // Partial<Record<'fruit' | 'veg', …[]>>
147
+ const byName = keyBy(items, 'name')
148
+ const counts = countBy(items, 'type') // { fruit: 1, veg: 1 }
109
149
  ```
110
150
 
111
- `keyBy` indexes items by a property or callback.
151
+ `sumBy`, `sortBy` and `range` cover common list maths. `sortBy` is stable,
152
+ non-mutating, and puts `null`/`undefined` last.
112
153
 
113
154
  ```ts
114
- import { keyBy } from 'hanzio'
155
+ import { range, sortBy, sumBy } from 'hanzio'
115
156
 
116
- const byId = keyBy([{ id: 'user_1', name: 'Ada' }], 'id')
117
- // { user_1: { id: 'user_1', name: 'Ada' } }
157
+ sumBy(orders, 'total')
158
+ sortBy(users, { by: 'lastLogin', order: 'desc' }, 'name')
159
+ range(5) // [0, 1, 2, 3, 4]
160
+ range(0, 10, 5) // [0, 5]
118
161
  ```
119
162
 
120
163
  `partition` splits an array into matched and unmatched items.
164
+ `pickItemsInArray` filters by a key and narrows discriminated unions.
121
165
 
122
166
  ```ts
123
- import { partition } from 'hanzio'
167
+ import { partition, pickItemsInArray } from 'hanzio'
124
168
 
125
169
  const [even, odd] = partition([1, 2, 3, 4], (value) => value % 2 === 0)
126
- ```
127
-
128
- `pickItemsInArray` filters by a key and narrows discriminated unions for const
129
- value lists.
130
-
131
- ```ts
132
- import { pickItemsInArray } from 'hanzio'
133
170
 
134
171
  type Item = { type: 'user'; name: string } | { type: 'team'; members: number }
135
172
  const users = pickItemsInArray([] as Item[], 'type', ['user'] as const)
@@ -138,21 +175,34 @@ const users = pickItemsInArray([] as Item[], 'type', ['user'] as const)
138
175
 
139
176
  ### String
140
177
 
141
- `extractNumber` parses the first number-like value from a string.
178
+ `extractNumber` parses the first number in a string, including thousands
179
+ separators.
142
180
 
143
181
  ```ts
144
182
  import { extractNumber } from 'hanzio'
145
183
 
146
- const amount = extractNumber('Total: 123.45 kr')
147
- // 123.45
184
+ extractNumber('Total: 1,234.45 kr') // 1234.45
185
+ extractNumber('1.234,56 kr', { decimalSeparator: ',' }) // 1234.56
186
+ extractNumber('2024-01-05') // 2024
148
187
  ```
149
188
 
150
- `generateId` creates a stable short ID from JSON-like input.
189
+ `generateId` creates a short, stable, non-cryptographic hash key from JSON-like
190
+ input (object key order doesn't matter). Use `sha256Id` when collisions matter.
151
191
 
152
192
  ```ts
153
- import { generateId } from 'hanzio'
193
+ import { generateId, sha256Id } from 'hanzio'
154
194
 
155
195
  const id = generateId({ page: 1, filters: ['active'] })
196
+ const strongId = await sha256Id({ page: 1 }, 24)
197
+ ```
198
+
199
+ `stableStringify` is `JSON.stringify` with sorted keys plus `Map`, `Set` and
200
+ `bigint` support. Use it for cache and dedupe keys.
201
+
202
+ ```ts
203
+ import { stableStringify } from 'hanzio'
204
+
205
+ stableStringify({ b: 1, a: 2 }) === stableStringify({ a: 2, b: 1 }) // true
156
206
  ```
157
207
 
158
208
  `slugify` normalizes text for URL slugs.
@@ -160,109 +210,154 @@ const id = generateId({ page: 1, filters: ['active'] })
160
210
  ```ts
161
211
  import { slugify } from 'hanzio'
162
212
 
163
- const slug = slugify('Hello, København!')
164
- // "hello-kobenhavn"
213
+ slugify('Hello, København!') // "hello-kobenhavn"
214
+ slugify('Hello World', { separator: '_', maxLength: 8 }) // "hello_wo"
165
215
  ```
166
216
 
167
- ### Promise
217
+ `truncate` cuts to a length that includes the ellipsis, never splits emoji,
218
+ and can stop at a word boundary. `normalizeWhitespace` collapses whitespace
219
+ runs (including non-breaking spaces).
168
220
 
169
- `tryCatch` converts a promise into a `Result<T, E>` object.
221
+ ```ts
222
+ import { normalizeWhitespace, truncate } from 'hanzio'
223
+
224
+ truncate('Hello wonderful world', 12) // "Hello wonde…"
225
+ truncate('Hello wonderful world', 12, { wordBoundary: true }) // "Hello…"
226
+ normalizeWhitespace(' a \n\t b ') // "a b"
227
+ ```
228
+
229
+ `matchesSearch` checks that every query term appears in one of the fields,
230
+ ignoring case and accents. `initials` handles names, emails and emoji.
170
231
 
171
232
  ```ts
172
- import { tryCatch } from 'hanzio'
233
+ import { initials, matchesSearch } from 'hanzio'
173
234
 
174
- const result = await tryCatch(fetch('/api/users'))
235
+ customers.filter((c) => matchesSearch(query, c.name, c.email)) // "sø" finds "Søren"
236
+ initials('Ada Lovelace') // "AL"
237
+ initials('ada.lovelace+work@example.com') // "AL"
175
238
  ```
176
239
 
177
- `isSuccess`, `isFailure`, and `unwrapResult` help narrow and consume
178
- `Result<T, E>`.
240
+ `safeJsonParse` returns a `Result` instead of throwing; `escapeRegExp` escapes
241
+ text for use inside a `RegExp`.
179
242
 
180
243
  ```ts
181
- import { isSuccess, unwrapResult } from 'hanzio'
244
+ import { escapeRegExp, safeJsonParse } from 'hanzio'
182
245
 
183
- if (isSuccess(result)) {
184
- result.data
185
- }
246
+ const parsed = safeJsonParse<Settings>(raw)
247
+ if (parsed.ok) parsed.data
248
+ new RegExp(`\\b${escapeRegExp(term)}\\b`, 'i')
249
+ ```
250
+
251
+ ### Promise
252
+
253
+ `tryCatch` converts a promise, or a function that may throw synchronously, into
254
+ a `Result<T, E>`. `isSuccess`, `isFailure` and `unwrapResult` consume it.
186
255
 
256
+ ```ts
257
+ import { tryCatch, unwrapResult } from 'hanzio'
258
+
259
+ const result = await tryCatch(() => fetch('/api/users'))
260
+ if (result.ok) result.data
187
261
  const response = unwrapResult(result)
188
262
  ```
189
263
 
190
- `backgroundPromise` runs async work and reports success or failure through hooks.
264
+ `retry` retries a function with exponential backoff (full jitter) by default.
265
+ `exponentialBackoff` is exported for your own retry loops.
191
266
 
192
267
  ```ts
193
- import { backgroundPromise } from 'hanzio'
268
+ import { retry } from 'hanzio'
194
269
 
195
- backgroundPromise(() => syncAnalytics(), {
196
- onError: (error) => console.error(error)
270
+ const data = await retry(() => fetchReport(), {
271
+ retries: 5,
272
+ shouldRetry: (error) => !(error instanceof ValidationError),
273
+ signal
197
274
  })
198
275
  ```
199
276
 
200
- `backgroundPromiseSync` schedules background work in a microtask by default.
277
+ `withTimeout` rejects with `PromiseTimeoutError` after a deadline. Pass a
278
+ function to receive a signal that aborts on timeout.
201
279
 
202
280
  ```ts
203
- import { backgroundPromiseSync } from 'hanzio'
281
+ import { withTimeout } from 'hanzio'
204
282
 
205
- backgroundPromiseSync(() => refreshCache())
283
+ const res = await withTimeout((signal) => fetch(url, { signal }), 5_000)
206
284
  ```
207
285
 
208
- `promiseTimeout` resolves after a delay.
286
+ `pMap` maps with bounded concurrency and keeps input order.
209
287
 
210
288
  ```ts
211
- import { promiseTimeout } from 'hanzio'
289
+ import { pMap } from 'hanzio'
212
290
 
213
- await promiseTimeout(250)
291
+ const users = await pMap(ids, (id) => fetchUser(id), { concurrency: 4 })
214
292
  ```
215
293
 
216
- ### Secrets
294
+ `promiseTimeout` resolves after a delay (and rejects early if its signal
295
+ aborts). `backgroundPromise` runs async work and reports success or failure
296
+ through hooks; `backgroundPromiseSync` schedules it in a microtask.
217
297
 
218
- `defineSecretSet` loads a typed set of required secrets from `process.env` or
219
- Infisical before the rest of the program starts.
298
+ ```ts
299
+ import { backgroundPromise, promiseTimeout } from 'hanzio'
220
300
 
221
- When both `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are set, it uses
222
- Infisical's HTTP API. If either is missing, it uses your signed-in Infisical CLI
223
- session. For local development, install the CLI and run
224
- `infisical login --domain=https://eu.infisical.com` (or your configured
225
- `siteUrl`). See the [secrets documentation](src/secrets/README.md) for details.
301
+ await promiseTimeout(250, { signal })
302
+ backgroundPromise(() => syncAnalytics(), {
303
+ onError: (error) => console.error(error)
304
+ })
305
+ ```
306
+
307
+ `ok` and `err` build `Result` values. `startTimer` and `timed` measure
308
+ durations without `performance.now()` bookkeeping.
226
309
 
227
310
  ```ts
228
- import { defineSecretSet } from 'hanzio/secrets'
311
+ import { err, ok, startTimer, timed } from 'hanzio'
229
312
 
230
- export const backendSecrets = await defineSecretSet(
231
- ['DATABASE_URL', 'APP_SECRET'] as const,
232
- {
233
- organizationId: 'infisical-organization-id',
234
- projectId: 'infisical-project-id'
235
- }
236
- )
313
+ function parseAge(input: string) {
314
+ const age = Number(input)
315
+ return Number.isInteger(age) ? ok(age) : err('not a number')
316
+ }
237
317
 
238
- const databaseUrl = backendSecrets.secret('DATABASE_URL')
318
+ const elapsed = startTimer()
319
+ await work()
320
+ log.info('done', { durationMs: elapsed() })
321
+
322
+ const { result, durationMs } = await timed(() => fetchReport())
239
323
  ```
240
324
 
241
- The required `organizationId` scopes the CLI session per secret set, allowing projects
242
- in different organizations to run together without switching the active CLI
243
- organization. This flow reads the CLI user token and fetches secrets over HTTP;
244
- tokens remain in memory. Use `writeToProcessEnv: false` for multiple sets in one
245
- process. Both `organizationId` and `projectId` are required for Infisical loading.
325
+ Abort helpers: `anySignal` combines signals (ignoring `undefined`),
326
+ `timeoutSignal` adds a deadline to an optional caller signal, and `raceAbort`
327
+ rejects a promise as soon as a signal aborts.
246
328
 
247
- For Vite config, `viteSecretSetPlugin(secretSet)` exposes the loaded values as
248
- `import.meta.env.*` replacements. Use `getViteDefine(secretSet)` if you need the
249
- raw `define` object. Vite helpers accept only `VITE_` keys by default; use an
250
- explicit `publicKeys` list to select public values from a mixed set. Import
251
- these helpers from `hanzio/secrets/vite`.
329
+ ```ts
330
+ import { anySignal, raceAbort, timeoutSignal } from 'hanzio'
252
331
 
253
- The helper supports `auth: 'auto' | 'http' | 'cli'`, `secretPath`, deadlines,
254
- cancellation, optional schema validation, per-key source metadata, and
255
- `writeToProcessEnv: false` for isolated sets. Use `hanzio/secrets/worker` for
256
- Cloudflare bindings without Node compatibility. See the
257
- [migration notes](src/secrets/README.md#migration-notes) for stricter environment
258
- validation and browser exposure rules.
332
+ await fetch(url, { signal: timeoutSignal(10_000, request.signal) })
333
+ const signal = anySignal(userSignal, shutdownSignal)
334
+ const result = await raceAbort(sdkCallWithoutSignalSupport(), signal)
335
+ ```
336
+
337
+ `createLatestGuard` ignores results from outdated async calls (e.g. a search
338
+ box where an old request resolves after a newer one).
339
+
340
+ ```ts
341
+ import { createLatestGuard } from 'hanzio'
342
+
343
+ const guard = createLatestGuard()
344
+ async function search(query: string) {
345
+ const outcome = await guard.run(() => api.search(query))
346
+ if (!outcome.stale) results.value = outcome.value
347
+ }
348
+ ```
349
+
350
+ `exponentialBackoff` also supports `jitter: 'partial'` (adds up to
351
+ `jitterRatio`, default 25%, on top of the computed delay, which suits reconnect
352
+ loops) and an injectable `random` for tests.
259
353
 
260
354
  ### Cache
261
355
 
262
356
  `cacheFunction` wraps sync or async functions with TTL caching and request
263
357
  deduplication. With `refreshInBackground: true`, stale entries are served
264
358
  immediately while a background refresh updates the cache
265
- (stale-while-revalidate).
359
+ (stale-while-revalidate). Keys are built with `stableStringify`, and
360
+ `maxEntries` bounds the in-memory cache with LRU eviction.
266
361
 
267
362
  ```ts
268
363
  import { cacheFunction } from 'hanzio'
@@ -270,11 +365,25 @@ import { cacheFunction } from 'hanzio'
270
365
  const getUser = cacheFunction({
271
366
  name: 'getUser',
272
367
  fn: async (id: string) => ({ id, name: 'Ada' }),
273
- cacheTimeMs: 60_000
368
+ cacheTimeMs: 60_000,
369
+ maxEntries: 1_000
274
370
  })
275
371
 
276
372
  const user = await getUser('user_1')
277
- getUser.clearCache()
373
+ getUser.peek('user_1') // cached value without calling fn
374
+ getUser.invalidate('user_1') // drop one entry
375
+ getUser.clearCache() // drop everything
376
+ ```
377
+
378
+ `createCache` is a plain key/value cache with TTL, LRU bound and in-flight
379
+ dedupe, for when you're not wrapping a single function.
380
+
381
+ ```ts
382
+ import { createCache } from 'hanzio'
383
+
384
+ const tokens = createCache({ defaultTtlMs: 50 * 60_000, maxEntries: 100 })
385
+ const token = await tokens.getOrSet('auth', () => login())
386
+ tokens.delete('auth')
278
387
  ```
279
388
 
280
389
  #### External stores (e.g. Redis)
@@ -318,41 +427,64 @@ Store failures never break the cached function: on a failed `get` or `set`
318
427
  the wrapped function falls back to calling `fn` directly, and the optional
319
428
  `onStoreError` callback is invoked.
320
429
 
430
+ For token-style entries, `ttlMs` can be computed from the value and
431
+ `staleMarginMs` refreshes the entry early (`get` still returns the old value
432
+ until it really expires).
433
+
434
+ ```ts
435
+ const tokens = createCache()
436
+ const token = await tokens.getOrSet('shopify', fetchAccessToken, {
437
+ ttlMs: (token) => token.expiresIn * 1000,
438
+ staleMarginMs: 60_000
439
+ })
440
+ ```
441
+
321
442
  ### State Machine
322
443
 
323
- `SimpleStateMachine` models typed states, actions, validation, and action
324
- payloads.
444
+ `createStateMachine` defines typed states, actions, validation, and action
445
+ payloads once; `.for(item, stateKey)` binds it to an entity.
325
446
 
326
447
  ```ts
327
- import { SimpleStateMachine } from 'hanzio'
448
+ import { createStateMachine } from 'hanzio'
328
449
 
329
450
  type State = 'draft' | 'published'
330
451
  type Action = 'publish'
452
+ type Post = { status: State }
331
453
 
332
- const machine = new SimpleStateMachine<
454
+ const postMachine = createStateMachine<
333
455
  State,
334
456
  Action,
335
- { status: State },
457
+ Post,
336
458
  { publish: { notify: boolean } }
337
- >(
338
- {
339
- stateActionMap: { draft: { publish: 'published' }, published: {} },
340
- stateTransitions: { draft: ['published'], published: [] }
459
+ >({
460
+ initialState: 'draft',
461
+ stateActionMap: { draft: { publish: 'published' }, published: {} },
462
+ stateTransitions: { draft: ['published'], published: [] },
463
+ actionValidators: {
464
+ publish: (post) => post.status === 'draft' || 'Already published'
341
465
  },
342
- { status: 'draft' },
343
- 'status'
344
- )
466
+ applyTransition: true,
467
+ onTransition: async (post, from, to) => savePost(post)
468
+ })
345
469
 
470
+ const machine = postMachine.for({ status: 'draft' }, 'status')
471
+ await machine.canExecute('publish') // true | 'reason'
346
472
  const result = await machine.executeAction('publish', { notify: true })
473
+ machine.getCurrentStatus() // 'published'
347
474
  ```
348
475
 
476
+ `new SimpleStateMachine(config, item, stateKey)` still works for one-off use.
477
+ Validators run in order: `globalValidator`, `validators[toState]`, then
478
+ `actionValidators[action]`.
479
+
349
480
  ### typedSwitch
350
481
 
351
482
  `typedSwitch` is an exhaustive switch helper for string unions and discriminated
352
- unions.
483
+ unions (string, number or boolean discriminants). `assertNever` covers regular
484
+ `switch` statements. See the [typedSwitch docs](src/typedswitch/README.md).
353
485
 
354
486
  ```ts
355
- import { typedSwitch } from 'hanzio'
487
+ import { assertNever, typedSwitch } from 'hanzio'
356
488
 
357
489
  type Event =
358
490
  | { type: 'click'; x: number; y: number }
@@ -362,17 +494,37 @@ const label = typedSwitch({ type: 'click', x: 10, y: 20 } as Event, 'type', {
362
494
  click: (event) => `Clicked ${event.x},${event.y}`,
363
495
  scroll: (event) => `Scrolled ${event.offset}`
364
496
  })
497
+
498
+ type Response = { code: 200; body: string } | { code: 404 }
499
+ const text = typedSwitch(response as Response, 'code', {
500
+ 200: (res) => res.body,
501
+ 404: () => 'Not found'
502
+ })
503
+
504
+ function area(shape: Shape) {
505
+ switch (shape.kind) {
506
+ case 'circle':
507
+ return Math.PI * shape.r ** 2
508
+ default:
509
+ return assertNever(shape.kind)
510
+ }
511
+ }
365
512
  ```
366
513
 
367
514
  ### Date
368
515
 
369
- `getPreviousReportPeriod` returns the previous monthly, quarterly, or yearly
370
- date range.
516
+ `getPeriod` returns the daily, weekly (ISO weeks by default), monthly,
517
+ quarterly or yearly range around a date, shifted by `offset` periods. `end` is
518
+ the start of the last day, so compare with `< addPeriod(end, 'DAILY', 1)` for
519
+ an exclusive bound. Pass `{ utc: true }` on servers.
371
520
 
372
521
  ```ts
373
- import { getPreviousReportPeriod } from 'hanzio'
522
+ import { addPeriod, getPeriod, getPreviousReportPeriod } from 'hanzio'
374
523
 
524
+ const thisQuarter = getPeriod('QUARTERLY', new Date('2026-04-15'))
525
+ const lastWeek = getPeriod('WEEKLY', new Date(), -1, { utc: true })
375
526
  const previousMonth = getPreviousReportPeriod('MONTHLY', new Date('2026-04-15'))
527
+ addPeriod(new Date(2026, 0, 31), 'MONTHLY', 1) // Feb 28 2026
376
528
  ```
377
529
 
378
530
  `formatPeriodLabel` formats a date range for display.
@@ -380,56 +532,230 @@ const previousMonth = getPreviousReportPeriod('MONTHLY', new Date('2026-04-15'))
380
532
  ```ts
381
533
  import { formatPeriodLabel } from 'hanzio'
382
534
 
383
- const label = formatPeriodLabel('2026-01-01', '2026-03-31')
535
+ formatPeriodLabel('2026-01-01', '2026-03-31')
536
+ // "January 2026 — March 2026"
537
+ formatPeriodLabel('2026-01-01', '2026-03-31', { collapse: true })
538
+ // "January – March 2026"
539
+ formatPeriodLabel(start, end, { locale: 'da-DK', includeDay: true, timeZone: 'UTC' })
384
540
  ```
385
541
 
386
542
  ### URL
387
543
 
388
- `getDomainFaviconUrl` builds a Google favicon URL for a domain.
544
+ `buildUrl` joins a base and path, fills `:params` and appends a query string.
545
+ `buildQueryString` repeats array keys and skips nullish values.
546
+
547
+ ```ts
548
+ import { buildQueryString, buildUrl, normalizeDomain } from 'hanzio'
549
+
550
+ buildUrl('https://api.example.com/', '/users/:id', {
551
+ params: { id: 7 },
552
+ query: { expand: ['teams', 'roles'] }
553
+ })
554
+ // "https://api.example.com/users/7?expand=teams&expand=roles"
555
+
556
+ buildQueryString({ page: 2, tag: ['a', 'b'], q: undefined }) // "page=2&tag=a&tag=b"
557
+ normalizeDomain('example.com/about') // "https://example.com"
558
+ ```
559
+
560
+ `getDomainFaviconUrl` builds a Google favicon URL from a domain or full URL.
389
561
 
390
562
  ```ts
391
563
  import { getDomainFaviconUrl } from 'hanzio'
392
564
 
393
- const favicon = getDomainFaviconUrl('example.com', 64)
565
+ const favicon = getDomainFaviconUrl('https://example.com/pricing', 64)
566
+ ```
567
+
568
+ `trimTrailingSlash`, `ensureLeadingSlash`, `buildMailto` (encodes with `%20`,
569
+ as mail clients expect) and `parseRetryAfter` (delta-seconds or HTTP date → ms).
570
+
571
+ ```ts
572
+ import { buildMailto, parseRetryAfter, trimTrailingSlash } from 'hanzio'
573
+
574
+ trimTrailingSlash('https://api.example.com//') // "https://api.example.com"
575
+ buildMailto({ to: 'sales@example.com', subject: 'Hi there' })
576
+ // "mailto:sales@example.com?subject=Hi%20there"
577
+ parseRetryAfter(response.headers.get('retry-after')) // ms | undefined
394
578
  ```
395
579
 
396
580
  ### Math
397
581
 
398
- `linearRegressionTrend` returns fitted trend values for a numeric series.
582
+ `linearRegression` fits a least-squares line and returns `slope`,
583
+ `intercept`, `r2` and `fitted` values; `linearRegressionTrend` returns just the
584
+ fitted values. `clamp`, `lerp` and `normalize` cover common scaling.
399
585
 
400
586
  ```ts
401
- import { linearRegressionTrend } from 'hanzio'
587
+ import { clamp, linearRegression, normalize } from 'hanzio'
402
588
 
403
- const trend = linearRegressionTrend([1, 3, 5])
404
- // [1, 3, 5]
589
+ const fit = linearRegression([1, 3, 5])
590
+ // { slope: 2, intercept: 1, r2: 1, fitted: [1, 3, 5] }
591
+ clamp(12, 0, 10) // 10
592
+ normalize(15, 10, 20) // 0.5
405
593
  ```
406
594
 
595
+ ### Color
596
+
407
597
  `hashStringToColorIndex`, `seriesColorKey`, `resolveSeriesColorMap`,
408
598
  `createResolveColorsByKeys`, `withAlpha`, and `getHeatmapCellStyle` help assign
409
- stable chart colors and heatmap styles.
599
+ stable chart colors and heatmap styles. Palette size defaults to 10 and can be
600
+ passed as the last argument.
410
601
 
411
602
  ```ts
412
603
  import { createResolveColorsByKeys, withAlpha } from 'hanzio'
413
604
 
605
+ const palette = ['#2563eb', '#16a34a', '#f59e0b', '#dc2626']
414
606
  const colors = createResolveColorsByKeys(
415
607
  ['revenue', 'profit'],
416
- (index) => ['#2563eb', '#16a34a'][index] ?? '#64748b',
417
- (index) => ['#1d4ed8', '#15803d'][index] ?? '#475569'
608
+ (index) => palette[index]!,
609
+ (index) => withAlpha(palette[index]!, 0.8),
610
+ palette.length
418
611
  )
419
- const faded = withAlpha('#2563eb', 0.4)
612
+
613
+ withAlpha('#2563eb', 0.4) // "rgba(37, 99, 235, 0.4)"
614
+ withAlpha('hsl(220 83% 53%)', 0.4) // "hsla(220, 83%, 53%, 0.4)"
615
+ withAlpha('var(--brand)', 0.4) // "color-mix(in srgb, var(--brand) 40%, transparent)"
616
+ ```
617
+
618
+ `parseColor` reads hex, `rgb()` and `hsl()` strings; `contrastText`,
619
+ `contrastRatio` and `relativeLuminance` follow WCAG 2.x; `shade`, `mixColors`,
620
+ `toHex` and `toRgbString` convert and adjust colors. `pickFromPalette` picks a
621
+ stable palette entry for a string (avatars, calendars).
622
+
623
+ ```ts
624
+ import { contrastText, pickFromPalette, shade } from 'hanzio'
625
+
626
+ const text = contrastText(brandColor) // '#ffffff' or '#000000'
627
+ const hover = shade(brandColor, -0.1) // 10% darker
628
+ const avatarColor = pickFromPalette(user.id, AVATAR_COLORS)
629
+ ```
630
+
631
+ ### Errors and assertions
632
+
633
+ `getErrorMessage` and `toError` normalize `unknown` catch values. `must` and
634
+ `invariant` replace "if missing, throw" blocks and non-null assertions; they
635
+ throw `InvariantError`, or your own error when you pass a function.
636
+
637
+ ```ts
638
+ import { getErrorMessage, invariant, must, toError } from 'hanzio'
639
+
640
+ try {
641
+ await sync()
642
+ } catch (error) {
643
+ log.error(getErrorMessage(error, 'Sync failed'))
644
+ throw toError(error, 'sync')
645
+ }
646
+
647
+ const user = must(await findUser(id), () => new ORPCError('NOT_FOUND'))
648
+ const first = must(rows[0], 'Expected at least one row')
649
+ invariant(order.status === 'paid', 'Order must be paid before shipping')
650
+ ```
651
+
652
+ ### Type guards
653
+
654
+ ```ts
655
+ import {
656
+ hasKey,
657
+ isDefined,
658
+ isOneOf,
659
+ isRecord,
660
+ isTruthy,
661
+ nonEmpty
662
+ } from 'hanzio'
663
+
664
+ const STATUSES = ['draft', 'published'] as const
665
+ if (isOneOf(STATUSES, input)) input // 'draft' | 'published'
666
+ const valid = values.filter(isOneOf(STATUSES))
667
+
668
+ if (isRecord(json) && hasKey(LABELS, json.kind)) LABELS[json.kind]
669
+ const ids = rows.map((row) => row.id).filter(isDefined) // string[]
670
+ const parts = [a, b && `(${b})`].filter(isTruthy)
671
+ const Status = z.enum(nonEmpty(statuses)) // [T, ...T[]]
672
+ ```
673
+
674
+ Also: `isPlainObject`, `isString`, `isNumber`, `isBoolean`, `isFunction`,
675
+ `isNonEmpty`.
676
+
677
+ ### Objects
678
+
679
+ Typed versions of the `Object.*` helpers that otherwise need casts, plus
680
+ `pick`, `omit`, `mapValues` and dot-path access (which rejects `__proto__`,
681
+ `constructor` and `prototype` segments).
682
+
683
+ ```ts
684
+ import {
685
+ getAtPath,
686
+ omit,
687
+ pick,
688
+ recordFromKeys,
689
+ setAtPath,
690
+ typedEntries,
691
+ typedKeys
692
+ } from 'hanzio'
693
+
694
+ for (const key of typedKeys(config)) config[key]
695
+ for (const [key, value] of typedEntries(config)) {}
696
+ const enabled = recordFromKeys(FEATURES, (feature) => false) // Record<Feature, boolean>
697
+ const publicUser = omit(user, ['passwordHash'])
698
+
699
+ setAtPath(form, 'contacts.0.email', 'ada@example.com')
700
+ getAtPath(form, 'contacts.0.email')
420
701
  ```
421
702
 
422
- ### Zod
703
+ `typedFromEntries` is also available.
704
+
705
+ ### Functions and events
423
706
 
424
- `createZId` and `GenericId` create branded string ID schemas.
707
+ `debounce`, `keyedDebounce` (one independent debounce per key), `throttle`,
708
+ `once` and `createHeartbeat` ("send now, then at most once per interval while
709
+ active, stop when idle", e.g. typing indicators). `createEmitter` is a small
710
+ typed event emitter.
711
+
712
+ ```ts
713
+ import { createEmitter, createHeartbeat, debounce, keyedDebounce } from 'hanzio'
714
+
715
+ const save = debounce(() => persist(draft), 500, { maxWaitMs: 5_000 })
716
+ const refetch = keyedDebounce((domain: string) => invalidate(domain), 250)
717
+ refetch.call('sessions')
718
+
719
+ const typing = createHeartbeat({
720
+ send: () => socket.send({ type: 'typing' }),
721
+ onStop: () => socket.send({ type: 'typing-stopped' }),
722
+ intervalMs: 3_000,
723
+ idleMs: 5_000
724
+ })
725
+ input.addEventListener('input', () => typing.activity())
726
+
727
+ const events = createEmitter<{ login: { userId: string }; logout: undefined }>()
728
+ const off = events.on('login', ({ userId }) => track(userId))
729
+ events.emit('logout')
730
+ ```
731
+
732
+ ### Types
733
+
734
+ `MaybePromise`, `ValueOf`, `ElementOf`, `NonEmptyArray`, `Simplify` and
735
+ `Nullable`.
736
+
737
+ ```ts
738
+ import type { ElementOf, MaybePromise, ValueOf } from 'hanzio'
739
+
740
+ type ToolId = ValueOf<typeof TOOL_IDS>
741
+ type Status = ElementOf<typeof STATUSES>
742
+ type Handler = (event: Event) => MaybePromise<void>
743
+ ```
744
+
745
+ ## Subpath modules
746
+
747
+ ### Zod (`hanzio/zod`)
748
+
749
+ `createZId` and `GenericId` create branded string ID schemas, optionally
750
+ requiring a prefix.
425
751
 
426
752
  ```ts
427
753
  import { createZId, type GenericId } from 'hanzio/zod'
428
754
 
429
- const UserId = createZId('User')
755
+ const UserId = createZId('User', { prefix: 'usr_' })
430
756
  type UserId = GenericId<'User'>
431
757
 
432
- const userId: UserId = UserId.parse('user_123')
758
+ const userId: UserId = UserId.parse('usr_123')
433
759
  ```
434
760
 
435
761
  `zSingleOrArray` accepts either one schema value or an array of schema values.
@@ -443,57 +769,98 @@ Tags.parse('news')
443
769
  Tags.parse(['news', 'product'])
444
770
  ```
445
771
 
446
- `normalizeSingleOrArray` normalizes nullable single-or-array inputs to an array.
772
+ `createMinMaxFilter` builds `{ min?, max? }` filter schemas for any value type
773
+ (`zodMinMaxFilter` is the number version). `toGteLteFilter` and `toGtLtFilter`
774
+ map them to inclusive or exclusive ORM-style ranges.
447
775
 
448
776
  ```ts
449
- import { normalizeSingleOrArray } from 'hanzio/zod'
450
-
451
- const values = normalizeSingleOrArray('tag')
452
- // ['tag']
453
- ```
454
-
455
- `zodMinMaxFilter` and `toGteLteFilter` model common min/max filters.
456
-
457
- ```ts
458
- import { toGteLteFilter, zodMinMaxFilter } from 'hanzio/zod'
777
+ import { createMinMaxFilter, toGteLteFilter, zodMinMaxFilter } from 'hanzio/zod'
778
+ import { z } from 'zod'
459
779
 
460
- const filter = zodMinMaxFilter.parse({ min: 10, max: 20 })
461
- const query = toGteLteFilter(filter)
780
+ toGteLteFilter(zodMinMaxFilter.parse({ min: 10, max: 20 }))
462
781
  // { gte: 10, lte: 20 }
782
+
783
+ const DateRange = createMinMaxFilter(z.coerce.date())
784
+ toGteLteFilter(DateRange.parse({ min: '2026-01-01' }))
785
+ // { gte: Date(2026-01-01) }
463
786
  ```
464
787
 
465
- `jsonToZodSchemaMapper` maps raw JSON into a Zod schema shape with optional field
466
- mapping.
788
+ `jsonToZodSchemaMapper` maps raw JSON into a Zod schema shape. Each config
789
+ entry is either a mapper function or a nested config for an object (or array of
790
+ objects) field.
467
791
 
468
792
  ```ts
469
793
  import { jsonToZodSchemaMapper } from 'hanzio/zod'
470
794
  import { z } from 'zod'
471
795
 
472
- const User = z.object({ id: z.string(), name: z.string() })
473
- const user = jsonToZodSchemaMapper({ user_id: '1', name: 'Ada' }, User, {
474
- id: (json) => json.user_id
796
+ const User = z.object({
797
+ id: z.string(),
798
+ address: z.object({ city: z.string() })
475
799
  })
800
+ const user = jsonToZodSchemaMapper(
801
+ { user_id: '1', address: { town: 'Aarhus' } },
802
+ User,
803
+ {
804
+ id: (json) => json.user_id,
805
+ address: { city: (address) => address.town }
806
+ }
807
+ )
476
808
  ```
477
809
 
478
- `zodToTypeString` and `zodToExample` render simple documentation artifacts from
479
- Zod schemas.
810
+ `zodToTypeString` and `zodToExample` render TypeScript-like types and example
811
+ JSON from Zod 4 schemas (objects, arrays, tuples, unions, intersections, enums,
812
+ literals, records, pipes, lazy/recursive schemas and more).
480
813
 
481
814
  ```ts
482
815
  import { zodToExample, zodToTypeString } from 'hanzio/zod'
483
816
  import { z } from 'zod'
484
817
 
485
- const schema = z.object({ id: z.string() })
486
- const typeString = zodToTypeString(schema)
487
- const example = zodToExample(schema)
818
+ const schema = z.object({ id: z.string(), role: z.enum(['admin', 'user']) })
819
+ zodToTypeString(schema) // '{\n id: string\n role: "admin" | "user"\n}'
820
+ zodToExample(schema) // { id: '', role: 'admin' }
488
821
  ```
489
822
 
490
- ### API Wrapper
823
+ `createIdHelpers` binds a project's entity names once, so branded IDs can't
824
+ be swapped (`Id<'User'>` is not assignable to `Id<'Organization'>`).
825
+
826
+ ```ts
827
+ import { createIdHelpers, type Id } from 'hanzio/zod'
491
828
 
492
- `createApiClient` and `createApiWrapper` create a Zod-backed API client with
493
- typed request inputs and response data.
829
+ export const { zId, asId, parseId, safeParseId } = createIdHelpers<
830
+ 'User' | 'Organization'
831
+ >()
832
+
833
+ const Input = z.object({ userId: zId('User') })
834
+ const userId = parseId('User', params.id) // throws on empty/non-string
835
+ const fromDb = asId<'User'>(row.id) // unchecked brand
836
+ ```
837
+
838
+ `zJsonValue`/`zJsonObject` validate JSON values, `parseJson` parses and
839
+ validates in one step, and `getEnumValues`, `getObjectShape`, `unwrapSchema`
840
+ and `zodDefaults` inspect schemas (e.g. to build form defaults).
494
841
 
495
842
  ```ts
496
- import { createApiClient } from 'hanzio/api-wrapper'
843
+ import { parseJson, zJsonObject, zodDefaults } from 'hanzio/zod'
844
+
845
+ const settings = parseJson(raw, Settings) // Result<Settings, SyntaxError | ZodError>
846
+ const metadata = zJsonObject.parse(input)
847
+ const initialForm = zodDefaults(FormSchema) // defaults, first enum value, '' for strings…
848
+ ```
849
+
850
+ ### API Wrapper (`hanzio/api-wrapper`)
851
+
852
+ `createApiClient` creates a Zod-backed API client with typed request inputs and
853
+ response data (`defineEndpoint` infers path params from `'/users/:id'`),
854
+ `api.safe` for `Result`-based error handling, retries with exponential backoff, middleware (`use`),
855
+ dependency-free OpenTelemetry tracing (`otelMiddleware`), lazy
856
+ `defaultHeaders`, `checkResponse` for error envelopes, composite actions and
857
+ structured errors. Failed requests are logged with a category (server error,
858
+ rejected request, input or output validation, network, timeout) and the
859
+ details needed to fix them. `paginate` iterates cursor- or page-based endpoints. See the
860
+ [API wrapper docs](src/api-wrapper/README.md).
861
+
862
+ ```ts
863
+ import { createApiClient, isHttpResponseError, paginate } from 'hanzio/api-wrapper'
497
864
  import { z } from 'zod'
498
865
 
499
866
  const api = createApiClient({
@@ -508,105 +875,276 @@ const api = createApiClient({
508
875
  }
509
876
  })
510
877
 
511
- const result = await api.request('getUser', { reqParams: { id: 1 } })
512
- ```
513
-
514
- `ResponseValidationError` and `HttpResponseError` are thrown for invalid
515
- responses and non-OK HTTP statuses.
516
-
517
- ```ts
518
- import { HttpResponseError, ResponseValidationError } from 'hanzio/api-wrapper'
519
-
520
878
  try {
521
- await api.request('getUser', { reqParams: { id: 1 } })
879
+ const { data } = await api.getUser({ reqParams: { id: 1 } })
522
880
  } catch (error) {
523
- if (error instanceof ResponseValidationError) {
524
- console.error(error.validationIssues)
525
- }
526
- if (error instanceof HttpResponseError) console.error(error.status)
881
+ if (isHttpResponseError(error) && error.status === 404) return null
882
+ throw error
883
+ }
884
+
885
+ for await (const page of paginate(
886
+ (cursor?: string) => fetchPage(cursor),
887
+ (page) => page.nextCursor
888
+ )) {
889
+ // …
527
890
  }
528
891
  ```
529
892
 
530
- ### JWT
893
+ ### JWT (`hanzio/jwt`)
531
894
 
532
- `jwtSign` signs a payload with HS256, and `jwtVerify` verifies a token or returns
533
- `null`.
895
+ `jwtSign` signs an HMAC JWT (HS256 by default) with optional `expiresIn`,
896
+ `notBefore`, `issuer` and `audience`. `jwtVerify` returns the payload or `null`;
897
+ `jwtVerifyResult` explains failures with an error `code`. `jwtDecode` reads a
898
+ token without verifying it. See the [JWT docs](src/jwt/README.md).
534
899
 
535
900
  ```ts
536
- import { jwtSign, jwtVerify } from 'hanzio/jwt'
901
+ import { jwtSign, jwtVerify, jwtVerifyResult } from 'hanzio/jwt'
537
902
 
538
- const token = await jwtSign({ sub: 'user_1' }, 'secret')
539
- const payload = await jwtVerify<{ sub: string }>(token, 'secret')
903
+ const token = await jwtSign({ sub: 'user_1' }, secret, {
904
+ expiresIn: 3600,
905
+ audience: 'api'
906
+ })
907
+ const payload = await jwtVerify<{ sub: string }>(token, secret, {
908
+ audience: 'api',
909
+ leewaySec: 30
910
+ })
911
+
912
+ const result = await jwtVerifyResult(token, secret)
913
+ if (!result.ok) console.warn(result.error.code) // 'expired', 'invalid_signature', …
540
914
  ```
541
915
 
542
- ### Sitemap
916
+ ### Sitemap (`hanzio/sitemap`)
543
917
 
544
- `getDomainSitemap` discovers and crawls sitemap URLs for a domain.
918
+ `getDomainSitemap` discovers (robots.txt, then common locations) and crawls a
919
+ domain's sitemaps, including nested indexes and gzipped files.
920
+ `getDomainSitemapEntries` also returns `lastmod`. See the
921
+ [sitemap docs](src/sitemap/README.md).
545
922
 
546
923
  ```ts
547
- import { getDomainSitemap } from 'hanzio/sitemap'
924
+ import { getDomainSitemap, getDomainSitemapEntries } from 'hanzio/sitemap'
548
925
 
549
926
  const urls = await getDomainSitemap('example.com', {
550
927
  concurrency: 5,
551
- maxDepth: 3
928
+ filterUrls: /\/blog\//,
929
+ maxUrls: 500
930
+ })
931
+ const entries = await getDomainSitemapEntries('example.com', {
932
+ onError: (url, error) => log.warn('sitemap failed', { url, error })
552
933
  })
553
934
  ```
554
935
 
555
- ### PQueue
556
-
557
- `PQueue` runs promise-returning tasks with concurrency, priority, timeout, abort,
558
- and rate-limit controls.
936
+ `respectRobots: true` drops page URLs that the site's robots.txt disallows for
937
+ `userAgent`, reusing the robots.txt fetched during discovery.
938
+ `parseRobotsTxt` and `isAllowedByRobots` (RFC 9309: longest match, `*` and `$`
939
+ wildcards, Allow wins ties) are exported for other crawlers.
559
940
 
560
941
  ```ts
561
- import PQueue from 'hanzio/p-queue'
562
-
563
- const queue = new PQueue({ concurrency: 2 })
942
+ import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
564
943
 
565
- const result = await queue.add(async () => 'done')
566
- await queue.onIdle()
944
+ const rules = parseRobotsTxt(await (await fetch(`${origin}/robots.txt`)).text(), 'MyBot')
945
+ isAllowedByRobots(rules, 'https://example.com/private/page') // false
567
946
  ```
568
947
 
569
- `PriorityQueue` and `TimeoutError` are exported for custom queue behavior and
570
- timeout handling.
948
+ ### PQueue (`hanzio/p-queue`)
949
+
950
+ `PQueue` runs promise-returning tasks with concurrency, priority, timeout, abort,
951
+ and rate-limit controls. It's available as the default export and as the named
952
+ export `PQueue`. See the [PQueue docs](src/p-queue/README.md).
571
953
 
572
954
  ```ts
573
- import PQueue, { TimeoutError } from 'hanzio/p-queue'
955
+ import { PQueue, TimeoutError } from 'hanzio/p-queue'
956
+
957
+ const queue = new PQueue({ concurrency: 2, timeout: 5_000 })
574
958
 
575
959
  try {
576
- await new PQueue({ timeout: 100 }).add(() => new Promise(() => {}))
960
+ await queue.add(async () => 'done')
577
961
  } catch (error) {
578
962
  if (error instanceof TimeoutError) console.error('Task timed out')
579
963
  }
964
+ await queue.onIdle()
580
965
  ```
581
966
 
582
- ### Cool console log
967
+ For simple bounded-concurrency mapping, `pMap` from `hanzio` is lighter.
583
968
 
584
- ANSI-colored logging for terminals. Exports include `terminalColors`, `createCoolLogger`,
585
- `logOperationSummary`, `colorize`, and `logBanner`. By default colors and banners are
586
- development-only (`NODE_ENV !== 'production'`); loggers honor `colorsInDevOnly`
587
- and callbacks via `CoolLoggerOptions.onLog`.
969
+ ### Cool console log (`hanzio/cool-console-log`)
970
+
971
+ ANSI-colored logging for terminals: `createCoolLogger`, `startOperation`,
972
+ `logOperationSummary`, `colorize`, `logBanner`, `supportsColor` and
973
+ `terminalColors`. Colors are used only for TTY output in development
974
+ (`NODE_ENV !== 'production'`), honor `NO_COLOR`/`FORCE_COLOR`, and the module
975
+ is safe to import in browsers and Workers.
588
976
 
589
977
  ```ts
590
978
  import {
591
- createCoolLogger,
592
979
  colorize,
980
+ createCoolLogger,
593
981
  logBanner,
594
- logOperationSummary,
595
- terminalColors
596
- } from 'hanzio'
982
+ startOperation
983
+ } from 'hanzio/cool-console-log'
984
+
985
+ const log = createCoolLogger({ minLevel: 'info' })
986
+ log.info('User logged in', { userId: '123', roles: ['admin'] })
597
987
 
598
- const log = createCoolLogger()
599
- log.info('User logged in', { userId: '123' })
600
- log.warn('Rate limit approaching', { remaining: 10 })
988
+ const requestLog = log.child({ requestId: 'req_1' })
989
+ requestLog.warn('Rate limit approaching', { remaining: 10 })
601
990
 
602
- const start = performance.now()
603
- await doSomething()
604
- logOperationSummary('user.create', performance.now() - start, true)
991
+ const done = startOperation('user.create')
992
+ await createUser()
993
+ done() // "user.create ✓ · 12.3ms"
605
994
 
606
995
  console.log(colorize('OK', 'brightGreen'))
607
996
  logBanner('Server started', 'cyan')
608
997
  ```
609
998
 
999
+ ### Format (`hanzio/format`)
1000
+
1001
+ Locale-aware formatting on `Intl`, with memoized formatters (`getNumberFormat`,
1002
+ `getDateTimeFormat`, …) so lists don't create one per row. Locales are BCP-47
1003
+ tags; functions that read the clock accept `now`. See the
1004
+ [format docs](src/format/README.md).
1005
+
1006
+ ```ts
1007
+ import {
1008
+ formatBytes,
1009
+ formatCappedCount,
1010
+ formatCurrency,
1011
+ formatDuration,
1012
+ formatRelativeTime,
1013
+ pluralize
1014
+ } from 'hanzio/format'
1015
+
1016
+ formatDuration(3_900_000, 'en-US') // "1 hr, 5 min"
1017
+ formatDuration(3_900_000, 'en-US', { style: 'narrow' }) // "1h 5m"
1018
+ formatRelativeTime(message.createdAt, 'da-DK') // "for 5 minutter siden"
1019
+ formatCurrency(1299, 'DKK', 'da-DK') // "1.299,00 kr."
1020
+ formatBytes(1_572_864) // "1.5 MB"
1021
+ formatCappedCount(140) // "99+"
1022
+ pluralize(3, { one: '{count} item', other: '{count} items' }, 'en-US') // "3 items"
1023
+ ```
1024
+
1025
+ Also: `formatNumber`, `formatPercent`, `formatCompactNumber`, `splitDuration`,
1026
+ `formatClockDuration`, `formatWait`, `formatDateRange`, `formatList`.
1027
+
1028
+ ### Crypto (`hanzio/crypto`)
1029
+
1030
+ Web Crypto only, so it runs in browsers, Workers, Node and Bun. See the
1031
+ [crypto docs](src/crypto/README.md).
1032
+
1033
+ ```ts
1034
+ import {
1035
+ createSealer,
1036
+ randomToken,
1037
+ safeEqual,
1038
+ verifyHmacSignature,
1039
+ verifySvixSignature
1040
+ } from 'hanzio/crypto'
1041
+
1042
+ safeEqual(providedPassword, expectedPassword) // constant-time
1043
+
1044
+ // Meta-style webhooks: x-hub-signature-256: sha256=<hex>
1045
+ await verifyHmacSignature(appSecret, rawBody, header, { prefix: 'sha256=' })
1046
+
1047
+ // Svix / Standard Webhooks (Resend, Clerk, …)
1048
+ await verifySvixSignature({ secret: 'whsec_…', payload: rawBody, headers: request.headers })
1049
+
1050
+ // AES-256-GCM with an HKDF-derived key; output looks like enc:v1:…
1051
+ const sealer = createSealer({ secret: appSecret })
1052
+ const stored = await sealer.sealJson({ refreshToken })
1053
+ const { refreshToken: token } = await sealer.openJson<{ refreshToken: string }>(stored)
1054
+
1055
+ const apiKey = randomToken() // 32 random bytes, base64url
1056
+ ```
1057
+
1058
+ Also: `hmacSign`, `sha256`, `sha256Hex`, `randomBytes`, and base64, base64url,
1059
+ hex and UTF-8 encoders/decoders.
1060
+
1061
+ ### Storage (`hanzio/storage`)
1062
+
1063
+ Typed values persisted in `localStorage`/`sessionStorage` or an async store
1064
+ like React Native's AsyncStorage. Items never throw: corrupt or invalid data
1065
+ returns the fallback, and when storage itself fails (private mode, sandboxed
1066
+ iframes, quota) the latest value is kept in memory. See the
1067
+ [storage docs](src/storage/README.md).
1068
+
1069
+ ```ts
1070
+ import { createAsyncStorageItem, createStorageItem } from 'hanzio/storage'
1071
+
1072
+ const panelPrefs = createStorageItem({
1073
+ key: 'panel-prefs',
1074
+ fallback: { open: false, width: 320 },
1075
+ parse: PanelPrefs.parse // zod schema; invalid data → fallback
1076
+ })
1077
+ panelPrefs.set({ ...panelPrefs.get(), open: true })
1078
+ const off = panelPrefs.subscribe((prefs) => apply(prefs)) // other tabs
1079
+
1080
+ const inbox = createAsyncStorageItem({ key: 'inbox', fallback: [], storage: AsyncStorage })
1081
+ await inbox.get()
1082
+ ```
1083
+
1084
+ ### i18n (`hanzio/i18n`)
1085
+
1086
+ Typed `{placeholder}` interpolation and a small typed translator. Params are
1087
+ required exactly when a message has placeholders. See the
1088
+ [i18n docs](src/i18n/README.md).
1089
+
1090
+ ```ts
1091
+ import { createTranslator, interpolate } from 'hanzio/i18n'
1092
+
1093
+ interpolate('Hi {name}, you have {count} messages', { name: 'Ada', count: 3 })
1094
+
1095
+ const t = createTranslator({
1096
+ inbox: { title: 'Inbox', unread: '{count} unread' }
1097
+ } as const)
1098
+ t('inbox.title')
1099
+ t('inbox.unread', { count: 4 })
1100
+ // @ts-expect-error missing params
1101
+ t('inbox.unread')
1102
+ ```
1103
+
1104
+ ### Secrets (`hanzio/secrets`)
1105
+
1106
+ `defineSecretSet` loads a typed set of required secrets from `process.env` or
1107
+ Infisical before the rest of the program starts.
1108
+
1109
+ When both `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are set, it uses
1110
+ Infisical's HTTP API. If either is missing, it uses your signed-in Infisical CLI
1111
+ session. For local development, install the CLI and run
1112
+ `infisical login --domain=https://eu.infisical.com` (or your configured
1113
+ `siteUrl`). See the [secrets documentation](src/secrets/README.md) for details.
1114
+
1115
+ ```ts
1116
+ import { defineSecretSet } from 'hanzio/secrets'
1117
+
1118
+ export const backendSecrets = await defineSecretSet(
1119
+ ['DATABASE_URL', 'APP_SECRET'] as const,
1120
+ {
1121
+ organizationId: 'infisical-organization-id',
1122
+ projectId: 'infisical-project-id'
1123
+ }
1124
+ )
1125
+
1126
+ const databaseUrl = backendSecrets.secret('DATABASE_URL')
1127
+ ```
1128
+
1129
+ The required `organizationId` scopes the CLI session per secret set, allowing projects
1130
+ in different organizations to run together without switching the active CLI
1131
+ organization. This flow reads the CLI user token and fetches secrets over HTTP;
1132
+ tokens remain in memory. Use `writeToProcessEnv: false` for multiple sets in one
1133
+ process. Both `organizationId` and `projectId` are required for Infisical loading.
1134
+
1135
+ For Vite config, `viteSecretSetPlugin(secretSet)` exposes the loaded values as
1136
+ `import.meta.env.*` replacements. Use `getViteDefine(secretSet)` if you need the
1137
+ raw `define` object. Vite helpers accept only `VITE_` keys by default; use an
1138
+ explicit `publicKeys` list to select public values from a mixed set. Import
1139
+ these helpers from `hanzio/secrets/vite`.
1140
+
1141
+ The helper supports `auth: 'auto' | 'http' | 'cli'`, `secretPath`, deadlines,
1142
+ cancellation, optional schema validation, per-key source metadata, and
1143
+ `writeToProcessEnv: false` for isolated sets. Use `hanzio/secrets/worker` for
1144
+ Cloudflare bindings without Node compatibility. See the
1145
+ [migration notes](src/secrets/README.md#migration-notes) for stricter environment
1146
+ validation and browser exposure rules.
1147
+
610
1148
  ## Development
611
1149
 
612
1150
  ```bash
@@ -615,3 +1153,12 @@ bun test
615
1153
  bun run lint
616
1154
  bun run build
617
1155
  ```
1156
+
1157
+ ## Releasing
1158
+
1159
+ Releases are cut by [Release Please](https://github.com/googleapis/release-please)
1160
+ from squash-merged PR titles written as conventional commits. `feat:` bumps the
1161
+ minor version, `fix:` the patch, `feat!:` or a `BREAKING CHANGE:` footer the
1162
+ major. `chore:`, `ci:`, `docs:`, `refactor:` and `test:` do not release. Each
1163
+ merge to main updates a release PR; merging that PR tags the version and
1164
+ publishes to npm. Nobody runs `npm publish` locally.