hanzio 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +70 -4
  2. package/dist/api-wrapper/builders.d.ts +13 -2
  3. package/dist/api-wrapper/errors.d.ts +1 -0
  4. package/dist/api-wrapper/headers.d.ts +2 -1
  5. package/dist/api-wrapper/transport.d.ts +8 -5
  6. package/dist/api-wrapper/types.d.ts +1 -0
  7. package/dist/cache/index.d.ts +26 -0
  8. package/dist/secrets/core.d.ts +47 -0
  9. package/dist/secrets/deadline.d.ts +6 -0
  10. package/dist/secrets/environment.d.ts +9 -0
  11. package/dist/secrets/errors.d.ts +11 -0
  12. package/dist/secrets/index.d.ts +13 -25
  13. package/dist/secrets/infisical-cli.d.ts +6 -0
  14. package/dist/secrets/infisical-config.d.ts +17 -0
  15. package/dist/secrets/infisical-session.d.ts +4 -0
  16. package/dist/secrets/infisical.d.ts +8 -13
  17. package/dist/secrets/loaders.d.ts +5 -12
  18. package/dist/secrets/vite.d.ts +7 -3
  19. package/dist/secrets/worker-loader.d.ts +9 -0
  20. package/dist/secrets/worker.d.ts +3 -0
  21. package/dist/src/api-wrapper/index.js +152 -122
  22. package/dist/src/index.js +3173 -2713
  23. package/dist/src/jwt/index.js +2 -2
  24. package/dist/src/p-queue/index.js +3 -3
  25. package/dist/src/secrets/index.js +670 -402
  26. package/dist/src/secrets/infisical.js +451 -168
  27. package/dist/src/secrets/loaders.js +467 -174
  28. package/dist/src/secrets/vite.js +33 -11
  29. package/dist/src/secrets/worker.js +275 -0
  30. package/dist/src/sitemap/index.js +1 -1
  31. package/dist/src/zod/index.js +7 -7
  32. package/dist/typedswitch/index.d.ts +9 -5
  33. package/package.json +12 -3
  34. package/src/api-wrapper/builders.ts +68 -43
  35. package/src/api-wrapper/create-client.ts +82 -62
  36. package/src/api-wrapper/errors.ts +10 -0
  37. package/src/api-wrapper/headers.ts +14 -1
  38. package/src/api-wrapper/redaction.ts +1 -0
  39. package/src/api-wrapper/transport.ts +34 -38
  40. package/src/api-wrapper/types.ts +1 -0
  41. package/src/cache/index.ts +228 -61
  42. package/src/secrets/core.ts +348 -0
  43. package/src/secrets/deadline.ts +69 -0
  44. package/src/secrets/environment.ts +69 -0
  45. package/src/secrets/errors.ts +29 -0
  46. package/src/secrets/index.ts +79 -210
  47. package/src/secrets/infisical-cli.ts +99 -0
  48. package/src/secrets/infisical-config.ts +70 -0
  49. package/src/secrets/infisical-session.ts +42 -0
  50. package/src/secrets/infisical.ts +214 -66
  51. package/src/secrets/loaders.ts +24 -48
  52. package/src/secrets/vite.ts +44 -15
  53. package/src/secrets/worker-loader.ts +56 -0
  54. package/src/secrets/worker.ts +4 -0
  55. package/src/typedswitch/index.ts +80 -69
@@ -9,6 +9,25 @@ type CachedFunction<T extends AnyFunction> = {
9
9
  clearCache: () => void
10
10
  }
11
11
 
