hanzio 1.1.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 (172) hide show
  1. package/README.md +832 -219
  2. package/dist/api-wrapper/builders.d.ts +15 -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 +40 -2
  7. package/dist/api-wrapper/headers.d.ts +2 -1
  8. package/dist/api-wrapper/index.d.ts +5 -0
  9. package/dist/api-wrapper/otel.d.ts +44 -0
  10. package/dist/api-wrapper/paginate.d.ts +12 -0
  11. package/dist/api-wrapper/redaction.d.ts +2 -0
  12. package/dist/api-wrapper/shared.d.ts +16 -2
  13. package/dist/api-wrapper/testing.d.ts +23 -0
  14. package/dist/api-wrapper/transport.d.ts +12 -10
  15. package/dist/api-wrapper/types.d.ts +104 -18
  16. package/dist/array/index.d.ts +50 -4
  17. package/dist/cache/createCache.d.ts +31 -0
  18. package/dist/cache/index.d.ts +45 -2
  19. package/dist/color/contrast.d.ts +40 -0
  20. package/dist/color/index.d.ts +2 -0
  21. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  22. package/dist/cool-console-log/index.d.ts +34 -5
  23. package/dist/crypto/encoding.d.ts +18 -0
  24. package/dist/crypto/hash.d.ts +33 -0
  25. package/dist/crypto/index.d.ts +4 -0
  26. package/dist/crypto/sealer.d.ts +25 -0
  27. package/dist/crypto/webhook.d.ts +20 -0
  28. package/dist/date/index.d.ts +46 -2
  29. package/dist/emitter/index.d.ts +21 -0
  30. package/dist/error/index.d.ts +12 -0
  31. package/dist/format/index.d.ts +102 -0
  32. package/dist/function/index.d.ts +60 -0
  33. package/dist/guard/index.d.ts +20 -0
  34. package/dist/i18n/index.d.ts +37 -0
  35. package/dist/index-0zvtcbzy.js +224 -0
  36. package/dist/index-181e6vfw.js +29 -0
  37. package/dist/index-8r2vdzjg.js +87 -0
  38. package/dist/index-hy7kbb99.js +157 -0
  39. package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
  40. package/dist/index-k9yx6bne.js +89 -0
  41. package/dist/index-kj1phgrx.js +169 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-srt06xd2.js +15 -0
  46. package/dist/index-wjyb9bsd.js +263 -0
  47. package/dist/index.d.ts +7 -7
  48. package/dist/jwt/index.d.ts +50 -2
  49. package/dist/math/index.d.ts +2 -2
  50. package/dist/math/linearRegressionTrend.d.ts +14 -1
  51. package/dist/math/scale.d.ts +8 -0
  52. package/dist/object/index.d.ts +19 -0
  53. package/dist/p-queue/index.d.ts +1 -1
  54. package/dist/promise/index.d.ts +97 -2
  55. package/dist/secrets/core.d.ts +47 -0
  56. package/dist/secrets/deadline.d.ts +6 -0
  57. package/dist/secrets/environment.d.ts +9 -0
  58. package/dist/secrets/errors.d.ts +11 -0
  59. package/dist/secrets/index.d.ts +13 -25
  60. package/dist/secrets/infisical-cli.d.ts +6 -0
  61. package/dist/secrets/infisical-config.d.ts +17 -0
  62. package/dist/secrets/infisical-session.d.ts +4 -0
  63. package/dist/secrets/infisical.d.ts +8 -13
  64. package/dist/secrets/loaders.d.ts +5 -12
  65. package/dist/secrets/vite.d.ts +7 -3
  66. package/dist/secrets/worker-loader.d.ts +9 -0
  67. package/dist/secrets/worker.d.ts +3 -0
  68. package/dist/sitemap/index.d.ts +48 -6
  69. package/dist/src/api-wrapper/index.js +60 -1100
  70. package/dist/src/api-wrapper/testing.js +20 -0
  71. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  72. package/dist/src/crypto/index.js +195 -0
  73. package/dist/src/format/index.js +211 -0
  74. package/dist/src/i18n/index.js +36 -0
  75. package/dist/src/index.js +863 -2809
  76. package/dist/src/jwt/index.js +138 -92
  77. package/dist/src/p-queue/index.js +9 -437
  78. package/dist/src/secrets/index.js +261 -1377
  79. package/dist/src/secrets/vite.js +6 -17
  80. package/dist/src/secrets/worker.js +12 -0
  81. package/dist/src/sitemap/index.js +183 -511
  82. package/dist/src/storage/index.js +119 -0
  83. package/dist/src/zod/index.js +347 -174
  84. package/dist/state/index.d.ts +21 -0
  85. package/dist/storage/index.d.ts +58 -0
  86. package/dist/string/index.d.ts +75 -2
  87. package/dist/typedswitch/index.d.ts +37 -26
  88. package/dist/types/index.d.ts +11 -0
  89. package/dist/url/index.d.ts +50 -1
  90. package/dist/zod/index.d.ts +32 -3
  91. package/dist/zod/introspect.d.ts +19 -0
  92. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  93. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  94. package/dist/zod/zodToTypeString.d.ts +8 -3
  95. package/package.json +87 -7
  96. package/src/api-wrapper/actions-impl.ts +11 -72
  97. package/src/api-wrapper/builders.ts +84 -52
  98. package/src/api-wrapper/create-client.ts +246 -96
  99. package/src/api-wrapper/describe.ts +157 -0
  100. package/src/api-wrapper/endpoint.ts +26 -0
  101. package/src/api-wrapper/error-reporting.ts +7 -1
  102. package/src/api-wrapper/errors.ts +117 -2
  103. package/src/api-wrapper/headers.ts +19 -1
  104. package/src/api-wrapper/index.ts +5 -0
  105. package/src/api-wrapper/otel.ts +139 -0
  106. package/src/api-wrapper/paginate.ts +28 -0
  107. package/src/api-wrapper/redaction.ts +43 -0
  108. package/src/api-wrapper/shared.ts +21 -2
  109. package/src/api-wrapper/testing.ts +48 -0
  110. package/src/api-wrapper/transport.ts +68 -74
  111. package/src/api-wrapper/types.ts +186 -18
  112. package/src/array/index.ts +195 -42
  113. package/src/cache/createCache.ts +157 -0
  114. package/src/cache/index.ts +235 -83
  115. package/src/color/contrast.ts +163 -0
  116. package/src/color/index.ts +22 -0
  117. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  118. package/src/cool-console-log/index.ts +128 -41
  119. package/src/crypto/encoding.ts +83 -0
  120. package/src/crypto/hash.ts +170 -0
  121. package/src/crypto/index.ts +26 -0
  122. package/src/crypto/sealer.ts +168 -0
  123. package/src/crypto/webhook.ts +86 -0
  124. package/src/date/index.ts +109 -34
  125. package/src/emitter/index.ts +82 -0
  126. package/src/error/index.ts +63 -0
  127. package/src/format/index.ts +389 -0
  128. package/src/function/index.ts +201 -0
  129. package/src/guard/index.ts +80 -0
  130. package/src/i18n/index.ts +125 -0
  131. package/src/index.ts +11 -7
  132. package/src/jwt/index.ts +199 -125
  133. package/src/math/index.ts +5 -10
  134. package/src/math/linearRegressionTrend.ts +33 -8
  135. package/src/math/scale.ts +17 -0
  136. package/src/object/index.ts +99 -0
  137. package/src/p-queue/index.ts +2 -0
  138. package/src/promise/index.ts +304 -9
  139. package/src/secrets/core.ts +348 -0
  140. package/src/secrets/deadline.ts +69 -0
  141. package/src/secrets/environment.ts +69 -0
  142. package/src/secrets/errors.ts +29 -0
  143. package/src/secrets/index.ts +79 -210
  144. package/src/secrets/infisical-cli.ts +99 -0
  145. package/src/secrets/infisical-config.ts +70 -0
  146. package/src/secrets/infisical-session.ts +42 -0
  147. package/src/secrets/infisical.ts +214 -66
  148. package/src/secrets/loaders.ts +24 -48
  149. package/src/secrets/vite.ts +44 -15
  150. package/src/secrets/worker-loader.ts +56 -0
  151. package/src/secrets/worker.ts +4 -0
  152. package/src/sitemap/index.ts +293 -130
  153. package/src/state/index.ts +133 -81
  154. package/src/storage/index.ts +187 -0
  155. package/src/string/index.ts +261 -23
  156. package/src/typedswitch/index.ts +134 -103
  157. package/src/types/index.ts +14 -0
  158. package/src/url/index.ts +146 -1
  159. package/src/zod/index.ts +105 -9
  160. package/src/zod/introspect.ts +103 -0
  161. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  162. package/src/zod/zodMinMaxFilter.ts +37 -13
  163. package/src/zod/zodToTypeString.ts +184 -181
  164. package/dist/api-wrapper/index.js +0 -250
  165. package/dist/gzip/index.d.ts +0 -2
  166. package/dist/gzip/index.js +0 -16
  167. package/dist/index.js +0 -1823
  168. package/dist/jwt/index.js +0 -19
  169. package/dist/sitemap/index.js +0 -552
  170. package/dist/src/gzip/index.js +0 -16
  171. package/dist/src/secrets/infisical.js +0 -1194
  172. 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
