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.
- package/README.md +832 -219
- package/dist/api-wrapper/builders.d.ts +15 -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 +40 -2
- package/dist/api-wrapper/headers.d.ts +2 -1
- 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 +12 -10
- package/dist/api-wrapper/types.d.ts +104 -18
- package/dist/array/index.d.ts +50 -4
- package/dist/cache/createCache.d.ts +31 -0
- package/dist/cache/index.d.ts +45 -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-181e6vfw.js +29 -0
- package/dist/index-8r2vdzjg.js +87 -0
- package/dist/index-hy7kbb99.js +157 -0
- package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
- package/dist/index-k9yx6bne.js +89 -0
- package/dist/index-kj1phgrx.js +169 -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-srt06xd2.js +15 -0
- package/dist/index-wjyb9bsd.js +263 -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 +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/sitemap/index.d.ts +48 -6
- package/dist/src/api-wrapper/index.js +60 -1100
- 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 +863 -2809
- package/dist/src/jwt/index.js +138 -92
- package/dist/src/p-queue/index.js +9 -437
- package/dist/src/secrets/index.js +261 -1377
- package/dist/src/secrets/vite.js +6 -17
- package/dist/src/secrets/worker.js +12 -0
- package/dist/src/sitemap/index.js +183 -511
- package/dist/src/storage/index.js +119 -0
- package/dist/src/zod/index.js +347 -174
- 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 +37 -26
- 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 +87 -7
- package/src/api-wrapper/actions-impl.ts +11 -72
- package/src/api-wrapper/builders.ts +84 -52
- package/src/api-wrapper/create-client.ts +246 -96
- 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 +117 -2
- package/src/api-wrapper/headers.ts +19 -1
- 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 +43 -0
- package/src/api-wrapper/shared.ts +21 -2
- package/src/api-wrapper/testing.ts +48 -0
- package/src/api-wrapper/transport.ts +68 -74
- package/src/api-wrapper/types.ts +186 -18
- package/src/array/index.ts +195 -42
- package/src/cache/createCache.ts +157 -0
- package/src/cache/index.ts +235 -83
- 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 +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -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 +134 -103
- 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/infisical.js +0 -1194
- 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
|
-
```
|
|
141
|
+
const items = [
|
|
142
|
+
{ type: 'fruit', name: 'apple' },
|
|
143
|
+
{ type: 'veg', name: 'carrot' }
|
|
144
|
+
] as const
|
|
95
145
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
`
|
|
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,84 +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'
|
|
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 {
|
|
311
|
+
import { err, ok, startTimer, timed } from 'hanzio'
|
|
223
312
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
`
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
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
|
-
`
|
|
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 {
|
|
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
|
|
454
|
+
const postMachine = createStateMachine<
|
|
267
455
|
State,
|
|
268
456
|
Action,
|
|
269
|
-
|
|
457
|
+
Post,
|
|
270
458
|
{ publish: { notify: boolean } }
|
|
271
|
-
>(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
`
|
|
304
|
-
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.
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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 {
|
|
587
|
+
import { clamp, linearRegression, normalize } from 'hanzio'
|
|
336
588
|
|
|
337
|
-
const
|
|
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) => [
|
|
351
|
-
(index) => [
|
|
608
|
+
(index) => palette[index]!,
|
|
609
|
+
(index) => withAlpha(palette[index]!, 0.8),
|
|
610
|
+
palette.length
|
|
352
611
|
)
|
|
353
|
-
|
|
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
|
-
###
|
|
631
|
+
### Errors and assertions
|
|
357
632
|
|
|
358
|
-
`
|
|
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('
|
|
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
|
-
`
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
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({
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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.
|
|
879
|
+
const { data } = await api.getUser({ reqParams: { id: 1 } })
|
|
456
880
|
} catch (error) {
|
|
457
|
-
if (error
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
`
|
|
492
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
const queue = new PQueue({ concurrency: 2 })
|
|
942
|
+
import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
|
|
498
943
|
|
|
499
|
-
const
|
|
500
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
967
|
+
For simple bounded-concurrency mapping, `pMap` from `hanzio` is lighter.
|
|
517
968
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
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.
|