12
+ export type CacheStoreEntry = {
13
+ value: unknown
14
+ timestamp: number
15
+ }
16
+
17
+ /**
18
+ * Contract for external cache stores (e.g. a Redis adapter).
19
+ * Serialization is the adapter's responsibility; this library never imports
20
+ * the underlying client.
21
+ */
22
+ export type CacheStore = {
23
+ get: (key: string) => Promise<CacheStoreEntry | undefined>
24
+ /** `ttlMs` maps to the store's native expiry (e.g. Redis `PX`). */
25
+ set: (key: string, entry: CacheStoreEntry, ttlMs: number) => Promise<void>
26
+ delete: (key: string) => Promise<void>
27
+ /** Optional; called by clearCache when defined. */
28
+ clear?: () => Promise<void>
29
+ }
30
+
12
31
  export type CacheFunctionOptions<T extends AnyFunction> = {
13
32
  name: string
14
33
  fn: T
@@ -18,6 +37,13 @@ export type CacheFunctionOptions<T extends AnyFunction> = {
18
37
  cacheKeyFn?: (...args: Parameters<T>) => unknown[]
19
38
  /** Called when a background refresh fails (default: silent). */
20
39
  onBackgroundRefreshError?: (error: unknown, context: { name: string }) => void
40
+ /**
41
+ * External cache store (e.g. a Redis adapter). Only supported for async
42
+ * functions; when omitted the built-in in-memory cache is used.
43
+ */
44
+ store?: ReturnType<T> extends Promise<unknown> ? CacheStore : never
45
+ /** Called when a store get/set/delete/clear fails (default: silent). */
46
+ onStoreError?: (error: unknown, context: { name: string }) => void
21
47
  }
22
48
 
23
49
  export function cacheFunction<T extends AnyFunction>(
@@ -30,115 +56,256 @@ export function cacheFunction<T extends AnyFunction>(
30
56
  refreshInBackground = false,
31
57
  cacheKeyArgs,
32
58
  cacheKeyFn,
33
- onBackgroundRefreshError
59
+ onBackgroundRefreshError,
60
+ store,
61
+ onStoreError
34
62
  } = options
35
63
 
64
+ const externalStore = store as CacheStore | undefined
65
+
36
66
  const cache = new Map<string, CacheEntry<ReturnType<T>>>()
37
67
  const pendingPromises = new Map<string, Promise<unknown>>()
38
68
 
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)
69
+ // Stale entries are kept for an extra TTL so refreshInBackground can serve
70
+ // them while revalidating.
71
+ const entryLifetimeMs = refreshInBackground ? cacheTimeMs * 2 : cacheTimeMs
72
+
73
+ let cleanupInterval: ReturnType<typeof setInterval> | undefined
74
+
75
+ const stopCleanup = () => {
76
+ if (cleanupInterval !== undefined) {
77
+ clearInterval(cleanupInterval)
78
+ cleanupInterval = undefined
79
+ }
80
+ }
81
+
82
+ // Created lazily on first write and unref'd so it never keeps the process
83
+ // alive. Stops itself once the cache is empty and restarts on the next
84
+ // write, so clearCache does not permanently disable sweeping.
85
+ const ensureCleanup = () => {
86
+ if (cleanupInterval !== undefined) {
87
+ return
88
+ }
89
+ cleanupInterval = setInterval(
90
+ () => {
91
+ const now = Date.now()
92
+ for (const [key, entry] of cache.entries()) {
93
+ if (now - entry.timestamp >= entryLifetimeMs) {
94
+ cache.delete(key)
95
+ }
45
96
  }
46
- }
47
- },
48
- Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
49
- )
97
+ if (cache.size === 0) {
98
+ stopCleanup()
99
+ }
100
+ },
101
+ Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
102
+ )
103
+ cleanupInterval.unref?.()
104
+ }
50
105
 
