eip-cloud-services 1.2.6 → 1.4.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
@@ -81,6 +81,51 @@ module.exports = {
81
81
  4. [MySQL Module](#mysql-module)
82
82
  5. [AWS Lambda Module](#aws-lambda-module)
83
83
  6. [AWS SQS Module](#aws-sqs-module)
84
+ 7. [AWS Secrets Manager Module](#aws-secrets-manager-module)
85
+
86
+ # AWS Secrets Manager Module
87
+
88
+ ## Overview
89
+
90
+ This module provides helpers for reading and creating AWS Secrets Manager secrets. Secret reads use a local tmp-directory cache by default to avoid unnecessary AWS requests. Use `bypassCache` when the caller must force a fresh AWS lookup.
91
+
92
+ ## Usage
93
+
94
+ ```javascript
95
+ const { secrets } = require('eip-cloud-services');
96
+
97
+ const appKey = await secrets.get('my-service/apple-news-app-key');
98
+ const configSecret = await secrets.get('my-service/config', { parseJson: true });
99
+ ```
100
+
101
+ ## Creating Secrets
102
+
103
+ Existing secrets are never overwritten.
104
+
105
+ ```javascript
106
+ await secrets.set('my-service/apple-news-app-key', 'secret-value');
107
+ await secrets.set('my-service/config', { apiKey: 'secret-value' });
108
+ ```
109
+
110
+ CLI usage:
111
+
112
+ ```bash
113
+ npm run secrets:set -- --name=my-service/apple-news-app-key --value='secret-value'
114
+ npm run secrets:set -- --name=my-service/config --json='{"apiKey":"secret-value"}'
115
+ npm run secrets:set -- --name=my-service/apple-news-app-key --file=./secret.txt
116
+ ```
117
+
118
+ If `--name` is omitted, the script uses the current `package.json` name. If no package name is available, it prompts for one.
119
+
120
+ ## Cache Options
121
+
122
+ By default `secrets.get()` checks a local tmp cache first and writes AWS results back to that cache. There is no TTL. To force AWS:
123
+
124
+ ```javascript
125
+ await secrets.get('my-service/apple-news-app-key', { bypassCache: true });
126
+ ```
127
+
128
+ Set `EIP_SECRETS_CACHE_DIR` or `secrets.cacheDir` to control cache location.
84
129
 
85
130
  # AWS Lambda Module
86
131
 
@@ -228,7 +273,7 @@ The module is designed to handle errors gracefully, providing clear error messag
228
273
 
229
274
  ## Overview
230
275
 
231
- This module provides functionalities to manage and interact with Content Delivery Networks (CDNs) like Amazon CloudFront and Google Cloud CDN. It includes features for creating invalidations, thereby ensuring that the latest content is served to end-users.
276
+ This module provides a shared client for CDN invalidation requests. CDN invalidation requests are sent through the shared Tools API invalidate endpoint, so callers use one centralized route while the backend implementation remains independently updateable inside the invalidation service.
232
277
 
233
278
  ## Installation
234
279
 
@@ -242,7 +287,7 @@ const cdn = require('eip-cloud-services/cdn');
242
287
 
243
288
  ### Create a CDN Invalidation
244
289
 
245
- To invalidate cached content in a CDN, use the `createInvalidation` method. This method supports invalidating content in both Amazon CloudFront and Google Cloud CDN.
290
+ To invalidate cached content in a CDN, use the `createInvalidation` method. This sends the request to the shared Tools API invalidate endpoint, which executes the configured provider-specific invalidation in `eip-cdn-invalidator`.
246
291
 
247
292
  #### Invalidate in Amazon CloudFront
248
293
 
@@ -272,11 +317,9 @@ The module is equipped to handle errors gracefully, including validation of inpu
272
317
 
273
318
  ## Advanced Features
274
319
 
275
- - Supports both Amazon CloudFront and Google Cloud CDN.
276
320
  - Validates the key argument to ensure proper formatting.
277
321
  - Constructs invalidation paths based on the provided keys.
278
- - Initializes Google Auth if Google CDN is used.
279
- - Sends invalidation commands to the CDN client based on the type of CDN and environment.
322
+ - Centralizes invalidation routing through the shared Tools API endpoint.
280
323
 
281
324
 
282
325
  # AWS S3 Module
package/index.js CHANGED
@@ -4,5 +4,6 @@ exports.s3 = require ( './src/s3' );
4
4
  exports.cdn = require ( './src/cdn' );
5
5
  exports.lambda = require ( './src/lambda' );
6
6
  exports.sqs = require ( './src/sqs' );
7
+ exports.secrets = require ( './src/secrets' );
7
8
  exports.mysql = require ( './src/mysql' );
8
9
  exports.mysql8 = require ( './src/mysql8' );
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "eip-cloud-services",
3
- "version": "1.2.6",
3
+ "version": "1.4.0",
4
4
  "description": "Houses a collection of helpers for connecting with Cloud services.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "mocha"
7
+ "test": "mocha",
8
+ "secrets:set": "node scripts/set-secret.js"
8
9
  },
9
10
  "author": "Oliver Edgington <oliver@edgington.com>",
10
11
  "license": "ISC",
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require ( 'fs' );
4
+ const readline = require ( 'readline' );
5
+ const secrets = require ( '../src/secrets' );
6
+
7
+ const parseArgs = ( argv = process.argv.slice ( 2 ) ) => {
8
+ const args = {};
9
+ for ( let i = 0; i < argv.length; i++ ) {
10
+ const arg = argv[ i ];
11
+ if ( !arg.startsWith ( '--' ) ) continue;
12
+
13
+ const [ rawKey, inlineValue ] = arg.slice ( 2 ).split ( '=' );
14
+ const key = rawKey.replace ( /-([a-z])/g, ( _match, char ) => char.toUpperCase () );
15
+ if ( inlineValue !== undefined ) {
16
+ args[ key ] = inlineValue;
17
+ continue;
18
+ }
19
+
20
+ if ( argv[ i + 1 ] && !argv[ i + 1 ].startsWith ( '--' ) ) {
21
+ args[ key ] = argv[ i + 1 ];
22
+ i++;
23
+ continue;
24
+ }
25
+
26
+ args[ key ] = true;
27
+ }
28
+
29
+ return args;
30
+ };
31
+
32
+ const prompt = question => new Promise ( resolve => {
33
+ const rl = readline.createInterface ( {
34
+ input: process.stdin,
35
+ output: process.stdout
36
+ } );
37
+ rl.question ( question, answer => {
38
+ rl.close ();
39
+ resolve ( answer.trim () );
40
+ } );
41
+ } );
42
+
43
+ const resolveSecretValue = args => {
44
+ if ( args.value !== undefined ) return String ( args.value );
45
+ if ( args.json !== undefined ) return JSON.parse ( String ( args.json ) );
46
+ if ( args.file ) return fs.readFileSync ( String ( args.file ), 'utf8' );
47
+ throw new Error ( 'Missing secret value. Use --value, --json, or --file.' );
48
+ };
49
+
50
+ const main = async () => {
51
+ const args = parseArgs ();
52
+ const name = secrets.resolveDefaultSecretName ( { name: args.name } ) || await prompt ( 'Secret name: ' );
53
+ if ( !name ) throw new Error ( 'Secret name is required.' );
54
+
55
+ const value = resolveSecretValue ( args );
56
+ const response = await secrets.set ( name, value, {
57
+ region: args.region,
58
+ description: args.description,
59
+ cacheDir: args.cacheDir
60
+ } );
61
+
62
+ process.stdout.write ( JSON.stringify ( {
63
+ name,
64
+ arn: response.ARN || response.arn || null,
65
+ versionId: response.VersionId || response.versionId || null
66
+ }, null, 2 ) );
67
+ process.stdout.write ( '\n' );
68
+ };
69
+
70
+ if ( require.main === module ) {
71
+ main ().catch ( error => {
72
+ process.stderr.write ( `${ error?.message || error }\n` );
73
+ process.exitCode = 1;
74
+ } );
75
+ }
76
+
77
+ module.exports = {
78
+ parseArgs,
79
+ resolveSecretValue
80
+ };
package/src/cdn.js CHANGED
@@ -1,114 +1,61 @@
1
- const { CloudFrontClient, CreateInvalidationCommand } = require ( '@aws-sdk/client-cloudfront' );
2
- const { GoogleAuth } = require ( 'google-auth-library' );
3
- const { initialiseGoogleAuth } = require ( './gcp' );
4
1
  const fs = require ( 'fs' );
5
2
  let config = {};
6
3
  const configDirPath = `${ process.cwd ()}/config`;
7
4
  if ( fs.existsSync ( configDirPath ) && fs.statSync ( configDirPath ).isDirectory () ) {
8
5
  config = require ( 'config' ); // require the config directory if it exists
9
6
  }
10
- const packageJson = require ( '../package.json' );
11
7
  const { cwd } = require ( 'process' );
12
8
  const { log } = config?.s3?.logsFunction ? require ( `${ cwd ()}/${config.s3.logsFunction}` ) : console;
13
9
 
14
- const redis = require('./redis');
10
+ const INVALIDATION_ENDPOINT = process.env.EIP_CDN_INVALIDATE_ENDPOINT || config?.cdn?.invalidateEndpoint || 'https://tools.eip.telegraph.co.uk/v1/invalidate';
11
+ const shouldLog = Boolean ( config?.cdn?.log || config?.cdn?.logs );
15
12
 
16
- /**
17
- * Create a CDN invalidation for the specified key(s) and environment.
18
- *
19
- * @param {string} cdn - The CDN provider to be used (e.g., 'google', 'amazon').
20
- * @param {string|string[]} key - The key(s) representing the file(s) to invalidate.
21
- * @param {string} environment - The environment (e.g., 'staging', 'production').
22
- * @returns {Promise<void>} A promise that resolves when the invalidation is created.
23
- * @description Creates a CDN invalidation for the specified key(s) in the specified environment.
24
- * - The `cdn` parameter specifies the CDN provider (e.g., 'google', 'amazon').
25
- * - The `key` parameter can be a single string or an array of strings representing the file(s) to invalidate.
26
- * - The `environment` parameter specifies the environment (e.g., 'staging', 'production').
27
- * - The function validates the `key` argument and throws an error if it is not a string or an array of strings.
28
- * - The function constructs the invalidation paths based on the provided keys.
29
- * - The CDN client (either Google or Amazon CloudFront) is used to send the invalidation command.
30
- * - Returns a promise that resolves when the invalidation is created.
31
- * - The function initializes Google Auth if Google CDN is used.
32
- * - If Google CDN is used, the function makes a POST request to Google's 'invalidateCache' endpoint for each provided path.
33
- * - If Amazon CloudFront is used, the function sends an invalidation command to CloudFront.
34
- * - If the CDN type is not 'google' or 'amazon', the function throws an error.
35
- * - The function uses the 'config' module to access the CDN settings based on the CDN provider and environment.
36
- */
37
13
  exports.createInvalidation = async ( cdn, key, environment = 'production' ) => {
38
- const cdnSettings = config.cdn[ cdn ][ environment ];
14
+ const invalidation = normalizeInvalidation ( cdn, key, environment );
39
15
 
40
- // Ensure paths is an array and sanitize paths
41
- const paths = ( Array.isArray ( key ) ? key : [ key ] )
42
- .filter ( item => typeof item === 'string' )
43
- .map ( item => item.charAt ( 0 ) !== '/' ? '/' + item : item );
44
-
45
- if ( paths.length === 0 ) {
46
- throw new Error ( 'Invalid key argument. Expected a string or an array of strings.' );
16
+ if ( shouldLog ) {
17
+ log ( `CDN [INVALIDATE][REQUEST]: ${invalidation.cdn} (${invalidation.environment}) ${invalidation.paths.join ( ', ' )}\n` );
47
18
  }
48
19
 
49
- if ( config?.redis?.host ) {
50
- const redisKey = `cdn-invalidation:${cdn}:${environment}:${key}`;
51
- const redisValue = await redis.get(redisKey);
20
+ const response = await fetch ( INVALIDATION_ENDPOINT, {
21
+ method: 'POST',
22
+ headers: {
23
+ 'Content-Type': 'application/json'
24
+ },
25
+ body: JSON.stringify ( {
26
+ cdn: invalidation.cdn,
27
+ key: invalidation.key,
28
+ environment: invalidation.environment
29
+ } )
30
+ } );
52
31
 
53
- if(redisValue) {
54
- if ( config.cdn.log )
55
- log ( `CDN [INVALIDATE]: Invalidation already in progress - skipping: ${paths.map ( path => `https://${cdn}${environment !== 'production' ? '-test' : ''}.eip.telegraph.co.uk${path}` ).join ( ', ' )}\n` );
56
- await redis.increment(redisKey);
32
+ if ( !response.ok ) {
33
+ const responseText = await response.text ();
57
34
 
58
- return;
59
- }
60
- else{
61
- await redis.set(redisKey, 1, cdnSettings.type === 'google' ? 300 : 120); // 5 minutes for google, 2 minutes for amazon
62
- }
35
+ throw new Error ( `CDN invalidation request failed (${response.status}): ${responseText}` );
63
36
  }
37
+ };
64
38
 
65
- if ( config.cdn.log )
66
- log ( `CDN [INVALIDATE]: ${paths.map ( path => `https://${cdn}${environment !== 'production' ? '-test' : ''}.eip.telegraph.co.uk${path}` ).join ( ', ' )}\n` );
67
-
68
- switch ( cdnSettings.type ) {
69
- case 'google':
70
- await invalidateGoogleCDN ( cdnSettings, paths );
71
- break;
72
-
73
- case 'amazon':
74
- await invalidateAmazonCDN ( cdnSettings, paths );
75
- break;
39
+ function normalizeInvalidation ( cdn, key, environment ) {
40
+ const cdnSettings = config?.cdn?.[ cdn ]?.[ environment ];
76
41
 
77
- default:
78
- throw new Error ( `Invalid cdn type: ${cdnSettings.type}` );
42
+ if ( !cdnSettings ) {
43
+ throw new Error ( `Missing CDN configuration for ${cdn} (${environment})` );
79
44
  }
80
- };
81
45
 
82
- async function invalidateGoogleCDN ( cdnSettings, paths ) {
83
- await initialiseGoogleAuth ();
84
-
85
- const auth = new GoogleAuth ( {
86
- scopes: 'https://www.googleapis.com/auth/cloud-platform'
87
- } );
46
+ const paths = ( Array.isArray ( key ) ? key : [ key ] )
47
+ .filter ( item => typeof item === 'string' )
48
+ .map ( item => item.charAt ( 0 ) !== '/' ? '/' + item : item );
88
49
 
89
- const client = await auth.getClient ();
90
- const url = `https://compute.googleapis.com/compute/v1/projects/${cdnSettings.projectId}/global/urlMaps/${cdnSettings.urlMapName}/invalidateCache`;
91
- const headers = await client.getRequestHeaders ( url );
50
+ if ( paths.length === 0 ) {
51
+ throw new Error ( 'Invalid key argument. Expected a string or an array of strings.' );
52
+ }
92
53
 
93
- await Promise.all ( paths.map ( path => fetch ( url, {
94
- method: 'POST',
95
- headers,
96
- body: JSON.stringify ( { path } )
97
- } ) ) );
54
+ return {
55
+ cdn,
56
+ key,
57
+ environment,
58
+ paths,
59
+ cdnSettings
60
+ };
98
61
  }
99
-
100
- async function invalidateAmazonCDN ( cdnSettings, paths ) {
101
- const client = new CloudFrontClient ();
102
- const command = new CreateInvalidationCommand ( {
103
- DistributionId: cdnSettings.distributionId,
104
- InvalidationBatch: {
105
- CallerReference: `${packageJson.name}-${Date.now ()}`,
106
- Paths: {
107
- Quantity: paths.length,
108
- Items: paths,
109
- },
110
- },
111
- } );
112
-
113
- await client.send ( command );
114
- }
package/src/s3.js CHANGED
@@ -50,7 +50,9 @@ const zlib = require ( 'zlib' );
50
50
  const crypto = require ( 'crypto' );
51
51
  const { cwd } = require ( 'process' );
52
52
  const { log } = config?.s3?.logsFunction ? require ( `${ cwd ()}/${config?.s3?.logsFunction}` ) : console;
53
- const S3 = new S3Client ( { region: 'eu-west-1' } );
53
+ const S3 = new S3Client ( {
54
+ region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || config?.s3?.region || 'eu-west-1'
55
+ } );
54
56
  const { pipeline, Writable } = require ( 'stream' );
55
57
  const util = require ( 'util' );
56
58
  const pipelineAsync = util.promisify ( pipeline );
@@ -408,4 +410,4 @@ const streamToBuffer = async ( stream ) => {
408
410
  await pipelineAsync ( stream, collectorStream );
409
411
 
410
412
  return Buffer.concat ( chunks );
411
- };
413
+ };
package/src/secrets.js ADDED
@@ -0,0 +1,169 @@
1
+ const { SecretsManagerClient, CreateSecretCommand, DescribeSecretCommand, GetSecretValueCommand } = require ( '@aws-sdk/client-secrets-manager' );
2
+ const crypto = require ( 'crypto' );
3
+ const fs = require ( 'fs' );
4
+ const os = require ( 'os' );
5
+ const path = require ( 'path' );
6
+
7
+ let config = {};
8
+ const configDirPath = `${ process.cwd ()}/config`;
9
+ if ( fs.existsSync ( configDirPath ) && fs.statSync ( configDirPath ).isDirectory () ) {
10
+ config = require ( 'config' );
11
+ }
12
+
13
+ const DEFAULT_REGION = 'eu-west-1';
14
+ const DEFAULT_CACHE_DIR = path.join ( os.tmpdir (), 'eip-cloud-services', 'secrets' );
15
+
16
+ let clientOverride = null;
17
+
18
+ const resolveRegion = options => options.region || config?.secrets?.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || DEFAULT_REGION;
19
+
20
+ const getClient = options => clientOverride || new SecretsManagerClient ( { region: resolveRegion ( options ) } );
21
+
22
+ const safeCacheFileName = ( secretName, region ) => `${ crypto.createHash ( 'sha256' ).update ( `${ region }:${ secretName }` ).digest ( 'hex' ) }.json`;
23
+
24
+ const resolveCacheDir = options => options.cacheDir || config?.secrets?.cacheDir || process.env.EIP_SECRETS_CACHE_DIR || DEFAULT_CACHE_DIR;
25
+
26
+ const resolveCachePath = ( secretName, options = {} ) => path.join ( resolveCacheDir ( options ), safeCacheFileName ( secretName, resolveRegion ( options ) ) );
27
+
28
+ const readCache = ( secretName, options = {} ) => {
29
+ if ( options.bypassCache ) return null;
30
+
31
+ const cachePath = resolveCachePath ( secretName, options );
32
+ if ( !fs.existsSync ( cachePath ) ) return null;
33
+
34
+ const cached = JSON.parse ( fs.readFileSync ( cachePath, 'utf8' ) );
35
+ return cached.value;
36
+ };
37
+
38
+ const writeCache = ( secretName, value, options = {} ) => {
39
+ if ( options.cache === false ) return;
40
+
41
+ const cachePath = resolveCachePath ( secretName, options );
42
+ fs.mkdirSync ( path.dirname ( cachePath ), { recursive: true, mode: 0o700 } );
43
+ fs.writeFileSync ( cachePath, JSON.stringify ( {
44
+ secretName,
45
+ region: resolveRegion ( options ),
46
+ cachedAt: Date.now (),
47
+ value
48
+ } ), { mode: 0o600 } );
49
+ };
50
+
51
+ const parseSecretValue = ( value, options = {} ) => {
52
+ if ( !options.parseJson || typeof value !== 'string' ) return value;
53
+
54
+ try {
55
+ return JSON.parse ( value );
56
+ }
57
+ catch ( _error ) {
58
+ return value;
59
+ }
60
+ };
61
+
62
+ const stringifySecretValue = value => typeof value === 'string' ? value : JSON.stringify ( value );
63
+
64
+ /**
65
+ * Resolve the default secret name from an explicit value or the current package name.
66
+ *
67
+ * @param {object} [options={}] Options.
68
+ * @param {string} [options.name] Explicit secret name.
69
+ * @param {string} [options.cwd=process.cwd()] Directory containing package.json.
70
+ * @returns {string} The resolved secret name, or an empty string when none can be resolved.
71
+ */
72
+ exports.resolveDefaultSecretName = ( options = {} ) => {
73
+ if ( options.name ) return String ( options.name ).trim ();
74
+
75
+ const packagePath = path.join ( options.cwd || process.cwd (), 'package.json' );
76
+ if ( !fs.existsSync ( packagePath ) ) return '';
77
+
78
+ try {
79
+ const packageJson = JSON.parse ( fs.readFileSync ( packagePath, 'utf8' ) );
80
+ return typeof packageJson.name === 'string' ? packageJson.name.trim () : '';
81
+ }
82
+ catch ( _error ) {
83
+ return '';
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Check whether a secret exists in AWS Secrets Manager.
89
+ *
90
+ * @param {string} secretName Secret name or ARN.
91
+ * @param {object} [options={}] Options.
92
+ * @param {string} [options.region] AWS region.
93
+ * @returns {Promise<boolean>} True when the secret exists, false when not found.
94
+ */
95
+ exports.exists = async ( secretName, options = {} ) => {
96
+ try {
97
+ await getClient ( options ).send ( new DescribeSecretCommand ( { SecretId: secretName } ) );
98
+ return true;
99
+ }
100
+ catch ( error ) {
101
+ if ( error?.name === 'ResourceNotFoundException' ) return false;
102
+ throw error;
103
+ }
104
+ };
105
+
106
+ /**
107
+ * Get a secret value, checking the local tmp cache first unless bypassed.
108
+ *
109
+ * @param {string} secretName Secret name or ARN.
110
+ * @param {object} [options={}] Options.
111
+ * @param {boolean} [options.bypassCache=false] Force AWS lookup instead of tmp cache.
112
+ * @param {boolean} [options.cache=true] Write AWS results to tmp cache.
113
+ * @param {boolean} [options.parseJson=false] Parse JSON string secrets when possible.
114
+ * @param {string} [options.region] AWS region.
115
+ * @param {string} [options.cacheDir] Custom cache directory.
116
+ * @returns {Promise<string|object>} Secret value.
117
+ */
118
+ exports.get = async ( secretName, options = {} ) => {
119
+ const cached = readCache ( secretName, options );
120
+ if ( cached !== null && cached !== undefined ) return parseSecretValue ( cached, options );
121
+
122
+ const response = await getClient ( options ).send ( new GetSecretValueCommand ( { SecretId: secretName } ) );
123
+ const value = response.SecretString || ( response.SecretBinary ? Buffer.from ( response.SecretBinary ).toString ( 'utf8' ) : '' );
124
+ writeCache ( secretName, value, options );
125
+
126
+ return parseSecretValue ( value, options );
127
+ };
128
+
129
+ /**
130
+ * Create a new secret in AWS Secrets Manager. Existing secrets are never overwritten.
131
+ *
132
+ * @param {string} secretName Secret name.
133
+ * @param {string|object} value Secret value.
134
+ * @param {object} [options={}] Options.
135
+ * @param {string} [options.description] Secret description.
136
+ * @param {string} [options.region] AWS region.
137
+ * @param {string} [options.cacheDir] Custom cache directory.
138
+ * @returns {Promise<object>} AWS CreateSecret response.
139
+ */
140
+ exports.set = async ( secretName, value, options = {} ) => {
141
+ if ( await exports.exists ( secretName, options ) ) {
142
+ throw new Error ( `Secret already exists: ${ secretName }` );
143
+ }
144
+
145
+ const secretString = stringifySecretValue ( value );
146
+ const response = await getClient ( options ).send ( new CreateSecretCommand ( {
147
+ Name: secretName,
148
+ SecretString: secretString,
149
+ Description: options.description
150
+ } ) );
151
+ writeCache ( secretName, secretString, options );
152
+
153
+ return response;
154
+ };
155
+
156
+ exports.__private = {
157
+ parseSecretValue,
158
+ readCache,
159
+ resolveCachePath,
160
+ safeCacheFileName,
161
+ stringifySecretValue,
162
+ writeCache,
163
+ setClient: client => {
164
+ clientOverride = client;
165
+ },
166
+ clearClient: () => {
167
+ clientOverride = null;
168
+ }
169
+ };