hanzio 1.2.0 → 2.1.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.
- package/README.md +786 -239
- package/dist/api-wrapper/builders.d.ts +4 -3
- package/dist/api-wrapper/create-client.d.ts +1 -1
- package/dist/api-wrapper/describe.d.ts +16 -0
- package/dist/api-wrapper/endpoint.d.ts +18 -0
- package/dist/api-wrapper/errors.d.ts +39 -2
- package/dist/api-wrapper/index.d.ts +5 -0
- package/dist/api-wrapper/otel.d.ts +44 -0
- package/dist/api-wrapper/paginate.d.ts +12 -0
- package/dist/api-wrapper/redaction.d.ts +2 -0
- package/dist/api-wrapper/shared.d.ts +16 -2
- package/dist/api-wrapper/testing.d.ts +23 -0
- package/dist/api-wrapper/transport.d.ts +4 -5
- package/dist/api-wrapper/types.d.ts +103 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +19 -2
- package/dist/color/contrast.d.ts +40 -0
- package/dist/color/index.d.ts +2 -0
- package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
- package/dist/cool-console-log/index.d.ts +34 -5
- package/dist/crypto/encoding.d.ts +18 -0
- package/dist/crypto/hash.d.ts +33 -0
- package/dist/crypto/index.d.ts +4 -0
- package/dist/crypto/sealer.d.ts +25 -0
- package/dist/crypto/webhook.d.ts +20 -0
- package/dist/date/index.d.ts +46 -2
- package/dist/emitter/index.d.ts +21 -0
- package/dist/error/index.d.ts +12 -0
- package/dist/format/index.d.ts +102 -0
- package/dist/function/index.d.ts +60 -0
- package/dist/guard/index.d.ts +20 -0
- package/dist/i18n/index.d.ts +37 -0
- package/dist/index-0zvtcbzy.js +224 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/index-hyt6c56h.js +290 -0
- package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -0
- package/dist/index-ktynw3vs.js +37 -0
- package/dist/index-mnkcfyx8.js +78 -0
- package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
- package/dist/index-rp5pqghz.js +157 -0
- package/dist/index-w882zry3.js +29 -0
- package/dist/index.d.ts +7 -7
- package/dist/jwt/index.d.ts +50 -2
- package/dist/math/index.d.ts +2 -2
- package/dist/math/linearRegressionTrend.d.ts +14 -1
- package/dist/math/scale.d.ts +8 -0
- package/dist/object/index.d.ts +19 -0
- package/dist/p-queue/index.d.ts +1 -1
- package/dist/promise/index.d.ts +97 -2
- package/dist/secrets/core.d.ts +7 -0
- package/dist/secrets/index.d.ts +3 -1
- package/dist/secrets/public-config.d.ts +7 -0
- package/dist/secrets/vite.d.ts +1 -0
- package/dist/secrets/worker.d.ts +1 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1130
- package/dist/src/api-wrapper/testing.js +20 -0
- package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
- package/dist/src/crypto/index.js +195 -0
- package/dist/src/format/index.js +211 -0
- package/dist/src/i18n/index.js +36 -0
- package/dist/src/index.js +739 -3145
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +50 -1428
- package/dist/src/secrets/vite.js +10 -39
- package/dist/src/secrets/worker.js +14 -273
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +340 -167
- package/dist/state/index.d.ts +21 -0
- package/dist/storage/index.d.ts +58 -0
- package/dist/string/index.d.ts +75 -2
- package/dist/typedswitch/index.d.ts +31 -24
- package/dist/types/index.d.ts +11 -0
- package/dist/url/index.d.ts +50 -1
- package/dist/zod/index.d.ts +32 -3
- package/dist/zod/introspect.d.ts +19 -0
- package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
- package/dist/zod/zodMinMaxFilter.d.ts +22 -3
- package/dist/zod/zodToTypeString.d.ts +8 -3
- package/package.json +78 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +32 -25
- package/src/api-wrapper/create-client.ts +171 -41
- package/src/api-wrapper/describe.ts +157 -0
- package/src/api-wrapper/endpoint.ts +26 -0
- package/src/api-wrapper/error-reporting.ts +7 -1
- package/src/api-wrapper/errors.ts +107 -2
- package/src/api-wrapper/headers.ts +11 -6
- package/src/api-wrapper/index.ts +5 -0
- package/src/api-wrapper/otel.ts +139 -0
- package/src/api-wrapper/paginate.ts +28 -0
- package/src/api-wrapper/redaction.ts +42 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +41 -43
- package/src/api-wrapper/types.ts +185 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +132 -147
- package/src/color/contrast.ts +163 -0
- package/src/color/index.ts +22 -0
- package/src/{math → color}/metricsSeriesColors.ts +47 -49
- package/src/cool-console-log/index.ts +128 -41
- package/src/crypto/encoding.ts +83 -0
- package/src/crypto/hash.ts +170 -0
- package/src/crypto/index.ts +26 -0
- package/src/crypto/sealer.ts +168 -0
- package/src/crypto/webhook.ts +86 -0
- package/src/date/index.ts +109 -34
- package/src/emitter/index.ts +82 -0
- package/src/error/index.ts +63 -0
- package/src/format/index.ts +389 -0
- package/src/function/index.ts +201 -0
- package/src/guard/index.ts +80 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +11 -7
- package/src/jwt/index.ts +199 -125
- package/src/math/index.ts +5 -10
- package/src/math/linearRegressionTrend.ts +33 -8
- package/src/math/scale.ts +17 -0
- package/src/object/index.ts +99 -0
- package/src/p-queue/index.ts +2 -0
- package/src/promise/index.ts +304 -9
- package/src/secrets/core.ts +72 -16
- package/src/secrets/index.ts +16 -4
- package/src/secrets/public-config.ts +47 -0
- package/src/secrets/vite.ts +2 -0
- package/src/secrets/worker.ts +2 -0
- package/src/sitemap/index.ts +293 -130
- package/src/state/index.ts +133 -81
- package/src/storage/index.ts +187 -0
- package/src/string/index.ts +261 -23
- package/src/typedswitch/index.ts +58 -38
- package/src/types/index.ts +14 -0
- package/src/url/index.ts +146 -1
- package/src/zod/index.ts +105 -9
- package/src/zod/introspect.ts +103 -0
- package/src/zod/jsonToZodSchemaMapper.ts +44 -21
- package/src/zod/zodMinMaxFilter.ts +37 -13
- package/src/zod/zodToTypeString.ts +184 -181
- package/dist/api-wrapper/index.js +0 -250
- package/dist/gzip/index.d.ts +0 -2
- package/dist/gzip/index.js +0 -16
- package/dist/index.js +0 -1823
- package/dist/jwt/index.js +0 -19
- package/dist/sitemap/index.js +0 -552
- package/dist/src/gzip/index.js +0 -16
- package/dist/src/secrets/loaders.js +0 -1529
- 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,
|
|
5
|
-
JWTs, sitemap crawling, queues, secrets, and colorful terminal
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
73
|
-
// [{
|
|
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`
|
|
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
|
-
|
|
82
|
-
//
|
|
130
|
+
compact(['Ada', null, 'Grace', undefined]) // string[]
|
|
131
|
+
toArray('tag') // ['tag']
|
|
132
|
+
toArray(undefined) // []
|
|
83
133
|
```
|
|
84
134
|
|
|
85
|
-
`
|
|
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 {
|
|
139
|
+
import { countBy, groupBy, keyBy } from 'hanzio'
|
|
89
140
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
'
|
|
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
|
-
|
|
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
|
-
`
|
|
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 {
|
|
155
|
+
import { range, sortBy, sumBy } from 'hanzio'
|
|
115
156
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
147
|
-
//
|
|
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
|
|
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
|
-
|
|
164
|
-
// "
|
|
213
|
+
slugify('Hello, København!') // "hello-kobenhavn"
|
|
214
|
+
slugify('Hello World', { separator: '_', maxLength: 8 }) // "hello_wo"
|
|
165
215
|
```
|
|
166
216
|
|
|
167
|
-
|
|
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
|
-
|
|
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 {
|
|
233
|
+
import { initials, matchesSearch } from 'hanzio'
|
|
173
234
|
|
|
174
|
-
|
|
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
|
-
`
|
|
178
|
-
`
|
|
240
|
+
`safeJsonParse` returns a `Result` instead of throwing; `escapeRegExp` escapes
|
|
241
|
+
text for use inside a `RegExp`.
|
|
179
242
|
|
|
180
243
|
```ts
|
|
181
|
-
import {
|
|
244
|
+
import { escapeRegExp, safeJsonParse } from 'hanzio'
|
|
182
245
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
`
|
|
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 {
|
|
268
|
+
import { retry } from 'hanzio'
|
|
194
269
|
|
|
195
|
-
|
|
196
|
-
|
|
270
|
+
const data = await retry(() => fetchReport(), {
|
|
271
|
+
retries: 5,
|
|
272
|
+
shouldRetry: (error) => !(error instanceof ValidationError),
|
|
273
|
+
signal
|
|
197
274
|
})
|
|
198
275
|
```
|
|
199
276
|
|
|
200
|
-
`
|
|
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 {
|
|
281
|
+
import { withTimeout } from 'hanzio'
|
|
204
282
|
|
|
205
|
-
|
|
283
|
+
const res = await withTimeout((signal) => fetch(url, { signal }), 5_000)
|
|
206
284
|
```
|
|
207
285
|
|
|
208
|
-
`
|
|
286
|
+
`pMap` maps with bounded concurrency and keeps input order.
|
|
209
287
|
|
|
210
288
|
```ts
|
|
211
|
-
import {
|
|
289
|
+
import { pMap } from 'hanzio'
|
|
212
290
|
|
|
213
|
-
await
|
|
291
|
+
const users = await pMap(ids, (id) => fetchUser(id), { concurrency: 4 })
|
|
214
292
|
```
|
|
215
293
|
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
298
|
+
```ts
|
|
299
|
+
import { backgroundPromise, promiseTimeout } from 'hanzio'
|
|
220
300
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 {
|
|
311
|
+
import { err, ok, startTimer, timed } from 'hanzio'
|
|
229
312
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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.
|
|
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
|
-
`
|
|
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 {
|
|
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
|
|
454
|
+
const postMachine = createStateMachine<
|
|
333
455
|
State,
|
|
334
456
|
Action,
|
|
335
|
-
|
|
457
|
+
Post,
|
|
336
458
|
{ publish: { notify: boolean } }
|
|
337
|
-
>(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
`
|
|
370
|
-
date
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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 {
|
|
587
|
+
import { clamp, linearRegression, normalize } from 'hanzio'
|
|
402
588
|
|
|
403
|
-
const
|
|
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) => [
|
|
417
|
-
(index) => [
|
|
608
|
+
(index) => palette[index]!,
|
|
609
|
+
(index) => withAlpha(palette[index]!, 0.8),
|
|
610
|
+
palette.length
|
|
418
611
|
)
|
|
419
|
-
|
|
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
|
-
|
|
703
|
+
`typedFromEntries` is also available.
|
|
704
|
+
|
|
705
|
+
### Functions and events
|
|
423
706
|
|
|
424
|
-
`
|
|
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('
|
|
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
|
-
`
|
|
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 {
|
|
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
|
-
|
|
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
|
|
466
|
-
|
|
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({
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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 {
|
|
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.
|
|
879
|
+
const { data } = await api.getUser({ reqParams: { id: 1 } })
|
|
522
880
|
} catch (error) {
|
|
523
|
-
if (error
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
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' },
|
|
539
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
`
|
|
558
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
const queue = new PQueue({ concurrency: 2 })
|
|
942
|
+
import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
|
|
564
943
|
|
|
565
|
-
const
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
967
|
+
For simple bounded-concurrency mapping, `pMap` from `hanzio` is lighter.
|
|
583
968
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
599
|
-
|
|
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
|
|
603
|
-
await
|
|
604
|
-
|
|
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.
|