51
- const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
106
+ const buildKey = (args: Parameters<T>): string => {
52
107
  const keyArgs = cacheKeyFn
53
108
  ? cacheKeyFn(...args)
54
109
  : cacheKeyArgs
55
110
  ? cacheKeyArgs.map((i) => args[i])
56
111
  : args
57
- const key = `${name}:${JSON.stringify(keyArgs)}`
58
- const now = Date.now()
112
+ return `${name}:${JSON.stringify(keyArgs)}`
113
+ }
114
+
115
+ const saveEntry = (key: string, value: ReturnType<T>) => {
116
+ cache.set(key, { value, timestamp: Date.now() })
117
+ ensureCleanup()
118
+ }
119
+
120
+ const fetchAndPopulate = (
121
+ key: string,
122
+ args: Parameters<T>
123
+ ): ReturnType<T> => {
124
+ const result = fn(...args)
125
+
126
+ if (!isPromiseLike(result)) {
127
+ saveEntry(key, result as ReturnType<T>)
128
+ return result as ReturnType<T>
129
+ }
130
+
131
+ const promise = (result as Promise<ReturnType<T>>)
132
+ .then((value) => {
133
+ saveEntry(key, value)
134
+ pendingPromises.delete(key)
135
+ return value
136
+ })
137
+ .catch((error: unknown) => {
138
+ pendingPromises.delete(key)
139
+ throw error
140
+ })
141
+
142
+ pendingPromises.set(key, promise)
143
+ return promise as ReturnType<T>
144
+ }
145
+
146
+ const startBackgroundRefresh = (
147
+ key: string,
148
+ args: Parameters<T>,
149
+ staleValue: ReturnType<T>
150
+ ) => {
151
+ const result = fn(...args)
152
+
153
+ if (!isPromiseLike(result)) {
154
+ saveEntry(key, result as ReturnType<T>)
155
+ return
156
+ }
157
+
158
+ const promise = (result as Promise<ReturnType<T>>)
159
+ .then((value) => {
160
+ saveEntry(key, value)
161
+ pendingPromises.delete(key)
162
+ return value
163
+ })
164
+ .catch((error: unknown) => {
165
+ pendingPromises.delete(key)
166
+ onBackgroundRefreshError?.(error, { name })
167
+ // Anyone awaiting this promise gets the stale value instead of
168
+ // undefined or a rejection.
169
+ return staleValue
170
+ })
171
+
172
+ pendingPromises.set(key, promise)
173
+ }
174
+
175
+ const saveToStore = async (
176
+ cacheStore: CacheStore,
177
+ key: string,
178
+ value: unknown
179
+ ) => {
180
+ try {
181
+ await cacheStore.set(
182
+ key,
183
+ { value, timestamp: Date.now() },
184
+ entryLifetimeMs
185
+ )
186
+ } catch (error) {
187
+ onStoreError?.(error, { name })
188
+ }
189
+ }
190
+
191
+ const callWithStore = (
192
+ cacheStore: CacheStore,
193
+ args: Parameters<T>
194
+ ): Promise<unknown> => {
195
+ const key = buildKey(args)
59
196
 
60
197
  const pending = pendingPromises.get(key)
61
- if (pending) {
62
- return pending as ReturnType<T>
198
+ // Background refresh callers can still use an entry from the store.
199
+ if (pending && !refreshInBackground) {
200
+ return pending
63
201
  }
64
202
 
65
- const cached = cache.get(key)
203
+ return (async () => {
204
+ let entry: CacheStoreEntry | undefined
205
+ try {
206
+ entry = await cacheStore.get(key)
207
+ } catch (error) {
208
+ onStoreError?.(error, { name })
209
+ }
66
210
 
67
- if (cached) {
68
- const isCacheFresh = now - cached.timestamp < cacheTimeMs
211
+ if (entry) {
212
+ const isFresh = Date.now() - entry.timestamp < cacheTimeMs
69
213
 
70
- if (!isCacheFresh && !refreshInBackground) {
71
- cache.delete(key)
72
- } else {
73
- if (isCacheFresh && !refreshInBackground) {
74
- return cached.value
214
+ if (isFresh) {
215
+ return entry.value
75
216
  }
76
217
 
77
218
  if (refreshInBackground) {
78
- const existingPending = pendingPromises.get(key)
79
- if (existingPending) {
80
- return existingPending as ReturnType<T>
81
- }
82
-
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 })
219
+ if (!pendingPromises.has(key)) {
220
+ const staleValue = entry.value
221
+ const refresh = (fn(...args) as Promise<unknown>)
222
+ .then(async (value) => {
223
+ await saveToStore(cacheStore, key, value)
91
224
  pendingPromises.delete(key)
92
- return resolvedValue
225
+ return value
93
226
  })
94
227
  .catch((error: unknown) => {
95
228
  pendingPromises.delete(key)
96
229
  onBackgroundRefreshError?.(error, { name })
230
+ return staleValue
97
231
  })
98
-
99
- pendingPromises.set(key, backgroundPromise)
100
- } else {
101
- cache.set(key, {
102
- value: backgroundUpdate as ReturnType<T>,
103
- timestamp: now
104
- })
232
+ pendingPromises.set(key, refresh)
105
233
  }
106
-
107
- return cached.value
234
+ return entry.value
108
235
  }
109
236
 
110
- if (isCacheFresh) {
111
- return cached.value
237
+ try {
238
+ await cacheStore.delete(key)
239
+ } catch (error) {
240
+ onStoreError?.(error, { name })
112
241
  }
113
242
  }
114
- }
115
243
 
116
- const result = fn(...args)
244
+ // A concurrent caller may have started a fetch while we awaited the
245
+ // store lookup.
246
+ const existing = pendingPromises.get(key)
247
+ if (existing) {
248
+ return existing
249
+ }
117
250
 
118
- if (isPromiseLike(result)) {
119
- const promise = (result as Promise<ReturnType<T>>)
120
- .then((resolvedValue) => {
121
- cache.set(key, { value: resolvedValue, timestamp: now })
251
+ const promise = (fn(...args) as Promise<unknown>)
252
+ .then(async (value) => {
253
+ await saveToStore(cacheStore, key, value)
122
254
  pendingPromises.delete(key)
123
- return resolvedValue
255
+ return value
124
256
  })
125
- .catch((error) => {
257
+ .catch((error: unknown) => {
126
258
  pendingPromises.delete(key)
127
259
  throw error
128
260
  })
129
261
 
130
262
  pendingPromises.set(key, promise)
131
- return promise as ReturnType<T>
263
+ return promise
264
+ })()
265
+ }
266
+
267
+ const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
268
+ if (externalStore) {
269
+ return callWithStore(externalStore, args) as ReturnType<T>
270
+ }
271
+
272
+ const key = buildKey(args)
273
+ const cached = cache.get(key)
274
+
275
+ if (cached) {
276
+ const isFresh = Date.now() - cached.timestamp < cacheTimeMs
277
+
278
+ if (isFresh) {
279
+ return cached.value
280
+ }
281
+
282
+ if (refreshInBackground) {
283
+ if (!pendingPromises.has(key)) {
284
+ startBackgroundRefresh(key, args, cached.value)
285
+ }
286
+ return cached.value
287
+ }
288
+
289
+ cache.delete(key)
132
290
  }
133
291
 
134
- cache.set(key, { value: result as ReturnType<T>, timestamp: now })
135
- return result as ReturnType<T>
292
+ const pending = pendingPromises.get(key)
293
+ if (pending) {
294
+ return pending as ReturnType<T>
295
+ }
296
+
297
+ return fetchAndPopulate(key, args)
136
298
  }
137
299
 
138
300
  wrappedFn.clearCache = () => {
139
301
  cache.clear()
140
302
  pendingPromises.clear()
141
- clearInterval(cleanupInterval)
303
+ stopCleanup()
304
+ if (externalStore?.clear) {
305
+ externalStore.clear().catch((error: unknown) => {
306
+ onStoreError?.(error, { name })
307
+ })
308
+ }
142
309
  }
143
310
 
144
311
  return wrappedFn
@@ -0,0 +1,348 @@
1
+ import { withSecretDeadline, type SecretRequestOptions } from './deadline'
2
+ import {
3
+ resolveSecretEnvironment,
4
+ type EnvironmentVariables,
5
+ type SecretEnvironmentOption
6
+ } from './environment'
7
+ import { SecretLoadError } from './errors'
8
+
9
+ export type { SecretEnvironment, SecretEnvironmentOption } from './environment'
10
+ export { SecretLoadError } from './errors'
11
+
12
+ export type SecretLogAttributes = {
13
+ readonly environment: string
14
+ readonly count: number
15
+ readonly durationMs: number
16
+ readonly source?: string
17
+ readonly sources?: Readonly<Record<string, number>>
18
+ readonly code?: string
19
+ }
20
+
21
+ export type SecretLogger = {
22
+ info: (message: string, attributes: SecretLogAttributes) => void
23
+ error: (message: string, attributes: SecretLogAttributes) => void
24
+ }
25
+
26
+ export type SecretSchema<K extends string, Parsed> = {
27
+ parseAsync: (values: Record<K, string>) => Promise<Parsed>
28
+ }
29
+
30
+ export type CommonSecretSetOptions<
31
+ K extends string,
32
+ Parsed = Record<K, string>
33
+ > = SecretRequestOptions & {
34
+ readonly environment?: SecretEnvironmentOption
35
+ readonly writeToProcessEnv?: boolean
36
+ readonly logger?: SecretLogger | false
37
+ readonly schema?: SecretSchema<K, Parsed>
38
+ }
39
+
40
+ export type CustomLoaderDefineSecretSetOptions<
41
+ K extends string,
42
+ Parsed = Record<K, string>
43
+ > = CommonSecretSetOptions<K, Parsed> & {
44
+ readonly loader: SecretSetLoader<K>
45
+ }
46
+
47
+ export type SecretSetLoaderContext<K extends string> = {
48
+ readonly keys: readonly K[]
49
+ readonly environment: string
50
+ readonly signal?: AbortSignal
51
+ readonly timeoutMs?: number
52
+
53
+ /** Report the source actually selected by a dynamic loader. */
54
+ readonly reportSource?: (source: string) => void
55
+ }
56
+
57
+ export type SecretSetLoader<K extends string> = ((
58
+ context: SecretSetLoaderContext<K>
59
+ ) => Promise<Partial<Record<K, string>>>) & {
60
+ readonly source?: string
61
+ }
62
+
63
+ export type SecretSet<K extends string, Parsed = Record<K, string>> = {
64
+ secret: (key: K) => string
65
+ secrets: () => Record<K, string>
66
+ parsed: () => Parsed
67
+ sources: () => Record<K, string>
68
+ reload: (options?: SecretRequestOptions) => Promise<SecretSet<K, Parsed>>
69
+ }
70
+
71
+ // Shared across bundled entry points and package copies in the same JS realm.
72
+ // Only helper-written values are registered; original environment overrides aren't.
73
+ const registryKey = Symbol.for('hanzio.secrets.environmentWrites.v1')
74
+ const shared = globalThis as typeof globalThis & {
75
+ [registryKey]?: WeakMap<EnvironmentVariables, Map<string, string>>
76
+ }
77
+ const environmentWrites =
78
+ shared[registryKey] ??
79
+ new WeakMap<EnvironmentVariables, Map<string, string>>()
80
+
81
+ shared[registryKey] = environmentWrites
82
+
83
+ const defaultLogger: SecretLogger = {
84
+ info: (message, attrs) =>
85
+ console.log(
86
+ `${message} environment=${attrs.environment} count=${attrs.count} source=${attrs.source} durationMs=${attrs.durationMs}`,
87
+ { sources: attrs.sources }
88
+ ),
89
+ error: (message, attrs) => console.error(message, attrs)
90
+ }
91
+
92
+ function readOverrides<K extends string>(
93
+ keys: readonly K[],
94
+ env?: EnvironmentVariables
95
+ ): Partial<Record<K, string>> {
96
+ if (!env) {
97
+ return {}
98
+ }
99
+
100
+ const writes = environmentWrites.get(env)
101
+
102
+ return Object.fromEntries(
103
+ keys.flatMap((key) => {
104
+ const value = Object.hasOwn(env, key) ? env[key] : undefined
105
+
106
+ // A matching helper write is a mirror, not an explicit user override.
107
+ if (writes?.has(key)) {
108
+ if (writes.get(key) === value) {
109
+ return []
110
+ }
111
+
112
+ // An external change gives ownership back to the environment.
113
+ writes.delete(key)
114
+ }
115
+
116
+ return value ? [[key, value]] : []
117
+ })
118
+ ) as Partial<Record<K, string>>
119
+ }
120
+
121
+ export async function defineSecretSet<
122
+ const Keys extends readonly string[],
123
+ Parsed = Record<Keys[number], string>
124
+ >(
125
+ keys: Keys,
126
+ options: CustomLoaderDefineSecretSetOptions<Keys[number], Parsed>,
127
+ environmentVariables?: EnvironmentVariables
128
+ ): Promise<SecretSet<Keys[number], Parsed>> {
129
+ type K = Keys[number]
130
+
131
+ const keySet = [...new Set(keys)] as K[]
132
+ const environment = resolveSecretEnvironment(
133
+ options.environment,
134
+ environmentVariables
135
+ )
136
+ const logger =
137
+ options.logger === false ? undefined : (options.logger ?? defaultLogger)
138
+
139
+ // These snapshots change together only after an entire load succeeds.
140
+ let cached: Record<K, string>
141
+ let parsed: Parsed
142
+ let sources: Record<K, string>
143
+ let inFlight: Promise<void> | undefined
144
+
145
+ const log = (
146
+ level: 'info' | 'error',
147
+ message: string,
148
+ attributes: SecretLogAttributes
149
+ ) => {
150
+ try {
151
+ logger?.[level](message, attributes)
152
+ } catch {
153
+ /* Logging cannot change a load result. */
154
+ }
155
+ }
156
+
157
+ // Build, validate, and commit one snapshot under the caller’s deadline.
158
+ const loadSnapshot = async (
159
+ request: SecretRequestOptions,
160
+ signal: AbortSignal
161
+ ) => {
162
+ // Fetch only keys that lack genuine local overrides.
163
+ const local = readOverrides(keySet, environmentVariables)
164
+ const missing = keySet.filter((key) => !Object.hasOwn(local, key))
165
+ let loaderSource = options.loader.source ?? 'custom loader'
166
+ let loaded: Partial<Record<K, string>> = {}
167
+
168
+ if (missing.length) {
169
+ try {
170
+ loaded = await options.loader({
171
+ keys: missing,
172
+ environment,
173
+ signal,
174
+ timeoutMs: request.timeoutMs ?? options.timeoutMs ?? 30_000,
175
+ reportSource: (source) => {
176
+ loaderSource = source
177
+ }
178
+ })
179
+ } catch (error) {
180
+ if (error instanceof SecretLoadError) {
181
+ throw error
182
+ }
183
+
184
+ throw new SecretLoadError('LOADER', 'Secret loader failed.')
185
+ }
186
+ }
187
+
188
+ // Honor overrides changed while the loader was running.
189
+ const overrides = readOverrides(keySet, environmentVariables)
190
+ const next = Object.fromEntries(
191
+ keySet.map((key) => {
192
+ if (Object.hasOwn(overrides, key)) {
193
+ return [key, overrides[key]]
194
+ }
195
+
196
+ if (Object.hasOwn(loaded, key)) {
197
+ return [key, loaded[key]]
198
+ }
199
+
200
+ return [key, undefined]
201
+ })
202
+ ) as Record<K, string>
203
+ const missingKeys = keySet.filter(
204
+ (key) => typeof next[key] !== 'string' || !next[key]
205
+ )
206
+
207
+ if (missingKeys.length) {
208
+ throw new SecretLoadError(
209
+ 'MISSING_SECRETS',
210
+ `Missing secrets: ${missingKeys.join(', ')}`
211
+ )
212
+ }
213
+
214
+ // Validate the merged snapshot before touching the cache or environment.
215
+ let nextParsed: Parsed
216
+
217
+ try {
218
+ nextParsed = options.schema
219
+ ? await options.schema.parseAsync({ ...next })
220
+ : ({ ...next } as Parsed)
221
+
222
+ // Validate copyability before committing anything.
223
+ nextParsed = structuredClone(nextParsed)
224
+ } catch {
225
+ throw new SecretLoadError(
226
+ 'VALIDATION',
227
+ 'Secret schema validation failed. Check the configured schema; values are omitted.'
228
+ )
229
+ }
230
+
231
+ // A custom loader or schema may ignore cancellation while awaiting.
232
+ // Its late result must never replace the last successful snapshot.
233
+ signal.throwIfAborted()
234
+
235
+ const nextSources = Object.fromEntries(
236
+ keySet.map((key) => [
237
+ key,
238
+ Object.hasOwn(overrides, key) ? 'process.env' : loaderSource
239
+ ])
240
+ ) as Record<K, string>
241
+
242
+ // Register only fetched values so future loads can identify our mirrors.
243
+ if (options.writeToProcessEnv !== false && environmentVariables) {
244
+ let writes = environmentWrites.get(environmentVariables)
245
+
246
+ if (!writes) {
247
+ writes = new Map()
248
+ environmentWrites.set(environmentVariables, writes)
249
+ }
250
+
251
+ for (const key of keySet) {
252
+ if (Object.hasOwn(overrides, key)) {
253
+ continue
254
+ }
255
+
256
+ environmentVariables[key] = next[key]
257
+ writes.set(key, next[key])
258
+ }
259
+ }
260
+
261
+ cached = next
262
+ parsed = nextParsed
263
+ sources = nextSources
264
+ }
265
+
266
+ const load = (request: SecretRequestOptions = {}): Promise<void> => {
267
+ // Concurrent reloads share the first caller's deadline and cancellation.
268
+ if (inFlight) {
269
+ return inFlight
270
+ }
271
+
272
+ inFlight = (async () => {
273
+ const start = Date.now()
274
+
275
+ try {
276
+ await withSecretDeadline(
277
+ {
278
+ timeoutMs: request.timeoutMs ?? options.timeoutMs,
279
+ signal: request.signal ?? options.signal
280
+ },
281
+ (signal) => loadSnapshot(request, signal)
282
+ )
283
+
284
+ // Report the sources of committed values, not attempted fetches.
285
+ const sourceCounts = new Map<string, number>()
286
+
287
+ for (const key of keySet) {
288
+ const source = sources[key]
289
+
290
+ sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1)
291
+ }
292
+
293
+ const counts = Object.fromEntries(sourceCounts)
294
+
295
+ log('info', 'Loaded secrets', {
296
+ environment,
297
+ count: keySet.length,
298
+ durationMs: Date.now() - start,
299
+ source: Object.keys(counts).join(' + ') || 'none',
300
+ sources: counts
301
+ })
302
+ } catch (error) {
303
+ const safe =
304
+ error instanceof SecretLoadError
305
+ ? error
306
+ : new SecretLoadError('LOADER', 'Secret loading failed.')
307
+
308
+ log('error', 'Secret loading failed', {
309
+ environment,
310
+ count: keySet.length,
311
+ durationMs: Date.now() - start,
312
+ code: safe.code
313
+ })
314
+
315
+ throw safe
316
+ }
317
+ })().finally(() => {
318
+ inFlight = undefined
319
+ })
320
+
321
+ return inFlight
322
+ }
323
+
324
+ const secretSet: SecretSet<K, Parsed> = {
325
+ secret(key) {
326
+ if (!Object.hasOwn(cached, key)) {
327
+ throw new SecretLoadError('MISSING_SECRETS', `Secret ${key} not found`)
328
+ }
329
+
330
+ return cached[key]
331
+ },
332
+
333
+ // Return copies so consumers cannot mutate the internal snapshots.
334
+ secrets: () => ({ ...cached }),
335
+ parsed: () => structuredClone(parsed),
336
+ sources: () => ({ ...sources }),
337
+
338
+ async reload(request) {
339
+ await load(request)
340
+
341
+ return secretSet
342
+ }
343
+ }
344
+
345
+ await load()
346
+
347
+ return secretSet
348
+ }