- ```
141
+ const items = [
142
+ { type: 'fruit', name: 'apple' },
143
+ { type: 'veg', name: 'carrot' }
144
+ ] as const
95
145
 
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'
101
-
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,84 +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'
300
+
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.
220
309
 
221
310
  ```ts
222
- import { defineSecretSet } from 'hanzio/secrets'
311
+ import { err, ok, startTimer, timed } from 'hanzio'
223
312
 
224
- export const backendSecrets = await defineSecretSet(
225
- ['DATABASE_URL', 'APP_SECRET'] as const,
226
- { projectId: 'infisical-project-id' }
227
- )
313
+ function parseAge(input: string) {
314
+ const age = Number(input)
315
+ return Number.isInteger(age) ? ok(age) : err('not a number')
316
+ }
228
317
 
229
- 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())
230
323
  ```
231
324
 
232
- For Vite config, `viteSecretSetPlugin(secretSet)` exposes the loaded values as
233
- `import.meta.env.*` replacements. Use `getViteDefine(secretSet)` if you need the
234
- raw `define` object. Import Vite helpers from `hanzio/secrets/vite` so browser
235
- bundles do not pull in the Infisical SDK.
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.
328
+
329
+ ```ts
330
+ import { anySignal, raceAbort, timeoutSignal } from 'hanzio'
331
+
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.
236
353
 
