hanzio 1.1.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 (172) hide show
  1. package/README.md +832 -219
  2. package/dist/api-wrapper/builders.d.ts +15 -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 +40 -2
  7. package/dist/api-wrapper/headers.d.ts +2 -1
  8. package/dist/api-wrapper/index.d.ts +5 -0
  9. package/dist/api-wrapper/otel.d.ts +44 -0
  10. package/dist/api-wrapper/paginate.d.ts +12 -0
  11. package/dist/api-wrapper/redaction.d.ts +2 -0
  12. package/dist/api-wrapper/shared.d.ts +16 -2
  13. package/dist/api-wrapper/testing.d.ts +23 -0
  14. package/dist/api-wrapper/transport.d.ts +12 -10
  15. package/dist/api-wrapper/types.d.ts +104 -18
  16. package/dist/array/index.d.ts +50 -4
  17. package/dist/cache/createCache.d.ts +31 -0
  18. package/dist/cache/index.d.ts +45 -2
  19. package/dist/color/contrast.d.ts +40 -0
  20. package/dist/color/index.d.ts +2 -0
  21. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  22. package/dist/cool-console-log/index.d.ts +34 -5
  23. package/dist/crypto/encoding.d.ts +18 -0
  24. package/dist/crypto/hash.d.ts +33 -0
  25. package/dist/crypto/index.d.ts +4 -0
  26. package/dist/crypto/sealer.d.ts +25 -0
  27. package/dist/crypto/webhook.d.ts +20 -0
  28. package/dist/date/index.d.ts +46 -2
  29. package/dist/emitter/index.d.ts +21 -0
  30. package/dist/error/index.d.ts +12 -0
  31. package/dist/format/index.d.ts +102 -0
  32. package/dist/function/index.d.ts +60 -0
  33. package/dist/guard/index.d.ts +20 -0
  34. package/dist/i18n/index.d.ts +37 -0
  35. package/dist/index-0zvtcbzy.js +224 -0
  36. package/dist/index-181e6vfw.js +29 -0
  37. package/dist/index-8r2vdzjg.js +87 -0
  38. package/dist/index-hy7kbb99.js +157 -0
  39. package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
  40. package/dist/index-k9yx6bne.js +89 -0
  41. package/dist/index-kj1phgrx.js +169 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-srt06xd2.js +15 -0
  46. package/dist/index-wjyb9bsd.js +263 -0
  47. package/dist/index.d.ts +7 -7
  48. package/dist/jwt/index.d.ts +50 -2
  49. package/dist/math/index.d.ts +2 -2
  50. package/dist/math/linearRegressionTrend.d.ts +14 -1
  51. package/dist/math/scale.d.ts +8 -0
  52. package/dist/object/index.d.ts +19 -0
  53. package/dist/p-queue/index.d.ts +1 -1
  54. package/dist/promise/index.d.ts +97 -2
  55. package/dist/secrets/core.d.ts +47 -0
  56. package/dist/secrets/deadline.d.ts +6 -0
  57. package/dist/secrets/environment.d.ts +9 -0
  58. package/dist/secrets/errors.d.ts +11 -0
  59. package/dist/secrets/index.d.ts +13 -25
  60. package/dist/secrets/infisical-cli.d.ts +6 -0
  61. package/dist/secrets/infisical-config.d.ts +17 -0
  62. package/dist/secrets/infisical-session.d.ts +4 -0
  63. package/dist/secrets/infisical.d.ts +8 -13
  64. package/dist/secrets/loaders.d.ts +5 -12
  65. package/dist/secrets/vite.d.ts +7 -3
  66. package/dist/secrets/worker-loader.d.ts +9 -0
  67. package/dist/secrets/worker.d.ts +3 -0
  68. package/dist/sitemap/index.d.ts +48 -6
  69. package/dist/src/api-wrapper/index.js +60 -1100
  70. package/dist/src/api-wrapper/testing.js +20 -0
  71. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  72. package/dist/src/crypto/index.js +195 -0
  73. package/dist/src/format/index.js +211 -0
  74. package/dist/src/i18n/index.js +36 -0
  75. package/dist/src/index.js +863 -2809
  76. package/dist/src/jwt/index.js +138 -92
  77. package/dist/src/p-queue/index.js +9 -437
  78. package/dist/src/secrets/index.js +261 -1377
  79. package/dist/src/secrets/vite.js +6 -17
  80. package/dist/src/secrets/worker.js +12 -0
  81. package/dist/src/sitemap/index.js +183 -511
  82. package/dist/src/storage/index.js +119 -0
  83. package/dist/src/zod/index.js +347 -174
  84. package/dist/state/index.d.ts +21 -0
  85. package/dist/storage/index.d.ts +58 -0
  86. package/dist/string/index.d.ts +75 -2
  87. package/dist/typedswitch/index.d.ts +37 -26
  88. package/dist/types/index.d.ts +11 -0
  89. package/dist/url/index.d.ts +50 -1
  90. package/dist/zod/index.d.ts +32 -3
  91. package/dist/zod/introspect.d.ts +19 -0
  92. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  93. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  94. package/dist/zod/zodToTypeString.d.ts +8 -3
  95. package/package.json +87 -7
  96. package/src/api-wrapper/actions-impl.ts +11 -72
  97. package/src/api-wrapper/builders.ts +84 -52
  98. package/src/api-wrapper/create-client.ts +246 -96
  99. package/src/api-wrapper/describe.ts +157 -0
  100. package/src/api-wrapper/endpoint.ts +26 -0
  101. package/src/api-wrapper/error-reporting.ts +7 -1
  102. package/src/api-wrapper/errors.ts +117 -2
  103. package/src/api-wrapper/headers.ts +19 -1
  104. package/src/api-wrapper/index.ts +5 -0
  105. package/src/api-wrapper/otel.ts +139 -0
  106. package/src/api-wrapper/paginate.ts +28 -0
  107. package/src/api-wrapper/redaction.ts +43 -0
  108. package/src/api-wrapper/shared.ts +21 -2
  109. package/src/api-wrapper/testing.ts +48 -0
  110. package/src/api-wrapper/transport.ts +68 -74
  111. package/src/api-wrapper/types.ts +186 -18
  112. package/src/array/index.ts +195 -42
  113. package/src/cache/createCache.ts +157 -0
  114. package/src/cache/index.ts +235 -83
  115. package/src/color/contrast.ts +163 -0
  116. package/src/color/index.ts +22 -0
  117. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  118. package/src/cool-console-log/index.ts +128 -41
  119. package/src/crypto/encoding.ts +83 -0
  120. package/src/crypto/hash.ts +170 -0
  121. package/src/crypto/index.ts +26 -0
  122. package/src/crypto/sealer.ts +168 -0
  123. package/src/crypto/webhook.ts +86 -0
  124. package/src/date/index.ts +109 -34
  125. package/src/emitter/index.ts +82 -0
  126. package/src/error/index.ts +63 -0
  127. package/src/format/index.ts +389 -0
  128. package/src/function/index.ts +201 -0
  129. package/src/guard/index.ts +80 -0
  130. package/src/i18n/index.ts +125 -0
  131. package/src/index.ts +11 -7
  132. package/src/jwt/index.ts +199 -125
  133. package/src/math/index.ts +5 -10
  134. package/src/math/linearRegressionTrend.ts +33 -8
  135. package/src/math/scale.ts +17 -0
  136. package/src/object/index.ts +99 -0
  137. package/src/p-queue/index.ts +2 -0
  138. package/src/promise/index.ts +304 -9
  139. package/src/secrets/core.ts +348 -0
  140. package/src/secrets/deadline.ts +69 -0
  141. package/src/secrets/environment.ts +69 -0
  142. package/src/secrets/errors.ts +29 -0
  143. package/src/secrets/index.ts +79 -210
  144. package/src/secrets/infisical-cli.ts +99 -0
  145. package/src/secrets/infisical-config.ts +70 -0
  146. package/src/secrets/infisical-session.ts +42 -0
  147. package/src/secrets/infisical.ts +214 -66
  148. package/src/secrets/loaders.ts +24 -48
  149. package/src/secrets/vite.ts +44 -15
  150. package/src/secrets/worker-loader.ts +56 -0
  151. package/src/secrets/worker.ts +4 -0
  152. package/src/sitemap/index.ts +293 -130
  153. package/src/state/index.ts +133 -81
  154. package/src/storage/index.ts +187 -0
  155. package/src/string/index.ts +261 -23
  156. package/src/typedswitch/index.ts +134 -103
  157. package/src/types/index.ts +14 -0
  158. package/src/url/index.ts +146 -1
  159. package/src/zod/index.ts +105 -9
  160. package/src/zod/introspect.ts +103 -0
  161. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  162. package/src/zod/zodMinMaxFilter.ts +37 -13
  163. package/src/zod/zodToTypeString.ts +184 -181
  164. package/dist/api-wrapper/index.js +0 -250
  165. package/dist/gzip/index.d.ts +0 -2
  166. package/dist/gzip/index.js +0 -16
  167. package/dist/index.js +0 -1823
  168. package/dist/jwt/index.js +0 -19
  169. package/dist/sitemap/index.js +0 -552
  170. package/dist/src/gzip/index.js +0 -16
  171. package/dist/src/secrets/infisical.js +0 -1194
  172. package/dist/zod/index.js +0 -210
