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