237
354
  ### Cache
238
355
 
239
356
  `cacheFunction` wraps sync or async functions with TTL caching and request
240
- deduplication.
357
+ deduplication. With `refreshInBackground: true`, stale entries are served
358
+ immediately while a background refresh updates the cache
359
+ (stale-while-revalidate). Keys are built with `stableStringify`, and
360
+ `maxEntries` bounds the in-memory cache with LRU eviction.
241
361
 
242
362
  ```ts
243
363
  import { cacheFunction } from 'hanzio'
@@ -245,48 +365,126 @@ import { cacheFunction } from 'hanzio'
245
365
  const getUser = cacheFunction({
246
366
  name: 'getUser',
247
367
  fn: async (id: string) => ({ id, name: 'Ada' }),
248
- cacheTimeMs: 60_000
368
+ cacheTimeMs: 60_000,
369
+ maxEntries: 1_000
249
370
  })
250
371
 
251
372
  const user = await getUser('user_1')
252
- 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')
387
+ ```
388
+
389
+ #### External stores (e.g. Redis)
390
+
391
+ By default entries live in an in-memory `Map`. Pass a `store` to keep entries
392
+ in an external cache instead. A store implements `get`, `set`, and `delete`
393
+ (plus an optional `clear`, used by `clearCache`); hanzio never imports the
394
+ underlying client, so any backend works. Serialization is the adapter's
395
+ responsibility, and the `ttlMs` passed to `set` maps to the store's native
396
+ expiry. External stores are only supported for async functions.
397
+
398
+ ```ts
399
+ import { cacheFunction, type CacheStore, type CacheStoreEntry } from 'hanzio'
400
+ import Redis from 'ioredis'
401
+
402
+ const redis = new Redis()
403
+
404
+ const redisStore: CacheStore = {
405
+ get: async (key) => {
406
+ const raw = await redis.get(key)
407
+ return raw ? (JSON.parse(raw) as CacheStoreEntry) : undefined
408
+ },
409
+ set: async (key, entry, ttlMs) => {
410
+ await redis.set(key, JSON.stringify(entry), 'PX', ttlMs)
411
+ },
412
+ delete: async (key) => {
413
+ await redis.del(key)
414
+ }
415
+ }
416
+
417
+ const getUser = cacheFunction({
418
+ name: 'getUser',
419
+ fn: async (id: string) => ({ id, name: 'Ada' }),
420
+ cacheTimeMs: 60_000,
421
+ store: redisStore,
422
+ onStoreError: (error) => console.error('cache store failed', error)
423
+ })
424
+ ```
425
+
426
+ Store failures never break the cached function: on a failed `get` or `set`
427
+ the wrapped function falls back to calling `fn` directly, and the optional
428
+ `onStoreError` callback is invoked.
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
+ })
253
440
  ```
254
441
 
255
442
  ### State Machine
256
443
 
257
- `SimpleStateMachine` models typed states, actions, validation, and action
258
- payloads.
444
+ `createStateMachine` defines typed states, actions, validation, and action
445
+ payloads once; `.for(item, stateKey)` binds it to an entity.
259
446
 
260
447
  ```ts
