hanzio 2.0.0 → 2.2.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 +5 -6
- 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-wjyb9bsd.js → index-hyt6c56h.js} +39 -12
- package/dist/index-ktynw3vs.js +37 -0
- package/dist/{index-181e6vfw.js → index-w882zry3.js} +1 -1
- package/dist/{index-j1ybktbr.js → index-zbj9s3s3.js} +6 -4
- package/dist/secrets/core.d.ts +7 -0
- package/dist/secrets/index.d.ts +3 -1
- package/dist/secrets/public-config.d.ts +7 -0
- package/dist/secrets/vite.d.ts +1 -0
- package/dist/secrets/worker.d.ts +1 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/secrets/index.js +14 -8
- package/dist/src/secrets/vite.js +5 -1
- package/dist/src/secrets/worker.js +8 -4
- package/package.json +10 -3
- package/src/api-wrapper/README.md +738 -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/secrets/core.ts +72 -16
- package/src/secrets/index.ts +16 -4
- package/src/secrets/public-config.ts +47 -0
- package/src/secrets/vite.ts +2 -0
- package/src/secrets/worker.ts +2 -0
- package/src/sitemap/README.md +61 -0
- package/src/storage/README.md +71 -0
- package/src/typedswitch/README.md +319 -0
- package/dist/index-srt06xd2.js +0 -15
|
@@ -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.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Typed `{name}` placeholders, interpolation and a small typed translator.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# hanzio/i18n
|
|
6
|
+
|
|
7
|
+
A typed helper for `{name}` message templates, not an i18n framework: no
|
|
8
|
+
plurals, ICU, locale negotiation or escaping.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createTranslator, extractPlaceholders, interpolate } from 'hanzio/i18n'
|
|
12
|
+
|
|
13
|
+
interpolate('Hi {name}, you have {count} messages', { name: 'Ada', count: 3 })
|
|
14
|
+
// 'Hi Ada, you have 3 messages'
|
|
15
|
+
|
|
16
|
+
interpolate('Hi {name}') // type error: params required
|
|
17
|
+
interpolate('Hi {name}', { nam: 'Ada' }) // type error: misspelled
|
|
18
|
+
|
|
19
|
+
extractPlaceholders('{name} has {count} items, {name}') // ['name', 'count']
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Placeholders
|
|
23
|
+
|
|
24
|
+
A placeholder is `{` + word characters (`[A-Za-z0-9_]`) + `}`. Anything else is
|
|
25
|
+
plain text: `{ name }`, `{}`, `{count, plural, ...}`. The types and the runtime
|
|
26
|
+
use the same rule.
|
|
27
|
+
|
|
28
|
+
- A placeholder without a param (or with `null`/`undefined`) stays as `{name}`.
|
|
29
|
+
- Only own properties of `params` are used, so `{toString}` is never filled from
|
|
30
|
+
`Object.prototype`.
|
|
31
|
+
- Values are `string | number`, converted with `String()`.
|
|
32
|
+
|
|
33
|
+
Types:
|
|
34
|
+
|
|
35
|
+
| Type | `'Hi {name}, {count}'` |
|
|
36
|
+
| ------------------------ | -------------------------------------------- |
|
|
37
|
+
| `PlaceholderNames<S>` | `'name' \| 'count'` |
|
|
38
|
+
| `PlaceholderParams<S>` | `{ name: string \| number; count: ... }` |
|
|
39
|
+
| `PlaceholderArgs<S>` | `[params: PlaceholderParams<S>]`, `[]` if none |
|
|
40
|
+
|
|
41
|
+
For a non-literal `string` template, names are `string` and params are optional.
|
|
42
|
+
|
|
43
|
+
## Translator
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const messages = {
|
|
47
|
+
common: { save: 'Save' },
|
|
48
|
+
greeting: 'Hi {name}'
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
const t = createTranslator(messages)
|
|
52
|
+
|
|
53
|
+
t('common.save') // 'Save'
|
|
54
|
+
t('greeting', { name: 'Ada' }) // 'Hi Ada'
|
|
55
|
+
t('greeting') // type error: params required
|
|
56
|
+
t('common.save', { name: 'Ada' }) // type error: no placeholders
|
|
57
|
+
t('common.missing') // type error: unknown key
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Keys are the dot paths of the string leaves (`MessageKeys<T>`). Pass the
|
|
61
|
+
messages inline or `as const` so the templates stay literal; with plain
|
|
62
|
+
`Record<string, string>` messages (for example JSON imports) keys are `string`
|
|
63
|
+
and params are optional. A key that is missing at runtime returns the key.
|
|
64
|
+
|
|
65
|
+
`flattenMessages(messages)` gives the flat map: `{ 'common.save': 'Save', greeting: 'Hi {name}' }`.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: JWT sign/verify/decode using built-in Web Crypto HMAC (HS256/HS384/HS512).
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# hanzio/jwt
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { jwtDecode, jwtSign, jwtVerify, jwtVerifyResult } from 'hanzio/jwt'
|
|
9
|
+
|
|
10
|
+
const token = await jwtSign({ sub: 'user-1' }, secret, {
|
|
11
|
+
expiresIn: 60 * 60, // seconds
|
|
12
|
+
issuer: 'api',
|
|
13
|
+
audience: 'web'
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Payload or null
|
|
17
|
+
const payload = await jwtVerify<{ sub: string }>(token, secret)
|
|
18
|
+
|
|
19
|
+
// Result with a reason
|
|
20
|
+
const result = await jwtVerifyResult(token, secret, {
|
|
21
|
+
issuer: 'api',
|
|
22
|
+
audience: 'web',
|
|
23
|
+
leewaySec: 30
|
|
24
|
+
})
|
|
25
|
+
if (!result.ok) console.log(result.error.code) // 'expired', 'invalid_signature', …
|
|
26
|
+
|
|
27
|
+
// Unverified: for reading claims client-side, never for auth
|
|
28
|
+
const decoded = jwtDecode(token) // { header, payload } | null
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Signing
|
|
32
|
+
|
|
33
|
+
`jwtSign(payload, secret, options?)`
|
|
34
|
+
|
|
35
|
+
| Option | Claim | Notes |
|
|
36
|
+
| ------------ | ----- | ----------------------------------- |
|
|
37
|
+
| `expiresIn` | `exp` | seconds from now |
|
|
38
|
+
| `notBefore` | `nbf` | seconds from now |
|
|
39
|
+
| `issuer` | `iss` | |
|
|
40
|
+
| `audience` | `aud` | `string \| string[]` |
|
|
41
|
+
| `algorithm` | — | `'HS256'` (default), `'HS384'`, `'HS512'` |
|
|
42
|
+
|
|
43
|
+
- `iat` is always set to the current time.
|
|
44
|
+
- Options win: a claim set through an option replaces the same claim in `payload`.
|
|
45
|
+
Payload claims are kept when the option is omitted.
|
|
46
|
+
- Non-finite `expiresIn`/`notBefore` throw a `TypeError`.
|
|
47
|
+
- The header is `{ alg, typ: 'JWT' }`.
|
|
48
|
+
|
|
49
|
+
## Verifying
|
|
50
|
+
|
|
51
|
+
`jwtVerifyResult(token, secret, options?)` returns
|
|
52
|
+
`Result<Payload, JwtVerifyError>` (see `hanzio` promise helpers). `error.code` is one of
|
|
53
|
+
`'malformed' | 'unsupported_alg' | 'invalid_signature' | 'expired' | 'not_yet_valid' | 'invalid_issuer' | 'invalid_audience'`.
|
|
54
|
+
|
|
55
|
+
| Option | Default | Notes |
|
|
56
|
+
| ------------ | ----------------------- | ------------------------------------------ |
|
|
57
|
+
| `leewaySec` | `0` | clock skew tolerance for `exp` / `nbf` |
|
|
58
|
+
| `issuer` | — | required `iss` |
|
|
59
|
+
| `audience` | — | must equal `aud` or be one of its entries |
|
|
60
|
+
| `algorithms` | `['HS256','HS384','HS512']` | the header `alg` must be in this list |
|
|
61
|
+
|
|
62
|
+
- The signature is checked before any claim, so unsigned claims never decide the error code.
|
|
63
|
+
- `alg: 'none'` and anything outside `algorithms` is rejected as `unsupported_alg`.
|
|
64
|
+
- Configuration errors (no Web Crypto, empty secret) throw. `jwtVerify` swallows them
|
|
65
|
+
and returns `null`, like any other failure.
|
|
66
|
+
|
|
67
|
+
Imported `CryptoKey`s are cached per secret, algorithm and usage (up to 100 entries).
|
|
68
|
+
|
|
69
|
+
The base64url and UTF-8 helpers this module uses are public in
|
|
70
|
+
[`hanzio/crypto`](../crypto/README.md) (`encodeBase64Url`, `decodeBase64Url`, …).
|