hanzio 3.1.1 → 3.3.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 +8 -1
- package/dist/cli/fields.d.ts +16 -6
- package/dist/cli/index.d.ts +57 -4
- package/dist/crypto/node.d.ts +17 -0
- package/dist/crypto/sealer.d.ts +34 -2
- package/dist/format/index.d.ts +6 -3
- package/dist/{index-nfvq3zax.js → index-818f7jz4.js} +228 -88
- package/dist/index-zz773050.js +135 -0
- package/dist/redis/concurrency-limit.d.ts +17 -7
- package/dist/src/cli/index.js +3 -1
- package/dist/src/crypto/index.js +15 -106
- package/dist/src/crypto/node.js +67 -0
- package/dist/src/format/index.js +4 -3
- package/dist/src/i18n/cli.js +33 -1
- package/dist/src/redis/index.js +5 -1
- package/dist/src/testing/index.js +23 -4
- package/dist/string/index.d.ts +1 -1
- package/dist/testing/module-mocker.d.ts +9 -9
- package/dist/testing/prisma.d.ts +5 -1
- package/dist/typedswitch/index.d.ts +26 -1
- package/package.json +8 -2
- package/src/cli/README.md +63 -6
- package/src/cli/fields.ts +95 -38
- package/src/cli/index.ts +302 -91
- package/src/crypto/README.md +21 -7
- package/src/crypto/node.ts +90 -0
- package/src/crypto/sealer.ts +120 -62
- package/src/format/README.md +7 -0
- package/src/format/index.ts +11 -5
- package/src/i18n/README.md +3 -1
- package/src/i18n/cli.ts +40 -0
- package/src/redis/README.md +9 -7
- package/src/redis/concurrency-limit.ts +26 -8
- package/src/string/index.ts +1 -1
- package/src/testing/README.md +12 -3
- package/src/testing/module-mocker.ts +40 -10
- package/src/testing/prisma.ts +6 -1
- package/src/typedswitch/README.md +20 -9
- package/src/typedswitch/index.ts +84 -8
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Synchronous twin of `createSealer`, on `node:crypto`, for code that cannot
|
|
2
|
+
// await: formatters, getters, Prisma result extensions. Same options, same
|
|
3
|
+
// envelope, so either sealer opens what the other sealed.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createCipheriv,
|
|
7
|
+
createDecipheriv,
|
|
8
|
+
hkdfSync,
|
|
9
|
+
randomBytes
|
|
10
|
+
} from 'node:crypto'
|
|
11
|
+
import {
|
|
12
|
+
type Envelope,
|
|
13
|
+
formatEnvelope,
|
|
14
|
+
jsonToSeal,
|
|
15
|
+
parseEnvelope,
|
|
16
|
+
type SealerOptions,
|
|
17
|
+
SealerError,
|
|
18
|
+
sealerKeyInputs
|
|
19
|
+
} from './sealer'
|
|
20
|
+
|
|
21
|
+
export { SealerError, type SealerErrorCode, type SealerOptions } from './sealer'
|
|
22
|
+
|
|
23
|
+
export type SealerSync = {
|
|
24
|
+
seal(plaintext: string): string
|
|
25
|
+
open(sealed: string): string
|
|
26
|
+
sealJson(value: unknown): string
|
|
27
|
+
openJson<T = unknown>(sealed: unknown): T
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* AES-256-GCM sealing with a random 96-bit IV per value, synchronously.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const secrets = createSealerSync({ secret: process.env.APP_SECRET!, info: 'app.credentials.v1' })
|
|
37
|
+
* const stored = secrets.sealJson({ accessToken })
|
|
38
|
+
* const { accessToken } = secrets.openJson<{ accessToken: string }>(stored)
|
|
39
|
+
*/
|
|
40
|
+
export function createSealerSync(options: SealerOptions): SealerSync {
|
|
41
|
+
const { secret, contexts } = sealerKeyInputs(options)
|
|
42
|
+
|
|
43
|
+
const keys: Buffer[] = []
|
|
44
|
+
const getKey = (index: number) => {
|
|
45
|
+
let key = keys[index]
|
|
46
|
+
if (!key) {
|
|
47
|
+
const { salt, info } = contexts[index] as (typeof contexts)[number]
|
|
48
|
+
key = Buffer.from(hkdfSync('sha256', secret, salt, info, 32))
|
|
49
|
+
keys[index] = key
|
|
50
|
+
}
|
|
51
|
+
return key
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const seal = (plaintext: string) => {
|
|
55
|
+
const iv = randomBytes(12)
|
|
56
|
+
const cipher = createCipheriv(ALGORITHM, getKey(0), iv)
|
|
57
|
+
const ciphertext = Buffer.concat([
|
|
58
|
+
cipher.update(plaintext, 'utf8'),
|
|
59
|
+
cipher.final()
|
|
60
|
+
])
|
|
61
|
+
return formatEnvelope(iv, cipher.getAuthTag(), ciphertext)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const openEnvelope = ({ iv, tag, ciphertext }: Envelope) => {
|
|
65
|
+
for (let index = 0; index < contexts.length; index++) {
|
|
66
|
+
try {
|
|
67
|
+
const decipher = createDecipheriv(ALGORITHM, getKey(index), iv)
|
|
68
|
+
decipher.setAuthTag(tag)
|
|
69
|
+
return Buffer.concat([
|
|
70
|
+
decipher.update(ciphertext),
|
|
71
|
+
decipher.final()
|
|
72
|
+
]).toString('utf8')
|
|
73
|
+
} catch (_error) {}
|
|
74
|
+
}
|
|
75
|
+
throw new SealerError('decrypt_failed')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
seal,
|
|
80
|
+
open(sealed) {
|
|
81
|
+
const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
|
|
82
|
+
return envelope ? openEnvelope(envelope) : sealed
|
|
83
|
+
},
|
|
84
|
+
sealJson: (value) => seal(jsonToSeal(value)),
|
|
85
|
+
openJson<T = unknown>(sealed: unknown) {
|
|
86
|
+
const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
|
|
87
|
+
return (envelope ? JSON.parse(openEnvelope(envelope)) : sealed) as T
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/crypto/sealer.ts
CHANGED
|
@@ -15,13 +15,24 @@ export type SealerOptions = {
|
|
|
15
15
|
salt?: string
|
|
16
16
|
/** HKDF info; use a distinct value per purpose. Default: `'hanzio.sealer.aes-256-gcm.v1'` */
|
|
17
17
|
info?: string
|
|
18
|
+
/**
|
|
19
|
+
* Older `{ salt, info }` pairs that `open` also tries, so values sealed before
|
|
20
|
+
* a rename stay readable. `seal` always uses `salt` and `info`.
|
|
21
|
+
*/
|
|
22
|
+
fallbacks?: readonly { salt?: string; info?: string }[]
|
|
23
|
+
/**
|
|
24
|
+
* `open` and `openJson` return a value that was never sealed unchanged
|
|
25
|
+
* (`{}`, a legacy plaintext) instead of throwing. Malformed envelopes and
|
|
26
|
+
* other envelope versions still throw.
|
|
27
|
+
*/
|
|
28
|
+
passthroughUnsealed?: boolean
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export type Sealer = {
|
|
21
32
|
seal(plaintext: string): Promise<string>
|
|
22
33
|
open(sealed: string): Promise<string>
|
|
23
34
|
sealJson(value: unknown): Promise<string>
|
|
24
|
-
openJson<T = unknown>(sealed:
|
|
35
|
+
openJson<T = unknown>(sealed: unknown): Promise<T>
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export type SealerErrorCode =
|
|
@@ -50,26 +61,92 @@ const IV_BYTES = 12
|
|
|
50
61
|
const TAG_BYTES = 16
|
|
51
62
|
const DEFAULT_INFO = 'hanzio.sealer.aes-256-gcm.v1'
|
|
52
63
|
|
|
64
|
+
// The helpers below are shared with the `node:crypto` sealer in `./node`.
|
|
65
|
+
|
|
66
|
+
/** The secret's bytes and every HKDF context `open` tries, primary first. */
|
|
67
|
+
export function sealerKeyInputs(options: SealerOptions) {
|
|
68
|
+
const secret = toBytes(options.secret)
|
|
69
|
+
if (secret.length === 0) {
|
|
70
|
+
throw new TypeError('Sealer secret must not be empty')
|
|
71
|
+
}
|
|
72
|
+
const contexts = [options, ...(options.fallbacks ?? [])].map(
|
|
73
|
+
({ salt = '', info = DEFAULT_INFO }) => ({ salt, info })
|
|
74
|
+
)
|
|
75
|
+
return { secret, contexts }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatEnvelope(
|
|
79
|
+
iv: Uint8Array,
|
|
80
|
+
tag: Uint8Array,
|
|
81
|
+
ciphertext: Uint8Array
|
|
82
|
+
): string {
|
|
83
|
+
return `${PREFIX}${encodeBase64Url(iv)}.${encodeBase64Url(tag)}.${encodeBase64Url(ciphertext)}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type Envelope = {
|
|
87
|
+
iv: Uint8Array<ArrayBuffer>
|
|
88
|
+
tag: Uint8Array<ArrayBuffer>
|
|
89
|
+
ciphertext: Uint8Array<ArrayBuffer>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `null` means `sealed` was never sealed and `passthroughUnsealed` allows it. */
|
|
93
|
+
export function parseEnvelope(
|
|
94
|
+
sealed: unknown,
|
|
95
|
+
passthroughUnsealed = false
|
|
96
|
+
): Envelope | null {
|
|
97
|
+
if (typeof sealed !== 'string' || !sealed.startsWith(PREFIX)) {
|
|
98
|
+
const isEnvelope = typeof sealed === 'string' && /^enc:v\d+:/.test(sealed)
|
|
99
|
+
if (isEnvelope) throw new SealerError('unsupported_version')
|
|
100
|
+
if (passthroughUnsealed) return null
|
|
101
|
+
throw new SealerError('malformed')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parts = sealed.slice(PREFIX.length).split('.')
|
|
105
|
+
let envelope: Envelope
|
|
106
|
+
try {
|
|
107
|
+
if (parts.length !== 3) throw new Error()
|
|
108
|
+
envelope = {
|
|
109
|
+
iv: decodeBase64Url(parts[0] as string),
|
|
110
|
+
tag: decodeBase64Url(parts[1] as string),
|
|
111
|
+
ciphertext: decodeBase64Url(parts[2] as string)
|
|
112
|
+
}
|
|
113
|
+
} catch (_error) {
|
|
114
|
+
throw new SealerError('malformed')
|
|
115
|
+
}
|
|
116
|
+
if (envelope.iv.length !== IV_BYTES || envelope.tag.length !== TAG_BYTES) {
|
|
117
|
+
throw new SealerError('malformed')
|
|
118
|
+
}
|
|
119
|
+
return envelope
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function jsonToSeal(value: unknown): string {
|
|
123
|
+
const json = JSON.stringify(value)
|
|
124
|
+
if (json === undefined) {
|
|
125
|
+
throw new TypeError('sealJson value is not JSON-serializable')
|
|
126
|
+
}
|
|
127
|
+
return json
|
|
128
|
+
}
|
|
129
|
+
|
|
53
130
|
/**
|
|
54
131
|
* AES-256-GCM sealing with a random 96-bit IV per value. Envelope:
|
|
55
|
-
* `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`.
|
|
132
|
+
* `enc:v1:<b64url(iv)>.<b64url(tag)>.<b64url(ciphertext)>`. Async, on Web
|
|
133
|
+
* Crypto; `hanzio/crypto/node` has a synchronous twin with the same envelope.
|
|
56
134
|
*/
|
|
57
135
|
export function createSealer(options: SealerOptions): Sealer {
|
|
58
|
-
const { secret,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
keyPromise = undefined
|
|
136
|
+
const { secret, contexts } = sealerKeyInputs(options)
|
|
137
|
+
|
|
138
|
+
const keys: (Promise<CryptoKey> | undefined)[] = []
|
|
139
|
+
const getKey = (index: number) => {
|
|
140
|
+
let key = keys[index]
|
|
141
|
+
if (!key) {
|
|
142
|
+
const context = contexts[index] as (typeof contexts)[number]
|
|
143
|
+
key = deriveKey(secret, context.salt, context.info)
|
|
144
|
+
keys[index] = key
|
|
145
|
+
key.catch(() => {
|
|
146
|
+
keys[index] = undefined
|
|
70
147
|
})
|
|
71
148
|
}
|
|
72
|
-
return
|
|
149
|
+
return key
|
|
73
150
|
}
|
|
74
151
|
|
|
75
152
|
const seal = async (plaintext: string): Promise<string> => {
|
|
@@ -77,69 +154,50 @@ export function createSealer(options: SealerOptions): Sealer {
|
|
|
77
154
|
const encrypted = new Uint8Array(
|
|
78
155
|
await getSubtleCrypto().encrypt(
|
|
79
156
|
{ name: 'AES-GCM', iv },
|
|
80
|
-
await getKey(),
|
|
157
|
+
await getKey(0),
|
|
81
158
|
utf8Encode(plaintext)
|
|
82
159
|
)
|
|
83
160
|
)
|
|
84
161
|
const tagStart = encrypted.length - TAG_BYTES
|
|
85
|
-
return
|
|
162
|
+
return formatEnvelope(
|
|
163
|
+
iv,
|
|
164
|
+
encrypted.subarray(tagStart),
|
|
165
|
+
encrypted.subarray(0, tagStart)
|
|
166
|
+
)
|
|
86
167
|
}
|
|
87
168
|
|
|
88
|
-
const
|
|
89
|
-
if (typeof sealed !== 'string' || !sealed.startsWith(PREFIX)) {
|
|
90
|
-
const isOtherVersion =
|
|
91
|
-
typeof sealed === 'string' && /^enc:v\d+:/.test(sealed)
|
|
92
|
-
throw new SealerError(
|
|
93
|
-
isOtherVersion ? 'unsupported_version' : 'malformed'
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const parts = sealed.slice(PREFIX.length).split('.')
|
|
98
|
-
let iv: Uint8Array<ArrayBuffer>
|
|
99
|
-
let tag: Uint8Array
|
|
100
|
-
let ciphertext: Uint8Array
|
|
101
|
-
try {
|
|
102
|
-
if (parts.length !== 3) throw new Error()
|
|
103
|
-
iv = decodeBase64Url(parts[0] as string)
|
|
104
|
-
tag = decodeBase64Url(parts[1] as string)
|
|
105
|
-
ciphertext = decodeBase64Url(parts[2] as string)
|
|
106
|
-
} catch (_error) {
|
|
107
|
-
throw new SealerError('malformed')
|
|
108
|
-
}
|
|
109
|
-
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
|
110
|
-
throw new SealerError('malformed')
|
|
111
|
-
}
|
|
112
|
-
|
|
169
|
+
const openEnvelope = async ({ iv, tag, ciphertext }: Envelope) => {
|
|
113
170
|
const encrypted = new Uint8Array(ciphertext.length + TAG_BYTES)
|
|
114
171
|
encrypted.set(ciphertext)
|
|
115
172
|
encrypted.set(tag, ciphertext.length)
|
|
116
173
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
174
|
+
for (let index = 0; index < contexts.length; index++) {
|
|
175
|
+
const key = await getKey(index)
|
|
176
|
+
try {
|
|
177
|
+
return utf8Decode(
|
|
178
|
+
await getSubtleCrypto().decrypt(
|
|
179
|
+
{ name: 'AES-GCM', iv },
|
|
180
|
+
key,
|
|
181
|
+
encrypted
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
} catch (_error) {}
|
|
127
185
|
}
|
|
128
|
-
|
|
186
|
+
throw new SealerError('decrypt_failed')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const open = async (sealed: string): Promise<string> => {
|
|
190
|
+
const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
|
|
191
|
+
return envelope ? openEnvelope(envelope) : sealed
|
|
129
192
|
}
|
|
130
193
|
|
|
131
194
|
return {
|
|
132
195
|
seal,
|
|
133
196
|
open,
|
|
134
|
-
async
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
return seal(json)
|
|
140
|
-
},
|
|
141
|
-
async openJson<T = unknown>(sealed: string) {
|
|
142
|
-
return JSON.parse(await open(sealed)) as T
|
|
197
|
+
sealJson: async (value) => seal(jsonToSeal(value)),
|
|
198
|
+
async openJson<T = unknown>(sealed: unknown) {
|
|
199
|
+
const envelope = parseEnvelope(sealed, options.passthroughUnsealed)
|
|
200
|
+
return (envelope ? JSON.parse(await openEnvelope(envelope)) : sealed) as T
|
|
143
201
|
}
|
|
144
202
|
}
|
|
145
203
|
}
|
package/src/format/README.md
CHANGED
|
@@ -87,6 +87,13 @@ formatDateTime(date, 'en-GB') // '5 Mar 2026, 07:04' (newer ICU: '5 Mar 2026 at
|
|
|
87
87
|
Presets: `'DD/MM/YYYY'`, `'DD/MM/YYYY HH:mm'`, `'HH:mm'`, `'ddd'`, `'D MMM'`,
|
|
88
88
|
`'D MMM YYYY'`, `'dddd, MMMM D'`, `'MMMM YYYY'`.
|
|
89
89
|
|
|
90
|
+
For any other layout, pass `Intl.DateTimeFormat` options instead of a preset:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
formatDate(date, { weekday: 'long', day: 'numeric', month: 'long' }, 'en-GB') // 'Thursday 5 March'
|
|
94
|
+
formatDate(date, { dateStyle: 'short', timeZone: 'Europe/Copenhagen' }, 'da-DK') // '05.03.2026'
|
|
95
|
+
```
|
|
96
|
+
|
|
90
97
|
`formatCompactTime(date, locale, { now? })` is an activity timestamp for list
|
|
91
98
|
rows: the time today, "yesterday", else the date (with the year when it is not
|
|
92
99
|
this year). `formatDayDivider(date, locale, { now? })` labels a chat timeline:
|
package/src/format/index.ts
CHANGED
|
@@ -401,13 +401,17 @@ const DATE_PRESETS = {
|
|
|
401
401
|
*/
|
|
402
402
|
export type DatePreset = keyof typeof DATE_PRESETS
|
|
403
403
|
|
|
404
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* A date in one of the `DatePreset` layouts, or with any
|
|
406
|
+
* `Intl.DateTimeFormat` options, in local time unless they set `timeZone`.
|
|
407
|
+
*/
|
|
405
408
|
export function formatDate(
|
|
406
409
|
date: DateInput,
|
|
407
|
-
|
|
410
|
+
format: DatePreset | Intl.DateTimeFormatOptions,
|
|
408
411
|
locale: string
|
|
409
412
|
): string {
|
|
410
|
-
|
|
413
|
+
const options = typeof format === 'string' ? DATE_PRESETS[format] : format
|
|
414
|
+
return getDateTimeFormat(locale, options).format(toTime(date))
|
|
411
415
|
}
|
|
412
416
|
|
|
413
417
|
/** Medium date and short time: "5 Mar 2026, 14:30" (en-GB). */
|
|
@@ -519,8 +523,10 @@ export function pluralize(
|
|
|
519
523
|
*/
|
|
520
524
|
export function withLocale(getLocale: () => string) {
|
|
521
525
|
return {
|
|
522
|
-
formatDate: (
|
|
523
|
-
|
|
526
|
+
formatDate: (
|
|
527
|
+
date: DateInput,
|
|
528
|
+
format: DatePreset | Intl.DateTimeFormatOptions
|
|
529
|
+
) => formatDate(date, format, getLocale()),
|
|
524
530
|
formatDateTime: (date: DateInput) => formatDateTime(date, getLocale()),
|
|
525
531
|
formatCompactTime: (date: DateInput) =>
|
|
526
532
|
formatCompactTime(date, getLocale()),
|
package/src/i18n/README.md
CHANGED
|
@@ -203,7 +203,9 @@ Both read `i18n.config.ts` (or `.mts`, `.js`, `.mjs`, or `--config <file>`)
|
|
|
203
203
|
from the current directory; paths in it are relative to the config file.
|
|
204
204
|
`defineI18nConfig` rejects a `baseLocale`, `disjointNamespaces` catalog or
|
|
205
205
|
`identical.allowKeys` locale the config does not declare. A
|
|
206
|
-
`.ts` config needs Bun
|
|
206
|
+
`.ts` config needs Bun or Node 22.18+. Its relative imports may leave out the
|
|
207
|
+
extension or `/index`, as TypeScript's bundler resolution allows, so
|
|
208
|
+
`npx hanzio-i18n check` works without `bun --bun`. The bin is built
|
|
207
209
|
on [`hanzio/cli`](../cli/README.md), so it needs `zod` installed; `--help`
|
|
208
210
|
lists its options and it exits 1 on catalog problems, 2 on bad arguments.
|
|
209
211
|
|
package/src/i18n/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// a Node version that strips types.
|
|
6
6
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
|
+
import * as nodeModule from 'node:module'
|
|
8
9
|
import { dirname, resolve } from 'node:path'
|
|
9
10
|
import { pathToFileURL } from 'node:url'
|
|
10
11
|
import { z } from 'zod'
|
|
@@ -25,6 +26,44 @@ const CONFIG_NAMES = [
|
|
|
25
26
|
|
|
26
27
|
type Loaded = { config: I18nConfig; root: string }
|
|
27
28
|
|
|
29
|
+
// Node resolves a config's imports like a browser: `./src/locales` fails
|
|
30
|
+
// because it names neither a file nor an extension. Bun resolves it like a
|
|
31
|
+
// bundler. These are the suffixes TypeScript's bundler resolution tries.
|
|
32
|
+
const BUNDLER_SUFFIXES = [
|
|
33
|
+
'.ts',
|
|
34
|
+
'.mts',
|
|
35
|
+
'.js',
|
|
36
|
+
'.mjs',
|
|
37
|
+
'/index.ts',
|
|
38
|
+
'/index.mts',
|
|
39
|
+
'/index.js',
|
|
40
|
+
'/index.mjs'
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function resolveLikeBundlers() {
|
|
44
|
+
// Missing under Bun, which needs no help, and under Node before 22.15.
|
|
45
|
+
nodeModule.registerHooks?.({
|
|
46
|
+
resolve(specifier, context, nextResolve) {
|
|
47
|
+
try {
|
|
48
|
+
return nextResolve(specifier, context)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const code = (error as { code?: string }).code
|
|
51
|
+
const retry =
|
|
52
|
+
specifier.startsWith('.') &&
|
|
53
|
+
(code === 'ERR_MODULE_NOT_FOUND' ||
|
|
54
|
+
code === 'ERR_UNSUPPORTED_DIR_IMPORT')
|
|
55
|
+
if (!retry) throw error
|
|
56
|
+
for (const suffix of BUNDLER_SUFFIXES) {
|
|
57
|
+
try {
|
|
58
|
+
return nextResolve(specifier + suffix, context)
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
28
67
|
async function loadConfig(file: string | undefined): Promise<Loaded> {
|
|
29
68
|
const path = file
|
|
30
69
|
? resolve(file)
|
|
@@ -33,6 +72,7 @@ async function loadConfig(file: string | undefined): Promise<Loaded> {
|
|
|
33
72
|
throw new CliError(
|
|
34
73
|
`No i18n config found: pass --config or add ${CONFIG_NAMES[0]}.`
|
|
35
74
|
)
|
|
75
|
+
resolveLikeBundlers()
|
|
36
76
|
const loaded = (await import(pathToFileURL(path).href)) as {
|
|
37
77
|
default?: I18nConfig
|
|
38
78
|
}
|
package/src/redis/README.md
CHANGED
|
@@ -89,17 +89,19 @@ Metrics and logs are hooks:
|
|
|
89
89
|
```ts
|
|
90
90
|
const withLimit = createRedisConcurrencyLimit({
|
|
91
91
|
...options,
|
|
92
|
-
logger, // throttled
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
logger, // logger.warn(message, { key, error }), throttled; defaults to console
|
|
93
|
+
metricLabel: 'openai', // defaults to key
|
|
94
|
+
onWait: (stage, label) => {
|
|
95
|
+
const timer = waitHistogram.startTimer({ pool: label, stage })
|
|
95
96
|
return () => timer.stop()
|
|
96
|
-
}
|
|
97
|
+
},
|
|
98
|
+
onQueueDepth: (queueDepth, label) => queueDepthGauge.register(label, queueDepth)
|
|
97
99
|
})
|
|
98
|
-
queueDepthGauge.observe(() => withLimit.queueDepth())
|
|
99
100
|
```
|
|
100
101
|
|
|
101
|
-
A limiter created per tenant or per host should
|
|
102
|
-
|
|
102
|
+
A limiter created per tenant or per host should share one `metricLabel`, not
|
|
103
|
+
default to its key, and must be `dispose()`d when discarded. `dispose()` runs
|
|
104
|
+
the function `onQueueDepth` returned, so the gauge stops reading the limiter.
|
|
103
105
|
|
|
104
106
|
## Lua scripts
|
|
105
107
|
|
|
@@ -176,10 +176,23 @@ export type RedisConcurrencyLimitOptions = {
|
|
|
176
176
|
maxStartsPerSecond?: number
|
|
177
177
|
failOpenAfterMs?: number
|
|
178
178
|
commandTimeoutMs?: number
|
|
179
|
-
/** Receives throttled warnings
|
|
180
|
-
logger?:
|
|
179
|
+
/** Receives throttled warnings with `{ key, error }`. Defaults to `console`. */
|
|
180
|
+
logger?: { warn(message: string, attributes: Record<string, unknown>): void }
|
|
181
|
+
/** The label `onWait` and `onQueueDepth` report under. Default: `key`. */
|
|
182
|
+
metricLabel?: string
|
|
181
183
|
/** Called when a caller starts queueing; the returned function runs once it stops. */
|
|
182
|
-
onWait?: (
|
|
184
|
+
onWait?: (
|
|
185
|
+
stage: ConcurrencyLimitStage,
|
|
186
|
+
metricLabel: string
|
|
187
|
+
) => (() => void) | void
|
|
188
|
+
/**
|
|
189
|
+
* Called once with `queueDepth`, to register a gauge; the returned function
|
|
190
|
+
* runs on `dispose()`.
|
|
191
|
+
*/
|
|
192
|
+
onQueueDepth?: (
|
|
193
|
+
queueDepth: () => number,
|
|
194
|
+
metricLabel: string
|
|
195
|
+
) => (() => void) | void
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
export type RedisConcurrencyLimit = {
|
|
@@ -216,10 +229,11 @@ export type RedisConcurrencyLimit = {
|
|
|
216
229
|
* That verdict is shared per limiter: once tripped, later calls degrade
|
|
217
230
|
* immediately while a background probe watches for Redis to come back.
|
|
218
231
|
*
|
|
219
|
-
* `onWait` and `
|
|
220
|
-
* customer host should
|
|
221
|
-
* or every key ever touched leaves a permanent
|
|
222
|
-
* such a limiter is discarded
|
|
232
|
+
* `onWait` and `onQueueDepth` feed metrics under `metricLabel`. A limiter
|
|
233
|
+
* minted per tenant or per customer host should share one pool label rather
|
|
234
|
+
* than default to its key, or every key ever touched leaves a permanent
|
|
235
|
+
* series. Call `dispose()` when such a limiter is discarded; it also runs the
|
|
236
|
+
* unregister function `onQueueDepth` returned.
|
|
223
237
|
*
|
|
224
238
|
* @example
|
|
225
239
|
* const withLimit = createRedisConcurrencyLimit({
|
|
@@ -236,6 +250,7 @@ export function createRedisConcurrencyLimit(
|
|
|
236
250
|
if (!Number.isInteger(input.max) || input.max < 1)
|
|
237
251
|
throw new Error(`max must be a positive integer, got ${input.max}`)
|
|
238
252
|
const logger = input.logger ?? console
|
|
253
|
+
const metricLabel = input.metricLabel ?? input.key
|
|
239
254
|
const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
240
255
|
const send = withCommandTimeout(input.send, commandTimeoutMs)
|
|
241
256
|
const ttlMs = String(input.slotTtlMs)
|
|
@@ -399,7 +414,7 @@ export function createRedisConcurrencyLimit(
|
|
|
399
414
|
depth: () => queue.length,
|
|
400
415
|
/** Resolves true once the token holds a slot, false when failing open. */
|
|
401
416
|
acquire(token: string) {
|
|
402
|
-
const stopWait = input.onWait?.(stage)
|
|
417
|
+
const stopWait = input.onWait?.(stage, metricLabel)
|
|
403
418
|
const granted = new Promise<boolean>((resolve) => {
|
|
404
419
|
queue.push({ token, settle: resolve })
|
|
405
420
|
})
|
|
@@ -437,6 +452,7 @@ export function createRedisConcurrencyLimit(
|
|
|
437
452
|
)
|
|
438
453
|
|
|
439
454
|
const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0)
|
|
455
|
+
let unregisterQueueDepth = input.onQueueDepth?.(queueDepth, metricLabel)
|
|
440
456
|
|
|
441
457
|
// -------------------------------------------------------------- heartbeat
|
|
442
458
|
|
|
@@ -573,6 +589,8 @@ export function createRedisConcurrencyLimit(
|
|
|
573
589
|
* before it resolves - so there is nothing left to heartbeat.
|
|
574
590
|
*/
|
|
575
591
|
function dispose() {
|
|
592
|
+
unregisterQueueDepth?.()
|
|
593
|
+
unregisterQueueDepth = undefined
|
|
576
594
|
if (probeTimer) {
|
|
577
595
|
clearTimeout(probeTimer)
|
|
578
596
|
probeTimer = null
|
package/src/string/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type JsonValue =
|
|
|
8
8
|
| JsonObject
|
|
9
9
|
| JsonArray
|
|
10
10
|
export type JsonObject = { [key: string]: JsonValue | undefined }
|
|
11
|
-
export type JsonArray =
|
|
11
|
+
export type JsonArray = JsonValue[]
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Parses the first number-like token in a string. Only a `-` directly before
|
package/src/testing/README.md
CHANGED
|
@@ -33,9 +33,18 @@ await moduleMocker.mock(path.resolve(import.meta.dir, '../lib/posthog.ts'), () =
|
|
|
33
33
|
afterAll(() => moduleMocker.clear())
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Bun resolves a
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
Bun resolves a specifier from the file that registers the mock, which would be
|
|
37
|
+
hanzio's: a relative path points into hanzio, and under isolated installs a
|
|
38
|
+
package name can find another copy or none. Pass your source root, and the
|
|
39
|
+
mocker resolves package names from there and other paths relative to it:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const moduleMocker = new ModuleMocker(path.resolve(import.meta.dir, '..'))
|
|
43
|
+
await moduleMocker.mock('lib/posthog.ts', () => ({ captureException: mock() }))
|
|
44
|
+
await moduleMocker.mock('ai', () => ({ generateText: mock() }))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or give the constructor a resolver for your own shorthand:
|
|
39
48
|
|
|
40
49
|
```ts
|
|
41
50
|
const moduleMocker = new ModuleMocker((specifier) =>
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
|
|
1
3
|
type ModuleExports = Record<string, unknown>
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Package names resolve from `baseDir`, so a mock targets the copy your code
|
|
7
|
+
* imports even under isolated installs; anything else is a path relative to it.
|
|
8
|
+
*/
|
|
9
|
+
function resolveFrom(baseDir: string) {
|
|
10
|
+
return (specifier: string) => {
|
|
11
|
+
if (isAbsolute(specifier)) return specifier
|
|
12
|
+
if (specifier.startsWith('.')) return Bun.resolveSync(specifier, baseDir)
|
|
13
|
+
try {
|
|
14
|
+
return Bun.resolveSync(specifier, baseDir)
|
|
15
|
+
} catch (error) {
|
|
16
|
+
try {
|
|
17
|
+
return Bun.resolveSync(`./${specifier}`, baseDir)
|
|
18
|
+
} catch {
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
3
25
|
/**
|
|
4
26
|
* `mock.module` that can be undone. Bun replaces a mocked module for the whole
|
|
5
27
|
* process and `mock.restore()` does not bring it back
|
|
@@ -10,24 +32,31 @@ type ModuleExports = Record<string, unknown>
|
|
|
10
32
|
* keep working, and `clear()` re-registers the originals. Call it in
|
|
11
33
|
* `afterEach` or `afterAll`.
|
|
12
34
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
35
|
+
* Without an argument, pass absolute paths. Pass your source root to resolve
|
|
36
|
+
* against it: package names from there (so mocks hit the same copy your code
|
|
37
|
+
* imports), and `./lib/x` or `lib/x` relative to it. A function maps your own
|
|
38
|
+
* shorthand, such as an `@/` alias, to absolute paths instead.
|
|
16
39
|
*
|
|
17
40
|
* @example
|
|
18
|
-
* const moduleMocker = new ModuleMocker(
|
|
19
|
-
*
|
|
20
|
-
* )
|
|
21
|
-
* await moduleMocker.mock('@/lib/posthog', () => ({ captureException: mock() }))
|
|
41
|
+
* const moduleMocker = new ModuleMocker(srcDir)
|
|
42
|
+
* await moduleMocker.mock('lib/posthog.ts', () => ({ captureException: mock() }))
|
|
43
|
+
* await moduleMocker.mock('ai', () => ({ generateText: mock() }))
|
|
22
44
|
* afterAll(() => moduleMocker.clear())
|
|
23
45
|
*/
|
|
24
46
|
export class ModuleMocker {
|
|
25
47
|
private restorers: (() => void)[] = []
|
|
26
48
|
|
|
49
|
+
private readonly resolve: (specifier: string) => string
|
|
50
|
+
|
|
27
51
|
constructor(
|
|
28
|
-
|
|
52
|
+
baseDirOrResolve: string | ((specifier: string) => string) = (specifier) =>
|
|
29
53
|
specifier
|
|
30
|
-
) {
|
|
54
|
+
) {
|
|
55
|
+
this.resolve =
|
|
56
|
+
typeof baseDirOrResolve === 'string'
|
|
57
|
+
? resolveFrom(baseDirOrResolve)
|
|
58
|
+
: baseDirOrResolve
|
|
59
|
+
}
|
|
31
60
|
|
|
32
61
|
async mock(
|
|
33
62
|
specifier: string,
|
|
@@ -44,7 +73,8 @@ export class ModuleMocker {
|
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
clear(): void {
|
|
47
|
-
|
|
76
|
+
// Newest first, so a module mocked twice ends on its real exports.
|
|
77
|
+
for (const restore of this.restorers.toReversed()) restore()
|
|
48
78
|
this.restorers = []
|
|
49
79
|
}
|
|
50
80
|
}
|
package/src/testing/prisma.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Mock, mock } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `never[]` parameters let `mockImplementation` take a callback that types its
|
|
5
|
+
* arguments; the plain call signature keeps the mock callable with any.
|
|
6
|
+
*/
|
|
7
|
+
export type PrismaMethodMock = Mock<(...args: never[]) => Promise<unknown>> &
|
|
8
|
+
((...args: unknown[]) => Promise<unknown>)
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* A Prisma client where every model delegate and every method is a Bun mock,
|