261
- import { SimpleStateMachine } from 'hanzio'
448
+ import { createStateMachine } from 'hanzio'
262
449
 
263
450
  type State = 'draft' | 'published'
264
451
  type Action = 'publish'
452
+ type Post = { status: State }
265
453
 
266
- const machine = new SimpleStateMachine<
454
+ const postMachine = createStateMachine<
267
455
  State,
268
456
  Action,
269
- { status: State },
457
+ Post,
270
458
  { publish: { notify: boolean } }
271
- >(
272
- {
273
- stateActionMap: { draft: { publish: 'published' }, published: {} },
274
- 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'
275
465
  },
276
- { status: 'draft' },
277
- 'status'
278
- )
466
+ applyTransition: true,
467
+ onTransition: async (post, from, to) => savePost(post)
468
+ })
279
469
 
470
+ const machine = postMachine.for({ status: 'draft' }, 'status')
471
+ await machine.canExecute('publish') // true | 'reason'
280
472
  const result = await machine.executeAction('publish', { notify: true })
473
+ machine.getCurrentStatus() // 'published'
281
474
  ```
282
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
+
283
480
  ### typedSwitch
284
481
 
285
482
  `typedSwitch` is an exhaustive switch helper for string unions and discriminated
286
- unions.
483
+ unions (string, number or boolean discriminants). `assertNever` covers regular
484
+ `switch` statements. See the [typedSwitch docs](src/typedswitch/README.md).
287
485
 
288
486
  ```ts
289
- import { typedSwitch } from 'hanzio'
487
+ import { assertNever, typedSwitch } from 'hanzio'
290
488
 
291
489
  type Event =
292
490
  | { type: 'click'; x: number; y: number }
