hanzio 1.2.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.
Files changed (145) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-181e6vfw.js +29 -0
  36. package/dist/index-8r2vdzjg.js +87 -0
  37. package/dist/index-hy7kbb99.js +157 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-mnkcfyx8.js +78 -0
  42. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  43. package/dist/index-rp5pqghz.js +157 -0
  44. package/dist/index-srt06xd2.js +15 -0
  45. package/dist/index-wjyb9bsd.js +263 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/sitemap/index.d.ts +48 -6
  55. package/dist/src/api-wrapper/index.js +60 -1130
  56. package/dist/src/api-wrapper/testing.js +20 -0
  57. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  58. package/dist/src/crypto/index.js +195 -0
  59. package/dist/src/format/index.js +211 -0
  60. package/dist/src/i18n/index.js +36 -0
  61. package/dist/src/index.js +739 -3145
  62. package/dist/src/jwt/index.js +138 -92
  63. package/dist/src/p-queue/index.js +9 -437
  64. package/dist/src/secrets/index.js +44 -1428
  65. package/dist/src/secrets/vite.js +6 -39
  66. package/dist/src/secrets/worker.js +10 -273
  67. package/dist/src/sitemap/index.js +183 -511
  68. package/dist/src/storage/index.js +119 -0
  69. package/dist/src/zod/index.js +340 -167
  70. package/dist/state/index.d.ts +21 -0
  71. package/dist/storage/index.d.ts +58 -0
  72. package/dist/string/index.d.ts +75 -2
  73. package/dist/typedswitch/index.d.ts +31 -24
  74. package/dist/types/index.d.ts +11 -0
  75. package/dist/url/index.d.ts +50 -1
  76. package/dist/zod/index.d.ts +32 -3
  77. package/dist/zod/introspect.d.ts +19 -0
  78. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  79. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  80. package/dist/zod/zodToTypeString.d.ts +8 -3
  81. package/package.json +78 -7
  82. package/src/api-wrapper/actions-impl.ts +11 -72
  83. package/src/api-wrapper/builders.ts +32 -25
  84. package/src/api-wrapper/create-client.ts +171 -41
  85. package/src/api-wrapper/describe.ts +157 -0
  86. package/src/api-wrapper/endpoint.ts +26 -0
  87. package/src/api-wrapper/error-reporting.ts +7 -1
  88. package/src/api-wrapper/errors.ts +107 -2
  89. package/src/api-wrapper/headers.ts +11 -6
  90. package/src/api-wrapper/index.ts +5 -0
  91. package/src/api-wrapper/otel.ts +139 -0
  92. package/src/api-wrapper/paginate.ts +28 -0
  93. package/src/api-wrapper/redaction.ts +42 -0
  94. package/src/api-wrapper/shared.ts +21 -2
  95. package/src/api-wrapper/testing.ts +48 -0
  96. package/src/api-wrapper/transport.ts +41 -43
  97. package/src/api-wrapper/types.ts +185 -18
  98. package/src/array/index.ts +195 -42
  99. package/src/cache/createCache.ts +157 -0
  100. package/src/cache/index.ts +132 -147
  101. package/src/color/contrast.ts +163 -0
  102. package/src/color/index.ts +22 -0
  103. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  104. package/src/cool-console-log/index.ts +128 -41
  105. package/src/crypto/encoding.ts +83 -0
  106. package/src/crypto/hash.ts +170 -0
  107. package/src/crypto/index.ts +26 -0
  108. package/src/crypto/sealer.ts +168 -0
  109. package/src/crypto/webhook.ts +86 -0
  110. package/src/date/index.ts +109 -34
  111. package/src/emitter/index.ts +82 -0
  112. package/src/error/index.ts +63 -0
  113. package/src/format/index.ts +389 -0
  114. package/src/function/index.ts +201 -0
  115. package/src/guard/index.ts +80 -0
  116. package/src/i18n/index.ts +125 -0
  117. package/src/index.ts +11 -7
  118. package/src/jwt/index.ts +199 -125
  119. package/src/math/index.ts +5 -10
  120. package/src/math/linearRegressionTrend.ts +33 -8
  121. package/src/math/scale.ts +17 -0
  122. package/src/object/index.ts +99 -0
  123. package/src/p-queue/index.ts +2 -0
  124. package/src/promise/index.ts +304 -9
  125. package/src/sitemap/index.ts +293 -130
  126. package/src/state/index.ts +133 -81
  127. package/src/storage/index.ts +187 -0
  128. package/src/string/index.ts +261 -23
  129. package/src/typedswitch/index.ts +58 -38
  130. package/src/types/index.ts +14 -0
  131. package/src/url/index.ts +146 -1
  132. package/src/zod/index.ts +105 -9
  133. package/src/zod/introspect.ts +103 -0
  134. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  135. package/src/zod/zodMinMaxFilter.ts +37 -13
  136. package/src/zod/zodToTypeString.ts +184 -181
  137. package/dist/api-wrapper/index.js +0 -250
  138. package/dist/gzip/index.d.ts +0 -2
  139. package/dist/gzip/index.js +0 -16
  140. package/dist/index.js +0 -1823
  141. package/dist/jwt/index.js +0 -19
  142. package/dist/sitemap/index.js +0 -552
  143. package/dist/src/gzip/index.js +0 -16
  144. package/dist/src/secrets/loaders.js +0 -1529
  145. package/dist/zod/index.js +0 -210