@@ -1,12 +1,40 @@
1
- type CacheEntry<T> = {
2
- value: T
3
- timestamp: number
4
- }
1
+ import { stableStringify } from '../string'
5
2
 
6
3
  type AnyFunction = (...args: never[]) => unknown
7
- type CachedFunction<T extends AnyFunction> = {
4
+ type CachedFunction<
5
+ T extends AnyFunction,
6
+ TPeek = Awaited<ReturnType<T>> | undefined
7
+ > = {
8
8
  (...args: Parameters<T>): ReturnType<T>
9
9
  clearCache: () => void
10
+ /** Drops the entry (and any in-flight call) for these arguments. */
11
+ invalidate: (...args: Parameters<T>) => void
12
+ /**
13
+ * What a call would return from the cache right now (including a stale
14
+ * value with refreshInBackground), without calling fn, starting a refresh
15
+ * or counting as a use for maxEntries. `undefined` on a miss. With a
16
+ * `store` this reads the store and returns a Promise.
17
+ */
18
+ peek: (...args: Parameters<T>) => TPeek
19
+ }
20
+
21
+ export type CacheStoreEntry = {
22
+ value: unknown
23
+ timestamp: number
24
+ }
25
+
26
+ /**
27
+ * Contract for external cache stores (e.g. a Redis adapter).
28
+ * Serialization is the adapter's responsibility; this library never imports
29
+ * the underlying client.
30
+ */
31
+ export type CacheStore = {
32
+ get: (key: string) => Promise<CacheStoreEntry | undefined>
33
+ /** `ttlMs` maps to the store's native expiry (e.g. Redis `PX`). */
34
+ set: (key: string, entry: CacheStoreEntry, ttlMs: number) => Promise<void>
35
+ delete: (key: string) => Promise<void>
36
+ /** Optional; called by clearCache when defined. */
37
+ clear?: () => Promise<void>
10
38
  }
11
39
 
12
40
  export type CacheFunctionOptions<T extends AnyFunction> = {
@@ -18,11 +46,29 @@ export type CacheFunctionOptions<T extends AnyFunction> = {
18
46
  cacheKeyFn?: (...args: Parameters<T>) => unknown[]
19
47
  /** Called when a background refresh fails (default: silent). */
20
48
  onBackgroundRefreshError?: (error: unknown, context: { name: string }) => void
49
+ /**
50
+ * External cache store (e.g. a Redis adapter). Only supported for async
51
+ * functions; when omitted the built-in in-memory cache is used.
52
+ */
53
+ store?: ReturnType<T> extends Promise<unknown> ? CacheStore : never
54
+ /** Called when a store get/set/delete/clear fails (default: silent). */
55
+ onStoreError?: (error: unknown, context: { name: string }) => void
56
+ /**
57
+ * Evicts the least recently used entry beyond this many entries (default:
58
+ * unbounded). Applies to the in-memory cache only.
59
+ */
60
+ maxEntries?: number
21
61
  }
22
62
 
63
+ export function cacheFunction<T extends AnyFunction>(
64
+ options: CacheFunctionOptions<T> & { store: CacheStore }
65
+ ): CachedFunction<T, Promise<Awaited<ReturnType<T>> | undefined>>
23
66
  export function cacheFunction<T extends AnyFunction>(
24
67
  options: CacheFunctionOptions<T>
25
- ): CachedFunction<T> {
68
+ ): CachedFunction<T>
69
+ export function cacheFunction<T extends AnyFunction>(
70
+ options: CacheFunctionOptions<T>
71
+ ): CachedFunction<T, unknown> {
26
72
  const {
27
73
  name,
28
74
  fn,
@@ -30,115 +76,220 @@ export function cacheFunction<T extends AnyFunction>(
30
76
  refreshInBackground = false,
31
77
  cacheKeyArgs,
32
78
  cacheKeyFn,
33
- onBackgroundRefreshError
79
+ onBackgroundRefreshError,
80
+ store,
81
+ onStoreError,
82
+ maxEntries
34
83
  } = options
35
84
 
36
- const cache = new Map<string, CacheEntry<ReturnType<T>>>()
85
+ const externalStore = store as CacheStore | undefined
86
+
87
+ const cache = new Map<string, CacheStoreEntry>()
37
88
  const pendingPromises = new Map<string, Promise<unknown>>()
38
89
 
39
- const cleanupInterval = setInterval(
40
- () => {
41
- const now = Date.now()
42
- for (const [key, entry] of cache.entries()) {
43
- if (now - entry.timestamp >= cacheTimeMs) {
44
- cache.delete(key)
90
+ // Stale entries are kept for an extra TTL so refreshInBackground can serve
91
+ // them while revalidating.
92
+ const entryLifetimeMs = refreshInBackground ? cacheTimeMs * 2 : cacheTimeMs
93
+
94
+ let cleanupInterval: ReturnType<typeof setInterval> | undefined
95
+
96
+ const stopCleanup = () => {
97
+ if (cleanupInterval !== undefined) {
98
+ clearInterval(cleanupInterval)
99
+ cleanupInterval = undefined
100
+ }
101
+ }
102
+
103
+ // Created lazily on first write and unref'd so it never keeps the process
104
+ // alive. Stops itself once the cache is empty and restarts on the next
105
+ // write, so clearCache does not permanently disable sweeping.
106
+ const ensureCleanup = () => {
107
+ if (cleanupInterval !== undefined) {
108
+ return
109
+ }
110
+ cleanupInterval = setInterval(
111
+ () => {
112
+ const now = Date.now()
113
+ for (const [key, entry] of cache.entries()) {
114
+ if (now - entry.timestamp >= entryLifetimeMs) {
115
+ cache.delete(key)
116
+ }
45
117
  }
46
- }
47
- },
48
- Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
49
- )
118
+ if (cache.size === 0) {
119
+ stopCleanup()
120
+ }
121
+ },
122
+ Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
123
+ )
124
+ cleanupInterval.unref?.()
125
+ }
50
126
 
51
- const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
127
+ const buildKey = (args: Parameters<T>): string => {
52
128
  const keyArgs = cacheKeyFn
53
129
  ? cacheKeyFn(...args)
54
130
  : cacheKeyArgs
55
131
  ? cacheKeyArgs.map((i) => args[i])
56
132
  : args
57
- const key = `${name}:${JSON.stringify(keyArgs)}`
58
- const now = Date.now()
133
+ return `${name}:${stableStringify(keyArgs)}`
134
+ }
59
135
 
60
- const pending = pendingPromises.get(key)
61
- if (pending) {
62
- return pending as ReturnType<T>
136
+ const isFresh = (entry: CacheStoreEntry) =>
137
+ Date.now() - entry.timestamp < cacheTimeMs
138
+
139
+ // The value a call would return straight from the cache, if any.
140
+ const servable = (entry: CacheStoreEntry | undefined) =>
141
+ entry && (refreshInBackground || isFresh(entry)) ? entry : undefined
142
+
143
+ // Map keeps insertion order, so re-inserting on every hit and write makes
144
+ // the first key the least recently used.
145
+ const touch = (key: string, entry: CacheStoreEntry) => {
146
+ cache.delete(key)
147
+ cache.set(key, entry)
148
+ if (maxEntries !== undefined && cache.size > maxEntries) {
149
+ cache.delete(cache.keys().next().value as string)
63
150
  }
151
+ }
64
152
 
65
- const cached = cache.get(key)
153
+ const guardStore = async <R>(
154
+ operation: () => Promise<R>
155
+ ): Promise<R | undefined> => {
156
+ try {
157
+ return await operation()
158
+ } catch (error) {
159
+ onStoreError?.(error, { name })
160
+ return undefined
161
+ }
162
+ }
66
163
 
67
- if (cached) {
68
- const isCacheFresh = now - cached.timestamp < cacheTimeMs
164
+ const save = (key: string, value: unknown) => {
165
+ const entry = { value, timestamp: Date.now() }
166
+ if (externalStore) {
167
+ return guardStore(() => externalStore.set(key, entry, entryLifetimeMs))
168
+ }
169
+ touch(key, entry)
170
+ ensureCleanup()
171
+ }
69
172
 
70
- if (!isCacheFresh && !refreshInBackground) {
71
- cache.delete(key)
72
- } else {
73
- if (isCacheFresh && !refreshInBackground) {
74
- return cached.value
75
- }
173
+ // Calls fn and saves the result. Async calls are tracked in
174
+ // pendingPromises for dedupe. A failed background refresh (`stale` given)
175
+ // resolves to the stale value instead of rejecting.
176
+ const run = (
177
+ key: string,
178
+ args: Parameters<T>,
179
+ stale?: CacheStoreEntry
180
+ ): unknown => {
181
+ const result = fn(...args)
76
182
 
77
- if (refreshInBackground) {
78
- const existingPending = pendingPromises.get(key)
79
- if (existingPending) {
80
- return existingPending as ReturnType<T>
81
- }
183
+ if (!isPromiseLike(result)) {
184
+ save(key, result)
185
+ return result
186
+ }
82
187
 
83
- const backgroundUpdate = fn(...args)
84
-
85
- if (isPromiseLike(backgroundUpdate)) {
86
- const backgroundPromise = (
87
- backgroundUpdate as Promise<ReturnType<T>>
88
- )
89
- .then((resolvedValue) => {
90
- cache.set(key, { value: resolvedValue, timestamp: now })
91
- pendingPromises.delete(key)
92
- return resolvedValue
93
- })
94
- .catch((error: unknown) => {
95
- pendingPromises.delete(key)
96
- onBackgroundRefreshError?.(error, { name })
97
- })
98
-
99
- pendingPromises.set(key, backgroundPromise)
100
- } else {
101
- cache.set(key, {
102
- value: backgroundUpdate as ReturnType<T>,
103
- timestamp: now
104
- })
188
+ const promise: Promise<unknown> = Promise.resolve(result)
189
+ .then(
190
+ async (value) => {
191
+ // Skip the save when invalidate/clearCache ran meanwhile.
192
+ if (pendingPromises.get(key) === promise) {
193
+ await save(key, value)
105
194
  }
106
-
107
- return cached.value
195
+ return value
196
+ },
197
+ (error: unknown) => {
198
+ if (!stale) throw error
199
+ onBackgroundRefreshError?.(error, { name })
200
+ return stale.value
108
201
  }
109
-
110
- if (isCacheFresh) {
111
- return cached.value
202
+ )
203
+ .finally(() => {
204
+ if (pendingPromises.get(key) === promise) {
205
+ pendingPromises.delete(key)
112
206
  }
113
- }
207
+ })
208
+
209
+ pendingPromises.set(key, promise)
210
+ return promise
211
+ }
212
+
213
+ // Serves a cached entry, starting a background refresh when it is stale.
214
+ const serve = (key: string, args: Parameters<T>, entry: CacheStoreEntry) => {
215
+ if (!isFresh(entry) && !pendingPromises.has(key)) {
216
+ run(key, args, entry)
114
217
  }
218
+ return entry.value
219
+ }
115
220
 
116
- const result = fn(...args)
221
+ const callWithStore = (
222
+ cacheStore: CacheStore,
223
+ key: string,
224
+ args: Parameters<T>
225
+ ): Promise<unknown> => {
226
+ const pending = pendingPromises.get(key)
227
+ // Background refresh callers can still use an entry from the store.
228
+ if (pending && !refreshInBackground) {
229
+ return pending
230
+ }
117
231
 
118
- if (isPromiseLike(result)) {
119
- const promise = (result as Promise<ReturnType<T>>)
120
- .then((resolvedValue) => {
121
- cache.set(key, { value: resolvedValue, timestamp: now })
122
- pendingPromises.delete(key)
123
- return resolvedValue
124
- })
125
- .catch((error) => {
126
- pendingPromises.delete(key)
127
- throw error
128
- })
232
+ return (async () => {
233
+ const entry = await guardStore(() => cacheStore.get(key))
234
+ const cached = servable(entry)
129
235
 
130
- pendingPromises.set(key, promise)
131
- return promise as ReturnType<T>
236
+ if (cached) {
237
+ return serve(key, args, cached)
238
+ }
239
+ if (entry) {
240
+ await guardStore(() => cacheStore.delete(key))
241
+ }
242
+
243
+ // A concurrent caller may have started a fetch while we awaited the
244
+ // store lookup.
245
+ return pendingPromises.get(key) ?? run(key, args)
246
+ })()
247
+ }
248
+
249
+ const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
250
+ const key = buildKey(args)
251
+
252
+ if (externalStore) {
253
+ return callWithStore(externalStore, key, args) as ReturnType<T>
254
+ }
255
+
256
+ const cached = servable(cache.get(key))
257
+ if (cached) {
258
+ touch(key, cached)
259
+ return serve(key, args, cached) as ReturnType<T>
132
260
  }
261
+ cache.delete(key)
133
262
 
134
- cache.set(key, { value: result as ReturnType<T>, timestamp: now })
135
- return result as ReturnType<T>
263
+ return (pendingPromises.get(key) ?? run(key, args)) as ReturnType<T>
136
264
  }
137
265
 
138
266
  wrappedFn.clearCache = () => {
139
267
  cache.clear()
140
268
  pendingPromises.clear()
141
- clearInterval(cleanupInterval)
269
+ stopCleanup()
270
+ const clearStore = externalStore?.clear?.bind(externalStore)
271
+ if (clearStore) {
272
+ void guardStore(clearStore)
273
+ }
274
+ }
275
+
276
+ wrappedFn.invalidate = (...args: Parameters<T>) => {
277
+ const key = buildKey(args)
278
+ cache.delete(key)
279
+ pendingPromises.delete(key)
280
+ if (externalStore) {
281
+ void guardStore(() => externalStore.delete(key))
282
+ }
283
+ }
284
+
285
+ wrappedFn.peek = (...args: Parameters<T>): unknown => {
286
+ const key = buildKey(args)
287
+ if (externalStore) {
288
+ return guardStore(() => externalStore.get(key)).then(
289
+ (entry) => servable(entry)?.value
290
+ )
291
+ }
292
+ return servable(cache.get(key))?.value
142
293
  }
143
294
 
144
295
  return wrappedFn
@@ -152,3 +303,4 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
152
303
  typeof value.then === 'function'
153
304
  )
154
305
  }
306
+ export * from './createCache'
@@ -0,0 +1,163 @@
1
+ import { clamp } from '../math/scale'
2
+
3
+ /** An sRGB color: `r`/`g`/`b` in 0..255, `a` in 0..1. */
4
+ export type Rgba = { r: number; g: number; b: number; a: number }
5
+
6
+ /**
7
+ * Splits `rgb(…)`/`rgba(…)`/`hsl(…)`/`hsla(…)` (comma, space or slash syntax)
8
+ * into its name and 3-4 raw arguments. Internal; shared with `withAlpha`.
9
+ */
10
+ export function colorFunctionArgs(
11
+ color: string
12
+ ): { name: 'rgb' | 'hsl'; args: string[] } | undefined {
13
+ const match = /^(rgb|hsl)a?\(([^()]*)\)$/i.exec(color.trim())
14
+ const args = match?.[2]?.trim().split(/[\s,/]+/)
15
+ if (!match || !args || (args.length !== 3 && args.length !== 4)) {
16
+ return undefined
17
+ }
18
+ return { name: match[1]!.toLowerCase() as 'rgb' | 'hsl', args }
19
+ }
20
+
21
+ // A number, or a percentage scaled so 100% = `percentOf`.
22
+ function parseChannel(raw: string, percentOf: number): number | undefined {
23
+ const isPercent = raw.endsWith('%')
24
+ const text = isPercent ? raw.slice(0, -1) : raw
25
+ const n = text === '' ? Number.NaN : Number(text)
26
+ if (!Number.isFinite(n)) return undefined
27
+ return isPercent ? (n / 100) * percentOf : n
28
+ }
29
+
30
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31
+ const a = s * Math.min(l, 1 - l)
32
+ const f = (n: number) => {
33
+ const k = (n + h / 30) % 12
34
+ return (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255
35
+ }
36
+ return [f(0), f(8), f(4)]
37
+ }
38
+
39
+ /**
40
+ * Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` and
41
+ * `hsl()`/`hsla()` (comma or space/slash syntax, percentages allowed).
42
+ * Returns `undefined` for anything else (named colors, CSS variables, …).
43
+ */
44
+ export function parseColor(color: string): Rgba | undefined {
45
+ const trimmed = color.trim()
46
+
47
+ const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(trimmed)?.[1]
48
+ if (hex) {
49
+ const full = hex.length <= 4 ? hex.replace(/./g, '$&$&') : hex
50
+ const [r = 0, g = 0, b = 0, a = 255] = [0, 2, 4, 6].map((i) =>
51
+ i < full.length ? Number.parseInt(full.slice(i, i + 2), 16) : undefined
52
+ )
53
+ return { r, g, b, a: a / 255 }
54
+ }
55
+
56
+ const fn = colorFunctionArgs(trimmed)
57
+ if (!fn) return undefined
58
+ const [x = '', y = '', z = '', alpha = '1'] = fn.args
59
+ const isRgb = fn.name === 'rgb'
60
+ const c1 = parseChannel(isRgb ? x : x.replace(/deg$/i, ''), isRgb ? 255 : 360)
61
+ const c2 = parseChannel(y, isRgb ? 255 : 100)
62
+ const c3 = parseChannel(z, isRgb ? 255 : 100)
63
+ const a = parseChannel(alpha, 1)
64
+ if (c1 === undefined || c2 === undefined || c3 === undefined) return undefined
65
+ if (a === undefined) return undefined
66
+
67
+ const [r, g, b] = isRgb
68
+ ? [c1, c2, c3]
69
+ : hslToRgb(
70
+ ((c1 % 360) + 360) % 360,
71
+ clamp(c2 / 100, 0, 1),
72
+ clamp(c3 / 100, 0, 1)
73
+ )
74
+ const channel = (v: number) => Math.round(clamp(v, 0, 255))
75
+ return { r: channel(r), g: channel(g), b: channel(b), a: clamp(a, 0, 1) }
76
+ }
77
+
78
+ function toRgba(color: Rgba | string): Rgba {
79
+ if (typeof color !== 'string') return color
80
+ const parsed = parseColor(color)
81
+ if (!parsed) throw new TypeError(`Unsupported color: ${color}`)
82
+ return parsed
83
+ }
84
+
85
+ /** `#rrggbb` (or `#rrggbbaa` with `alpha: true`). Throws on unparseable strings. */
86
+ export function toHex(
87
+ color: Rgba | string,
88
+ options: { alpha?: boolean } = {}
89
+ ): string {
90
+ const { r, g, b, a } = toRgba(color)
91
+ const channels = options.alpha ? [r, g, b, a * 255] : [r, g, b]
92
+ return `#${channels
93
+ .map((v) =>
94
+ Math.round(clamp(v, 0, 255))
95
+ .toString(16)
96
+ .padStart(2, '0')
97
+ )
98
+ .join('')}`
99
+ }
100
+
101
+ /** `rgb(r, g, b)`, or `rgba(r, g, b, a)` when translucent. Throws on unparseable strings. */
102
+ export function toRgbString(color: Rgba | string): string {
103
+ const { r, g, b, a } = toRgba(color)
104
+ const [rr, gg, bb] = [r, g, b].map((v) => Math.round(clamp(v, 0, 255)))
105
+ return a < 1 ? `rgba(${rr}, ${gg}, ${bb}, ${a})` : `rgb(${rr}, ${gg}, ${bb})`
106
+ }
107
+
108
+ /** WCAG 2.x relative luminance (0 = black, 1 = white); alpha is ignored. */
109
+ export function relativeLuminance(color: Rgba | string): number {
110
+ const { r, g, b } = toRgba(color)
111
+ const [lr, lg, lb] = [r, g, b].map((v) => {
112
+ const c = clamp(v, 0, 255) / 255
113
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
114
+ }) as [number, number, number]
115
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb
116
+ }
117
+
118
+ /** WCAG contrast ratio between two colors, from 1 to 21. */
119
+ export function contrastRatio(a: Rgba | string, b: Rgba | string): number {
120
+ const la = relativeLuminance(a)
121
+ const lb = relativeLuminance(b)
122
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
123
+ }
124
+
125
+ /** Whichever of `light`/`dark` contrasts more with `background` (ties go to `light`). */
126
+ export function contrastText(
127
+ background: Rgba | string,
128
+ options: { light?: string; dark?: string } = {}
129
+ ): string {
130
+ const { light = '#ffffff', dark = '#000000' } = options
131
+ return contrastRatio(background, light) >= contrastRatio(background, dark)
132
+ ? light
133
+ : dark
134
+ }
135
+
136
+ function mix(a: Rgba, b: Rgba, weight: number): Rgba {
137
+ const t = clamp(weight, 0, 1)
138
+ const at = (x: number, y: number) => x + (y - x) * t
139
+ return {
140
+ r: at(a.r, b.r),
141
+ g: at(a.g, b.g),
142
+ b: at(a.b, b.b),
143
+ a: at(a.a, b.a)
144
+ }
145
+ }
146
+
147
+ const hexOf = (color: Rgba) => toHex(color, { alpha: color.a < 1 })
148
+
149
+ /** Blend `b` into `a` by `weight` (0 = `a`, 1 = `b`) as hex; alpha kept only when translucent. */
150
+ export function mixColors(
151
+ a: Rgba | string,
152
+ b: Rgba | string,
153
+ weight: number
154
+ ): string {
155
+ return hexOf(mix(toRgba(a), toRgba(b), weight))
156
+ }
157
+
158
+ /** Darken toward black (`amount` < 0) or lighten toward white (> 0); `amount` in -1..1. Returns hex. */
159
+ export function shade(color: Rgba | string, amount: number): string {
160
+ const base = toRgba(color)
161
+ const v = amount < 0 ? 0 : 255
162
+ return hexOf(mix(base, { r: v, g: v, b: v, a: base.a }, Math.abs(amount)))
163
+ }
@@ -0,0 +1,22 @@
1
+ export {
2
+ contrastRatio,
3
+ contrastText,
4
+ mixColors,
5
+ parseColor,
6
+ type Rgba,
7
+ relativeLuminance,
8
+ shade,
9
+ toHex,
10
+ toRgbString
11
+ } from './contrast'
12
+ export {
13
+ createResolveColorsByKeys,
14
+ getHeatmapCellStyle,
15
+ hashStringToColorIndex,
16
+ METRICS_SERIES_PALETTE_SIZE,
17
+ pickFromPalette,
18
+ type ResolveColorsByKeysResult,
19
+ resolveSeriesColorMap,
20
+ seriesColorKey,
21
+ withAlpha
22
+ } from './metricsSeriesColors'