@@ -296,17 +494,37 @@ const label = typedSwitch({ type: 'click', x: 10, y: 20 } as Event, 'type', {
296
494
  click: (event) => `Clicked ${event.x},${event.y}`,
297
495
  scroll: (event) => `Scrolled ${event.offset}`
298
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
+ }
299
512
  ```
300
513
 
301
514
  ### Date
302
515
 
303
- `getPreviousReportPeriod` returns the previous monthly, quarterly, or yearly
304
- 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.
305
520
 
306
521
  ```ts
307
- import { getPreviousReportPeriod } from 'hanzio'
522
+ import { addPeriod, getPeriod, getPreviousReportPeriod } from 'hanzio'
308
523
 
524
+ const thisQuarter = getPeriod('QUARTERLY', new Date('2026-04-15'))
525
+ const lastWeek = getPeriod('WEEKLY', new Date(), -1, { utc: true })
309
526
  const previousMonth = getPreviousReportPeriod('MONTHLY', new Date('2026-04-15'))
527
+ addPeriod(new Date(2026, 0, 31), 'MONTHLY', 1) // Feb 28 2026
310
528
  ```
311
529
 
312
530
  `formatPeriodLabel` formats a date range for display.
@@ -314,56 +532,230 @@ const previousMonth = getPreviousReportPeriod('MONTHLY', new Date('2026-04-15'))
314
532
  ```ts
315
533
  import { formatPeriodLabel } from 'hanzio'
316
534
 
317
- 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' })
318
540
  ```
319
541
 
320
542
  ### URL
321
543
 
322
- `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.
323
561
 
324
562
  ```ts
325
563
  import { getDomainFaviconUrl } from 'hanzio'
326
564
 
327
- 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
328
578
  ```
329
579
 
330
580
  ### Math
331
581
 
332
- `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.
333
585
 
334
586
  ```ts
335
- import { linearRegressionTrend } from 'hanzio'
587
+ import { clamp, linearRegression, normalize } from 'hanzio'
336
588
 
337
- const trend = linearRegressionTrend([1, 3, 5])
338
- // [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
339
593
  ```
340
594
 
595
+ ### Color
596
+
341
597
  `hashStringToColorIndex`, `seriesColorKey`, `resolveSeriesColorMap`,
342
598
  `createResolveColorsByKeys`, `withAlpha`, and `getHeatmapCellStyle` help assign
343
- 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.
344
601
 
345
602
  ```ts
346
603
  import { createResolveColorsByKeys, withAlpha } from 'hanzio'
347
604
 
605
+ const palette = ['#2563eb', '#16a34a', '#f59e0b', '#dc2626']
348
606
  const colors = createResolveColorsByKeys(
349
607
  ['revenue', 'profit'],
350
- (index) => ['#2563eb', '#16a34a'][index] ?? '#64748b',
351
- (index) => ['#1d4ed8', '#15803d'][index] ?? '#475569'
608
+ (index) => palette[index]!,
609
+ (index) => withAlpha(palette[index]!, 0.8),
610
+ palette.length
352
611
  )
353
- 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)
354
629
  ```
355
630
 
356
- ### Zod
631
+ ### Errors and assertions
357
632
 
358
- `createZId` and `GenericId` create branded string ID schemas.
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')
701
+ ```
702
+
703
+ `typedFromEntries` is also available.
704
+
705
+ ### Functions and events
706
+
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.
359
751
 
360
752
  ```ts
361
753
  import { createZId, type GenericId } from 'hanzio/zod'
362
754
 
363
- const UserId = createZId('User')
755
+ const UserId = createZId('User', { prefix: 'usr_' })
364
756
  type UserId = GenericId<'User'>
365
757
 
366
- const userId: UserId = UserId.parse('user_123')
758
+ const userId: UserId = UserId.parse('usr_123')
367
759
  ```
368
760
 
369
761
  `zSingleOrArray` accepts either one schema value or an array of schema values.
@@ -377,57 +769,98 @@ Tags.parse('news')
377
769
  Tags.parse(['news', 'product'])
