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
@@ -1,21 +1,33 @@
1
+ import { lerp, normalize } from '../math/scale'
2
+ import { colorFunctionArgs, parseColor } from './contrast'
3
+
1
4
  /**
2
- * Shared metrics chart series color assignment for web (CSS tokens) and Expo (hex palette).
3
- * Same hash + collision strategy so labels map to the same slot index everywhere.
5
+ * Stable series color assignment: the same hash + collision strategy maps a
6
+ * label to the same palette slot index on every platform.
4
7
  */
5
8
 
6
- /** Matches PrimeVue semantic palette slot count (cyan…blue) and Expo SERIES_COLORS length */
9
+ /** Default palette slot count. */
7
10
  export const METRICS_SERIES_PALETTE_SIZE = 10
8
11
 
9
12
  /**
10
13
  * FNV-1a 32-bit hash — stable palette index for a string key.
11
14
  */
12
- export function hashStringToColorIndex(str: string): number {
15
+ export function hashStringToColorIndex(
16
+ str: string,
17
+ paletteSize = METRICS_SERIES_PALETTE_SIZE
18
+ ): number {
13
19
  let hash = 2166136261
14
20
  for (let i = 0; i < str.length; i++) {
15
21
  hash ^= str.charCodeAt(i)
16
22
  hash = Math.imul(hash, 16777619)
17
23
  }
18
- return Math.abs(hash) % METRICS_SERIES_PALETTE_SIZE
24
+ return Math.abs(hash) % paletteSize
25
+ }
26
+
27
+ /** Stable pick from `palette` for `seed` (same seed → same item). Throws on an empty palette. */
28
+ export function pickFromPalette<T>(seed: string, palette: readonly T[]): T {
29
+ if (palette.length === 0) throw new RangeError('palette is empty')
30
+ return palette[hashStringToColorIndex(seed, palette.length)]!
19
31
  }
20
32
 
21
33
  function circularHueDistance(
@@ -59,23 +71,24 @@ export function seriesColorKey(label: string | undefined, key: string): string {
59
71
  /**
60
72
  * Map each distinct key to a palette index (hash preference, then max-min circular spacing).
61
73
  */
62
- export function resolveSeriesColorMap(keys: string[]): Map<string, number> {
74
+ export function resolveSeriesColorMap(
75
+ keys: string[],
76
+ paletteSize = METRICS_SERIES_PALETTE_SIZE
77
+ ): Map<string, number> {
63
78
  const result = new Map<string, number>()
64
79
  const uniqueKeys = [...new Set(keys)]
65
80
  if (uniqueKeys.length === 0) return result
66
81
 
67
- const paletteSize = METRICS_SERIES_PALETTE_SIZE
68
-
69
82
  if (uniqueKeys.length === 1) {
70
83
  const only = uniqueKeys[0]!
71
- result.set(only, hashStringToColorIndex(only))
84
+ result.set(only, hashStringToColorIndex(only, paletteSize))
72
85
  return result
73
86
  }
74
87
 
75
88
  const usedIndices = new Set<number>()
76
89
  const entries = uniqueKeys.map((key) => ({
77
90
  key,
78
- preferred: hashStringToColorIndex(key)
91
+ preferred: hashStringToColorIndex(key, paletteSize)
79
92
  }))
80
93
 
81
94
  for (const entry of entries) {
@@ -114,53 +127,38 @@ export type ResolveColorsByKeysResult = {
114
127
  export function createResolveColorsByKeys(
115
128
  keys: string[],
116
129
  getSeriesColor: (index: number) => string,
117
- getSeriesHoverColor: (index: number) => string
130
+ getSeriesHoverColor: (index: number) => string,
131
+ paletteSize = METRICS_SERIES_PALETTE_SIZE
118
132
  ): ResolveColorsByKeysResult {
119
- const colorMap = resolveSeriesColorMap(keys)
133
+ const colorMap = resolveSeriesColorMap(keys, paletteSize)
134
+ const indexOf = (key: string) =>
135
+ colorMap.get(key) ?? hashStringToColorIndex(key, paletteSize)
120
136
  return {
121
- getColor: (key: string) =>
122
- getSeriesColor(colorMap.get(key) ?? hashStringToColorIndex(key)),
123
- getHoverColor: (key: string) =>
124
- getSeriesHoverColor(colorMap.get(key) ?? hashStringToColorIndex(key))
137
+ getColor: (key: string) => getSeriesColor(indexOf(key)),
138
+ getHoverColor: (key: string) => getSeriesHoverColor(indexOf(key))
125
139
  }
126
140
  }
127
141
 
128
- function clamp(value: number, min: number, max: number): number {
129
- return Math.min(max, Math.max(min, value))
130
- }
131
-
132
142
  /**
133
- * Add alpha to hex, rgb(), or rgba() strings.
143
+ * Apply `alpha` (0..1) to a color, replacing any existing alpha.
144
+ *
145
+ * - `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` → `rgba(r, g, b, a)` (works in React Native)
146
+ * - `rgb()`/`rgba()`/`hsl()`/`hsla()`, comma or space syntax → `rgba(…)` / `hsla(…)`
147
+ * - anything else (CSS variables, named colors, `oklch()`, …) →
148
+ * `color-mix(in srgb, <color> <alpha*100>%, transparent)` (web only)
134
149
  */
135
150
  export function withAlpha(color: string, alpha: number): string {
136
151
  const trimmed = color.trim()
152
+ const rgba = parseColor(trimmed)
153
+ const fn = rgba && colorFunctionArgs(trimmed)
137
154
 
138
- if (trimmed.startsWith('#')) {
139
- const hex = trimmed.slice(1)
140
- const normalized =
141
- hex.length === 3
142
- ? hex
143
- .split('')
144
- .map((c) => c + c)
145
- .join('')
146
- : hex
147
- if (normalized.length === 6) {
148
- const r = Number.parseInt(normalized.slice(0, 2), 16)
149
- const g = Number.parseInt(normalized.slice(2, 4), 16)
150
- const b = Number.parseInt(normalized.slice(4, 6), 16)
151
- return `rgba(${r}, ${g}, ${b}, ${alpha})`
152
- }
153
- }
154
-
155
- if (trimmed.startsWith('rgb(')) {
156
- return trimmed.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
155
+ if (fn?.name === 'hsl') {
156
+ const [h, s, l] = fn.args
157
+ return `hsla(${h}, ${s}, ${l}, ${alpha})`
157
158
  }
159
+ if (rgba) return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha})`
158
160
 
159
- if (trimmed.startsWith('rgba(')) {
160
- return trimmed.replace(/,\s*[\d.]+\)$/, `, ${alpha})`)
161
- }
162
-
163
- return trimmed
161
+ return `color-mix(in srgb, ${trimmed} ${alpha * 100}%, transparent)`
164
162
  }
165
163
 
166
164
  /**
@@ -199,10 +197,10 @@ export function getHeatmapCellStyle(params: {
199
197
  }
200
198
  }
201
199
 
202
- const normalized = clamp((value - min) / (max - min), 0, 1)
203
- const alpha = minAlpha + (maxAlpha - minAlpha) * normalized
204
-
205
200
  return {
206
- backgroundColor: withAlpha(baseColor, alpha)
201
+ backgroundColor: withAlpha(
202
+ baseColor,
203
+ lerp(minAlpha, maxAlpha, normalize(value, min, max))
204
+ )
207
205
  }
208
206
  }
@@ -40,11 +40,44 @@ export const terminalColors = {
40
40
 
41
41
  export type TerminalColor = keyof typeof terminalColors
42
42
 
43
+ const noColors = Object.fromEntries(
44
+ Object.keys(terminalColors).map((key) => [key, ''])
45
+ ) as unknown as typeof terminalColors
46
+
47
+ function getProcessEnvironment():
48
+ | Record<string, string | undefined>
49
+ | undefined {
50
+ return typeof process === 'undefined' ? undefined : process.env
51
+ }
52
+
53
+ function isDev(): boolean {
54
+ return getProcessEnvironment()?.NODE_ENV !== 'production'
55
+ }
56
+
57
+ /**
58
+ * Whether stdout should get ANSI colors: `NO_COLOR` disables, `FORCE_COLOR`
59
+ * (other than `'0'`) enables, otherwise only when stdout is a TTY.
60
+ * Always false where `process` doesn't exist (browsers, Workers).
61
+ */
62
+ export function supportsColor(): boolean {
63
+ const env = getProcessEnvironment()
64
+ if (!env || env.NO_COLOR) return false
65
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0') return true
66
+ return process.stdout?.isTTY === true
67
+ }
68
+
43
69
  /** Log levels supported by the logger */
44
70
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
45
71
 
46
- /** Attributes that can be passed to log methods */
47
- export type LogAttributes = Record<string, string | number | boolean>
72
+ const levelRank: Record<LogLevel, number> = {
73
+ debug: 0,
74
+ info: 1,
75
+ warn: 2,
76
+ error: 3
77
+ }
78
+
79
+ /** Attributes that can be passed to log methods. Objects are rendered as compact JSON. */
80
+ export type LogAttributes = Record<string, unknown>
48
81
 
49
82
  /** Log method signature */
50
83
  export type LogMethod = (message: string, attributes?: LogAttributes) => void
@@ -55,6 +88,8 @@ export type CoolLogger = {
55
88
  info: LogMethod
56
89
  warn: LogMethod
57
90
  error: LogMethod
91
+ /** Returns a logger that adds `attributes` to every entry (call-site attributes win). */
92
+ child: (attributes: LogAttributes) => CoolLogger
58
93
  }
59
94
 
60
95
  /** Color config per log level */
@@ -65,11 +100,24 @@ const levelColors: Record<LogLevel, { badge: string; text: string }> = {
65
100
  error: { badge: terminalColors.red, text: terminalColors.brightRed }
66
101
  }
67
102
 
103
+ function formatValue(value: unknown): string {
104
+ if (value instanceof Error) return String(value)
105
+ if (typeof value !== 'object' || value === null) return String(value)
106
+ try {
107
+ return JSON.stringify(value)
108
+ } catch (_error) {
109
+ return '[Unserializable]'
110
+ }
111
+ }
112
+
68
113
  /** Format attributes as a compact colored string */
69
114
  function formatAttributes(attrs?: LogAttributes): string {
70
115
  if (!attrs || Object.keys(attrs).length === 0) return ''
71
116
  const formatted = Object.entries(attrs)
72
- .map(([k, v]) => `${terminalColors.dim}${k}=${terminalColors.reset}${v}`)
117
+ .map(
118
+ ([k, v]) =>
119
+ `${terminalColors.dim}${k}=${terminalColors.reset}${formatValue(v)}`
120
+ )
73
121
  .join(' ')
74
122
  return ` ${formatted}`
75
123
  }
@@ -78,9 +126,11 @@ function formatAttributes(attrs?: LogAttributes): string {
78
126
  * Options for creating a cool logger
79
127
  */
80
128
  export type CoolLoggerOptions = {
81
- /** Only use colors in development (default: true) */
129
+ /** Only use colors in development (default: true). Colors also require `supportsColor()`. */
82
130
  colorsInDevOnly?: boolean
83
- /** Callback for each log entry (e.g., to add to OpenTelemetry span) */
131
+ /** Lowest level that is printed (default: 'debug' in dev, 'info' in production) */
132
+ minLevel?: LogLevel
133
+ /** Callback for each log entry, before level filtering (e.g., to add to OpenTelemetry span) */
84
134
  onLog?: (level: LogLevel, message: string, attributes?: LogAttributes) => void
85
135
  }
86
136
 
@@ -93,40 +143,53 @@ export type CoolLoggerOptions = {
93
143
  * logger.info('User logged in', { userId: '123' })
94
144
  * logger.warn('Rate limit approaching', { remaining: 10 })
95
145
  * logger.error('Payment failed', { reason: 'insufficient_funds' })
146
+ * const requestLog = logger.child({ requestId: 'abc' })
96
147
  * ```
97
148
  */
98
149
  export function createCoolLogger(options: CoolLoggerOptions = {}): CoolLogger {
99
- const { colorsInDevOnly = true, onLog } = options
100
- const isDev = process.env.NODE_ENV !== 'production'
101
- const useColors = colorsInDevOnly ? isDev : true
102
-
103
- const createLogMethod =
104
- (level: LogLevel): LogMethod =>
105
- (message, attributes) => {
106
- // Call the onLog callback if provided
107
- onLog?.(level, message, attributes)
108
-
109
- // Skip debug logs in production
110
- if (level === 'debug' && !isDev) return
111
-
112
- if (useColors) {
113
- const { badge, text } = levelColors[level]
114
- const label = level.toUpperCase().padEnd(5)
115
- const attrStr = formatAttributes(attributes)
116
- console.log(
117
- `${badge}[${label}]${terminalColors.reset} ${text}${message}${terminalColors.reset}${attrStr}`
118
- )
119
- } else {
120
- console[level](`[${level.toUpperCase()}]`, message, attributes ?? '')
150
+ const dev = isDev()
151
+ const {
152
+ colorsInDevOnly = true,
153
+ minLevel = dev ? 'debug' : 'info',
154
+ onLog
155
+ } = options
156
+ const useColors = supportsColor() && (!colorsInDevOnly || dev)
157
+
158
+ const createLogger = (base?: LogAttributes): CoolLogger => {
159
+ const createLogMethod =
160
+ (level: LogLevel): LogMethod =>
161
+ (message, callAttributes) => {
162
+ const attributes =
163
+ base && callAttributes
164
+ ? { ...base, ...callAttributes }
165
+ : (callAttributes ?? base)
166
+
167
+ onLog?.(level, message, attributes)
168
+
169
+ if (levelRank[level] < levelRank[minLevel]) return
170
+
171
+ if (useColors) {
172
+ const { badge, text } = levelColors[level]
173
+ const label = level.toUpperCase().padEnd(5)
174
+ const attrStr = formatAttributes(attributes)
175
+ console.log(
176
+ `${badge}[${label}]${terminalColors.reset} ${text}${message}${terminalColors.reset}${attrStr}`
177
+ )
178
+ } else {
179
+ console[level](`[${level.toUpperCase()}]`, message, attributes ?? '')
180
+ }
121
181
  }
122
- }
123
182
 
124
- return {
125
- debug: createLogMethod('debug'),
126
- info: createLogMethod('info'),
127
- warn: createLogMethod('warn'),
128
- error: createLogMethod('error')
183
+ return {
184
+ debug: createLogMethod('debug'),
185
+ info: createLogMethod('info'),
186
+ warn: createLogMethod('warn'),
187
+ error: createLogMethod('error'),
188
+ child: (attributes) => createLogger({ ...base, ...attributes })
189
+ }
129
190
  }
191
+
192
+ return createLogger()
130
193
  }
131
194
 
132
195
  /**
@@ -151,11 +214,10 @@ export function logOperationSummary(
151
214
  options: { devOnly?: boolean; error?: Error | unknown } = {}
152
215
  ): void {
153
216
  const { devOnly = true, error } = options
154
- const isDev = process.env.NODE_ENV !== 'production'
155
217
 
156
- if (devOnly && !isDev) return
218
+ if (devOnly && !isDev()) return
157
219
 
158
- const c = terminalColors
220
+ const c = supportsColor() ? terminalColors : noColors
159
221
  const statusIcon = success
160
222
  ? `${c.brightGreen}✓${c.reset}`
161
223
  : `${c.brightRed}✗${c.reset}`
@@ -172,7 +234,34 @@ export function logOperationSummary(
172
234
  }
173
235
 
174
236
  /**
175
- * Helper to colorize text for console output
237
+ * Starts timing an operation. Call the returned `done` to log it via `logOperationSummary`.
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * const done = startOperation('user.create')
242
+ * try {
243
+ * await doSomething()
244
+ * done()
245
+ * } catch (error) {
246
+ * done(false, error)
247
+ * }
248
+ * ```
249
+ */
250
+ export function startOperation(
251
+ operationName: string,
252
+ options: { devOnly?: boolean } = {}
253
+ ): (success?: boolean, error?: unknown) => void {
254
+ const start = performance.now()
255
+ return (success = true, error) =>
256
+ logOperationSummary(operationName, performance.now() - start, success, {
257
+ ...options,
258
+ error
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Helper to colorize text for console output. Returns `text` unchanged in
264
+ * production or when colors aren't supported (see `supportsColor`).
176
265
  *
177
266
  * @example
178
267
  * ```ts
@@ -185,8 +274,7 @@ export function colorize(
185
274
  color: TerminalColor,
186
275
  ...modifiers: TerminalColor[]
187
276
  ): string {
188
- const isDev = process.env.NODE_ENV !== 'production'
189
- if (!isDev) return text
277
+ if (!isDev() || !supportsColor()) return text
190
278
 
191
279
  const colorCodes = [
192
280
  terminalColors[color],
@@ -210,9 +298,8 @@ export function logBanner(
210
298
  options: { devOnly?: boolean } = {}
211
299
  ): void {
212
300
  const { devOnly = true } = options
213
- const isDev = process.env.NODE_ENV !== 'production'
214
301
 
215
- if (devOnly && !isDev) {
302
+ if ((devOnly && !isDev()) || !supportsColor()) {
216
303
  console.log(`=== ${text} ===`)
217
304
  return
218
305
  }
@@ -0,0 +1,83 @@
1
+ const BYTE_CHUNK_SIZE = 0x8000
2
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
3
+ const BASE64URL = /^[A-Za-z0-9_-]*={0,2}$/
4
+ const HEX = /^(?:[0-9a-fA-F]{2})*$/
5
+ const textEncoder = new TextEncoder()
6
+ const textDecoder = new TextDecoder()
7
+
8
+ export type BytesLike = Uint8Array | ArrayBuffer
9
+
10
+ export function utf8Encode(value: string): Uint8Array<ArrayBuffer> {
11
+ return textEncoder.encode(value)
12
+ }
13
+
14
+ /** Invalid UTF-8 sequences become U+FFFD (same as `TextDecoder` / `Buffer#toString`). */
15
+ export function utf8Decode(bytes: BytesLike): string {
16
+ return textDecoder.decode(bytes)
17
+ }
18
+
19
+ /** Standard base64 with `=` padding. */
20
+ export function encodeBase64(bytes: BytesLike): string {
21
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
22
+ let binary = ''
23
+ for (let offset = 0; offset < view.length; offset += BYTE_CHUNK_SIZE) {
24
+ binary += String.fromCharCode(
25
+ ...view.subarray(offset, offset + BYTE_CHUNK_SIZE)
26
+ )
27
+ }
28
+ return btoa(binary)
29
+ }
30
+
31
+ /** Decodes standard base64 (padding optional). Throws `TypeError` on invalid input. */
32
+ export function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
33
+ if (!BASE64.test(value)) throw new TypeError('Invalid base64 string')
34
+ const unpadded = value.replace(/=+$/, '')
35
+ if (unpadded.length % 4 === 1) throw new TypeError('Invalid base64 string')
36
+
37
+ const binary = atob(unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '='))
38
+ const bytes = new Uint8Array(binary.length)
39
+ for (let index = 0; index < binary.length; index += 1) {
40
+ bytes[index] = binary.charCodeAt(index)
41
+ }
42
+ return bytes
43
+ }
44
+
45
+ /** URL-safe base64 without padding (RFC 4648 §5). */
46
+ export function encodeBase64Url(bytes: BytesLike): string {
47
+ return encodeBase64(bytes)
48
+ .replaceAll('+', '-')
49
+ .replaceAll('/', '_')
50
+ .replace(/=+$/, '')
51
+ }
52
+
53
+ /** Decodes URL-safe base64 (padding optional). Throws `TypeError` on invalid input. */
54
+ export function decodeBase64Url(value: string): Uint8Array<ArrayBuffer> {
55
+ if (!BASE64URL.test(value)) throw new TypeError('Invalid base64url string')
56
+ return decodeBase64(value.replaceAll('-', '+').replaceAll('_', '/'))
57
+ }
58
+
59
+ /** Lowercase hex. */
60
+ export function bytesToHex(bytes: BytesLike): string {
61
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
62
+ let hex = ''
63
+ for (const byte of view) hex += byte.toString(16).padStart(2, '0')
64
+ return hex
65
+ }
66
+
67
+ /** Decodes hex (either case). Throws `TypeError` on odd length or non-hex characters. */
68
+ export function hexToBytes(value: string): Uint8Array<ArrayBuffer> {
69
+ if (!HEX.test(value)) throw new TypeError('Invalid hex string')
70
+ const bytes = new Uint8Array(value.length / 2)
71
+ for (let index = 0; index < bytes.length; index += 1) {
72
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)
73
+ }
74
+ return bytes
75
+ }
76
+
77
+ /** UTF-8 for strings; a copy for bytes, so Web Crypto always gets an `ArrayBuffer`-backed view. */
78
+ export function toBytes(data: string | BytesLike): Uint8Array<ArrayBuffer> {
79
+ if (typeof data === 'string') return utf8Encode(data)
80
+ return data instanceof Uint8Array
81
+ ? new Uint8Array(data)
82
+ : new Uint8Array(data.slice(0))
83
+ }
@@ -0,0 +1,170 @@
1
+ import {
2
+ type BytesLike,
3
+ bytesToHex,
4
+ decodeBase64,
5
+ decodeBase64Url,
6
+ encodeBase64,
7
+ encodeBase64Url,
8
+ hexToBytes,
9
+ toBytes,
10
+ utf8Encode
11
+ } from './encoding'
12
+
13
+ export type HmacAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512'
14
+ export type SignatureEncoding = 'hex' | 'base64' | 'base64url'
15
+
16
+ export type HmacSignOptions = {
17
+ /** Default: `'SHA-256'` */
18
+ algorithm?: HmacAlgorithm
19
+ /** Default: `'hex'` */
20
+ encoding?: SignatureEncoding
21
+ }
22
+
23
+ export type HmacVerifyOptions = HmacSignOptions & {
24
+ /** Required prefix stripped before decoding, e.g. `'sha256='`. Missing prefix → `false`. */
25
+ prefix?: string
26
+ }
27
+
28
+ const HMAC_ALGORITHMS: readonly string[] = ['SHA-256', 'SHA-384', 'SHA-512']
29
+ const RANDOM_CHUNK_SIZE = 0x10000
30
+ const ENCODERS: Record<SignatureEncoding, (bytes: Uint8Array) => string> = {
31
+ hex: bytesToHex,
32
+ base64: encodeBase64,
33
+ base64url: encodeBase64Url
34
+ }
35
+ const DECODERS: Record<
36
+ SignatureEncoding,
37
+ (value: string) => Uint8Array<ArrayBuffer>
38
+ > = { hex: hexToBytes, base64: decodeBase64, base64url: decodeBase64Url }
39
+
40
+ /**
41
+ * Constant-time equality for strings (compared as UTF-8) or bytes. Runs over the
42
+ * longer input, so timing reveals at most the lengths, never where they differ.
43
+ */
44
+ export function safeEqual(
45
+ a: string | Uint8Array,
46
+ b: string | Uint8Array
47
+ ): boolean {
48
+ const left = typeof a === 'string' ? utf8Encode(a) : a
49
+ const right = typeof b === 'string' ? utf8Encode(b) : b
50
+ const length = Math.max(left.length, right.length)
51
+ let diff = left.length ^ right.length
52
+ for (let index = 0; index < length; index += 1) {
53
+ diff |= (left[index] ?? 0) ^ (right[index] ?? 0)
54
+ }
55
+ return diff === 0
56
+ }
57
+
58
+ /** Cryptographically secure random bytes. */
59
+ export function randomBytes(byteLength: number): Uint8Array<ArrayBuffer> {
60
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
61
+ throw new RangeError('byteLength must be a non-negative integer')
62
+ }
63
+ const cryptoApi = globalThis.crypto
64
+ if (!cryptoApi?.getRandomValues) {
65
+ throw new Error('Web Crypto is not available in this runtime')
66
+ }
67
+ const bytes = new Uint8Array(byteLength)
68
+ // getRandomValues rejects more than 65536 bytes per call.
69
+ for (let offset = 0; offset < byteLength; offset += RANDOM_CHUNK_SIZE) {
70
+ cryptoApi.getRandomValues(
71
+ bytes.subarray(offset, offset + RANDOM_CHUNK_SIZE)
72
+ )
73
+ }
74
+ return bytes
75
+ }
76
+
77
+ /** Random base64url token. 32 bytes → 43 characters, 256 bits of entropy. */
78
+ export function randomToken(byteLength = 32): string {
79
+ if (!Number.isSafeInteger(byteLength) || byteLength < 1) {
80
+ throw new RangeError('byteLength must be a positive integer')
81
+ }
82
+ return encodeBase64Url(randomBytes(byteLength))
83
+ }
84
+
85
+ export async function sha256(data: string | BytesLike): Promise<Uint8Array> {
86
+ return new Uint8Array(
87
+ await getSubtleCrypto().digest('SHA-256', toBytes(data))
88
+ )
89
+ }
90
+
91
+ export async function sha256Hex(data: string | BytesLike): Promise<string> {
92
+ return bytesToHex(await sha256(data))
93
+ }
94
+
95
+ export async function hmacSign(
96
+ secret: string | BytesLike,
97
+ data: string | BytesLike,
98
+ options: HmacSignOptions = {}
99
+ ): Promise<string> {
100
+ const { algorithm = 'SHA-256', encoding = 'hex' } = options
101
+ assertEncoding(encoding)
102
+ const key = await importHmacKey(secret, algorithm, 'sign')
103
+ const signature = await getSubtleCrypto().sign('HMAC', key, toBytes(data))
104
+ return ENCODERS[encoding](new Uint8Array(signature))
105
+ }
106
+
107
+ /**
108
+ * Checks `signature` against the HMAC of `data` using Web Crypto's `verify`
109
+ * (constant-time). Malformed or wrongly-prefixed signatures return `false`;
110
+ * configuration problems (empty secret, unknown algorithm) throw.
111
+ */
112
+ export async function verifyHmacSignature(
113
+ secret: string | BytesLike,
114
+ data: string | BytesLike,
115
+ signature: string,
116
+ options: HmacVerifyOptions = {}
117
+ ): Promise<boolean> {
118
+ const { algorithm = 'SHA-256', encoding = 'hex', prefix } = options
119
+ assertEncoding(encoding)
120
+ const key = await importHmacKey(secret, algorithm, 'verify')
121
+
122
+ let encoded = signature
123
+ if (prefix !== undefined) {
124
+ if (!signature.startsWith(prefix)) return false
125
+ encoded = signature.slice(prefix.length)
126
+ }
127
+
128
+ let received: Uint8Array<ArrayBuffer>
129
+ try {
130
+ received = DECODERS[encoding](encoded)
131
+ } catch (_error) {
132
+ return false
133
+ }
134
+ return getSubtleCrypto().verify('HMAC', key, received, toBytes(data))
135
+ }
136
+
137
+ function importHmacKey(
138
+ secret: string | BytesLike,
139
+ algorithm: HmacAlgorithm,
140
+ usage: 'sign' | 'verify'
141
+ ): Promise<CryptoKey> {
142
+ if (!HMAC_ALGORITHMS.includes(algorithm)) {
143
+ throw new TypeError(`Unsupported HMAC algorithm: ${algorithm}`)
144
+ }
145
+ const keyBytes = toBytes(secret)
146
+ if (keyBytes.length === 0)
147
+ throw new TypeError('HMAC secret must not be empty')
148
+ return getSubtleCrypto().importKey(
149
+ 'raw',
150
+ keyBytes,
151
+ { name: 'HMAC', hash: algorithm },
152
+ false,
153
+ [usage]
154
+ )
155
+ }
156
+
157
+ function assertEncoding(encoding: SignatureEncoding): void {
158
+ if (!Object.hasOwn(ENCODERS, encoding)) {
159
+ throw new TypeError(`Unsupported signature encoding: ${encoding}`)
160
+ }
161
+ }
162
+
163
+ /** @internal */
164
+ export function getSubtleCrypto(): SubtleCrypto {
165
+ const subtle = globalThis.crypto?.subtle
166
+ if (!subtle) {
167
+ throw new Error('Web Crypto is not available in this runtime')
168
+ }
169
+ return subtle
170
+ }
@@ -0,0 +1,26 @@
1
+ export {
2
+ type BytesLike,
3
+ bytesToHex,
4
+ decodeBase64,
5
+ decodeBase64Url,
6
+ encodeBase64,
7
+ encodeBase64Url,
8
+ hexToBytes,
9
+ utf8Decode,
10
+ utf8Encode
11
+ } from './encoding'
12
+ export {
13
+ type HmacAlgorithm,
14
+ type HmacSignOptions,
15
+ type HmacVerifyOptions,
16
+ hmacSign,
17
+ randomBytes,
18
+ randomToken,
19
+ type SignatureEncoding,
20
+ safeEqual,
21
+ sha256,
22
+ sha256Hex,
23
+ verifyHmacSignature
24
+ } from './hash'
25
+ export * from './sealer'
26
+ export * from './webhook'