@@ -0,0 +1,389 @@
1
+ // Locale-aware formatting on top of `Intl`. Every function takes the locale
2
+ // (a BCP-47 tag) explicitly, and everything that reads the clock takes `now`.
3
+
4
+ import { stableStringify } from '../string'
5
+
6
+ type DateInput = Date | string | number
7
+
8
+ const MAX_CACHED_FORMATTERS = 100
9
+
10
+ // ponytail: one LRU per formatter kind, 100 entries each; raise if an app
11
+ // really cycles through more (locale, options) pairs than that.
12
+ function memoize<O extends object, F>(
13
+ create: (locale: string, options?: O) => F
14
+ ): (locale: string, options?: O) => F {
15
+ const cache = new Map<string, F>()
16
+ return (locale, options) => {
17
+ const key = `${locale}|${options ? stableStringify(options) : ''}`
18
+ let formatter = cache.get(key)
19
+ if (formatter) {
20
+ cache.delete(key)
21
+ } else {
22
+ formatter = create(locale, options)
23
+ if (cache.size >= MAX_CACHED_FORMATTERS) {
24
+ const oldest = cache.keys().next().value
25
+ if (oldest !== undefined) cache.delete(oldest)
26
+ }
27
+ }
28
+ cache.set(key, formatter)
29
+ return formatter
30
+ }
31
+ }
32
+
33
+ /** Cached `Intl.NumberFormat`, one instance per (locale, options). */
34
+ export const getNumberFormat = memoize(
35
+ (locale, options?: Intl.NumberFormatOptions) =>
36
+ new Intl.NumberFormat(locale, options)
37
+ )
38
+
39
+ /** Cached `Intl.DateTimeFormat`, one instance per (locale, options). */
40
+ export const getDateTimeFormat = memoize(
41
+ (locale, options?: Intl.DateTimeFormatOptions) =>
42
+ new Intl.DateTimeFormat(locale, options)
43
+ )
44
+
45
+ /** Cached `Intl.RelativeTimeFormat`, one instance per (locale, options). */
46
+ export const getRelativeTimeFormat = memoize(
47
+ (locale, options?: Intl.RelativeTimeFormatOptions) =>
48
+ new Intl.RelativeTimeFormat(locale, options)
49
+ )
50
+
51
+ /** Cached `Intl.ListFormat`, one instance per (locale, options). */
52
+ export const getListFormat = memoize(
53
+ (locale, options?: Intl.ListFormatOptions) =>
54
+ new Intl.ListFormat(locale, options)
55
+ )
56
+
57
+ /** Cached `Intl.PluralRules`, one instance per (locale, options). */
58
+ export const getPluralRules = memoize(
59
+ (locale, options?: Intl.PluralRulesOptions) =>
60
+ new Intl.PluralRules(locale, options)
61
+ )
62
+
63
+ function toTime(value: DateInput): number {
64
+ return value instanceof Date ? value.getTime() : new Date(value).getTime()
65
+ }
66
+
67
+ // ─── Numbers ────────────────────────────────────────────────────────────────
68
+
69
+ export function formatNumber(
70
+ value: number,
71
+ locale: string,
72
+ options?: Intl.NumberFormatOptions
73
+ ): string {
74
+ return getNumberFormat(locale, options).format(value)
75
+ }
76
+
77
+ /** A fraction (`0.25`) as a percentage (`"25%"`). */
78
+ export function formatPercent(
79
+ value: number,
80
+ locale: string,
81
+ options?: Intl.NumberFormatOptions
82
+ ): string {
83
+ return formatNumber(value, locale, { ...options, style: 'percent' })
84
+ }
85
+
86
+ /**
87
+ * An amount in whole currency units (`12.5`, not cents). Pass
88
+ * `{ maximumFractionDigits: 0 }` for whole-unit prices.
89
+ */
90
+ export function formatCurrency(
91
+ amount: number,
92
+ currency: string,
93
+ locale: string,
94
+ options?: Intl.NumberFormatOptions
95
+ ): string {
96
+ return formatNumber(amount, locale, {
97
+ ...options,
98
+ style: 'currency',
99
+ currency
100
+ })
101
+ }
102
+
103
+ /** Short compact notation: `1234` -> `"1.2K"` (en-US), `"1,2 t"` (da-DK). */
104
+ export function formatCompactNumber(value: number, locale: string): string {
105
+ return formatNumber(value, locale, { notation: 'compact' })
106
+ }
107
+
108
+ export type FormatBytesOptions = {
109
+ /** Default `'en-US'`. */
110
+ locale?: string
111
+ /** Fraction digits (trailing zeros dropped). Default 0 for KB, 1 above. */
112
+ decimals?: number
113
+ /** 1 KB = 1024 B when true (default), 1000 B when false. */
114
+ binary?: boolean
115
+ }
116
+
117
+ const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
118
+
119
+ /**
120
+ * Human-readable file size: `"512 B"`, `"12 KB"`, `"3.4 MB"`. Unit labels are
121
+ * not localized; the number is.
122
+ */
123
+ export function formatBytes(
124
+ bytes: number,
125
+ options: FormatBytesOptions = {}
126
+ ): string {
127
+ const { locale = 'en-US', decimals, binary = true } = options
128
+ const base = binary ? 1024 : 1000
129
+ let exponent = 0
130
+ while (
131
+ Math.abs(bytes) >= base ** (exponent + 1) &&
132
+ exponent < BYTE_UNITS.length - 1
133
+ ) {
134
+ exponent++
135
+ }
136
+ const maximumFractionDigits =
137
+ exponent === 0 ? 0 : (decimals ?? (exponent === 1 ? 0 : 1))
138
+ const value = formatNumber(bytes / base ** exponent, locale, {
139
+ maximumFractionDigits
140
+ })
141
+ return `${value} ${BYTE_UNITS[exponent]}`
142
+ }
143
+
144
+ // ─── Durations ──────────────────────────────────────────────────────────────
145
+
146
+ const SECOND_MS = 1000
147
+ const MINUTE_MS = 60 * SECOND_MS
148
+ const HOUR_MS = 60 * MINUTE_MS
149
+ const DAY_MS = 24 * HOUR_MS
150
+
151
+ export type DurationParts = {
152
+ days: number
153
+ hours: number
154
+ minutes: number
155
+ seconds: number
156
+ milliseconds: number
157
+ }
158
+
159
+ /** Break milliseconds into whole units. Negative or non-finite input is 0. */
160
+ export function splitDuration(ms: number): DurationParts {
161
+ const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms) : 0
162
+ return {
163
+ days: Math.floor(total / DAY_MS),
164
+ hours: Math.floor((total % DAY_MS) / HOUR_MS),
165
+ minutes: Math.floor((total % HOUR_MS) / MINUTE_MS),
166
+ seconds: Math.floor((total % MINUTE_MS) / SECOND_MS),
167
+ milliseconds: total % SECOND_MS
168
+ }
169
+ }
170
+
171
+ /** Stopwatch style: `"1:05:09"` from an hour up, `"5:09"` below. */
172
+ export function formatClockDuration(ms: number): string {
173
+ const { days, hours, minutes, seconds } = splitDuration(ms)
174
+ const totalHours = days * 24 + hours
175
+ const pad = (n: number) => String(n).padStart(2, '0')
176
+ return totalHours > 0
177
+ ? `${totalHours}:${pad(minutes)}:${pad(seconds)}`
178
+ : `${minutes}:${pad(seconds)}`
179
+ }
180
+
181
+ export type FormatDurationOptions = {
182
+ /** `'long'` "1 hour, 5 minutes", `'short'` "1 hr, 5 min" (default), `'narrow'` "1h 5m". */
183
+ style?: 'long' | 'short' | 'narrow'
184
+ /** Adjacent units shown, starting at the largest non-zero one. Default 2. */
185
+ maxUnits?: number
186
+ }
187
+
188
+ const DURATION_UNITS = [
189
+ ['days', 'day'],
190
+ ['hours', 'hour'],
191
+ ['minutes', 'minute'],
192
+ ['seconds', 'second'],
193
+ ['milliseconds', 'millisecond']
194
+ ] as const
195
+
196
+ /**
197
+ * Localized duration, truncated (never rounded up). Units are adjacent: with
198
+ * the default `maxUnits: 2`, 1 day 0 h 5 min reads "1 day", not "1 day, 5 min".
199
+ */
200
+ export function formatDuration(
201
+ ms: number,
202
+ locale: string,
203
+ options: FormatDurationOptions = {}
204
+ ): string {
205
+ const { style = 'short', maxUnits = 2 } = options
206
+ const parts = splitDuration(ms)
207
+ const unit = (value: number, name: string) =>
208
+ formatNumber(value, locale, {
209
+ style: 'unit',
210
+ unit: name,
211
+ unitDisplay: style
212
+ })
213
+ const first = DURATION_UNITS.findIndex(([key]) => parts[key] > 0)
214
+ if (first === -1) return unit(0, 'second')
215
+ const shown = DURATION_UNITS.slice(first, first + Math.max(1, maxUnits))
216
+ .filter(([key]) => parts[key] > 0)
217
+ .map(([key, name]) => unit(parts[key], name))
218
+ return formatList(shown, locale, { type: 'unit', style })
219
+ }
220
+
221
+ // ─── Relative time ──────────────────────────────────────────────────────────
222
+
223
+ function relativeUnit(ms: number): {
224
+ value: number
225
+ unit: Intl.RelativeTimeFormatUnitSingular
226
+ } {
227
+ const seconds = ms / 1000
228
+ if (Math.abs(seconds) < 60)
229
+ return { value: Math.round(seconds), unit: 'second' }
230
+ const minutes = seconds / 60
231
+ if (Math.abs(minutes) < 60)
232
+ return { value: Math.round(minutes), unit: 'minute' }
233
+ const hours = minutes / 60
234
+ if (Math.abs(hours) < 24) return { value: Math.round(hours), unit: 'hour' }
235
+ const days = hours / 24
236
+ if (Math.abs(days) < 30) return { value: Math.round(days), unit: 'day' }
237
+ const months = days / 30
238
+ if (Math.abs(months) < 12) return { value: Math.round(months), unit: 'month' }
239
+ return { value: Math.round(days / 365), unit: 'year' }
240
+ }
241
+
242
+ export type FormatRelativeTimeOptions = {
243
+ /** Reference time. Default `Date.now()`. */
244
+ now?: DateInput
245
+ /** `'auto'` (default) says "yesterday"; `'always'` says "1 day ago". */
246
+ numeric?: Intl.RelativeTimeFormatNumeric
247
+ style?: Intl.RelativeTimeFormatStyle
248
+ /**
249
+ * `false` drops "ago"/"in" and prints the bare amount ("5 minutes"), for
250
+ * use next to a label that already says when. Default `true`.
251
+ */
252
+ withSuffix?: boolean
253
+ }
254
+
255
+ /**
256
+ * Time from `now` in the best-fitting unit: "5 minutes ago", "in 2 days",
257
+ * "yesterday". Unparseable input returns `''`.
258
+ */
259
+ export function formatRelativeTime(
260
+ date: DateInput,
261
+ locale: string,
262
+ options: FormatRelativeTimeOptions = {}
263
+ ): string {
264
+ const {
265
+ now = Date.now(),
266
+ numeric = 'auto',
267
+ style,
268
+ withSuffix = true
269
+ } = options
270
+ const diff = toTime(date) - toTime(now)
271
+ if (!Number.isFinite(diff)) return ''
272
+ const { value, unit } = relativeUnit(diff)
273
+ if (withSuffix) {
274
+ return getRelativeTimeFormat(locale, { numeric, style }).format(value, unit)
275
+ }
276
+ return formatNumber(Math.abs(value), locale, {
277
+ style: 'unit',
278
+ unit,
279
+ unitDisplay: style ?? 'long'
280
+ })
281
+ }
282
+
283
+ /**
284
+ * How long something has waited, compact for a pill or list row: "14 min",
285
+ * "2 hr", "3 days". Floors (never claims more waiting than happened), never
286
+ * shows less than "1 min", and treats a future timestamp as no waiting.
287
+ *
288
+ * A `number` is a duration in milliseconds; a `Date`/string is a start
289
+ * timestamp measured against `now`. Unparseable input returns `''`.
290
+ */
291
+ export function formatWait(
292
+ value: DateInput,
293
+ locale: string,
294
+ options: { now?: DateInput } = {}
295
+ ): string {
296
+ const elapsed =
297
+ typeof value === 'number'
298
+ ? value
299
+ : toTime(options.now ?? Date.now()) - toTime(value)
300
+ const waited = Math.max(0, elapsed)
301
+ if (!Number.isFinite(waited)) return ''
302
+ const [amount, unit]: [number, string] =
303
+ waited < HOUR_MS
304
+ ? [Math.max(1, Math.floor(waited / MINUTE_MS)), 'minute']
305
+ : waited < DAY_MS
306
+ ? [Math.floor(waited / HOUR_MS), 'hour']
307
+ : [Math.floor(waited / DAY_MS), 'day']
308
+ return formatNumber(amount, locale, {
309
+ style: 'unit',
310
+ unit,
311
+ unitDisplay: 'short'
312
+ })
313
+ }
314
+
315
+ // ─── Dates and lists ────────────────────────────────────────────────────────
316
+
317
+ const DATE_COMPONENTS = [
318
+ 'weekday',
319
+ 'era',
320
+ 'year',
321
+ 'month',
322
+ 'day',
323
+ 'dayPeriod',
324
+ 'hour',
325
+ 'minute',
326
+ 'second',
327
+ 'fractionalSecondDigits',
328
+ 'dateStyle',
329
+ 'timeStyle'
330
+ ] as const
331
+
332
+ /**
333
+ * One span with the shared parts collapsed: "24–30 Aug 2026". Without any
334
+ * date/time fields in `options` it uses day + short month + year; `timeZone`
335
+ * and friends combine with that default.
336
+ */
337
+ export function formatDateRange(
338
+ start: DateInput,
339
+ end: DateInput,
340
+ locale: string,
341
+ options: Intl.DateTimeFormatOptions = {}
342
+ ): string {
343
+ const hasFields = DATE_COMPONENTS.some((key) => options[key] !== undefined)
344
+ const format = getDateTimeFormat(
345
+ locale,
346
+ hasFields
347
+ ? options
348
+ : { day: 'numeric', month: 'short', year: 'numeric', ...options }
349
+ )
350
+ const from = new Date(toTime(start))
351
+ const to = new Date(toTime(end))
352
+ // Older engines (some Hermes builds) lack formatRange.
353
+ return typeof format.formatRange === 'function'
354
+ ? format.formatRange(from, to)
355
+ : `${format.format(from)} – ${format.format(to)}`
356
+ }
357
+
358
+ /** "a, b, and c". Falls back to a comma join where `Intl.ListFormat` is missing. */
359
+ export function formatList(
360
+ items: readonly string[],
361
+ locale: string,
362
+ options?: Intl.ListFormatOptions
363
+ ): string {
364
+ if (typeof Intl.ListFormat !== 'function') {
365
+ return items.join(options?.style === 'narrow' ? ' ' : ', ')
366
+ }
367
+ return getListFormat(locale, options).format(items)
368
+ }
369
+
370
+ // ─── Counts ─────────────────────────────────────────────────────────────────
371
+
372
+ /** Badge count: `150` -> `"99+"`. Negative or NaN is `"0"`. */
373
+ export function formatCappedCount(count: number, max = 99): string {
374
+ if (!(count > 0)) return '0'
375
+ return count > max ? `${max}+` : String(count)
376
+ }
377
+
378
+ /**
379
+ * Pick the plural form for `count` and fill in `{count}` (locale-formatted).
380
+ * `pluralize(3, { one: '{count} item', other: '{count} items' }, 'en-US')`.
381
+ */
382
+ export function pluralize(
383
+ count: number,
384
+ forms: Partial<Record<Intl.LDMLPluralRule, string>> & { other: string },
385
+ locale: string
386
+ ): string {
387
+ const form = forms[getPluralRules(locale).select(count)] ?? forms.other
388
+ return form.replaceAll('{count}', formatNumber(count, locale))
389
+ }
@@ -0,0 +1,201 @@
1
+ type Timer = ReturnType<typeof setTimeout>
2
+
3
+ export type Debounced<A extends unknown[]> = ((...args: A) => void) & {
4
+ /** Drops the pending call. */
5
+ cancel(): void
6
+ /** Runs the pending call now, if any. */
7
+ flush(): void
8
+ /** True while a trailing call is waiting. */
9
+ pending(): boolean
10
+ }
11
+
12
+ /**
13
+ * Calls `fn` with the latest args once calls stop for `waitMs` (trailing).
14
+ * `leading` also calls on the first call of a burst; `maxWaitMs` forces a
15
+ * call at least that often during a continuous burst.
16
+ */
17
+ export function debounce<A extends unknown[]>(
18
+ fn: (...args: A) => void,
19
+ waitMs: number,
20
+ options: { leading?: boolean; maxWaitMs?: number } = {}
21
+ ): Debounced<A> {
22
+ let timer: Timer | undefined
23
+ let maxTimer: Timer | undefined
24
+ let lastArgs: A | undefined
25
+
26
+ const cancel = () => {
27
+ clearTimeout(timer)
28
+ clearTimeout(maxTimer)
29
+ timer = maxTimer = lastArgs = undefined
30
+ }
31
+ const flush = () => {
32
+ const args = lastArgs
33
+ cancel()
34
+ if (args) fn(...args)
35
+ }
36
+
37
+ const debounced = (...args: A) => {
38
+ const idle = timer === undefined
39
+ clearTimeout(timer)
40
+ timer = setTimeout(flush, waitMs)
41
+ if (options.maxWaitMs !== undefined && maxTimer === undefined) {
42
+ maxTimer = setTimeout(flush, options.maxWaitMs)
43
+ }
44
+ if (idle && options.leading) fn(...args)
45
+ else lastArgs = args
46
+ }
47
+
48
+ return Object.assign(debounced, {
49
+ cancel,
50
+ flush,
51
+ pending: () => lastArgs !== undefined
52
+ })
53
+ }
54
+
55
+ /**
56
+ * One independent trailing debounce per key. `cancel`/`flush` without a key
57
+ * apply to every pending key.
58
+ */
59
+ export function keyedDebounce<K, A extends unknown[]>(
60
+ fn: (key: K, ...args: A) => void,
61
+ waitMs: number
62
+ ): {
63
+ call(key: K, ...args: A): void
64
+ cancel(key?: K): void
65
+ flush(key?: K): void
66
+ } {
67
+ const byKey = new Map<K, Debounced<A>>()
68
+ const each = (key: K | undefined, action: (d: Debounced<A>) => void) => {
69
+ const targets = key === undefined ? [...byKey.values()] : [byKey.get(key)]
70
+ for (const debounced of targets) if (debounced) action(debounced)
71
+ }
72
+
73
+ return {
74
+ call(key, ...args) {
75
+ let debounced = byKey.get(key)
76
+ if (!debounced) {
77
+ debounced = debounce((...latest: A) => {
78
+ byKey.delete(key)
79
+ fn(key, ...latest)
80
+ }, waitMs)
81
+ byKey.set(key, debounced)
82
+ }
83
+ debounced(...args)
84
+ },
85
+ cancel(key) {
86
+ each(key, (debounced) => debounced.cancel())
87
+ if (key === undefined) byKey.clear()
88
+ else byKey.delete(key)
89
+ },
90
+ flush(key) {
91
+ each(key, (debounced) => debounced.flush())
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Calls `fn` immediately, then at most once per `intervalMs`. With `trailing`
98
+ * (default), the last call made during an interval runs when it ends.
99
+ */
100
+ export function throttle<A extends unknown[]>(
101
+ fn: (...args: A) => void,
102
+ intervalMs: number,
103
+ options: { trailing?: boolean } = {}
104
+ ): ((...args: A) => void) & { cancel(): void } {
105
+ const { trailing = true } = options
106
+ let timer: Timer | undefined
107
+ let trailingArgs: A | undefined
108
+
109
+ const invoke = (args: A) => {
110
+ timer = setTimeout(() => {
111
+ timer = undefined
112
+ const next = trailingArgs
113
+ trailingArgs = undefined
114
+ if (next) invoke(next)
115
+ }, intervalMs)
116
+ fn(...args)
117
+ }
118
+
119
+ const throttled = (...args: A) => {
120
+ if (timer === undefined) invoke(args)
121
+ else if (trailing) trailingArgs = args
122
+ }
123
+
124
+ return Object.assign(throttled, {
125
+ cancel: () => {
126
+ clearTimeout(timer)
127
+ timer = trailingArgs = undefined
128
+ }
129
+ })
130
+ }
131
+
132
+ /** Calls `fn` on the first call only; later calls return the first result. */
133
+ export function once<A extends unknown[], R>(
134
+ fn: (...args: A) => R
135
+ ): (...args: A) => R {
136
+ let called = false
137
+ let result: R
138
+ return (...args) => {
139
+ if (!called) {
140
+ called = true
141
+ result = fn(...args)
142
+ }
143
+ return result
144
+ }
145
+ }
146
+
147
+ export type HeartbeatOptions = {
148
+ /** Called on the first activity, then at most once per `intervalMs`. */
149
+ send: () => void
150
+ /** Called once when an active heartbeat stops (idle or `stop()`). */
151
+ onStop?: () => void
152
+ intervalMs: number
153
+ /** Stop after this long without `activity()`. */
154
+ idleMs: number
155
+ now?: () => number
156
+ setTimeout?: (callback: () => void, ms: number) => unknown
157
+ clearTimeout?: (timer: unknown) => void
158
+ }
159
+
160
+ /**
161
+ * Turns bursts of activity into heartbeats (e.g. "is typing" signals): sends
162
+ * immediately on the first activity, then at most once per `intervalMs` while
163
+ * activity continues, and stops after `idleMs` without activity.
164
+ */
165
+ export function createHeartbeat(options: HeartbeatOptions): {
166
+ /** Report activity. */
167
+ activity(): void
168
+ /** Stop now; calls `onStop` if active. Safe to call repeatedly. */
169
+ stop(): void
170
+ } {
171
+ const {
172
+ now = Date.now,
173
+ setTimeout: schedule = (callback, ms) => setTimeout(callback, ms),
174
+ clearTimeout: unschedule = (timer) => clearTimeout(timer as Timer)
175
+ } = options
176
+ let active = false
177
+ let lastSentAt = 0
178
+ let idleTimer: unknown
179
+
180
+ const stop = () => {
181
+ if (idleTimer !== undefined) unschedule(idleTimer)
182
+ idleTimer = undefined
183
+ if (!active) return
184
+ active = false
185
+ options.onStop?.()
186
+ }
187
+
188
+ return {
189
+ activity() {
190
+ const time = now()
191
+ if (!active || time - lastSentAt >= options.intervalMs) {
192
+ active = true
193
+ lastSentAt = time
194
+ options.send()
195
+ }
196
+ if (idleTimer !== undefined) unschedule(idleTimer)
197
+ idleTimer = schedule(stop, options.idleMs)
198
+ },
199
+ stop
200
+ }
201
+ }
@@ -0,0 +1,80 @@
1
+ import { InvariantError } from '../error'
2
+ import type { NonEmptyArray } from '../types'
3
+
4
+ /** Non-null object that is not an array. */
5
+ export function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
7
+ }
8
+
9
+ /** Object literal or `Object.create(null)`; excludes arrays, class instances, Dates, Maps. */
10
+ export function isPlainObject(
11
+ value: unknown
12
+ ): value is Record<string, unknown> {
13
+ if (!isRecord(value)) return false
14
+ const prototype = Object.getPrototypeOf(value)
15
+ return prototype === Object.prototype || prototype === null
16
+ }
17
+
18
+ export function isDefined<T>(value: T): value is NonNullable<T> {
19
+ return value !== null && value !== undefined
20
+ }
21
+
22
+ export function isTruthy<T>(
23
+ value: T
24
+ ): value is Exclude<T, false | 0 | 0n | '' | null | undefined> {
25
+ return Boolean(value)
26
+ }
27
+
28
+ export function isString(value: unknown): value is string {
29
+ return typeof value === 'string'
30
+ }
31
+
32
+ /** `typeof number`, excluding NaN. Infinity passes. */
33
+ export function isNumber(value: unknown): value is number {
34
+ return typeof value === 'number' && !Number.isNaN(value)
35
+ }
36
+
37
+ export function isBoolean(value: unknown): value is boolean {
38
+ return typeof value === 'boolean'
39
+ }
40
+
41
+ // biome-ignore lint/suspicious/noExplicitAny: `any` params so every function signature narrows
42
+ export function isFunction(value: unknown): value is (...args: any[]) => any {
43
+ return typeof value === 'function'
44
+ }
45
+
46
+ /** Whether `value` is in `list`. Omit `value` to get a reusable guard, e.g. for `.filter`. */
47
+ export function isOneOf<const T extends readonly unknown[]>(
48
+ list: T
49
+ ): (value: unknown) => value is T[number]
50
+ export function isOneOf<const T extends readonly unknown[]>(
51
+ list: T,
52
+ value: unknown
53
+ ): value is T[number]
54
+ export function isOneOf(list: readonly unknown[], ...value: unknown[]) {
55
+ if (value.length === 0) return (item: unknown) => list.includes(item)
56
+ return list.includes(value[0])
57
+ }
58
+
59
+ /** Own-property check that narrows `key` to `keyof O`. */
60
+ export function hasKey<O extends object>(
61
+ object: O,
62
+ key: PropertyKey
63
+ ): key is keyof O {
64
+ return Object.hasOwn(object, key)
65
+ }
66
+
67
+ export function isNonEmpty<T>(
68
+ array: readonly T[]
69
+ ): array is readonly [T, ...T[]] {
70
+ return array.length > 0
71
+ }
72
+
73
+ /** Copy of `array` typed as non-empty (e.g. for `z.enum`); throws when empty. */
74
+ export function nonEmpty<T>(
75
+ array: readonly T[],
76
+ message = 'Expected a non-empty array'
77
+ ): NonEmptyArray<T> {
78
+ if (!isNonEmpty(array)) throw new InvariantError(message)
79
+ return [...array]
80
+ }