378
770
  ```
379
771
 
380
- `normalizeSingleOrArray` normalizes nullable single-or-array inputs to an array.
381
-
382
- ```ts
383
- import { normalizeSingleOrArray } from 'hanzio/zod'
384
-
385
- const values = normalizeSingleOrArray('tag')
386
- // ['tag']
387
- ```
388
-
389
- `zodMinMaxFilter` and `toGteLteFilter` model common min/max filters.
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.
390
775
 
391
776
  ```ts
392
- import { toGteLteFilter, zodMinMaxFilter } from 'hanzio/zod'
777
+ import { createMinMaxFilter, toGteLteFilter, zodMinMaxFilter } from 'hanzio/zod'
778
+ import { z } from 'zod'
393
779
 
394
- const filter = zodMinMaxFilter.parse({ min: 10, max: 20 })
395
- const query = toGteLteFilter(filter)
780
+ toGteLteFilter(zodMinMaxFilter.parse({ min: 10, max: 20 }))
396
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) }
397
786
  ```
398
787
 
399
- `jsonToZodSchemaMapper` maps raw JSON into a Zod schema shape with optional field
400
- 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.
401
791
 
402
792
  ```ts
403
793
  import { jsonToZodSchemaMapper } from 'hanzio/zod'
404
794
  import { z } from 'zod'
405
795
 
406
- const User = z.object({ id: z.string(), name: z.string() })
407
- const user = jsonToZodSchemaMapper({ user_id: '1', name: 'Ada' }, User, {
408
- id: (json) => json.user_id
796
+ const User = z.object({
797
+ id: z.string(),
798
+ address: z.object({ city: z.string() })
409
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
+ )
410
808
  ```
411
809
 
412
- `zodToTypeString` and `zodToExample` render simple documentation artifacts from
413
- 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).
414
813
 
415
814
  ```ts
416
815
  import { zodToExample, zodToTypeString } from 'hanzio/zod'
417
816
  import { z } from 'zod'
418
817
 
419
- const schema = z.object({ id: z.string() })
420
- const typeString = zodToTypeString(schema)
421
- 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' }
422
821
  ```
423
822
 
424
- ### 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'>`).
425
825
 
426
- `createApiClient` and `createApiWrapper` create a Zod-backed API client with
427
- typed request inputs and response data.
826
+ ```ts
827
+ import { createIdHelpers, type Id } from 'hanzio/zod'
828
+
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).
841
+
842
+ ```ts
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).
428
861
 
429
862
  ```ts
430
- import { createApiClient } from 'hanzio/api-wrapper'
863
+ import { createApiClient, isHttpResponseError, paginate } from 'hanzio/api-wrapper'
431
864
  import { z } from 'zod'
432
865
 
433
866
  const api = createApiClient({
@@ -442,105 +875,276 @@ const api = createApiClient({
442
875
  }
443
876
  })
444
877
 
445
- const result = await api.request('getUser', { reqParams: { id: 1 } })
446
- ```
447
-
448
- `ResponseValidationError` and `HttpResponseError` are thrown for invalid
449
- responses and non-OK HTTP statuses.
450
-
451
- ```ts
452
- import { HttpResponseError, ResponseValidationError } from 'hanzio/api-wrapper'
453
-
454
878
  try {
455
- await api.request('getUser', { reqParams: { id: 1 } })
879
+ const { data } = await api.getUser({ reqParams: { id: 1 } })
456
880
  } catch (error) {
457
- if (error instanceof ResponseValidationError) {
458
- console.error(error.validationIssues)
459
- }
460
- 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
+ // …
461
890
  }
462
891
  ```
463
892
 
464
- ### JWT
893
+ ### JWT (`hanzio/jwt`)
465
894
 
466
- `jwtSign` signs a payload with HS256, and `jwtVerify` verifies a token or returns
467
- `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).
468
899
 
469
900
  ```ts
470
- import { jwtSign, jwtVerify } from 'hanzio/jwt'
901
+ import { jwtSign, jwtVerify, jwtVerifyResult } from 'hanzio/jwt'
902
+
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
+ })
471
911
 
