Explorar o código

feat(asset-server-plugin): Create S3AssetStorageStrategy

Closes #191
Michael Bromley %!s(int64=5) %!d(string=hai) anos
pai
achega
3f89022d42

+ 1 - 0
packages/asset-server-plugin/index.ts

@@ -1,3 +1,4 @@
 export * from './src/plugin';
+export * from './src/s3-asset-storage-strategy';
 export * from './src/sharp-asset-preview-strategy';
 export * from './src/types';

+ 1 - 0
packages/asset-server-plugin/package.json

@@ -24,6 +24,7 @@
     "@types/sharp": "^0.24.0",
     "@vendure/common": "^0.11.1",
     "@vendure/core": "^0.11.1",
+    "aws-sdk": "^2.670.0",
     "express": "^4.16.4",
     "node-fetch": "^2.6.0",
     "rimraf": "^3.0.0",

+ 2 - 1
packages/asset-server-plugin/src/plugin.ts

@@ -27,7 +27,8 @@ import { AssetServerOptions, ImageTransformPreset } from './types';
 
 /**
  * @description
- * The `AssetServerPlugin` serves assets (images and other files) from the local file system. It can also perform on-the-fly image transformations
+ * The `AssetServerPlugin` serves assets (images and other files) from the local file system, and can also be configured to use
+ * other storage strategies (e.g. {@link S3AssetStorageStrategy}. It can also perform on-the-fly image transformations
  * and caches the results for subsequent calls.
  *
  * ## Installation

+ 232 - 0
packages/asset-server-plugin/src/s3-asset-storage-strategy.ts

@@ -0,0 +1,232 @@
+import { AssetStorageStrategy, Injector, Logger } from '@vendure/core';
+import { Request } from 'express';
+import { Readable, Stream } from 'stream';
+
+import { loggerCtx } from './constants';
+import { AssetServerOptions } from './types';
+
+export type S3Credentials = {
+    accessKeyId: string;
+    secretAccessKey: string;
+};
+
+export type S3CredentialsProfile = {
+    profile: string;
+};
+
+/**
+ * @description
+ * Configuration for connecting to AWS S3.
+ *
+ * @docsCategory asset-server-plugin
+ * @docsPage S3AssetStorageStrategy
+ */
+export interface S3Config {
+    /**
+     * @description
+     * The credentials used to access your s3 account. You can supply either the access key ID & secret,
+     * or you can make use of a
+     * [shared credentials file](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html)
+     * and supply the profile name (e.g. `'default'`)
+     */
+    credentials: S3Credentials | S3CredentialsProfile;
+    /**
+     * @description
+     * The S3 bucket in which to store the assets. If it does not exist, it will be created on startup.
+     */
+    bucket: string;
+    /**
+     * @description
+     * The AWS region in which to host the assets.
+     */
+    region?: string;
+}
+
+/**
+ * @description
+ * Returns a configured instance of the {@link S3AssetStorageStrategy} which can then be passed to the {@link AssetServerOptions}
+ * `storageStrategyFactory` property.
+ *
+ * Before using this strategy, make sure you have the `aws-sdk` package installed:
+ *
+ * ```sh
+ * npm install aws-sdk
+ * ```
+ *
+ * @example
+ * ```TypeScript
+ * plugins: [
+ *   AssetServerPlugin.init({
+ *     route: 'assets',
+ *     assetUploadDir: path.join(__dirname, 'assets'),
+ *     port: 5002,
+ *     namingStrategy: new DefaultAssetNamingStrategy(),
+ *     storageStrategyFactory: configureS3AssetStorage({
+ *       bucket: 'my-s3-bucket',
+ *       credentials: {
+ *         accessKeyId: process.env.AWS_ACCESS_KEY_ID,
+ *         secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
+ *       },
+ *     }),
+ * }),
+ * ```
+ *
+ * @docsCategory asset-server-plugin
+ * @docsPage S3AssetStorageStrategy
+ */
+export function configureS3AssetStorage(s3Config: S3Config) {
+    return (options: AssetServerOptions) => {
+        const { assetUrlPrefix, route } = options;
+        const toAbsoluteUrlFn = (request: Request, identifier: string): string => {
+            if (!identifier) {
+                return '';
+            }
+            const prefix = assetUrlPrefix || `${request.protocol}://${request.get('host')}/${route}/`;
+            return identifier.startsWith(prefix) ? identifier : `${prefix}${identifier}`;
+        };
+        return new S3AssetStorageStrategy(s3Config, toAbsoluteUrlFn);
+    };
+}
+
+/**
+ * @description
+ * An {@link AssetStorageStrategy} which uses [Amazon S3](https://aws.amazon.com/s3/) object storage service.
+ * To us this strategy you must first have access to an AWS account.
+ * See their [getting started guide](https://aws.amazon.com/s3/getting-started/?nc=sn&loc=5) for how to get set up.
+ *
+ * Before using this strategy, make sure you have the `aws-sdk` package installed:
+ *
+ * ```sh
+ * npm install aws-sdk
+ * ```
+ *
+ * **Note:** Rather than instantiating this manually, use the {@link configureS3AssetStorage} function.
+ *
+ * @docsCategory asset-server-plugin
+ * @docsPage S3AssetStorageStrategy
+ */
+export class S3AssetStorageStrategy implements AssetStorageStrategy {
+    private AWS: typeof import('aws-sdk');
+    private s3: import('aws-sdk').S3;
+    constructor(
+        private s3Config: S3Config,
+        public readonly toAbsoluteUrl: (reqest: Request, identifier: string) => string,
+    ) {}
+
+    async init() {
+        try {
+            this.AWS = await import('aws-sdk');
+        } catch (e) {
+            Logger.error(
+                `Could not find the "aws-sdk" package. Make sure it is installed`,
+                loggerCtx,
+                e.stack,
+            );
+        }
+
+        this.setCredentials();
+        if (this.s3Config.region) {
+            this.AWS.config.update({ region: this.s3Config.region });
+        }
+        this.s3 = new this.AWS.S3();
+        await this.ensureBucket(this.s3Config.bucket);
+    }
+
+    destroy?: (() => void | Promise<void>) | undefined;
+
+    async writeFileFromBuffer(fileName: string, data: Buffer): Promise<string> {
+        const result = await this.s3
+            .upload({
+                Bucket: this.s3Config.bucket,
+                Key: fileName,
+                Body: data,
+            })
+            .promise();
+        return result.Key;
+    }
+
+    async writeFileFromStream(fileName: string, data: Stream): Promise<string> {
+        const result = await this.s3
+            .upload({
+                Bucket: this.s3Config.bucket,
+                Key: fileName,
+                Body: data,
+            })
+            .promise();
+        return result.Key;
+    }
+
+    async readFileToBuffer(identifier: string): Promise<Buffer> {
+        const result = await this.s3.getObject(this.getObjectParams(identifier)).promise();
+        return Buffer.from(result.Body as Stream);
+    }
+
+    async readFileToStream(identifier: string): Promise<Stream> {
+        const result = await this.s3.getObject(this.getObjectParams(identifier)).promise();
+        const body = result.Body;
+        if (!(body instanceof Stream)) {
+            const readable = new Readable();
+            readable._read = () => {
+                /* noop */
+            };
+            readable.push(body);
+            readable.push(null);
+            return readable;
+        }
+        return body;
+    }
+
+    async deleteFile(identifier: string): Promise<void> {
+        await this.s3.deleteObject(this.getObjectParams(identifier)).promise();
+    }
+
+    async fileExists(fileName: string): Promise<boolean> {
+        try {
+            await this.s3.headObject(this.getObjectParams(fileName)).promise();
+            return true;
+        } catch (e) {
+            return false;
+        }
+    }
+
+    private getObjectParams(identifier: string) {
+        return {
+            Bucket: this.s3Config.bucket,
+            Key: identifier.replace(/^\//, '').replace(/\//g, '\\'),
+        };
+    }
+
+    private setCredentials() {
+        const { credentials } = this.s3Config;
+        if (this.isCredentialsProfile(credentials)) {
+            this.AWS.config.credentials = new this.AWS.SharedIniFileCredentials(credentials);
+        } else {
+            this.AWS.config.credentials = new this.AWS.Credentials(credentials);
+        }
+    }
+
+    private async ensureBucket(bucket: string) {
+        let bucketExists = false;
+        try {
+            await this.s3.headBucket({ Bucket: this.s3Config.bucket }).promise();
+            bucketExists = true;
+            Logger.verbose(`Found S3 bucket "${bucket}"`, loggerCtx);
+        } catch (e) {
+            Logger.verbose(`Could not find bucket "${bucket}". Attempting to create...`);
+        }
+        if (!bucketExists) {
+            try {
+                await this.s3.createBucket({ Bucket: bucket, ACL: 'private' }).promise();
+                Logger.verbose(`Created S3 bucket "${bucket}"`, loggerCtx);
+            } catch (e) {
+                Logger.error(`Could not find nor create the S3 bucket "${bucket}"`, loggerCtx, e.stack);
+            }
+        }
+    }
+
+    private isCredentialsProfile(
+        credentials: S3Credentials | S3CredentialsProfile,
+    ): credentials is S3CredentialsProfile {
+        return credentials.hasOwnProperty('profile');
+    }
+}

+ 2 - 2
packages/asset-server-plugin/src/types.ts

@@ -59,9 +59,9 @@ export interface AssetServerOptions {
     route: string;
     /**
      * @description
-     * The local directory to which assets will be uploaded.
+     * The local directory to which assets will be uploaded when using the {@link LocalAssetStorageStrategy}.
      */
-    assetUploadDir: string;
+    assetUploadDir: string; // TODO: this is strategy-specific and should be moved out of the global options
     /**
      * @description
      * The complete URL prefix of the asset files. For example, "https://demo.vendure.io/assets/"

+ 115 - 2
yarn.lock

@@ -2769,7 +2769,7 @@
     multer "1.4.2"
     tslib "1.11.1"
 
-"@nestjs/terminus@^7.0.1":
+"@nestjs/terminus@7.0.1":
   version "7.0.1"
   resolved "https://registry.npmjs.org/@nestjs/terminus/-/terminus-7.0.1.tgz#7d748f8c18973d60023a8ab16760d0adab145b8b"
   integrity sha512-OKg1QQDb+whHJM3Xt+3RRUPiyZSyD0qLacfldK0TXcFpKyexA0yyY3GKeaBNApf01FEzJgkK3ARCUoELnAfXDA==
@@ -3138,6 +3138,11 @@
   dependencies:
     defer-to-connect "^1.0.1"
 
+"@tokenizer/token@^0.1.0", "@tokenizer/token@^0.1.1":
+  version "0.1.1"
+  resolved "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3"
+  integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==
+
 "@tootallnate/once@1":
   version "1.0.0"
   resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.0.0.tgz#9c13c2574c92d4503b005feca8f2e16cc1611506"
@@ -3276,6 +3281,11 @@
   resolved "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
   integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==
 
+"@types/debug@^4.1.5":
+  version "4.1.5"
+  resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd"
+  integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==
+
 "@types/detect-port@^1.1.0":
   version "1.1.0"
   resolved "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.1.0.tgz#07075d264e2e5a432624b1e7ffc11379fe66be8a"
@@ -4955,6 +4965,21 @@ await-to-js@^2.0.1:
   resolved "https://registry.npmjs.org/await-to-js/-/await-to-js-2.1.1.tgz#c2093cd5a386f2bb945d79b292817bbc3f41b31b"
   integrity sha512-CHBC6gQGCIzjZ09tJ+XmpQoZOn4GdWePB4qUweCaKNJ0D3f115YdhmYVTZ4rMVpiJ3cFzZcTYK1VMYEICV4YXw==
 
+aws-sdk@^2.670.0:
+  version "2.670.0"
+  resolved "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.670.0.tgz#d54d18b9245df7b89bea96102e5bdebd99587701"
+  integrity sha512-hGRnZtp1wDUh6hZRBHO0Ki7thx/xbRlIEiTKlWes+f/0E1Nhm3KpelsBZ3L/Q6y1ragwkQd4Q720AmWEqemLyA==
+  dependencies:
+    buffer "4.9.1"
+    events "1.1.1"
+    ieee754 "1.1.13"
+    jmespath "0.15.0"
+    querystring "0.2.0"
+    sax "1.2.1"
+    url "0.10.3"
+    uuid "3.3.2"
+    xml2js "0.4.19"
+
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
@@ -5477,6 +5502,15 @@ buffer-xor@^1.0.3:
   resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
   integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=
 
+buffer@4.9.1:
+  version "4.9.1"
+  resolved "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
+  integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+    isarray "^1.0.0"
+
 buffer@^4.3.0:
   version "4.9.2"
   resolved "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
@@ -7990,6 +8024,11 @@ eventemitter3@^4.0.0:
   resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
   integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
 
+events@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.npmjs.org/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
+  integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
+
 events@^3.0.0:
   version "3.1.0"
   resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59"
@@ -8375,6 +8414,16 @@ file-loader@4.2.0:
     loader-utils "^1.2.3"
     schema-utils "^2.0.0"
 
+file-type@^14.3.0:
+  version "14.3.0"
+  resolved "https://registry.npmjs.org/file-type/-/file-type-14.3.0.tgz#0afc57210e3c655d2106a2eba026d3d5161fea79"
+  integrity sha512-s71v6jMkbfwVdj87csLeNpL5K93mv4lN+lzgzifoICtPHhnXokDwBa3jrzfg+z6FK872iYJ0vS0i74v8XmoFDA==
+  dependencies:
+    readable-web-to-node-stream "^2.0.0"
+    strtok3 "^6.0.0"
+    token-types "^2.0.0"
+    typedarray-to-buffer "^3.1.5"
+
 file-uri-to-path@1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -9722,7 +9771,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.4:
+ieee754@1.1.13, ieee754@^1.1.13, ieee754@^1.1.4:
   version "1.1.13"
   resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
   integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
@@ -11063,6 +11112,11 @@ jest@^25.2.1:
     import-local "^3.0.2"
     jest-cli "^25.2.1"
 
+jmespath@0.15.0:
+  version "0.15.0"
+  resolved "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
+  integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=
+
 js-beautify@^1.6.14:
   version "1.10.3"
   resolved "https://registry.npmjs.org/js-beautify/-/js-beautify-1.10.3.tgz#c73fa10cf69d3dfa52d8ed624f23c64c0a6a94c1"
@@ -14450,6 +14504,11 @@ pbkdf2@^3.0.3:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+peek-readable@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.npmjs.org/peek-readable/-/peek-readable-3.1.0.tgz#250b08b7de09db8573d7fd8ea475215bbff14348"
+  integrity sha512-KGuODSTV6hcgdZvDrIDBUkN0utcAVj1LL7FfGbM0viKTtCHmtZcuEJ+lGqsp0fTFkGqesdtemV2yUSMeyy3ddA==
+
 pend@~1.2.0:
   version "1.2.0"
   resolved "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
@@ -15618,6 +15677,11 @@ readable-stream@1.1.x:
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
+readable-web-to-node-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz#751e632f466552ac0d5c440cc01470352f93c4b7"
+  integrity sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA==
+
 readdir-scoped-modules@^1.0.0, readdir-scoped-modules@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
@@ -16261,6 +16325,11 @@ saucelabs@^1.5.0:
   dependencies:
     https-proxy-agent "^2.2.1"
 
+sax@1.2.1:
+  version "1.2.1"
+  resolved "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
+  integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o=
+
 sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@@ -17234,6 +17303,16 @@ strong-log-transformer@^2.0.0:
     minimist "^1.2.0"
     through "^2.3.4"
 
+strtok3@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.npmjs.org/strtok3/-/strtok3-6.0.0.tgz#d6b900863daeacfe6c1724c6e7bb36d7a58e83c8"
+  integrity sha512-ZXlmE22LZnIBvEU3n/kZGdh770fYFie65u5+2hLK9s74DoFtpkQIdBZVeYEzlolpGa+52G5IkzjUWn+iXynOEQ==
+  dependencies:
+    "@tokenizer/token" "^0.1.1"
+    "@types/debug" "^4.1.5"
+    debug "^4.1.1"
+    peek-readable "^3.1.0"
+
 style-loader@1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/style-loader/-/style-loader-1.0.0.tgz#1d5296f9165e8e2c85d24eee0b7caf9ec8ca1f82"
@@ -17675,6 +17754,14 @@ toidentifier@1.0.0:
   resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+token-types@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/token-types/-/token-types-2.0.0.tgz#b23618af744818299c6fbf125e0fdad98bab7e85"
+  integrity sha512-WWvu8sGK8/ZmGusekZJJ5NM6rRVTTDO7/bahz4NGiSDb/XsmdYBn6a1N/bymUHuWYTWeuLUg98wUzvE4jPdCZw==
+  dependencies:
+    "@tokenizer/token" "^0.1.0"
+    ieee754 "^1.1.13"
+
 tough-cookie@^2.3.3, tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@@ -18196,6 +18283,14 @@ url-parse@^1.4.3:
     querystringify "^2.1.1"
     requires-port "^1.0.0"
 
+url@0.10.3:
+  version "0.10.3"
+  resolved "https://registry.npmjs.org/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
+  integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=
+  dependencies:
+    punycode "1.3.2"
+    querystring "0.2.0"
+
 url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@@ -18265,6 +18360,11 @@ utils-merge@1.0.1:
   resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
+uuid@3.3.2:
+  version "3.3.2"
+  resolved "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
+  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+
 uuid@7.0.2, uuid@^7.0.1:
   version "7.0.2"
   resolved "https://registry.npmjs.org/uuid/-/uuid-7.0.2.tgz#7ff5c203467e91f5e0d85cfcbaaf7d2ebbca9be6"
@@ -18911,6 +19011,14 @@ xml-name-validator@^3.0.0:
   resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
 
+xml2js@0.4.19:
+  version "0.4.19"
+  resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~9.0.1"
+
 xml2js@^0.4.17:
   version "0.4.23"
   resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
@@ -18924,6 +19032,11 @@ xmlbuilder@~11.0.0:
   resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
   integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
 
+xmlbuilder@~9.0.1:
+  version "9.0.7"
+  resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+  integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=
+
 xmlchars@^2.1.1:
   version "2.2.0"
   resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"