node-vault-client 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -505,6 +505,7 @@ Client constructor function.
505
505
  | options.api.url | <code>String</code> | | the url of the vault server |
506
506
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
507
507
  | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
508
+ | [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
508
509
  | [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
509
510
  | [options.api.kv.autoDetect] | <code>boolean</code> | `false` | auto-detect the KV version of each mount on first use (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
510
511
  | [options.api.engines] | <code>Object</code> | `{}` | static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }` (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
@@ -559,8 +560,35 @@ bundle undici 7.x, so pin `undici@^7`.
559
560
 
560
561
  ##### Request timeouts
561
562
 
562
- There is no default timeout. A Vault that accepts the connection and never answers will leave
563
- `read()` (and every other call) pending indefinitely. Set one through the dispatcher:
563
+ Every request to Vault, including logins, `lookup-self` and background `renew-self`, has to
564
+ finish within `api.timeout` milliseconds: **120 000 (2 minutes) by default**. The limit covers the
565
+ whole request: response headers, the body and any redirects. A request that runs out of time
566
+ rejects with `RequestTimeoutError` (its `timeout` property holds the limit, `cause` the
567
+ `fetch()` rejection). With `api.kv.autoDetect`, a mount detection that times out fails the call
568
+ with the usual detection `VaultError`, with `RequestTimeoutError` on its `cause`. A renewal that
569
+ times out is handled like any other failed renewal.
570
+
571
+ `api.timeout` is in milliseconds; `null` or leaving it out gives the default, and a value under
572
+ 1000 logs a warning in case it was meant as seconds. Anything but an integer from 0 to 2147483647
573
+ raises `InvalidArgumentsError` when the client is created.
574
+
575
+ The default sits above Vault's own default `max_request_duration` (90 s), so it does not cut
576
+ short a request that a Vault with default settings would still answer. Set it lower to fail
577
+ fast, or higher if your Vault allows longer requests:
578
+
579
+ ```javascript
580
+ const vaultClient = VaultClient.boot('main', {
581
+ api: { url: 'https://vault.example.com:8200/', timeout: 10000 },
582
+ auth: { type: 'token', config: { token: '...' } },
583
+ });
584
+ ```
585
+
586
+ `timeout: 0` turns the limit off, as it was before `api.timeout` existed. What remains then
587
+ are the timeouts of the dispatcher `fetch()` uses: Node's default one gives up after 300 s
588
+ without response headers (`TypeError: fetch failed`, `cause.code` `UND_ERR_HEADERS_TIMEOUT`) or
589
+ 300 s without body data (`UND_ERR_BODY_TIMEOUT`). Those limits apply per phase, so a body that
590
+ keeps trickling in is never cut off. Any timeouts set on a custom dispatcher still apply
591
+ alongside `api.timeout`, and whichever fires first rejects the request:
564
592
 
565
593
  ```javascript
566
594
  const { Agent } = require('undici');
@@ -574,12 +602,11 @@ const vaultClient = VaultClient.boot('main', {
574
602
  });
575
603
  ```
576
604
 
577
- A failed request rejects with `TypeError: fetch failed`, with `cause.code` set to
578
- `UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`.
579
-
580
- Do **not** put `signal: AbortSignal.timeout(ms)` in `requestOptions`. It is created once and
581
- shared by every request the client makes, so it works for the first call and then aborts all
582
- later ones with `TimeoutError`. Pass a fresh signal per call, or use the dispatcher above.
605
+ A `signal` in `requestOptions` still aborts every request in flight when it fires (with the
606
+ signal's own reason, not `RequestTimeoutError`), so an `AbortController` can cancel requests
607
+ on shutdown. `fetch()` then receives the client's own per-request signal, which follows it. Do **not** use `signal: AbortSignal.timeout(ms)` there: it is created once and
608
+ shared by every request the client makes, so it works for the first calls and then aborts all
609
+ later ones. Use `api.timeout` instead.
583
610
 
584
611
  ##### Redirects and the Vault token
585
612
 
@@ -816,6 +843,14 @@ the ones it was booted with, the client logs a warning rather than letting the d
816
843
  silently. Use [`VaultClient.get(name)`](#VaultClient.get) to fetch an existing instance, or
817
844
  [`VaultClient.clear(name)`](#VaultClient.clear) before booting to replace one.
818
845
 
846
+ Options are compared as hashes, so no credential is kept for the comparison. Plain objects and
847
+ arrays compare by content, in any key order. Functions and other objects compare by identity, and
848
+ a `Buffer`, `Date`, `URL` or `RegExp` by value. So a logger class instance, an undici `dispatcher`,
849
+ or a logger object built from new functions counts as different even when it would behave the
850
+ same; a plain object that reuses the same functions does not. The warning names the top-level
851
+ options that differ (for example `logger`) and is logged once per instance for each set of them.
852
+ Boot each name once and fetch it with `get()` elsewhere.
853
+
819
854
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
820
855
  **Returns**: <code>VaultClient</code>
821
856
 
@@ -1061,10 +1096,11 @@ try {
1061
1096
  |---|---|---|
1062
1097
  | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
1063
1098
  | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1064
- | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1099
+ | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1065
1100
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1066
1101
  | `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
1067
1102
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
1103
+ | `RequestTimeoutError` | `VaultError` | A request to Vault did not finish within `api.timeout` milliseconds (see [Request timeouts](#request-timeouts)). Carries the limit as `timeout` and the `fetch()` rejection (a `TimeoutError` DOMException) as `cause`. |
1068
1104
 
1069
1105
  ## Contributing
1070
1106
 
package/index.d.ts CHANGED
@@ -39,6 +39,8 @@ declare namespace VaultClient {
39
39
  apiVersion?: string;
40
40
  namespace?: string;
41
41
  requestOptions?: Record<string, any>;
42
+ /** Milliseconds a request may take before it rejects with RequestTimeoutError; `0` turns it off. Default 120000. */
43
+ timeout?: number | null;
42
44
  kv?: { autoDetect?: boolean };
43
45
  engines?: Record<string, 1 | 2>;
44
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/namecheap/node-vault-client.git"
@@ -21,6 +21,7 @@
21
21
  "test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
22
22
  "test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
23
23
  "test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
24
+ "test:e2e:generic": "mocha --exit \"test/e2e/e2e.generic.test.mjs\"",
24
25
  "test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
25
26
  "coverage": "c8 npm run test:unit",
26
27
  "lint": "eslint src/ test/ examples/",
@@ -54,10 +55,11 @@
54
55
  },
55
56
  "overrides": {
56
57
  "serialize-javascript": "^7.1.4",
57
- "glob": "^13.0.0",
58
+ "glob": "^14.0.3",
58
59
  "diff": "^8.0.3",
59
60
  "brace-expansion": "^5.0.12",
60
- "js-yaml": "^4.3.2"
61
+ "js-yaml": "^4.3.2",
62
+ "minimatch": "^11.0.4"
61
63
  },
62
64
  "devDependencies": {
63
65
  "@eslint/js": "^10.0.1",
@@ -1,8 +1,86 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
3
4
  const errors = require('./errors');
4
5
  const { trimTrailingSlashes } = require('./slashes');
5
6
 
7
+ // Above Vault's own default max_request_duration (90 s), so no request a default-configured
8
+ // Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
9
+ // dispatcher Node's fetch() uses by default.
10
+ const DEFAULT_TIMEOUT_MS = 120000;
11
+ // setTimeout() fires after 1 ms for a delay that does not fit in a signed 32-bit integer.
12
+ const MAX_TIMEOUT_MS = 2147483647;
13
+ // The listener limit fetch() gives a signal it is passed (see deadline()).
14
+ const FETCH_MAX_LISTENERS = 1500;
15
+
16
+ function validateTimeout(timeout) {
17
+ // null reads as "not set", as it does in a config file.
18
+ if (timeout === undefined || timeout === null) {
19
+ return DEFAULT_TIMEOUT_MS;
20
+ }
21
+ if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_TIMEOUT_MS) {
22
+ throw new errors.InvalidArgumentsError(
23
+ `"api.timeout" should be an integer number of milliseconds from 0 to ${MAX_TIMEOUT_MS}, ` +
24
+ `got ${String(timeout)}`
25
+ );
26
+ }
27
+ return timeout;
28
+ }
29
+
30
+ /**
31
+ * What fetch() accepts as a signal: Node 20 and 22 take any object with a boolean `aborted`
32
+ * and an `addEventListener` (an abort-controller polyfill, a signal from another realm).
33
+ */
34
+ function isSignalLike(value) {
35
+ return value !== null && typeof value === 'object'
36
+ && typeof value.aborted === 'boolean' && typeof value.addEventListener === 'function';
37
+ }
38
+
39
+ /**
40
+ * A per-request deadline. A plain timer, cleared once the request settles, rather than
41
+ * AbortSignal.timeout(), whose timer cannot be cancelled and so outlives every fast request.
42
+ * A `requestOptions.signal` still aborts the request: it is followed through a listener that
43
+ * is removed at the same point, since that signal lives as long as the client.
44
+ *
45
+ * @param {number} ms
46
+ * @param {AbortSignal} [signal] - `requestOptions.signal`
47
+ * @returns {{signal: AbortSignal, isTimeout: function(*): boolean, clear: function(): void}}
48
+ */
49
+ function deadline(ms, signal) {
50
+ const controller = new AbortController();
51
+ const reason = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
52
+ const timer = setTimeout(() => controller.abort(reason), ms);
53
+ const follow = () => controller.abort(signal.reason);
54
+ if (signal) {
55
+ if (signal.aborted) {
56
+ follow();
57
+ } else {
58
+ // fetch() raised a signal's listener limit to 1500 when it was still at the default;
59
+ // it now gets the client's own signal, so do the same here, or more than ten
60
+ // requests in flight on one signal print MaxListenersExceededWarning (Node 20).
61
+ try {
62
+ if (getMaxListeners(signal) === defaultMaxListeners) {
63
+ setMaxListeners(FETCH_MAX_LISTENERS, signal);
64
+ }
65
+ } catch {
66
+ // not an EventTarget (a polyfill): it has no listener limit to raise
67
+ }
68
+ signal.addEventListener('abort', follow, { once: true });
69
+ }
70
+ }
71
+
72
+ return {
73
+ signal: controller.signal,
74
+ isTimeout: (err) => err === reason,
75
+ clear: () => {
76
+ clearTimeout(timer);
77
+ if (signal && typeof signal.removeEventListener === 'function') {
78
+ signal.removeEventListener('abort', follow);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
6
84
  /**
7
85
  * Joins URL segments with single slashes while preserving the leading
8
86
  * protocol (e.g. "https://"). Replaces the former `url-join` dependency.
@@ -35,6 +113,9 @@ class VaultApiClient {
35
113
  * (`method`, `body`) always take precedence; `headers` are merged with the
36
114
  * per-request headers winning. Stored by reference (not deep-cloned) so live
37
115
  * objects such as a Dispatcher keep their prototype and remain usable.
116
+ * @param {number} [config.timeout=120000] - milliseconds a request may take, response
117
+ * headers, body and redirects included, before it rejects with
118
+ * {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
38
119
  * @param {Object} logger
39
120
  * @param {String} [namespace] - Vault namespace. When set, `X-Vault-Namespace` is added to
40
121
  * every request (login, token lookup/renewal, and secret operations) so namespacing is
@@ -55,6 +136,12 @@ class VaultApiClient {
55
136
  this.__config.requestOptions = requestOptions;
56
137
  }
57
138
 
139
+ this.__timeout = validateTimeout(this.__config.timeout);
140
+ // 3.0.x ignored an api.timeout key, so a config may still carry one written in seconds.
141
+ if (this.__timeout > 0 && this.__timeout < 1000) {
142
+ logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
143
+ }
144
+
58
145
  this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
59
146
  this._logger = logger;
60
147
  }
@@ -102,7 +189,28 @@ class VaultApiClient {
102
189
  uri
103
190
  );
104
191
 
105
- return fetch(uri, options).then((response) => {
192
+ // A requestOptions.signal that fetch() would reject is passed on untouched, without the
193
+ // deadline, so fetch() rejects it as it always has. The deadline is armed last, and
194
+ // cleared if fetch() throws synchronously, so no timer outlives a failed call.
195
+ const userSignal = requestOptions.signal;
196
+ const limit = this.__timeout > 0 && (userSignal === undefined || userSignal === null
197
+ || isSignalLike(userSignal))
198
+ ? deadline(this.__timeout, userSignal || undefined)
199
+ : null;
200
+ let pending;
201
+ if (limit === null) {
202
+ pending = fetch(uri, options);
203
+ } else {
204
+ options.signal = limit.signal;
205
+ try {
206
+ pending = fetch(uri, options);
207
+ } catch (err) {
208
+ limit.clear();
209
+ throw err;
210
+ }
211
+ }
212
+
213
+ const request = pending.then((response) => {
106
214
  return response.text().then((text) => {
107
215
  let body;
108
216
  if (text) {
@@ -127,6 +235,18 @@ class VaultApiClient {
127
235
  return body;
128
236
  });
129
237
  });
238
+ if (limit === null) {
239
+ return request;
240
+ }
241
+
242
+ return request
243
+ .catch((err) => {
244
+ if (limit.isTimeout(err)) {
245
+ throw new errors.RequestTimeoutError(this.__timeout, { cause: err });
246
+ }
247
+ throw err;
248
+ })
249
+ .finally(limit.clear);
130
250
  }
131
251
  }
132
252
 
@@ -18,51 +18,105 @@ const { trimTrailingSlashes } = require('./slashes');
18
18
  const noop = () => {};
19
19
  const vaultInstances = {};
20
20
 
21
- // Only credential providers compare by identity: a different provider means the client would
22
- // authenticate differently. Other functions (logger methods, an IAM `credentials` provider
23
- // chain) are often re-created per call with the same behaviour, so they all fingerprint alike.
24
- const IDENTITY_COMPARED_KEYS = new Set(['jwtProvider', 'distributedClaimAccessTokenProvider']);
21
+ // Functions, and objects that are not plain data (an undici dispatcher, a logger class
22
+ // instance), compare by identity. Their own enumerable keys say nothing about what they do:
23
+ // structurally, any two dispatchers look alike, and so do two loggers built from different
24
+ // functions, so a boot() that passed a different one was ignored without a warning (#224).
25
+ // A Buffer, Date, URL or RegExp compares by value, so an equal one re-created per call still
26
+ // matches. No user code (getters, toJSON, Proxy traps) decides the comparison.
27
+ //
28
+ // Per-process ids, in a WeakMap so that fingerprinting never keeps an option (or the closure
29
+ // state it captures) alive.
30
+ const identityIds = new WeakMap();
31
+ let nextIdentityId = 0;
32
+
33
+ function identityId(value) {
34
+ if (!identityIds.has(value)) {
35
+ identityIds.set(value, ++nextIdentityId);
36
+ }
37
+ return identityIds.get(value);
38
+ }
25
39
 
26
- // Per-process ids for those providers. A WeakMap so that fingerprinting never keeps a
27
- // provider (or the closure state it captures) alive.
28
- const functionIds = new WeakMap();
29
- let nextFunctionId = 0;
40
+ /**
41
+ * How an object takes part in the fingerprint: 'array' and 'plain' by content, 'value' by
42
+ * {@link valueOf}, anything else by identity. Plain includes a null prototype and an
43
+ * Object.prototype from another realm (vm, a test runner's structuredClone). A Proxy whose
44
+ * traps throw compares by identity rather than making boot() throw.
45
+ */
46
+ function kindOf(value) {
47
+ try {
48
+ if (Array.isArray(value)) {
49
+ return 'array';
50
+ }
51
+ if (Buffer.isBuffer(value) || value instanceof Date || value instanceof URL || value instanceof RegExp) {
52
+ return 'value';
53
+ }
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === null || Object.getPrototypeOf(proto) === null ? 'plain' : 'identity';
56
+ } catch {
57
+ return 'identity';
58
+ }
59
+ }
30
60
 
31
- function functionId(fn) {
32
- if (!functionIds.has(fn)) {
33
- functionIds.set(fn, ++nextFunctionId);
61
+ function valueOf(value) {
62
+ if (Buffer.isBuffer(value)) {
63
+ return `buffer:${value.toString('base64')}`;
34
64
  }
35
- return functionIds.get(fn);
65
+ if (value instanceof Date) {
66
+ return `date:${value.getTime()}`;
67
+ }
68
+ return `${value instanceof URL ? 'url' : 'regexp'}:${JSON.stringify(String(value))}`;
36
69
  }
37
70
 
38
71
  /**
39
- * Order-insensitive canonical form of the boot options, hashed so that no credential is
40
- * retained just to compare two boot() calls. `jwtProvider` and
41
- * `distributedClaimAccessTokenProvider` compare by identity; every other function is `fn`.
72
+ * Order-insensitive canonical form of a boot option, hashed so that no credential is retained
73
+ * just to compare two boot() calls. Plain objects and arrays compare by content, functions and
74
+ * other objects by identity, and a Buffer, Date, URL or RegExp by value.
42
75
  */
43
- function fingerprintOptions(value, seen, key) {
76
+ function fingerprintOptions(value, seen) {
44
77
  if (typeof value === 'function') {
45
- return IDENTITY_COMPARED_KEYS.has(key) ? `fn#${functionId(value)}` : 'fn';
78
+ return `fn#${identityId(value)}`;
79
+ }
80
+ if (typeof value === 'bigint') {
81
+ return `${value}n`;
46
82
  }
47
83
  if (value === null || typeof value !== 'object') {
48
84
  return JSON.stringify(value) || String(value);
49
85
  }
86
+ const kind = kindOf(value);
87
+ if (kind === 'value') {
88
+ return valueOf(value);
89
+ }
90
+ if (kind === 'identity') {
91
+ return `obj#${identityId(value)}`;
92
+ }
50
93
  seen = seen || new Set();
51
94
  if (seen.has(value)) {
52
95
  return 'circular';
53
96
  }
54
97
  seen.add(value);
55
- if (Array.isArray(value)) {
98
+ if (kind === 'array') {
56
99
  return `[${value.map((item) => fingerprintOptions(item, seen)).join(',')}]`;
57
100
  }
58
101
  const body = Object.keys(value).sort()
59
- .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen, key)}`)
102
+ .map((key) => `${JSON.stringify(key)}:${fingerprintOptions(value[key], seen)}`)
60
103
  .join(',');
61
104
  return `{${body}}`;
62
105
  }
63
106
 
64
- function optionsDigest(options) {
65
- return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
107
+ /** One digest per top-level option, so a mismatch can name the options that differ. */
108
+ function optionsDigests(options) {
109
+ const digests = new Map();
110
+ for (const key of Object.keys(options)) {
111
+ digests.set(key, crypto.createHash('sha256').update(fingerprintOptions(options[key])).digest('hex'));
112
+ }
113
+ return digests;
114
+ }
115
+
116
+ /** The top-level options whose digests differ, sorted; empty when the options match. */
117
+ function differingOptions(before, after) {
118
+ const keys = new Set([...before.keys(), ...after.keys()]);
119
+ return [...keys].filter((key) => before.get(key) !== after.get(key)).sort();
66
120
  }
67
121
 
68
122
  // fetch() parses the joined URL per the WHATWG URL Standard: '?' starts the query, '#' the
@@ -145,6 +199,9 @@ class VaultClient {
145
199
  * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
146
200
  * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
147
201
  * See {@link VaultApiClient#constructor}.
202
+ * @param {number} [options.api.timeout=120000] - milliseconds a request may take, response
203
+ * headers, body and redirects included, before it rejects with RequestTimeoutError.
204
+ * `0` turns the limit off.
148
205
  * @param {Object} [options.api.kv] - KV engine options
149
206
  * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
150
207
  * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
@@ -227,8 +284,15 @@ class VaultClient {
227
284
  * `options` are used only when the instance is first created. A later call for a name
228
285
  * that already exists returns the existing instance and ignores the options it was
229
286
  * given; when those options differ from the ones the instance was booted with, that is
230
- * logged as a warning rather than passing silently. Use {@link VaultClient.get} to
231
- * fetch an existing instance, or {@link VaultClient.clear} to replace one.
287
+ * logged as a warning, naming the top-level options that differ, rather than passing
288
+ * silently. The warning is logged once per instance for each set of differing options.
289
+ * Use {@link VaultClient.get} to fetch an existing instance, or {@link VaultClient.clear}
290
+ * to replace one.
291
+ *
292
+ * Options are compared as hashes, so no credential is kept for the comparison. Plain
293
+ * objects and arrays compare by content, in any key order. Functions and other objects (a
294
+ * logger class instance, an undici dispatcher) compare by identity, and a Buffer, Date,
295
+ * URL or RegExp by value.
232
296
  *
233
297
  * @param {String} name - Vault instance name
234
298
  * @param {Object} options - options for {@link VaultClient#constructor}. Required on
@@ -243,14 +307,22 @@ class VaultClient {
243
307
  let instance = vaultInstances[name];
244
308
  if (instance === undefined) {
245
309
  vaultInstances[name] = instance = new VaultClient(options);
246
- instance.__bootDigest = optionsDigest(options);
247
- } else if (instance.__bootDigest !== optionsDigest(options)) {
310
+ instance.__bootDigests = optionsDigests(options);
311
+ instance.__bootWarned = new Set();
312
+ return instance;
313
+ }
314
+
315
+ // Once per instance for each set of differing options: code that re-creates its logger
316
+ // for every boot() call would otherwise log this on every call.
317
+ const differing = differingOptions(instance.__bootDigests, optionsDigests(options)).join(', ');
318
+ if (differing !== '' && !instance.__bootWarned.has(differing)) {
319
+ instance.__bootWarned.add(differing);
248
320
  instance.__log.warn(
249
321
  'VaultClient.boot("%s") was called with different options than the existing ' +
250
322
  'instance was booted with; the existing instance is returned and these options ' +
251
323
  'are ignored. Use VaultClient.get("%s") to fetch it, or VaultClient.clear("%s") ' +
252
- 'first to replace it.',
253
- name, name, name
324
+ 'first to replace it. Differing options: %s.',
325
+ name, name, name, differing
254
326
  );
255
327
  }
256
328
  return instance;
package/src/errors.d.ts CHANGED
@@ -16,3 +16,8 @@ export declare class VaultHttpError extends VaultError {
16
16
  statusCode: number;
17
17
  error: unknown;
18
18
  }
19
+
20
+ export declare class RequestTimeoutError extends VaultError {
21
+ constructor(timeout: number, options?: { cause?: unknown });
22
+ timeout: number;
23
+ }
package/src/errors.js CHANGED
@@ -41,6 +41,23 @@ class VaultHttpError extends VaultError {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * A request to Vault did not complete within `api.timeout` milliseconds: response headers,
46
+ * body and any redirects included. The `fetch()` rejection (a `TimeoutError` DOMException)
47
+ * is on `cause`.
48
+ */
49
+ class RequestTimeoutError extends VaultError {
50
+ /**
51
+ * @param {number} timeout - the `api.timeout` that elapsed, in milliseconds. Exposed as
52
+ * `error.timeout`.
53
+ * @param {{cause?: *}} [options]
54
+ */
55
+ constructor(timeout, options) {
56
+ super(`Vault request timed out after ${timeout} ms (api.timeout)`, options);
57
+ this.timeout = timeout;
58
+ }
59
+ }
60
+
44
61
  module.exports = {
45
62
  VaultError,
46
63
  InvalidArgumentsError,
@@ -48,4 +65,5 @@ module.exports = {
48
65
  AuthTokenExpiredError,
49
66
  UnsupportedOperationError,
50
67
  VaultHttpError,
68
+ RequestTimeoutError,
51
69
  };