472
- const token = await jwtSign({ sub: 'user_1' }, 'secret')
473
- const payload = await jwtVerify<{ sub: string }>(token, 'secret')
912
+ const result = await jwtVerifyResult(token, secret)
913
+ if (!result.ok) console.warn(result.error.code) // 'expired', 'invalid_signature', …
474
914
  ```
475
915
 
476
- ### Sitemap
916
+ ### Sitemap (`hanzio/sitemap`)
477
917
 
478
- `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).
479
922
 
480
923
  ```ts
481
- import { getDomainSitemap } from 'hanzio/sitemap'
924
+ import { getDomainSitemap, getDomainSitemapEntries } from 'hanzio/sitemap'
482
925
 
483
926
  const urls = await getDomainSitemap('example.com', {
484
927
  concurrency: 5,
485
- 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 })
486
933
  })
487
934
  ```
488
935
 
489
- ### PQueue
490
-
491
- `PQueue` runs promise-returning tasks with concurrency, priority, timeout, abort,
492
- 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.
493
940
 
494
941
  ```ts
495
- import PQueue from 'hanzio/p-queue'
496
-
497
- const queue = new PQueue({ concurrency: 2 })
942
+ import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
498
943
 
499
- const result = await queue.add(async () => 'done')
500
- await queue.onIdle()
944
+ const rules = parseRobotsTxt(await (await fetch(`${origin}/robots.txt`)).text(), 'MyBot')
945
+ isAllowedByRobots(rules, 'https://example.com/private/page') // false
501
946
  ```
502
947
 
503
- `PriorityQueue` and `TimeoutError` are exported for custom queue behavior and
504
- 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).
505
953
 
506
954
  ```ts
507
- 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 })
508
958
 
509
959
  try {
510
- await new PQueue({ timeout: 100 }).add(() => new Promise(() => {}))
960
+ await queue.add(async () => 'done')
511
961
  } catch (error) {
512
962
  if (error instanceof TimeoutError) console.error('Task timed out')
513
963
  }
964
+ await queue.onIdle()
514
965
  ```
515
966
 
516
- ### Cool console log
967
+ For simple bounded-concurrency mapping, `pMap` from `hanzio` is lighter.
517
968
 
518
- ANSI-colored logging for terminals. Exports include `terminalColors`, `createCoolLogger`,
519
- `logOperationSummary`, `colorize`, and `logBanner`. By default colors and banners are
520
- development-only (`NODE_ENV !== 'production'`); loggers honor `colorsInDevOnly`
521
- 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.
522
976
 
523
977
  ```ts
524
978
  import {
525
- createCoolLogger,
526
979
  colorize,
980
+ createCoolLogger,
527
981
  logBanner,
528
- logOperationSummary,
529
- terminalColors
530
- } from 'hanzio'
982
+ startOperation
983
+ } from 'hanzio/cool-console-log'
531
984
 
532
- const log = createCoolLogger()
533
- log.info('User logged in', { userId: '123' })
534
- log.warn('Rate limit approaching', { remaining: 10 })
985
+ const log = createCoolLogger({ minLevel: 'info' })
986
+ log.info('User logged in', { userId: '123', roles: ['admin'] })
535
987
 
536
- const start = performance.now()
537
- await doSomething()
538
- logOperationSummary('user.create', performance.now() - start, true)
988
+ const requestLog = log.child({ requestId: 'req_1' })
989
+ requestLog.warn('Rate limit approaching', { remaining: 10 })
990
+
991
+ const done = startOperation('user.create')
992
+ await createUser()
993
+ done() // "user.create ✓ · 12.3ms"
539
994
 
540
995
  console.log(colorize('OK', 'brightGreen'))
541
996
  logBanner('Server started', 'cyan')
542
997
  ```
543
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
+
544
1148
  ## Development
545
1149
 
546
1150
  ```bash
@@ -549,3 +1153,12 @@ bun test
549
1153
  bun run lint
550
1154
  bun run build
551
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.